mcpField
V
vox-skill

vox-skill

github.com/jaredcassouttCreative & media1257 installsUpdated 4d ago
Repository

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

  1. 01Plan narration as timed beats
  2. 02Generate voiceover and timing data
  3. 03Build layered animated scenes in React
  4. 04Create code-drawn visual assets
  5. 05Mix and master narration and music
  6. 06Render horizontal or vertical films

Use cases

  1. 01Produce a narrated documentary from a topic
  2. 02Synchronize scene changes to spoken words
  3. 03Create animated charts and document visuals
  4. 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-skill

After installing, type/vox-skillin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

LICENSE
README.md
example/
.prettierrc.json
cues.json
package.json
remotion.config.ts
script.json
timing.json
tsconfig.json
example/src/
index.ts
Root.tsx
example/src/scenes/
AnnexationScene.tsx
BaylorFoundingSequence.tsx
CloserScene.tsx
FoundersScene.tsx
NamingScene.tsx
OlderThanStateScene.tsx
PetitionScene.tsx
RepublicScene.tsx
SigningScene.tsx
example/src/shared/
fonts.ts
pieces.tsx
system.tsx
texas.ts
timing.ts
references/
scene-patterns.md
vertical.md
visual-system.md
scripts/
captions.py
cutout.swift

Details

From the project README

Remotion 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 wantUse
Horizontal 1920×1080, React/Remotion, code-drawn charts and newspapersthis skill
Vertical 1080×1920 for TikTok, Reels or Shortsthis skill, --vertical — see references/vertical.md
Any narrated explainer where the pipeline choice is openexplainer-studio
Fast-cut product ad with sound designfastcut-ad

What decides quality, in order

  1. The fact you build on. One genuinely surprising, checkable reversal. Without it the film has no reason to exist and no craft rescues it.
  2. The narration. It is the clock and the spine. Every cut is timed to a spoken word.
  3. Depth. Whether the frame has three layers moving against each other.
  4. 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:

VoiceRegister
en-US-AndrewMultilingualNeuralnewer HD model, warm and confident. The best default.
en-US-ChristopherNeuraltagged "reliable, authority". The classic narrator.
en-GB-RyanNeural / en-GB-ThomasNeuralBritish, drier
en-US-SteffanNeuralclosest 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.

  1. 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.
  2. Midground — the subject, dropped in as a cut-out, each with an offset red marker stroke.
  3. 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 HzVerdict
30 dB or moreinaudible; the listener will say "there's no music"
18-22 dBpresent and unobtrusive. The target.
under 8 dBcrowding 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":

MoveWhy
high-pass 85 Hzrumble the voice never uses
-2 dB @ 350 Hzthe boxiness every neural TTS has
+2.5 dB @ 3 kHzpresence; it is what makes consonants land
-3 dB @ 6.8 kHzde-ess; TTS sibilance is harsher than a real mouth
3:1 compressionquiet clauses sit level with loud ones
loudnorm -16 LUFSsensible 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:

SymptomCause
A stamp or label unreadable over a subjectRed on ink. Move it onto clear paper, don't restyle it.
Two elements touchingAbsolute positions drifted. Move one, don't shrink both.
A tracked caption wrapping mid-phraseAdd whiteSpace: "nowrap".
A headline breaking across linesSame fix, plus drop 15–20% of the size.
Code-drawn SVG art rendering tinyThe viewBox is looser than the path. Bound it to the path's real extent.
A shape reading as the wrong objectSilhouettes 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

ThingValue
Output1920×1080, 30fps, MP4
Paper base / ink / red accent#DAD9D5 / #1A1A1A / #E04329
Headline typeArial Black, tight tracking, uppercase
Serif typePlayfairDisplay, PTSerif (newspapers, documents)
Typewriter typeSpecialElite (closing lines)
Entrance spring{damping: 18, mass: 0.75, stiffness: 110}
Rise distance360–390px, from below
Opacity ramp16 frames, Easing.out(Easing.cubic)
Stroke offsetstrokeX ±26, strokeY 8
Drop shadowdrop-shadow(0 28px 26px rgba(55,45,31,0.28))
Scaffold a projectscripts/scaffold.sh <dir> <SequenceName>
Music bedscripts/score.py out.wav --seconds N
Master + duckscripts/mix_audio.sh vo.mp3 bed.wav out.wav -20
Music under voiceset from measured >250 Hz separation, ~18-22 dB. Often -12 dB.
Music feels flatRaise --motif, not the level
Vertical cutscaffold.sh <dir> <Name> --vertical, then references/vertical.md
Captions (required on vertical)scripts/captions.py then <Captions />
Poster framescripts/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

MistakeFix
Guessing frame counts, then fixing sync laterRead every duration from vo/timing.json. The VO is the clock.
One scene per beatMerge beats. A held image under continuing narration reads as confidence.
Background changes between scenesIt never changes. The locked texture is what unifies the film.
No foreground layerAdd the occluding element. Without it the shot is a slide, not a frame.
Sourcing an image for something code can drawCharts, newspapers, documents, maps and lists are always better in code.
Everything enters on the same frameStagger by 20–30 frames. Simultaneous entrances read as a template.
Elements fading inThey spring up. Fades read as generic.
defaultProps imported from a constantInline the object literal or Studio silently discards your tuning.
Hard-coding positions instead of propsEvery position, scale and frame goes in the zod schema.
A 28 MB paper backgroundCrop and resize to exactly 1920×1080, under 5 MB.
Typing frame numbers into scene propsName the word in cues.json and generate them. Re-voicing must not mean re-tuning.
Music picked for the tunePick it for the spectrum. If it lives in 200-4000 Hz it fights the voice at any volume.
Music at one flat levelDuck it. A bed that swells in the gaps sounds mixed; a flat one sounds pasted on.
Shipping raw TTSMaster it. The EQ and compression chain matters more than which voice you chose.
Shipping the default thumbnailFrame zero is an empty background. Run pick_thumbnail.py.
A vertical cut with no captionsSound-off is the default. Burn them in.
A row of three subjects in vertical340px each. Stack them, or split across beats.
Rendering the film to find out how it looksContact-sheet the stills first. Ten minutes of render buys nothing a still won't show.
A subject with no public-domain likenessDraw 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.json exists
  • 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 full
  • references/scene-patterns.md — seven reusable scene archetypes with working code
  • scripts/gen_vo.py — narration, word timings, frame windows
  • scripts/cutout.swift — Vision subject lift to transparent PNG
  • scripts/halftone.py — paper/halftone treatment for cut-outs
  • scripts/sync_timing.py — cues.json + timing.json to a TypeScript timing module
  • scripts/score.py — tense-documentary music bed, spectrally carved for the voice
  • scripts/mix_audio.sh — narration mastering chain and sidechain duck
  • scripts/pick_thumbnail.py — choose and embed the busiest frame as the poster
  • scripts/captions.py — burned captions from the narration's word boundaries
  • scripts/scaffold.sh — runnable Remotion project skeleton (--vertical for 9:16)
  • references/vertical.md — what changes for TikTok, Reels and Shorts

Comments

Markdown supported