Purpose
Music-video ads need captions that feel like the song — short bursts, hits on the vocal, mid-frame placement, mixed-weight typography. This atom is the bridge between a rendered song (with timing data) and a burned-in caption pass.
It is a thin composition wrapper. It does NOT re-implement ASS generation or ffmpeg burning — it normalizes word timestamps to the schema the existing add-captions-burn/make_klap_style_subs.py script consumes, then invokes that script with a music-video preset.
If the song was produced by create-music-elevenlabs --with-timestamps, the timestamps come straight from music_metadata.json. If timestamps are missing or unreliable (a known risk for fast rap lines), the atom can fall back to running Whisper on the rendered MP3.
When to use
- After the song is approved at the MUSIC GENERATE state of
molecules/music-video-ad. - Re-burning captions after a song revision (re-runs are cheap; the song is the expensive part).
- Standalone: any time you have a vocal track + lyrics and want fast-paced word-burst captions in the music-video style.
Do NOT use for:
- VO ads with a deadpan narrator — those want bottom-third Klap karaoke; use
add-captions-burndirectly. - iMessage chat or motion-graphic ads — they have their own caption systems.
Inputs
--video <path>— the video to burn into (required). Typicallyedits/master-final.mp4BEFORE the captions pass.--lyrics <path>— lyric file with the canonical sung lines (required). Drives spelling and accent-word detection.- Prefer
lyrics-plain.md— a clean text file with one sung line per row, no analysis prose, no markdown tables/headings/lists, no inline-code backticks. - If you pass
lyrics-locked.mdand a siblinglyrics-plain.mdexists, the script auto-prefers the plain twin. - Why this matters:
lyrics-locked.mdoften becomes a project README with structure notes, accent-word reference sections, and timing tables. The parser tries to filter those out (skips|tables,#headings,>blockquotes, code-fenced blocks), but inline-code backticks (`word`) used to mark accent words bled into the captions on the DIBS run — burning literal backticks into every accented caption. The current parser strips backticks/bold-asterisks/italic-underscores defensively (since 2026-05-23), but the cleanest contract is: lyrics-plain.md = only the sung text.
- Prefer
--timestamps <path>— one of:- ElevenLabs
music_metadata.json(preferred, free with--with-timestamps) - Whisper word-level
word-timestamps.json({"words":[{"text","start","end"}]}) - Omit and pass
--whisper-fallbackto transcribe the song's audio track via Groq Whisper.
- ElevenLabs
--output <path>— destination video (required).--accent-words <comma-list>— words to render in bold-italic for emphasis. Defaults: brand name, product name, climax-line keywords if present in lyrics frontmatter.--chunk-size <int>— max words per caption chunk (default 3).--placement <mid|low|high>— vertical placement (defaultmidfor music-video,lowfor VO-style).--font <name>— defaultNew York(system serif on macOS) for music-video; falls back toGeorgia.--font-size <int>— default64at 1080×1920 (scaled to PlayResY).--style-preset <name>—music-video(default; serif, mid-frame, mixed weights) orklap-karaoke(Inter Bold ALL CAPS, bottom-third, yellow active-word).--preview-strip <path>— optional output PNG showing chunked timeline vs waveform.
Workflow
-
Resolve timestamps.
- If
--timestampspoints at ElevenLabsmusic_metadata.json, walk the JSON and extract every{text, start, end}tuple. Schema isn't formally documented by ElevenLabs across versions — the script tries common shapes (metadata.timestamps,timestamps,alignment.words,words) and fails loudly if none match. - If it points at a Whisper-shaped file with
{"words":[...]}, pass through. - If
--whisper-fallbackis set and no usable timestamps were extracted, extract audio from the video, upload to Groq Whisper (reuse thewatchskill's whisper.py wrapper), and writeword-timestamps.jsonalongside the output.
- If
-
Align timestamps to lyrics. ElevenLabs and Whisper both occasionally emit transcription artifacts (mishears, "uh"s, joined contractions). Diff against
lyrics-locked.mdand:- Drop tokens that don't appear in the lyrics.
- Re-spell tokens to match the locked lyric spelling (so brand names render correctly).
- Flag any line where word-count drift is > 20% — these need manual review before the burn.
-
Chunk into bursts. Group words into chunks of 1–
chunk-size(default 3) such that:- A chunk never crosses a
,,.,?,!, or end-of-line in the lyrics. - A chunk never crosses an inter-word gap > 600 ms (silence break → start a new chunk).
- Accent words get their own chunk OR are placed last in the chunk so they hit on the strong beat.
- A chunk never crosses a
-
Render ASS. Call
make_klap_style_subs.pywith--style-presettranslated into its--font,--size,--marginvknobs. Formusic-video:- Font:
New York64pt, primary white, no yellow karaoke highlight (still words, not karaoke). - Accent words:
{\b1\i1}WORD{\b0\i0}inline. - Placement:
MarginV≈ PlayResY × 0.50 (mid-frame),Alignment: 5(centered). - Display window:
chunk_first_word.start − 30 ms→chunk_last_word.end + 80 ms, clipped to never overlap the next chunk.
- Font:
-
Burn.
make_klap_style_subs.pyalready handles ffmpeg burning via-vf subtitles=. Keep audio stream copy. -
Preview strip (optional). If
--preview-stripis set, render a horizontal PNG: x-axis = song timeline, top row = ffmpeg-extracted waveform, bottom row = colored rectangles per chunk with the chunk text. This is the artifact the storyboard embeds for human review.
Output
<output>— captioned video (mp4, H.264, audio copied).<output>.ass— the generated subtitle file (kept for re-burn/inspect).chunks.json—[{start, end, text, accent: bool, words: [...]}]— feeds the storyboard.html preview.preview-strip.png(if requested) — waveform + chunk overlay.manifest.json:
{
"status": "ok",
"video_in": "...",
"video_out": "...",
"lyrics": "...",
"timestamps_source": "elevenlabs|whisper-passthrough|whisper-fallback",
"chunks": 38,
"accent_words": ["Gum", "Gods", "date", "bay"],
"warnings": []
}
verification.md— chunk count, longest chunk duration, longest inter-chunk gap, max snap shift.
Quality checks
- Every locked lyric word appears in at least one chunk.
- No chunk shorter than 180 ms (illegible flash) — merge or extend.
- No chunk longer than 1.4 s (camped on too long for music-video pacing) — split.
- Chunk start times within ±100 ms of the matching word's vocal onset.
- No chunk overlap with the next chunk's start.
- Accent words actually render with
\b1\i1inline tag.
Failure modes
- ElevenLabs metadata schema unrecognized → fail with the schema seen, hint to use
--whisper-fallback. - Whisper transcript diverges materially from locked lyrics (> 20% word drift) → emit
warnings[]and stop; operator should re-listen and decide whether the song is mumbling or the lyrics file is stale. - Font missing → fall back to
Georgia; record in warnings. - Accent words never appear in the lyrics — likely a frontmatter typo; surface the mismatched word list before burning.
- Subtitle line wraps to two lines on narrow frames — drop
chunk_sizeto 2.
Composition
- Reuses
atoms/captions/add-captions-burn/scripts/make_klap_style_subs.pyfor ASS+ffmpeg burn (does not re-implement). - Reuses
atoms/captions/retime-captions-to-wordsalgorithmically (the same snap-within-tolerance idea), but operates directly on word-level data, so we don't go through SRT. - Whisper fallback reuses the transcription path from the
watchskill (scripts/whisper.py).
Example
python3 scripts/sync.py \
--video peloton-ads/video-NN-gum-of-gods/edits/master-final.mp4 \
--lyrics peloton-ads/video-NN-gum-of-gods/lyrics/lyrics-locked.md \
--timestamps peloton-ads/video-NN-gum-of-gods/audio/music_metadata.json \
--output peloton-ads/video-NN-gum-of-gods/edits/master-captioned.mp4 \
--accent-words "Gum,Gods,date,bay,below" \
--preview-strip peloton-ads/video-NN-gum-of-gods/captions/preview-strip.png
Quality Checks
Run these against the emitted chunks.json, manifest.json, verification.md, and the burned <output>.mp4 before a music-video ad is considered captioned. These are the binary gates a verifier (human or skills/verifiers/) checks; the "Quality checks" list above is the prose rationale.
- Coverage. Every locked lyric word from
--lyricsappears in at least one chunk inchunks.json.manifest.json.warnings[]is empty of "lyric word(s) never matched" entries (or each is explained). - No drift gate tripped.
manifest.json.warnings[]contains noWORD-COUNT DRIFTline. Drift > 20% between the timed stream and the locked lyrics is a hard stop — re-listen before burning. - Legibility floor. No chunk in
chunks.jsonis shorter than 0.18 s (thechunk_burstsmerge-forward already enforces this;verification.md's longest/shortest read confirms it). Sub-180 ms flashes are illegible at music-video pace. - Camp ceiling. No chunk longer than ~1.4 s.
verification.mdreports "longest chunk"; anything above 1.4 s means a burst is parked on screen and should be split (drop--chunk-sizeor check for a missing punctuation boundary). - Onset snap. Each chunk's
startsits within ±100 ms of the matching word's vocal onset (chunks are seeded atfirst_word.start − 30 ms). Eyeball against the--preview-stripwaveform: rectangles should hug the vocal transients. - No overlap. No chunk's
endruns past the next chunk'sstart(the clip-overlaps pass guarantees this; confirm visually that captions never stack). - Accent emphasis present. Every word in
--accent-wordsthat exists in the lyrics renders as{\b1\i1}WORD{\b0\i0}in<output>.ass, andmanifest.json.accent_wordslists exactly the supplied (and lyric-present) accents. - Placement + type. For the default
music-videopreset, captions are mid-frame (Alignment: 5,MarginV ≈ PlayResY × 0.50) in the serif font (New York, falling back toGeorgia), not bottom-third Klap karaoke. - No literal markdown burned. No caption contains a stray backtick,
**, or__(the DIBS regression). Iflyrics-plain.mdexists it must be the parse target —[sync]stderr says which file was read.
Failure Modes
How the atom fails, what the failure looks like, and the operator's move. Most surface through manifest.json.warnings[], the [sync] stderr lines, or a non-zero exit.
- ElevenLabs schema unrecognized.
load_timestampscan't find words in any known shape (alignment.words,timestamps,metadata.timestamps, character-level rollup) and raisesValueErrorlisting the top-level keys it saw. Fix: re-run with--whisper-fallback, or point--timestampsat a realmusic_metadata.json. - No timestamps at all. Neither
--timestampsnor--whisper-fallbackwas given → exitsno timestamps provided and --whisper-fallback not set. Supply one. - Whisper wrapper missing.
--whisper-fallbackset but thewatchskill'swhisper.pyisn't importable → exits with the import error. Fix: install the watch plugin or pass pre-computedword-timestamps.json. - Lyrics/audio diverge (mumble or stale lyrics). Word-count drift > 20% → a
WORD-COUNT DRIFTwarning lands inmanifest.jsonand the operator must decide whether the vocal is mumbling orlyrics-locked.mdis stale; do not ship the burn until resolved. - Lyric file is a README, not lyrics. A
lyrics-locked.mdpacked with timing tables / accent-word sections over-counts "lyric words" (the Coinbase 729-word incident).parse_lyricsfilters|tables,#headings,>blockquotes,[section]markers, and code fences — but the safe contract is a siblinglyrics-plain.md, which the script auto-prefers and announces on stderr. - Backticks/bold burned into captions. Accent words written as
`word`in the lyric file used to render literal backticks (the DIBS run). The parser now strips`,**, and__defensively; if any leak, switch tolyrics-plain.md. - Accent word never in lyrics. A typo'd
--accent-words(or frontmatteraccent_words) entry that doesn't match any lyric word silently won't emphasize. Cross-checkmanifest.json.accent_wordsagainst intent before the burn; a missing entry usually means a frontmatter typo. - Font missing on host.
New Yorkabsent → the burn falls back toGeorgia; expected on non-macOS hosts. Note it and confirm the burned frame still reads as a serif, not a sans/Times fallback. - Caption wraps to two lines on narrow frames. A 3-word chunk overflows a tall 9:16 frame → re-run with
--chunk-size 2. - ffmpeg burn fails. A bad input path or codec issue surfaces as a non-zero
ffmpegexit (the subprocess ischeck=True). Inspect the kept<output>.assand the ffmpeg stderr; the subtitle file is preserved for re-burn.