Promo Video Production (local toolchain)
Agent-agnostic: written for any AI coding agent that can read files and
run shell commands — installed natively for Claude Code, GitHub Copilot CLI /
Microsoft Scout, and Google Antigravity via install.sh; usable from Cursor,
Codex, Windsurf, and others as a plain-markdown runbook.
Toolchain first demonstrated by Idan Shimon (Microsoft,
github.com/idanshimon) — his 58-second concept explainer, produced by an AI
agent driving this stack locally at $0, is what this skill was worked up
from.
Do not assume an install location: set PROMO_VIDEO_SKILL_DIR to the
directory containing this SKILL.md. Its scripts/ directory has the TTS
script, its acronym-pronunciation helper, and pinned TTS requirements;
assets/ has the composition skeleton,
spatial component templates, and decision-log schema; references/ has the
once-per-machine toolchain bootstrap (install.md).
Core principle: the script is locked first; every downstream number is DERIVED from measured narration audio, never guessed.
Promo, not explainer: this skill sells the WHY — audience, pain,
transformation, proof, one CTA. Mechanism ("how it works") gets at most one
beat, and only when the user directs it; requests to teach how something
works belong to the companion explainer-video skill.
Tool profiles
| Tool | Location | Version proven | License | Role | Invocation |
|---|---|---|---|---|---|
| OpenMontage | ~/OpenMontage | 2026-07 checkout | AGPLv3 (tooling only; nothing ships in the video) | Pipeline governance: stage order, decision log, gates, post-render self-review discipline | Read its AGENT_GUIDE.md + pipeline_defs/animation.yaml; you (the agent) are the orchestrator — no daemon runs |
| Kokoro | PyPI | Kokoro-82M (hexgrad), kokoro==0.9.4 | Code Apache-2.0; model weights Apache-2.0 | Narration TTS, 24 kHz mono WAV per scene | Python 3.12 venv; KPipeline(lang_code="a"), voice af_heart, speed param |
| HyperFrames | ~/hyperframes | 0.7.57, built from source | Apache 2.0 | HTML/CSS/GSAP composition → deterministic MP4 via headless Chrome | node ~/hyperframes/packages/cli/dist/cli.js <cmd> |
| FFmpeg | ~/FFmpeg source; binary in a build dir | git master (N-125608 era) | GPL build (libx264 enabled) | Music synthesis (aevalsrc), encode, ffprobe QA, loudness, platform packaging | Put build dir on PATH before hyperframes commands |
| bun | npm-global | 1.3.14 | — | Builds the hyperframes monorepo | npm i -g bun (brew cask silently failed once) |
| Node.js | nvm | 24.x (≥22 required) | — | Runs hyperframes CLI | — |
| Python | uv-managed | 3.12 | — | Kokoro inference | ALWAYS a venv (uv venv --python 3.12 .venv); black + pylint clean |
| espeak-ng | Homebrew or apt | any | GPL | Kokoro G2P fallback (required) | macOS: brew install espeak-ng; Debian/Ubuntu: sudo apt install espeak-ng |
| gsap | vendored file | 3.x gsap.min.js | GSAP standard license | Timeline animation inside the composition | Copy into video/assets/ — NEVER a CDN <script src> (offline determinism) |
Environment check (once per machine)
export FFMPEG_BUILD_DIR="${FFMPEG_BUILD_DIR:-$HOME/ffbuild}"
export HYPERFRAMES_DIR="${HYPERFRAMES_DIR:-$HOME/hyperframes}"
export PATH="$FFMPEG_BUILD_DIR:$PATH"
node "$HYPERFRAMES_DIR/packages/cli/dist/cli.js" doctor
FFmpeg, FFprobe, Node, and Chrome must all pass before production (other
doctor rows are optional components this skill does not use). If any of
the four fail, follow $PROMO_VIDEO_SKILL_DIR/references/install.md — the
full once-per-machine bootstrap: system packages (macOS/Linux), cloning the
source tools (including the OpenMontage reference checkout), source-building
FFmpeg with libx264, building the HyperFrames CLI, and agent permission
tips. Do not load that file when doctor already passes.
Chrome: the CLI auto-uses a cached chrome-headless-shell under
~/.cache/puppeteer/ (downloads once if absent). The first TTS run likewise
downloads Python packages and Kokoro-82M weights. After those one-time
downloads, synthesis, validation, rendering, and packaging have an offline
path.
Project layout (create per video, own git repo, atomic commits)
<project>/
.venv/ # uv venv, python 3.12
.gitignore # .venv/ __pycache__/ video/snapshots/ .DS_Store
README.md # how it was made + reproduction (write LAST)
production/
research/research-brief.md # facts w/ sources + honest competitor table
proposal.md # concept, scene list, delivery promise, runtime decision
script/script.md # LOCKED narration
scene_plan/scene-plan.md # per-scene visuals + motion notes
checkpoints/decision-log.json # append-only; (category, subject) is the key
checkpoints/self-review.md # post-render evidence
assets/audio/ # kokoro WAVs + durations.json
renders/ # master + platform derivatives
youtube/ # metadata.md + thumbnail.jpg
tools/tts_generate.py # copy from this skill, edit SCENE_NARRATIONS
tools/tts_pronounce.py # from scripts/, acronym pronunciation (do not edit)
tools/pronunciation.local.json # optional project lexicon overlay
video/
index.html # THE composition
assets/gsap.min.js # vendored
assets/audio/ # copies of the WAVs (composition-relative paths)
Stage 1 — Research (offline-first)
Mine the subject's own repo/README/docs — hunting for outcomes and evidence
(numbers, adoption, before/after results), not architecture. Produce
research-brief.md with:
- Positioning block (required — the script cannot lock without it). One sentence each: audience (who this is for), pain (what they feel today), transformation (what changes for them), differentiator (the one reason to pick this). This is the message; every downstream stage serves it.
- Product facts, each verified against source — kept only if they support a benefit (the "so what?" test: what does the viewer get?).
- A competitor table (weakness → how subject addresses it). Honesty rule: concede real points to competitors (a ✓ in their column buys credibility for yours).
- A "claims cleared for the script" list.
Stage 2 — Proposal + decisions
Write proposal.md: restate the positioning block, 5–7 scene concept,
target ≈60s, delivery promise ("motion-led, not a slideshow"), toolchain
table. Log to decision-log.json — start from the empty schema at
$PROMO_VIDEO_SKILL_DIR/assets/decision-log.json
(fields: category, subject, selected, options_considered,
rejected_because, confidence): pipeline_selection,
render_runtime_selection (record Remotion as the considered alternative —
OpenMontage hard rule), composition_mode (atelier for hero work),
voice_selection, music_selection, output_profile, approval_gates, and
cta_selection — exactly ONE call to action (star the repo / install /
watch the demo / visit the site); promos die from ending on a feature list
instead of an ask. If the
user's brief fixed a choice, mark approved_by: "user brief". Changed a
decision later? APPEND a new entry with the same (category, subject) — never
edit the old one.
Stage 3 — Script (the lock point)
~150–155 words ≈ 60s at kokoro af_heart speed 1.1. Promo arc (sell the
why): stakes/pain hook → reveal (name + the transformation it promises) →
benefits in the viewer's world → proof (honest comparison, real outcomes) →
aspiration → the single CTA from the decision log. Features appear only as
evidence for a benefit: every sentence must pass the "so what?" test (what
does the viewer get?) or be rewritten. At most ONE how-it-works beat, and
only when the user directs it — teaching mechanism is the explainer-video
skill's job. Voice: second person, present tense, short punchy sentences
("you ship faster", not "it enables faster shipping"). Write acronyms
naturally ("OID", "MCP", "AI") in both the script and on-screen text — at
synthesis time tools/tts_pronounce.py derives the spoken form (word vs
letter-by-letter) from its lexicon, and unknown acronyms are spelled letter
by letter and reported. Classify domain terms in an optional
tools/pronunciation.local.json ({"NASA": "word", "ECG": "letters", "NGINX": "engine X"}). Avoid ALL-CAPS emphasis: an all-caps word is
treated as an acronym. Word budget per scene ≈ its share of 60s ×
2.55 words/s.
Stage 4 — Narration + timing derivation (the load-bearing trick)
Create the project's Python 3.12 environment from the shipped, pinned direct
dependencies, copy the generic template, and edit SCENE_NARRATIONS only:
uv venv --clear --python 3.12 .venv
uv pip install --python .venv/bin/python \
-r "$PROMO_VIDEO_SKILL_DIR/scripts/tts-requirements.txt"
mkdir -p tools
cp "$PROMO_VIDEO_SKILL_DIR/scripts/tts_generate.py" tools/tts_generate.py
cp "$PROMO_VIDEO_SKILL_DIR/scripts/tts_pronounce.py" tools/tts_pronounce.py
PYTORCH_ENABLE_MPS_FALLBACK=1 .venv/bin/python tools/tts_generate.py
mkdir -p video/assets
curl -fL https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js \
-o video/assets/gsap.min.js
The first synthesis downloads Kokoro-82M weights (~330 MB); later runs use
the cache and can be offline. It writes per-scene WAVs + durations.json.
If the run prints an unknown-acronym WARNING, classify those tokens in
tools/pronunciation.local.json and re-run before deriving timing.
Treat 150–155 words at speed 1.1 as a starting estimate, not a calibration. Total >62s? Bump speed or cut words, then measure again.
Derive the timeline (pad ≈0.3–0.5s between scenes):
n_start[0] = 0.5 # lead-in
scene_start[i] = n_start[i] - 0.15..0.4 # visuals land just before VO
n_start[i+1] = n_start[i] + dur[i] + 0.3..0.35
TOTAL = last n_end + 0.6..0.9 tail (fade to black)
These numbers go in THREE places that must agree: scene data-start/
data-duration, <audio data-start> values, and the JS const S1..Sn
timeline constants. Copy WAVs to video/assets/audio/.
Stage 5 — Music bed (no downloads)
Generate a deterministic synthesized pad with the source-built FFmpeg. Set
TOTAL_SECONDS to the derived composition total; no external sample or
private reference is needed:
TOTAL_SECONDS=60.5
FADE_SECONDS=3.5
FFMPEG_BIN="${FFMPEG_BUILD_DIR:-$HOME/ffbuild}/ffmpeg"
FADE_OUT_START="$(awk -v total="$TOTAL_SECONDS" -v fade="$FADE_SECONDS" \
'BEGIN { printf "%.3f", total - fade }')"
mkdir -p production/assets/audio
"$FFMPEG_BIN" -hide_banner -y \
-f lavfi \
-i "aevalsrc=0.07*(sin(2*PI*110*t)+0.65*sin(2*PI*110.7*t)+0.45*sin(2*PI*164.81*t)+0.30*sin(2*PI*220.9*t)):s=48000:d=${TOTAL_SECONDS}" \
-af "highpass=f=55,lowpass=f=1200,afade=t=in:st=0:d=2,afade=t=out:st=${FADE_OUT_START}:d=${FADE_SECONDS},pan=stereo|c0=c0|c1=c0" \
-c:a pcm_s16le production/assets/audio/music-bed.wav
Keep it sparse; mixed at data-volume="0.12..0.16" it reads as atmosphere
— for promo energy sit toward the top of that range, never above narration.
Copy the WAV into video/assets/audio/ and use a distinct audio track.
Stage 6 — Composition authoring (HyperFrames contract)
Start from $PROMO_VIDEO_SKILL_DIR/assets/composition-skeleton.html (it
embodies every rule below); before writing HTML, skim
$HYPERFRAMES_DIR/skills/hyperframes-core/SKILL.md.
Structure: standalone index.html, root
<div data-composition-id="X" data-start="0" data-duration="TOTAL" data-width="1920" data-height="1080">, scenes as
<section class="clip" data-start data-duration data-track-index="1">,
ONE gsap.timeline({paused:true}) registered synchronously at
window.__timelines["X"], tween positions = S<i> + local_offset.
Rules that cost real debugging time — obey up front:
- Every
<video>/<audio>needs an explicit, non-emptyid. In HyperFrames, audio without one may play in a browser preview but renders SILENT (lint errors this; do not ignore). - Never put an initial
transform:in CSS on a GSAP-tweened element — usetl.fromTo(el, {y:24, opacity:0}, {y:0, opacity:1, ...}); GSAP owns the full transform. (opacity: 0alone in CSS is fine for.to()-only reveals.) - Background fill goes on a full-bleed CHILD (
position:absolute; inset:0), never on the root (root background can render black). - Media elements are direct children of the root,
data-track-indexon distinct audio tracks (e.g. 7=VO, 8=music). - No infinite repeats (
repeat: -1banned — use finite, e.g. cursor blinkrepeat: 9, yoyo: true), noDate.now/Math.random, no network fetches, no CDN scripts. - Unique
ideverywhere; animate only transforms/opacity (+strokeDashoffsetfor SVG line draws). - Elements appearing mid-scene: hidden initial state + tween at
S<i>+offset.fromTohasimmediateRenderso pre-tween seeks show the from state — that's what makes it seek-safe. - Design tokens in
:rootCSS vars = the theme; swap ground/ink/accent + type stack to re-skin. Use a font the renderer bundles (JetBrains Mono) — system fonts get substituted anyway (lint info tells you the mapping). - Keep secondary text ≥ WCAG AA on the ground color —
checkFAILS builds on contrast and prints the compliant color to use. - Intentional overlaps/scroll-unders: mark with
data-layout-allow-overflow/-overlap/-occlusioninstead of leaving findings unexplained.
Scene motif advice: show the OUTCOME state — the viewer's world improved
(before/after contrast, the pain gone, the result on screen). Pull
supporting glimpses from the subject's actual UI/domain (fake search
palette, terminal, tree, table) as evidence for a benefit, not a mechanism
tour. The narrative arc templates; the motifs must not.
$PROMO_VIDEO_SKILL_DIR/assets/spatial-components.html has promo-beat
layout templates (before/after split, proof callout, comparison row, CTA
end-card) — copy the spatial HTML/CSS only; timing always comes from
measured audio, never from a template.
Stage 7 — Validation ladder (in this order, cheap→expensive)
export PATH="${FFMPEG_BUILD_DIR:-$HOME/ffbuild}:$PATH"; cd video
# A function, not a string variable: `CLI="node …"; $CLI lint` breaks under
# zsh (no word-splitting). This form works in bash and zsh alike:
cli() { node "${HYPERFRAMES_DIR:-$HOME/hyperframes}/packages/cli/dist/cli.js" "$@"; }
cli lint # static; fix ALL errors (warnings: judgement)
cli check # browser gate: runtime errors, layout, motion, contrast — 0 errors required
cli snapshot --at <midpoint of every scene> # then READ each PNG and eyeball
Snapshot review checklist: content present & styled (not tiny top-left = broken CSS), nothing clipped/overflowing, text legible, scene matches plan. Fix → re-check → re-snapshot until boring.
Stage 8 — Render + post-render self-review (OpenMontage gate)
cli render --quality high --output ../production/renders/<name>-v1.mp4
(~30s for 63s @1080p30 on an M4.) Then self-review — never present unreviewed:
ffprobe -v error -show_entries format=duration,size,bit_rate \
-show_entries stream=codec_name,width,height,r_frame_rate <out>.mp4
for t in 3 <mid1> <mid2> 58; do ffmpeg -ss $t -i <out>.mp4 -frames:v 1 qa-$t.png; done
# READ the frames
ffmpeg -i <out>.mp4 -af volumedetect -f null - 2>&1 | grep -E 'mean|max'
Pass bar: duration ±0.1s of plan; no black/broken frames; max_volume < 0 dB;
narration audibly above bed (mean ≈ −25..−28 dB pre-normalization is normal).
Write checkpoints/self-review.md with the evidence. This completes the
offline review path.
Optional HyperFrames feedback (network, explicit opt-in only)
HyperFrames feedback sends telemetry over the network. Skip it by default and always skip it for an offline run. Only with the user's explicit permission:
cli feedback --rating <1-5> --comment "<specifics>"
Stage 9 — Platform packaging (derivatives, never new renders)
YouTube: the master's video stream is already ideal (H.264 High, yuv420p, bt709) — stream-copy it; fix only audio + packaging. YouTube normalizes to −14 LUFS and only turns DOWN, so a quiet master plays quiet forever:
# 1) measure
ffmpeg -i master.mp4 -af loudnorm=I=-14:TP=-1.0:LRA=11:print_format=summary -f null -
# 2) apply (two-pass, plug measured_* in), copy video, faststart
ffmpeg -i master.mp4 -c:v copy \
-af "loudnorm=I=-14:TP=-1.0:LRA=11:measured_I=<I>:measured_TP=<TP>:measured_LRA=<LRA>:measured_thresh=<T>:linear=true" \
-c:a aac -b:a 384k -ar 48000 -movflags +faststart out-youtube.mp4
# 3) verify: re-measure → must read −14.0 / −1.0
Thumbnail: ffmpeg -ss <best moment> -i master.mp4 -frames:v 1 -q:v 2 thumbnail.jpg (wordmark/title frame; must read at 320px wide). Write
production/youtube/metadata.md: title ≤70 chars keyword-front-loaded,
description with feature bullets + chapter timestamps (scene starts,
M:SS), tags, and a disclosure-policy checklist. Generic TTS narration does
not automatically require a "yes." Disclose realistic altered or synthetic
content that viewers could mistake for a real person, place, scene, or event,
including misleading impersonation, and apply YouTube's current policy to the
finished video:
https://support.google.com/youtube/answer/14328491. A 9:16/Shorts cut is a
new composition with new dimensions and layout, using the same audio and
machinery.
Failure modes already paid for
| Symptom | Cause → fix |
|---|---|
render: Unrecognized option 'preset' | ffmpeg built without libx264 → rebuild --enable-gpl --enable-libx264 |
packages/cli build: cannot resolve @hyperframes/core | built in isolation → root bun run build |
brew bun "succeeds", bun not found | broken keg → npm i -g bun |
| Audio silent in render, preview fine | <audio> missing id |
| Element visible before its entrance when scrubbing | CSS transform + .to() → fromTo |
| Text tiny/unstyled top-left in snapshot | (sub-comps) <style> outside <template> |
| Frame black though preview fine | background on composition root → full-bleed child |
| Upload plays quiet on YouTube | master < −14 LUFS → loudnorm derivative |
Killed: 9 mid-pipe under bun install | transient; re-run |
bun run build exits non-zero | optional sdk-playground package fails on Node 22 — harmless; test -f packages/cli/dist/cli.js is the real gate |
| TTS logs "No package installer found" (spaCy) | harmless warning; synthesis proceeds |
Non-negotiables
- Locked positioning before the script; locked script before any asset. Measured durations before any timing.
- Why before how: every scripted sentence passes the "so what?" test; mechanism gets at most one beat, and only when the user directs it.
- Atomic conventional commits per stage; venv for all Python; black+pylint.
- $0 cloud generation. Core production can be offline after disclosed one-time tool, dependency, browser, and model downloads; external research, upload, and explicitly opted-in feedback are separate network actions.
- Truthful claims only, sourced in the research brief; fictional pastiches must not name real vendors.
- New repo per video; private unless told otherwise; end commits with the Assisted-by / Co-Authored-By trailers.
- Before the video ships on a project's official channels, the project owner signs off.