Add line-caption generator for raw portrait shorts#91
Conversation
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Reconstructs whisper's BPE-level tokens into whole words before bucketing into fixed-size caption lines, so a line never splits a word or counts punctuation as its own slot. Lines never span a segment boundary, which keeps two speakers from ever sharing a line. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Orchestrates the raw-portrait-video-to-caption-lines pipeline: copy source, extract audio, transcribe, force-align for exact word boundaries, diarize and assign speakers when --num-speakers > 1, then chunk into 3-word lines and write lines.json + a human-editable lines.doc.txt. Reuses the existing Transcriber class and align/diarize CLI scripts unmodified. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Re-parses lines.doc.txt after a human reword pass and overwrites only the text field of each matching lines.json entry by [id] — startMs/endMs/speaker are left untouched, so the caption keeps its original best-effort time window regardless of how much the wording changed. Co-Authored-By: Claude Sonnet 5 <[email protected]>
A plain video pass-through (no jump cuts, no camera profiles) plus
LineCaptionOverlay, which shows fixed-window 3-word caption lines instead
of the timing-driven grouping CaptionOverlay uses. Registers one
LineCaptionClip-{id} composition per public/line-captions/*/lines.json,
mirroring the existing ShortFormClip-{id} pattern in Root.tsx.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Modeled on scripts/shorts/render-short.js — spawns remotion render against
the LineCaptionClip-{id} composition and writes to public/renders/.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Expected strings were hardcoded with POSIX forward slashes, so every test failed on Windows where path.join produces backslashes. Build expectations with path.join instead of literal strings — the production path.ts code was already correct, only the test's assertions were platform-specific. Co-Authored-By: Claude Sonnet 5 <[email protected]>
A vendored, gitignored Python virtualenv (.venv/Lib/site-packages/PIL/
ImageFont.py) contains a base64 font-glyph blob that coincidentally matches
the sk-[A-Za-z0-9]{48} pattern, false-positiving as a leaked key and
blocking every push on machines with a local .venv present. Exclude it
the same way node_modules and .next already are.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
There was a problem hiding this comment.
Manual end-to-end test on Windows — 3 render fixes
Nice work on this pipeline. I pulled the branch and ran the unchecked manual test-plan box end-to-end on Windows 11 (Node 24, Python 3.12 venv, whisper.cpp medium.en, whisperx, diarize) against a real 52 s two-speaker portrait clip.
Works great: captions:create (transcribe → align → diarize → chunk) is solid — diarization found 2 real speakers, lines.doc.txt is correctly chunked with no speaker-mixing, and captions:merge changed only the edited line's text across all 73 lines (timings/speaker preserved).
Three issues blocked captions:render; all three are fixed on my branch ytexplorer:feature/line-captions (full jest suite + lint + audit pass), and I've left inline suggestions below for the two that live in this file:
--propsinline JSON breaks on Windows — withspawn(shell:true), cmd.exe strips the quotes and Remotion gets unparseable JSON, aborting before bundling. → props via temp file (inline suggestion).--outNameisn't honored byremotion render— combined withConfig.setOutputLocation('public/renders')the file lands atpublic/renders.mp4instead ofpublic/renders/<id>.mp4. → positional output path +ensureDir(inline suggestion).- Remotion version mismatch (lockfile) —
remotion/@remotion/gif/captions/sfx/install-whisper-cpp/eslint-pluginare pinned at4.0.451while@remotion/cli/bundler/renderer/media-parserresolve to4.0.477. Remotion requires the whole family on a single version, so this split lockfile failsLineCaptionCliprenders (the coreremotionat 4.0.451 vs the 4.0.477 tooling is the main gap). Can't suggest inline (it's the lockfile), but it's ready to cherry-pick:ytexplorer/deckcreate@6b9da81— realigns the whole family to4.0.477.
| * node scripts/line-captions/render-line-captions.js --id <slug> | ||
| */ | ||
|
|
||
| import fs from 'fs-extra'; |
There was a problem hiding this comment.
Need os for the temp props file below.
| import fs from 'fs-extra'; | |
| import fs from 'fs-extra'; | |
| import os from 'os'; |
| const outName = `${args.id}.mp4`; | ||
| console.log(`Rendering line-caption clip: ${args.id}`); | ||
| console.log(`Output file: ${outName}`); | ||
|
|
||
| const renderArgs = [ | ||
| 'remotion', 'render', | ||
| 'remotion/index.ts', | ||
| `LineCaptionClip-${args.id}`, | ||
| '--outName', outName, | ||
| '--props', JSON.stringify({ | ||
| linesSrc: `line-captions/${args.id}/lines.json`, | ||
| brandSrc: 'brand.json', | ||
| }), | ||
| ]; |
There was a problem hiding this comment.
Write props to a temp file (Windows quote-safe) and pass the full output path positionally so it lands in public/renders/<id>.mp4.
| const outName = `${args.id}.mp4`; | |
| console.log(`Rendering line-caption clip: ${args.id}`); | |
| console.log(`Output file: ${outName}`); | |
| const renderArgs = [ | |
| 'remotion', 'render', | |
| 'remotion/index.ts', | |
| `LineCaptionClip-${args.id}`, | |
| '--outName', outName, | |
| '--props', JSON.stringify({ | |
| linesSrc: `line-captions/${args.id}/lines.json`, | |
| brandSrc: 'brand.json', | |
| }), | |
| ]; | |
| const outName = `${args.id}.mp4`; | |
| console.log(`Rendering line-caption clip: ${args.id}`); | |
| console.log(`Output file: ${outName}`); | |
| // Ensure the output directory exists so the positional output path below has | |
| // a parent to write into on a fresh checkout. | |
| await fs.ensureDir(path.join(cwd, 'public', 'renders')); | |
| // Pass props via a temp file rather than inline. Inline `--props '<json>'` | |
| // has its double quotes stripped by cmd.exe (spawn shell:true on win32), so | |
| // Remotion receives unparseable JSON and aborts before bundling. | |
| const propsFile = path.join(os.tmpdir(), `line-caption-props-${args.id}-${Date.now()}.json`); | |
| await fs.writeJson(propsFile, { | |
| linesSrc: `line-captions/${args.id}/lines.json`, | |
| brandSrc: 'brand.json', | |
| }); | |
| const renderArgs = [ | |
| 'remotion', 'render', | |
| 'remotion/index.ts', | |
| `LineCaptionClip-${args.id}`, | |
| // Full output path positionally. `--outName` is not honored by | |
| // `remotion render`; with Config.setOutputLocation('public/renders') it | |
| // writes public/renders.mp4 instead of public/renders/<id>.mp4. | |
| `public/renders/${outName}`, | |
| `--props=${propsFile}`, | |
| ]; |
| await new Promise((resolve, reject) => { | ||
| const proc = spawn('npx', renderArgs, { | ||
| stdio: 'inherit', | ||
| cwd, | ||
| shell: process.platform === 'win32', | ||
| }); | ||
| proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Render exited ${code}`))); | ||
| proc.on('error', e => reject(e)); | ||
| }); |
There was a problem hiding this comment.
Wrap the render in try/finally so the temp props file is cleaned up.
| await new Promise((resolve, reject) => { | |
| const proc = spawn('npx', renderArgs, { | |
| stdio: 'inherit', | |
| cwd, | |
| shell: process.platform === 'win32', | |
| }); | |
| proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Render exited ${code}`))); | |
| proc.on('error', e => reject(e)); | |
| }); | |
| try { | |
| await new Promise((resolve, reject) => { | |
| const proc = spawn('npx', renderArgs, { | |
| stdio: 'inherit', | |
| cwd, | |
| shell: process.platform === 'win32', | |
| }); | |
| proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Render exited ${code}`))); | |
| proc.on('error', e => reject(e)); | |
| }); | |
| } finally { | |
| await fs.remove(propsFile).catch(() => {}); | |
| } |
Summary
Adds a standalone pipeline that takes a raw short-form portrait video and produces a burned-in-caption render, independent of the existing longform/shorts camera+hook pipeline:
npm run captions:create -- --video <path> [--num-speakers N]— copies the source video, extracts audio, transcribes (Transcriber), force-aligns for exact word boundaries, diarizes + assigns speakers when--num-speakers > 1, chunks the transcript into fixed 3-wordCaptionLines (merging whisper's BPE sub-word tokens back into whole words so a line never splits mid-word, and never spanning a segment/speaker boundary so a line never mixes two speakers), and writespublic/line-captions/{id}/lines.json+ a human-editablelines.doc.txt.npm run captions:merge -- --id <slug>— reparseslines.doc.txtafter a human reword pass and overwrites only thetextfield per[id];startMs/endMs/speakerstay untouched (best-effort timing, no re-alignment).npm run captions:render -- --id <slug>— renders the newLineCaptionClip-{id}Remotion composition (plain video pass-through, no jump cuts/camera profiles, plus the newLineCaptionOverlay) topublic/renders/.Reuses existing primitives unmodified:
Transcriber,align-transcript.js,diarize-audio.js/assign-speakers.js, theSHORT_IDSper-clipCompositionregistration pattern inRoot.tsx, andstampMetadata.Also includes two unrelated pre-existing fixes discovered while getting this branch to push cleanly:
scripts/config/paths.test.tshardcoded POSIX path separators, failing all 26 of its assertions on Windows. Rewritten to build expectations withpath.join(test-only change;paths.tsitself was already correct)..husky/pre-push's secret-token scan wasn't excluding.venv/, so a base64 font-glyph blob in a vendoredPILfile false-positived as a leaked API key and blocked every push on machines with a local.venv. Added--exclude-dir=.venvalongside the existingnode_modules/.nextexclusions.Full design rationale and the step-by-step build log live in
docs/implementation-guides/LINE_CAPTION_SHORTS.md. Newlines.jsonschema and source files are documented inCLAUDE.md.Test plan
npm run test:unit— full suite passes (280 passed, 2 pre-existing skips, 0 failed), including new unit tests:chunkLines.test.js(BPE-word reconstruction, punctuation attach, speaker-boundary breaking, cut exclusion,t_endfallback),create-line-captions.test.js(buildLineDocformatting),merge-line-captions.test.js(doc parsing + text-only merge),LineCaptionOverlay.test.tsx(active-line selection, speaker tinting).tsc --noEmit— passes.npm run lint— passes on all new/changed files..only()/secret scans, npm audit, runtime-dir gitignore check) — passes clean.npm run captions:create -- --video <sample.mp4> --num-speakers 2on a short real two-speaker portrait clip.public/line-captions/{id}/lines.doc.txt— confirm 3-word lines read naturally, grouped under=== SPEAKER ===headers, and no line mixes two speakers.npm run captions:merge -- --id {id}, confirm only that line'stextchanged inlines.jsonand itsstartMs/endMsare untouched.npx remotion studio, selectLineCaptionClip-{id}, confirm captions are burned in, timed correctly against the audio, and the edited line shows its new wording during its original time window.npm run captions:render -- --id {id}, confirmpublic/renders/{id}.mp4is produced.🤖 Generated with Claude Code