Communitygithub.com

mhuot/promo-video-skill

Fully offline ~60s narrated promo: Kokoro TTS + HyperFrames + FFmpeg + OpenMontage governance. Includes templates and a demo video the skill made about itself.

What is promo-video-skill?

promo-video-skill is a Claude Code agent skill that fully offline ~60s narrated promo: Kokoro TTS + HyperFrames + FFmpeg + OpenMontage governance. Includes templates and a demo video the skill made about itself.

Works with✓Claude Code✓Codex CLI✓Cursor✓Antigravity✓Windsurf
npx skills add mhuot/promo-video-skill

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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

ToolLocationVersion provenLicenseRoleInvocation
OpenMontage~/OpenMontage2026-07 checkoutAGPLv3 (tooling only; nothing ships in the video)Pipeline governance: stage order, decision log, gates, post-render self-review disciplineRead its AGENT_GUIDE.md + pipeline_defs/animation.yaml; you (the agent) are the orchestrator — no daemon runs
KokoroPyPIKokoro-82M (hexgrad), kokoro==0.9.4Code Apache-2.0; model weights Apache-2.0Narration TTS, 24 kHz mono WAV per scenePython 3.12 venv; KPipeline(lang_code="a"), voice af_heart, speed param
HyperFrames~/hyperframes0.7.57, built from sourceApache 2.0HTML/CSS/GSAP composition → deterministic MP4 via headless Chromenode ~/hyperframes/packages/cli/dist/cli.js <cmd>
FFmpeg~/FFmpeg source; binary in a build dirgit master (N-125608 era)GPL build (libx264 enabled)Music synthesis (aevalsrc), encode, ffprobe QA, loudness, platform packagingPut build dir on PATH before hyperframes commands
bunnpm-global1.3.14—Builds the hyperframes monoreponpm i -g bun (brew cask silently failed once)
Node.jsnvm24.x (≥22 required)—Runs hyperframes CLI—
Pythonuv-managed3.12—Kokoro inferenceALWAYS a venv (uv venv --python 3.12 .venv); black + pylint clean
espeak-ngHomebrew or aptanyGPLKokoro G2P fallback (required)macOS: brew install espeak-ng; Debian/Ubuntu: sudo apt install espeak-ng
gsapvendored file3.x gsap.min.jsGSAP standard licenseTimeline animation inside the compositionCopy 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:

  1. Every <video>/<audio> needs an explicit, non-empty id. In HyperFrames, audio without one may play in a browser preview but renders SILENT (lint errors this; do not ignore).
  2. Never put an initial transform: in CSS on a GSAP-tweened element — use tl.fromTo(el, {y:24, opacity:0}, {y:0, opacity:1, ...}); GSAP owns the full transform. (opacity: 0 alone in CSS is fine for .to()-only reveals.)
  3. Background fill goes on a full-bleed CHILD (position:absolute; inset:0), never on the root (root background can render black).
  4. Media elements are direct children of the root, data-track-index on distinct audio tracks (e.g. 7=VO, 8=music).
  5. No infinite repeats (repeat: -1 banned — use finite, e.g. cursor blink repeat: 9, yoyo: true), no Date.now/Math.random, no network fetches, no CDN scripts.
  6. Unique id everywhere; animate only transforms/opacity (+ strokeDashoffset for SVG line draws).
  7. Elements appearing mid-scene: hidden initial state + tween at S<i>+offset. fromTo has immediateRender so pre-tween seeks show the from state — that's what makes it seek-safe.
  8. Design tokens in :root CSS 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).
  9. Keep secondary text ≥ WCAG AA on the ground color — check FAILS builds on contrast and prints the compliant color to use.
  10. Intentional overlaps/scroll-unders: mark with data-layout-allow-overflow/-overlap/-occlusion instead 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

SymptomCause → fix
render: Unrecognized option 'preset'ffmpeg built without libx264 → rebuild --enable-gpl --enable-libx264
packages/cli build: cannot resolve @hyperframes/corebuilt in isolation → root bun run build
brew bun "succeeds", bun not foundbroken keg → npm i -g bun
Audio silent in render, preview fine<audio> missing id
Element visible before its entrance when scrubbingCSS transform + .to() → fromTo
Text tiny/unstyled top-left in snapshot(sub-comps) <style> outside <template>
Frame black though preview finebackground on composition root → full-bleed child
Upload plays quiet on YouTubemaster < −14 LUFS → loudnorm derivative
Killed: 9 mid-pipe under bun installtransient; re-run
bun run build exits non-zerooptional 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.

Related Skills