About vox-skill
vox-skill is a production skill for creating narrated documentary-style films with React and Remotion. It guides the process from script and voice timing through layered scenes, audio mastering, quality checks and rendering, with a vertical-video option.
Key features
- 01Plan narration as timed beats
- 02Generate voiceover and timing data
- 03Build layered animated scenes in React
- 04Create code-drawn visual assets
- 05Mix and master narration and music
- 06Render horizontal or vertical films
Use cases
- 01Produce a narrated documentary from a topic
- 02Synchronize scene changes to spoken words
- 03Create animated charts and document visuals
- 04Check scene layouts before a full render
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ jaredcassoutt/ vox-editorial --skill vox-skillAfter installing, type/vox-skillin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
LICENSEREADME.mdexample/.prettierrc.jsoncues.jsonpackage.jsonremotion.config.tsscript.jsontiming.jsontsconfig.jsonexample/src/index.tsRoot.tsxexample/src/scenes/AnnexationScene.tsxBaylorFoundingSequence.tsxCloserScene.tsxFoundersScene.tsxNamingScene.tsxOlderThanStateScene.tsxPetitionScene.tsxRepublicScene.tsxSigningScene.tsxexample/src/shared/fonts.tspieces.tsxsystem.tsxtexas.tstiming.tsreferences/scene-patterns.mdvertical.mdvisual-system.mdscripts/captions.pycutout.swiftDetails
From the project READMERemotion paper B-roll
One topic in, one 1920×1080 narrated film out, built as React. The register is editorial documentary: it holds attention by being genuinely interesting, not by shouting.
Everything animates because code moves it. There is no After Effects step, no timeline to drag. You direct the film by writing components and tuning props.
Pick the right skill first
| You want | Use |
|---|---|
| Horizontal 1920×1080, React/Remotion, code-drawn charts and newspapers | this skill |
| Vertical 1080×1920 for TikTok, Reels or Shorts | this skill, --vertical — see references/vertical.md |
| Any narrated explainer where the pipeline choice is open | explainer-studio |
| Fast-cut product ad with sound design | fastcut-ad |
What decides quality, in order
- The fact you build on. One genuinely surprising, checkable reversal. Without it the film has no reason to exist and no craft rescues it.
- The narration. It is the clock and the spine. Every cut is timed to a spoken word.
- Depth. Whether the frame has three layers moving against each other.
- Everything else.
Spend effort in that order.
The pipeline
Ten phases. Do not start phase N+1 before phase N produces its artifact.
1. Find the reversal, write the beats
Find the thing that turns out to be the opposite of what a viewer assumes. Not a statistic, a contradiction.
Write 8 to 12 beats, one idea per line. The script IS the timeline. A beat is one sentence or one clause that can carry one image.
The hook is beat 1 and it withholds the subject. No proper nouns in it: no country, no person, no institution, no year. It states something that cannot be right. The final beat answers that exact question. Test a draft with two questions. Can the viewer name the subject from the hook? If yes, rewrite. Does the last line pay the debt the hook created? If not, one of the two is wrong.
Write the beats into script.json:
{"topic": "...", "beats": [{"id": 1, "narration": "This university is older than the state it stands in."}]}
2. Voice first, then timing
The voiceover is generated before any animation, because its duration decides every frame count.
python3 -m venv .venv && ./.venv/bin/pip install -q edge-tts
./.venv/bin/python ~/.claude/skills/vox-skill/scripts/gen_vo.py
This writes vo/beat_NN.mp3, one joined public/audio/vo.mp3, and
vo/timing.json holding per-beat start, end, duration, frame windows and
word-level boundaries. Every duration downstream is read from that file. Never
guess a frame count.
Voice defaults to en-GB-RyanNeural at +4%. Override with VOICE= and
RATE=. Worth auditioning before you commit, because re-voicing after the film
is built is only cheap if you did step 3:
| Voice | Register |
|---|---|
en-US-AndrewMultilingualNeural | newer HD model, warm and confident. The best default. |
en-US-ChristopherNeural | tagged "reliable, authority". The classic narrator. |
en-GB-RyanNeural / en-GB-ThomasNeural | British, drier |
en-US-SteffanNeural | closest to a straight news read |
The voice model matters less than the mastering. See phase 6.
3. Make the timings derived, not typed
Hand-tuning frame numbers against a waveform works exactly once. Change the voice, the rate or one word and every number is wrong.
Write cues.json naming the word each element lands on, then generate a
timing module from it:
python3 ~/.claude/skills/vox-skill/scripts/sync_timing.py --check # inspect
python3 ~/.claude/skills/vox-skill/scripts/sync_timing.py # write src/shared/timing.ts
Scenes import D for their lengths and CUE for every start frame. Re-voicing
is then three commands and a render, not an afternoon. --check also
bounds-checks every cue against its scene length.
4. Map beats to scenes
Fewer scenes than beats. Two or three beats often share one held image, and a scene that holds while narration continues reads as confidence. Seven scenes for ten beats is a good ratio.
A scene's duration is the sum of its beats' frame windows, taken from
vo/timing.json. Write the mapping down before writing any component.
5. Produce the assets
Three asset classes, in order of preference.
Code-drawn (best). Newspapers, charts, documents, maps, ledgers, typewriter
text, striking-through lists. No files, no keying, infinitely tunable, and they
animate natively. Reach for this first. See references/scene-patterns.md.
Transparent PNG cut-outs. Subject lifting runs locally through Vision, free and offline:
swiftc -O ~/.claude/skills/vox-skill/scripts/cutout.swift -o /tmp/cutout
/tmp/cutout raw/portrait.jpg public/scene-04/portrait.png
python3 ~/.claude/skills/vox-skill/scripts/halftone.py public/scene-04/portrait.png
halftone.py applies the paper treatment: desaturate, raise contrast, dot
screen, warm the ink. It makes a modern photo sit on 19th-century paper.
If Vision finds no subject, fall back to PIL: flood-fill from the four corners at tolerance ~55, then keep only the largest connected opaque component so interior fills such as collars survive. Never seed the flood-fill from mid-edges, it leaks into a wide subject.
Transparent video. Transcode to VP9 with alpha, then <OffthreadVideo transparent />:
ffmpeg -i in.mov -c:v libvpx-vp9 -pix_fmt yuva420p -vf scale=1920:-2 -b:v 0 -crf 32 out.webm
Keep the paper background under 5 MB at exactly 1920×1080. A 28 MB background makes Studio drop frames, and dropped frames drag the audio out of sync while you tune.
6. Build scenes on the locked visual system
Decide the system once, then every scene is a variation on it. Full palette,
type scale and motion rules in references/visual-system.md. The three
non-negotiables:
Every scene is three layers.
- Background — the same paper texture in every scene, full-cover,
zIndex 0, with a soft-light wash over it. It never changes. That continuity is what makes seven scenes read as one film. - Midground — the subject, dropped in as a cut-out, each with an offset red marker stroke.
- Foreground — something that occludes the subject's lower body and anchors the shot.
A scene missing the foreground looks like a slide. The occlusion is what creates depth.
The red marker stroke is the signature. Render a solid-colour silhouette of the cut-out behind the image, offset a few pixels, mostly horizontally so it reads on one side:
<div aria-hidden style={{
...sameBoxAsImg,
backgroundColor: "#E04329",
maskImage: `url(${assetUrl})`, WebkitMaskImage: `url(${assetUrl})`,
maskSize: "100% 100%", WebkitMaskSize: "100% 100%",
maskRepeat: "no-repeat", WebkitMaskRepeat: "no-repeat",
transform: `translate(calc(-50% + ${strokeX}px), ${rise + strokeY}px)`,
}}/>
<Img src={assetUrl} style={{ ...sameBoxAsImg, transform: `translate(-50%, ${rise}px)` }} />
strokeX around ±26, strokeY around 8. Mirror the sign so subjects on the left
of frame stroke left and subjects on the right stroke right.
Elements arrive, they do not fade. spring() a rise from ~390px with
{damping: 18, mass: 0.75, stiffness: 110}, crossed with a 16-frame opacity
ramp. Charts and signatures draw on via interpolate() over strokeDashoffset.
Stagger entrances so no two elements land on the same frame.
Every scene takes a zod schema with tuned props. Position, scale and start/end frame for every element. That is what makes the film adjustable in Studio instead of a recompile per nudge.
7. Score and master the audio
Judge the bed by what a laptop speaker reproduces, not by its spectrum plot.
A real documentary cue measures at 93% of its energy below 250 Hz. Copying that target literally produces a bed that is silent on every speaker without a woofer, because phones and laptops roll off hard below ~200 Hz. Ask how loud the music is above 250 Hz relative to speech above 250 Hz. That single number decides whether anyone hears it.
# speech passage vs a gap between lines, both high-passed
ffmpeg -ss <speech> -t 1.5 -i mix.wav -af "highpass=f=250,volumedetect" -f null /dev/null
ffmpeg -ss <gap> -t 0.3 -i mix.wav -af "highpass=f=250,volumedetect" -f null /dev/null
| Separation above 250 Hz | Verdict |
|---|---|
| 30 dB or more | inaudible; the listener will say "there's no music" |
| 18-22 dB | present and unobtrusive. The target. |
| under 8 dB | crowding the narration |
score.py prints this as a small-speaker penalty plus the audible level at four
points through the piece, so a silent stretch shows up before you render.
python3 ~/.claude/skills/vox-skill/scripts/score.py music/bed.wav --seconds <SEQ_SECONDS>
bash ~/.claude/skills/vox-skill/scripts/mix_audio.sh \
public/audio/vo.mp3 music/bed.wav /tmp/mix.wav -20
score.py prints its own spectral balance against those targets. Cut the bed to
the sequence length, not the voice length, so it carries the closing hold.
The bed is set with a music_db offset and then sidechain-ducked a further
~6 dB while the voice speaks, so it swells back in the gaps rather than sitting
flat. -18 to -22 dB is the textbook figure and it assumes a full-range mix.
For a sub-heavy bed it lands 30+ dB down where it counts. Set the offset from
the measured separation above, not from the convention. Around -12 dB is typical
once the bed carries real midrange.
Watch the arrangement curves on a short film. They are fractions of the whole piece, so a part that reaches full at 0.20 is absent for the first thirteen seconds of a 65-second cut. An opening of pure sub-bass drone reads as "there is no music", and that is the part everyone judges. Keep a floor under the musical parts from the start.
mix_audio.sh also masters the narration, and that is most of the distance
between "text to speech" and "documentary narration":
| Move | Why |
|---|---|
| high-pass 85 Hz | rumble the voice never uses |
| -2 dB @ 350 Hz | the boxiness every neural TTS has |
| +2.5 dB @ 3 kHz | presence; it is what makes consonants land |
| -3 dB @ 6.8 kHz | de-ess; TTS sibilance is harsher than a real mouth |
| 3:1 compression | quiet clauses sit level with loud ones |
| loudnorm -16 LUFS | sensible on phones and laptops |
Point the composition at the mixed file rather than the raw VO.
8. QC on stills before you render
Render one still per scene and look at them on a contact sheet before committing to a full render. A 70-second film is a 10-minute render; a still is seconds. Every collision, wrap and mis-scale shows up here.
for f in 200 541 695 1056; do
npx remotion still src/index.ts <SequenceId> out/qc/f$f.png --frame=$f --log=error
done
ffmpeg -y -i out/qc/f200.png -i out/qc/f541.png -i out/qc/f695.png -i out/qc/f1056.png \
-filter_complex "[0:v]scale=720:-1[a];[1:v]scale=720:-1[b];[2:v]scale=720:-1[c];[3:v]scale=720:-1[d];\
[a][b]hstack[t];[c][d]hstack[u];[t][u]vstack" out/qc/sheet.png
Pick each frame at the moment the scene is fullest, not its first frame. What this pass reliably catches:
| Symptom | Cause |
|---|---|
| A stamp or label unreadable over a subject | Red on ink. Move it onto clear paper, don't restyle it. |
| Two elements touching | Absolute positions drifted. Move one, don't shrink both. |
| A tracked caption wrapping mid-phrase | Add whiteSpace: "nowrap". |
| A headline breaking across lines | Same fix, plus drop 15–20% of the size. |
| Code-drawn SVG art rendering tiny | The viewBox is looser than the path. Bound it to the path's real extent. |
| A shape reading as the wrong object | Silhouettes need width. Narrow shoulders read as a keyhole, not a bust. |
9. Assemble and render
Chain the scenes with <Series>, one <Series.Sequence> per scene at the
duration from step 3.
Position props mirror each <Composition>'s defaultProps. Timing props are
retimed in the sequence, because a scene tuned in isolation is almost always
longer than the beat window it has to fit.
Gate the audio behind a flag so the composition renders silent by default and the stems ship separately for mixing:
const BAKE_AUDIO = false; // true → VO at 1.0, music ducked to ~0.2, 24f fade in, 50f fade out
npx remotion studio # preview and tune
npx remotion render src/index.ts <SequenceId> out/final.mp4 # render
ffmpeg -i out/final.mp4 -c:v copy -an out/video.mp4 # strip the silent track
Studio can only save prop edits back to disk if each <Composition>'s
defaultProps is an inline object literal, not an imported variable, and a
.prettierrc.json exists at the project root. Both are required. Get this wrong
and every tuning session is lost on reload.
10. Pick the poster frame
Never ship the default thumbnail. Frame zero of a film built this way is an empty paper background before anything has entered — the single worst frame in the file, and the one every player picks.
python3 ~/.claude/skills/vox-skill/scripts/pick_thumbnail.py \
out/film.mp4 out/thumbnail.png --embed --report
It scores every candidate and takes the busiest, most composed moment. "Interesting" is defined as several distinct elements spread across the frame, not raw ink coverage — a shot where the dark foreground band fills the bottom third has high coverage and no interest, so the dominant term is spatial spread across a grid. Edge density, a capped coverage term and the presence of the red accent make up the rest.
--embed attaches it to the mp4 as cover art. --report prints the shortlist so
you can take the runner-up if you prefer it. On a vertical cut pass --grid 5,
or the score biases toward frames whose content sits behind the platform's own
interface.
Quick reference
| Thing | Value |
|---|---|
| Output | 1920×1080, 30fps, MP4 |
| Paper base / ink / red accent | #DAD9D5 / #1A1A1A / #E04329 |
| Headline type | Arial Black, tight tracking, uppercase |
| Serif type | PlayfairDisplay, PTSerif (newspapers, documents) |
| Typewriter type | SpecialElite (closing lines) |
| Entrance spring | {damping: 18, mass: 0.75, stiffness: 110} |
| Rise distance | 360–390px, from below |
| Opacity ramp | 16 frames, Easing.out(Easing.cubic) |
| Stroke offset | strokeX ±26, strokeY 8 |
| Drop shadow | drop-shadow(0 28px 26px rgba(55,45,31,0.28)) |
| Scaffold a project | scripts/scaffold.sh <dir> <SequenceName> |
| Music bed | scripts/score.py out.wav --seconds N |
| Master + duck | scripts/mix_audio.sh vo.mp3 bed.wav out.wav -20 |
| Music under voice | set from measured >250 Hz separation, ~18-22 dB. Often -12 dB. |
| Music feels flat | Raise --motif, not the level |
| Vertical cut | scaffold.sh <dir> <Name> --vertical, then references/vertical.md |
| Captions (required on vertical) | scripts/captions.py then <Captions /> |
| Poster frame | scripts/pick_thumbnail.py out.mp4 --embed |
| "I can't hear the music" | Measure above 250 Hz before touching the fader |
| Final loudness | -16 LUFS integrated |
Common mistakes
| Mistake | Fix |
|---|---|
| Guessing frame counts, then fixing sync later | Read every duration from vo/timing.json. The VO is the clock. |
| One scene per beat | Merge beats. A held image under continuing narration reads as confidence. |
| Background changes between scenes | It never changes. The locked texture is what unifies the film. |
| No foreground layer | Add the occluding element. Without it the shot is a slide, not a frame. |
| Sourcing an image for something code can draw | Charts, newspapers, documents, maps and lists are always better in code. |
| Everything enters on the same frame | Stagger by 20–30 frames. Simultaneous entrances read as a template. |
| Elements fading in | They spring up. Fades read as generic. |
defaultProps imported from a constant | Inline the object literal or Studio silently discards your tuning. |
| Hard-coding positions instead of props | Every position, scale and frame goes in the zod schema. |
| A 28 MB paper background | Crop and resize to exactly 1920×1080, under 5 MB. |
| Typing frame numbers into scene props | Name the word in cues.json and generate them. Re-voicing must not mean re-tuning. |
| Music picked for the tune | Pick it for the spectrum. If it lives in 200-4000 Hz it fights the voice at any volume. |
| Music at one flat level | Duck it. A bed that swells in the gaps sounds mixed; a flat one sounds pasted on. |
| Shipping raw TTS | Master it. The EQ and compression chain matters more than which voice you chose. |
| Shipping the default thumbnail | Frame zero is an empty background. Run pick_thumbnail.py. |
| A vertical cut with no captions | Sound-off is the default. Burn them in. |
| A row of three subjects in vertical | 340px each. Stack them, or split across beats. |
| Rendering the film to find out how it looks | Contact-sheet the stills first. Ten minutes of render buys nothing a still won't show. |
| A subject with no public-domain likeness | Draw them as an ink form in the same box. Never borrow someone else's face. |
Red flags
- About to write a scene component before
vo/timing.jsonexists - About to open an image generator for a chart, a document or a map
- The hook names the subject
- The last beat does not answer the question the hook asked
- A scene has two layers
- Reaching for a fade because the spring "looks bouncy" (retune damping, don't fade)
- About to run a full render without having looked at a still from every scene
- Putting an unverifiable number or quotation on screen because the layout wanted one
Reference
references/visual-system.md— palette, type, motion, the layer model in fullreferences/scene-patterns.md— seven reusable scene archetypes with working codescripts/gen_vo.py— narration, word timings, frame windowsscripts/cutout.swift— Vision subject lift to transparent PNGscripts/halftone.py— paper/halftone treatment for cut-outsscripts/sync_timing.py— cues.json + timing.json to a TypeScript timing modulescripts/score.py— tense-documentary music bed, spectrally carved for the voicescripts/mix_audio.sh— narration mastering chain and sidechain duckscripts/pick_thumbnail.py— choose and embed the busiest frame as the posterscripts/captions.py— burned captions from the narration's word boundariesscripts/scaffold.sh— runnable Remotion project skeleton (--verticalfor 9:16)references/vertical.md— what changes for TikTok, Reels and Shorts