Cartoon-in-3D animated story shorts (three.js + Remotion)
What it makes: a 1080x1920, 30 fps, 45-65 s short. An original true story told the reference way: a past-tense absurd hook, doubters laughing, "You see," then a one-line payoff. Flat cartoon cards stand in a painted 3D world with ink outlines, motivated warm light against cool shade, lens blur and a camera that never cuts. A fast narrator (~3.7 words/s), one-word captions, an original score and synthesized SFX, mastered to -14 LUFS.
Worked example: The Fake Shoppers (1937, Sylvan Goldman pays actors to push his new shopping carts), which lives
in project/src/videos/fake-shoppers/ and project/stories/fake-shoppers/.
Ground rules (always)
- Story, characters, voice and music are our own. Use the reference for the look and the formula, never its scripts, characters or shots. Never say the channel's name in a video, title or description. The reference videos are private study material: never upload or send them.
- Facts are researched and sourced at the top of the story's SCRIPT.md. Jokes are hyperbole or real facts, never invented history presented as fact.
- Free, local tools only. Use edge-tts or the user's own voice; no paid generators. Ask before using credits on any connector.
- No pauses in the voice (cut gaps to 0.07 s, or 0.2 s at sentence ends). Captions are on (reference style). The video ends ≤0.5 s after the last word, with no outro or CTA.
- Check
references/TOPICS_DONE.mdbefore picking a topic. - Two approval gates, then "just do it" (kept from the original skill):
- Script: show the script (with its sources) and wait for the user's OK before the voice and shot plan. After that, work without asking until gate 2.
- Review board: before the full render, the user watches a half-size draft on the review board, pins notes to moments or scenes, and presses Looks good (step 8). Rounds repeat until then; no full render before it.
- The user's taste and learned checks (from past review boards). Read both PREFERENCES files before writing
the script (step 2), the shot plan (step 4), the sets (step 5) and the cast (step 6). Walk both CHECKS files at
step 8, together with the Readability traps:
- shared by every video skill:
~/video-editor/review-board/PREFERENCES.md,~/video-editor/review-board/CHECKS.md; - this style only:
references/PREFERENCES.md,references/CHECKS.md.
- shared by every video skill:
- Picking up an existing story: run
node ~/video-editor/review-board/cli.mjs show stories/<name>/reviewfirst. Notes the user sent while no session was running are handled before anything else (step 8.5). - One full render. Start it in the background, tell the user the ETA (~4 fps at 1080, ~10 min for 60 s at 2K),
and don't poll it: the job reports when it ends. Previews at
--scale 0.5are free to repeat.
This Mac (set up and tested 2026-11-07)
- Working project:
~/video-editor/toon3d-shorts/, created bysetup.sh. It holdssrc/engine(generic),src/videos/<name>/(one folder per video),stories/<name>/(script, voice, cues, score),audio/(synth kit + mixer),tools/measure.py,scripts/andpublic/. A fresh copy lives in this skill'sproject/. Recreate it anywhere withbash ~/.claude/skills/toon3d-story-shorts/setup.sh <dir>, which runs npm install (pinned versions) andpip install --user scipy. - Node 24 + Remotion 4.0.539: GPU through ANGLE/Metal. Stills take ~1.3 s each after a ~20 s bundle; a full
60 s preview renders in ~45 s at
--scale 0.5. - Python 3.9: numpy, PIL, scipy. ffmpeg-full. whisper-cli with
~/video-editor/models/ggml-large-v3-turbo-q5_0.bin. edge-tts (python3 -m edge_tts). - Reference study (yours to build, not included):
~/video-editor/reference-study/, made from the reference shorts you study (download them yourself into~/Downloads/reference-shorts/; they are study material, never share them). The author's study measured 19 shorts; the numbers inreferences/come from it:STYLE_BIBLE.mdandSCRIPTS_ANNOTATED.mdmeasure/(per-frame stats, transcripts, 2 s / 60-frame contact sheets, 1 fps frames, summary.json), which is the baseline fortools/measure.py ours ... --refsframes/andsheets/(1 fps / 2 s views used for side-by-sides)
- Set
TMPDIR/REMOTION_TMPto~/video-editor/remotion-tmp(stills.mjs does this). Remotion bundles pile up there. Delete oldremotion-webpack-bundle-*folders only when the user agrees.
Pipeline
-
Ideas → topic → facts. When the user has no topic, or asks for ideas, run an idea round (
references/VIRAL_IDEAS.mdhas the data, sources, rubric and fact rules):-
What wins now (a few YouTube-research calls; ask first if the connector bills credits):
- The reference channel's most popular shorts:
youtube_channel_shortswith channel_id<REFERENCE_CHANNEL_ID>(the channel whose shorts you study) and sort_bypopular. Add any new titles toTOPICS_DONE.md. - Niche breakouts:
faceless_outliers_videoswith a semanticqueryper winning kind (e.g. "animated short revealing the hidden reason behind a strange historical invention, trick or law, with a twist ending"), videoTypeshorts, languages["english"], minOutlierScore 3. Ignore off-niche hits (games, gadgets). Note the title patterns, and add taken stories toTOPICS_DONE.md.
- The reference channel's most popular shorts:
-
Candidates: collect ~30 true stories from the source list, with WebSearch/WebFetch. Map each one to a winning kind: hidden reason behind an old object or practice, foreign system or loophole, brain psychology, money trick or scam, fable or paradox.
-
Score each with the 10-test rubric (out of 20). For the top 10, check freshness with
youtube_search(typeshorts): drop a story already done above 1M views in the last 12 months, or find a fresh angle. -
Present 5-8 ideas with: a working title in a winning pattern, the one-line hook, the object, the mechanism
- analogy, the score breakdown, why it could go viral (its kind's reference numbers), and 2-3 sources. Save
them all to
references/IDEAS_BANK.md. The user picks one.
- analogy, the score breakdown, why it could go viral (its kind's reference numbers), and 2-3 sources. Save
them all to
-
Research the pick:
- ≥2 independent solid sources for every number, date, name and claim;
- legend told as legend; dramatised taunts marked as such;
- a facts block (claim → links → confidence) at the top of
stories/<name>/SCRIPT.md.
For a big topic, the deep-research skill can do this pass.
A good topic is one surprising true story with a single central object and a hidden mechanism the viewer can picture, plus an everyday analogy that explains it (soup bowl, phone sliding off a table, elevator buttons).
-
-
Script (160-240 words, 45-65 s). The channel's measured formula (
references/STYLE_BIBLE.md§2,SCRIPTS_ANNOTATED.md):- Hook (done by ~5 s, 9-20 words): an absurd action stated as fact, in past tense ("He paid strangers to pretend to shop in his own grocery store."). Alternatives: a paradox claim ("X is illegal, yet...") or a conditional promise ("If you ever..."). Never a question.
- Setting: 1-2 lines in present tense, with concrete numbers and the year.
- Doubters: someone laughs or mocks, with a quoted taunt in character. Use 1-2 taunts per video.
- Viewer question: "Wait, ...?" / "But how?" (about one per video).
- Escalation: time skips and repetition ("Week after week", "By July..."), plus a hyperbole joke every 10-15 s ("so X that Y", a deflating comparison).
- Turn: "Then, one night/morning...".
- Reveal: "You see," at ~65-87 % of the runtime, giving the real mechanism in 1-3 plain sentences, ideally through the everyday analogy. ("You see," right after the hook also works as a context pivot.)
- Payoff: one short declarative line that reframes everything (the doubters proven wrong). Cut on it. Bonus: write the last line so it flows back into the first, for a seamless loop.
- Style: past-tense hook, then present tense; spoken words with contractions; ~14 words per sentence; the connectives are "so / but / then", never "and then". Second person ("Imagine you...") is a valid mode.
- Never narrate what the viewer can already see (kept from the original skill). The voice adds what the picture can't: the cause, the number, the joke, the stakes. The picture shows the action.
- Title: a curiosity gap around the object, 4-8 words, in a winning pattern ("The [object] That [absurd result]", "Why [group] [did odd thing]", "How a [small thing] [did big thing]", "What [object] Really Meant", "The [person] Who [paradox]"). Offer 3 options with the script.
- Hook test: read the first sentence out loud. It must land in under ~3 s and sound wrong or impossible. If it needs context, rewrite it.
- Deliver for gate 1: the script, the 3 titles, and the facts block with sources.
-
Voice into
stories/<name>/voice/:- Generate:
python3 -m edge_tts --voice en-US-AndrewNeural --rate=+14% --text "$(cat ../script.txt)" --write-media raw.mp3, or use the user's recording. - Tighten:
python3 scripts/tighten_voice.py raw.mp3 voice.wav --sentence-gap 0.12cuts gaps to 0.07 s, or 0.12 s at sentence ends (the reference has ~1 pause over 0.25 s per video), and outputs 48 kHz mono. - Word timings: whisper-cli
-l en -ml 1 -sow -ojon a 16 kHz copy, then convert towords.json([{w, a, b}]in seconds). Use the script's spelling, not whisper's. Copy it towords_authored.jsontoo: the timeline the shot plan is written against (story.ts buildsRETIMEfrom the pair; identical files = no change). - The user's own recording: compare its words with the script and measure its pace (words/s, pauses over
0.25 s). Tightening barely helps a read that's already tight; if it's slower than the targets below, make a
pitch-preserving
--tempo 1.1version and let the user hear both before choosing. - Replacing the voice after the shot plan (same words, new recording; done for The Fake Shoppers 2026-11-08):
don't retime anything by hand.
- Keep the old timings as
words_authored.jsonand put the new ones inwords.json(script spelling, same word count). Different words mean a new shot plan. RETIME = makeRetime(authored, spoken, FPS)(engine/retime.ts) maps every output frame onto the authored timeline, following the new voice word by word with ±2-word smoothing (median onset error ~1 f). index.tsx usestotal: RETIME.total, retime: RETIME; cast.ts lip-sync keeps importingwords_authored.json.- Check a few keyword frames on stills, then post the new draft to the review board with every scene listed
under
--changed. - The sound moves with it, with nothing re-placed by hand:
node scripts/retime_cues.mjs <name>keepscues_authored.jsononce and writescues.jsonin output time (events,durargs, beds, whooshes) plustimemap.json.python3 score.py music.wav <RETIME.total> timemap.json: accents follow the picture, the beat grid follows section by section. Without a map the score is unchanged (checked sample for sample).python3 audio/mix.py stories/<name>/cues.json public/<name>/mix.wav, then whisper the mix to confirm every word is heard.
- Keep the old timings as
- Targets: 3.6-3.9 words/s, hook ends ≤6 s. TOTAL = ceil(last word end × 30) + 6 frames.
- Tighten before the shot plan, never after. Every shot frame is built on the tightened
words.json, so re-tightening later means retiming the whole video. (The example skipped this and kept 10 pauses over 0.25 s, against the reference's ~1.)
- Generate:
-
Shot plan in
src/videos/<name>/story.ts(copy the example):SHOTS:{id, a, b, light, set}in frames. A new visual every ~2.2 s (about every 8 words). The action lands on the keyword, 0-3 f before its onset.TRANSITIONS: how a shot comes in:dissolve(4-8 f) at place or time changesiris(12-16 f,cx/cy) through portholes, peepholes, lenses or clocksdust(20-26 f) when a puff or cloud fills the frameflash(4 f) after the hook only Shots in the same set with no transition are automatically joined by a chained camera glide centred on the boundary (makeCamAt;chain= 18 f, or a per-incoming-shot record sized by travel), so the staging swap lands mid-move. Never hard-cut. A chained boundary that would travel > ~1100 px or turn > 90° gets a dissolve instead.
CAMS: per-shot keys{f, dur?, dissolve?, s: f => dolly(pos, look, pushK(f, f0, rate, punch?), {fov, roll})}. Camera rules (references/studies/camera.md, 12 shorts at 30 fps):- Frame budget: 15-20 % still, ~45 % creep, ~33 % glide (3-30 px/f at 540 wide), ≤3 % fast.
- A new framing every ~1.4 s. Most are small reframes: wrap the keys as
withReframes(CAMS, ENDS)and every long hold gets a 14 f sideways pan of ±5 % of the subject distance every 42 f, keeping that key's own push. Mark deliberate still holdshold: true. About 4 fast moves a minute: peak 35-55 px/f (cap 70), 20-34 f. - Author ~7 moves per 10 s, most of them small, slow moves (peak ~10 px/f, 80-150 px of travel). Use
withNudges(CAMS, [[shot, baseKeyF, at, dur, ±0.05-0.07], ...])for small sideways reframes inside long holds (they keep that key's push), thenwithReframesfills what's left. The example's camera after this re-key measures within the reference on every class (still 19.5 / creep 49.9 / glide 27.1 / fast 3.5 %, 7.4 moves per 10 s; studies/camera.md). - Check every nudge's direction on a still. Move toward the speaker or the action, never crop diegetic text
(mark calendar/newspaper/sign inserts
hold: true), never slide a foreground head over the subject, and don't nudge a two-shot that already fills the frame width. - Glide length by travel:
durFor(D)= clamp(1.875·D/45, 18, 40) f, D = px at 540 wide. - Travel limit: one glide covers ≤ ~1100 px (2 frame widths) and turns ≤ 90°. Bigger jumps: a key with
dissolve: 6-8(a cross-dissolve inside the shot) or a shot transition. Glides blend as an orbit around the moving look point, so a big turn never swings through the lens. - Holds: push in 0.15-0.35 %/f (0.5-0.9 on punch words), but ~1 hold in 3 has rate 0 and ~1 in 5 pulls out (negative rate).
- Rolls 1.5-3.5° (0.03-0.05 rad) over 10-15 f. Motion blur shutter 0.1 f (reference frames at 35-59 px/f are crisp).
- Foreground framing in ~1 shot in 4 (the original skill said ~1/3; we counted ~30 of 119 reference frames across 4 videos). A recognisable near object at a frame edge: a head over the shoulder, a jar or pot, a window, porthole or door frame, cell bars, a railing. Place it 0.6-2 m from the lens so the lens blur softens it. It must still read as what it is (see Readability traps), and it never covers the speaker's face.
- 9:16 framing math: horizontal fov ≈ 2·atan(0.5625·tan(vfov/2)), so vfov 50 is only ~29° wide. A two-shot needs vfov 48-56 at 3.5-4.5 m. Stage people in depth (diagonals) rather than side by side, and keep heads inside the frame with ~10-15 % headroom.
DOF:cInfpx at 1080 (close-up 12-16, medium 8-12, wide/establishing 0) with focus on the eyes.KICKS:[time s, gain]impact shakes: 0-1 per video (2 at most, on the payoff).
-
Sets in
src/videos/<name>/sets.ts. Everything is built in code; no downloads. Targets come fromreferences/studies/world.md.- Kit (
engine/build.ts):- materials:
lam(two-tone painted),flat(unlit signs),glow(HDR emissive) - geometry:
box,cyl,plane,wallX(walls with openings) - textures:
tilesTex,wallTex,plankTex,brickWallTex,plasterTex,signTex,roadTex,tinTex facadeUnit(): real 3D buildings with framed windows, sills, cornices and a drainpipe;cast: falsefor buildings that must not shade the hero setshafts(): window light shaftsProducts+shelfRun(instanced, on NOINK)carModelprops.*: crate, sack, barrel, bucket, mat, clock, poster, priceStrip, wallShelf, blueprint, coat, broom, rope, newsBox, aFrame, bike, streetSign, kerb, manhole, hydrant, bin, planter, scaleprops2.*(added after testing the kit on a market square,src/videos/kit-test): stall (canopy + produce), tree (smooth clustered canopy), door (framed, with step), fence, stairs, lampPost (glowing bulb), bench, fountain
- materials:
build(root)builds every set once and returns handles.pose(handles, stageOut)moves props per frame. Keep separate sets ≥60 m apart in x.- World rules:
- ≥8 distinct prop types in wide and medium frames (≥4 in close-ups): ≥2 on walls, ≥2 on the floor, ≥1 overhead. No empty wall larger than 25 % of the frame.
- Ink 2.5-3 px at 1080. Small repeated items carry no ink.
- Painted joints 3-5 px, ≥10 ΔL*, still visible at night.
- Facades in real relief.
- Readable text in ~40 % of frames, as 2-4 small items (1-5 % of the frame each; hero signs ≤8 %).
text()in build.ts shrinks any line to fit its canvas, but check inserts on a still: the frame must show the whole headline / date. - Near floors get visible seams or props. Floor lines come from slabs/planks/tiles (
sidewalkTex,tilesTex, wood) with ink-dark 6 px joints; cobbles read as texture, not lines (ink-dark cobble joints made a busy mesh at ~600 line px/10k vs the reference's 15.7), so pair a cobbled floor with kerbs, manholes or props up close. - One hero object built in 3D and animatable; diegetic text (signs, counters, calendars, newspapers) carries the exposition.
- Light into interiors: walls need real openings so the world-fixed sun lays patches covering 15-25 % of the frame. Check that nothing outside (across-street buildings, awnings) blocks it.
- Kit (
-
Cast in
src/videos/<name>/cast.ts:stage(id, f, cam)returns{places, fx, ...propData}. Targets come fromreferences/studies/characters.md.- Outfits (
engine/toon.ts):- hats: fedora, cloche, curls, slick, bob, cap, hair, tophat, bonnet, shako
- glasses, bowtie, tie, apron, moustache, dress
- builds: adult, stout, kid
- about a third of the cast bare-headed; hair mid-brown, covering ≤30 % of the head
- Expressions: neutral, stern, grin, laugh, whistle, nervous, shock, relief, relief_open, dazed, pained, smile, smile_closed, lick, smug, deadpan, wink, sly, angry, proud, sad.
- Arm poses: hang, push, hold, juggle, cross, present, point, tug, pay, rub, cheer, hips, shrug, wave2.
Business loops (period = 1 unit of
armK): fiddle, pour (with propcan: tips over a cup in the other hand), eat (hand to mouth; pair withlip: chewAt(k)), flick (withbills: thumbs a bill 3x per loop), read (withpaper: both hands, slow nod). - Props: stack, bill, bills, paper, purse, can, icecream, hammer.
- Side view:
bodySidewith march / stand / push, pluswalk: true(push while walking) andleanAdd. - Effects: fxSparkle, fxBill, fxCan, fxMark (?, !), fxCobweb, fxShock, fxGlow, fxDust, fxNote, fxBurst, fxSplash, fxPuff, fxStars.
- Acting rules (measured):
- 5-6 expression swaps per 10 s of face-time, on stressed words. About 40 % are hidden by a blink, and each
re-tilts the head 5-10° (
EXPR_TILT, eased over 3 f via the example'sexprAt). - Every arm/pose change tweens over 3-6 f (
armsFrom/armT, via the example'sarmsAt): about one every 2 s for an acting character. - About half the characters in a medium shot run a looping prop action with a 0.5-1 s period (
fiddle,pour,eat,flick,read,juggle,rub,wave2witharmK). Vary them: no two neighbours on the same loop. - Quoted lines are word-synced:
lip: lipAt(WORDS, f, [a, b])(engine/lipsync.ts). Each syllable runs on twos: an open shape by vowel (talkA wide, talkO round, talkU tall, talkE flat) for 2 f, a half shape for 2 f, then a slit (talkM). Gaps under 0.25 s between words hold a slit; longer gaps rest on the expression's mouth. Mouths are solid dark ovals about a quarter of the head wide; TEETHY expressions (grin, laugh, smug, sly, proud, angry, wink...) draw the same timing with teeth. The oldtalkcounter is kept only for compatibility. - Blinks every 0.5-2.2 s, 3 f long.
- Walk at ~11 f per step (
STEP = 0.74m per phase unit at 1 m/s), 6 % bob, 3° sway. - No squash/stretch, no breathing idle.
- 5-6 expression swaps per 10 s of face-time, on stressed words. About 40 % are hidden by a blink, and each
re-tilts the head 5-10° (
- Helpers: copy the example's
put(),pushCart(),cue(),cueT(),armsAt(),exprAt(),sparkle(),dust()andglowPool().person()picks front or side view and the canvas tier by distance;blob()adds contact shadows.
- Outfits (
-
Register: in
src/videos/<name>/index.tsx,makeShort({fps, total, shots, transitions, lights: PRESETS (spread and override, e.g. a sun dir), camAt, dofAt, build, stage, pose, pool, words, audio: "<name>/mix.wav"}).pool(id, f)returns[x, y, z, radius]for a practical light (a bulb, a desk lamp, a street lamp) in that shot. Outside the radius the scene falls to shade, the way the reference lights night scenes. Add a<Composition>line insrc/Root.tsx. Typecheck withnpx tsc --noEmit. -
Review board (gate 2). The user reviews a draft and pins notes; Claude replies on the board and fixes the video in rounds until the user presses Looks good. The board is shared by every video skill:
~/video-editor/review-board/README.mdhas the file contract, the full loop and the reply rules.8.1 Self-check first, so the user sees a draft that's already been checked. Run
node scripts/stills.mjs <CompId> out/<name>/v1/s_ <frame,frame,...> 0.5(one frame per beat), then:python3 scripts/sheet.py out/<name>/v1 s_ out/<name>/v1/sheet.jpg 9 200python3 scripts/sbs.py out.jpg ours1.jpg ref1.jpg ...for side-by-sides against~/video-editor/reference-study/frames/<slug>/NNN.jpg- Fresh-eyes review (kept from the original skill's verifier, without the agent cost):
- Write one line per still saying what it shows, as a stranger would read it. If that line isn't what the story means, it's a readability bug ("a man hanging" instead of "a kid on a swing").
- Score colour, lighting, ink, textures, characters, depth and composition out of 10.
- Write a numbered fix list with frame numbers, and walk the Readability traps list below plus both CHECKS
files (
~/video-editor/review-board/CHECKS.md,references/CHECKS.md). - A fresh agent can do this pass if the user wants it (it costs usage). Fix framing, staging, readability and the look. The sheet is for you; the user reviews on the board.
8.2 Sound first, then the draft. The user reviews with sound (2026-11-08: "the video doesn't have any sfx").
- Build step 9's sound before opening the board: score, cues, whooshes, mix.
- Run
node scripts/review_draft.mjs <name> --title "<title>". It renders a half-size draft in ~1 min and writesstories/<name>/review/(manifest,round-1.mp4). A later run starts the next round. - The draft carries
public/<name>/mix.wav(voice + music + sound effects) when the mix matches the picture's length. Otherwise it falls back to the voice and says so: rebuild the mix whenever a round changes the timing or the camera.
8.3 Open the board:
- Start
node ~/video-editor/review-board/server.mjs stories/<name>/reviewwith Bashrun_in_background. - Read its one output line,
{"url": ...}. - Open that url in the built-in browser pane (
preview_startwithurl).
8.4 Wait for the user:
- Start
node ~/video-editor/review-board/wait.mjs stories/<name>/reviewwithrun_in_background. - Tell the user the board is open: play, pause anywhere, write notes (circle the exact thing if it helps), Send. Then end the turn.
- The waiter exits with
EVENT {...}as soon as they press Send, reply to a question, or press Looks good.
8.5 On a
sendorreplyevent, follow the README loop (cli=node ~/video-editor/review-board/cli.mjs):cli state <dir> working "Reading your notes…"first, thencli handled <dir> <seq>, thencli show <dir>. In that order, Looks good never becomes clickable in between. Run cli commands one at a time.- For every moment or circled note, look at
cli frame <dir> <noteId> out/<name>/review/<noteId>.jpg(red box = the user's circle). For full-size detail, also runnode scripts/stills.mjs <CompId> out/<name>/review/n_ <frame> 1. - Reply under every note with
cli reply: what you understood and what you'll change. Then give a 2-3 line summary in chat.- Unclear →
question. - Script/voice changes (they retime every shot) or rebuilding a whole set → a
questionthat states the cost. - "I don't like this scene" with no reason → ask what they'd like instead.
- Unclear →
- Fix. Check the touched shots on stills.
- Run
node scripts/review_draft.mjs <name> --changed "<id>:<reason>"..., listing every scene you changed, including side effects. It leaves the board onworking, so the user sees the new round but can't approve it yet. - Propose lessons with
cli lessons <dir> lessons.json:- general wording;
kindischeck(a mistake to catch) orpreference(taste);scopeisshared(any video) orskill(this style only);- leave one-offs out.
cli state <dir> review "Round <n> ready: …", which opens Looks good. Do it only after step 6. Then re-armwait.mjs(8.4).
8.6 On an
approveevent:cli handled, thencli apply-lessons stories/<name>/review --shared ~/video-editor/review-board --skill ~/.claude/skills/toon3d-story-shorts/references(the ticked lessons land in the CHECKS/PREFERENCES files).cli state <dir> approved "Approved", and stop the server.- Go on to step 9. The picture timing is now locked.
On demand ("show me the board for "): if
stories/<story>/review/manifest.jsonexists, do 8.3-8.4; otherwise start at 8.2. -
Sound into
stories/<name>/, built before the review board opens (step 8.2) so the user reviews with it. The sound is built on the current shot list. Any later timing or camera change means re-runningscripts/whooshes.mjs,score.py(if a section boundary moved) andaudio/mix.pybefore the next draft.- Score: copy
score.pyand re-section it to the story (playful / sneaky / bright / night reveal / payoff), thenpython3 score.py music.wav <TOTAL>. It's an original ~94 bpm piece built from the synth kit (brass/tuba, glock, vibes, pluck, pad, brushes, snare, whistle, kick, hat, sub). Keep a light steady beat (soft kick on 1 & 3, hat ticks) and a sine sub under the bass line: the reference's low end is steady and comes from the music. - Cue file: write
cues.json(format at the top ofaudio/mix.py):- whooshes from the camera, never by hand:
node scripts/whooshes.mjs src/videos/<name>/story.ts stories/<name>/cues.json. Re-run it after every camera change.- It whooshes only: dissolves where the place or time of day changes; fast chained cuts; fast moves (≥35 px/f, the camera study's "fast" class).
- Whooshes stay at least 2.5 s apart, and
audio/mix.pygives each its own length, band, level and side. - Expect ~one per 5-6 s. The old rule (every dissolve, every glide over 10 px/f) gave The Fake Shoppers 37 in 61 s, and the user heard "a whoosh repeating so much" (2026-11-08); it now has 10.
- an SFX on every visual action (pops, tings, thumps, rattles, rustles, bells, laughs, scuffles, steps)
- beds per set (murmur, crickets, birds, breeze), kept well under the SFX
- levels (sound study defaults):
music_under_voice_lu~13,sfx_db-2,beds_db-5,whoosh_lu_add5,voice_hp80,voice_air_db1.5,master_air_db1,side_gain.55,ceiling_dbtp-2
- whooshes from the camera, never by hand:
- Mix:
python3 audio/mix.py stories/<name>/cues.json public/<name>/mix.wav: -14 LUFS, -2 dBTP true peak, exact picture length. - Measure the mix like the reference (studies/sound.md):
python3 references/studies/scripts/sound.py ours.json public/<name>/mix.wav stories/<name>/voice/words.jsonand compare with the targets below. - Check: whisper the mix and confirm every script word is heard.
- Score: copy
-
Preview + measure. Render a fast preview:
npx remotion render src/index.ts <CompId> out/<name>/preview.mp4 --scale 0.5 --gl=angle --concurrency 4 --crf 20(~1 min). Then measure it against the reference baseline:python3 tools/measure.py ours out/<name>/preview.mp4 out/<name>/measure --refs ~/video-editor/reference-study/measure --words stories/<name>/voice/words.jsonClose the gaps inSUMMARY.mdagainst the targets below. The grade lives inengine/grade.tsand the light presets inengine/lights.ts. -
Optional look-match agent pass (the video's workflow). Only when the user wants it; it is the expensive part and can hit usage limits.
- Write a shared brief: reference paths, our stills/preview, engine file roles, "read-only, numbers not adjectives, ≤250-word report with ranked fixes mapped to file/param".
- Launch 3-6 read-only measuring agents (colour/grade, lighting, world textures, characters, camera/depth, motion at 30 fps).
- Apply the fixes yourself as the single writer. Splitting the work across writer agents needs sandbox copies with one owner per file, as in the original skill.
-
Final.
- Render with
npx remotion render src/index.ts <CompId> out/<name>/final.mp4 --codec h264 --crf 16 --gl=angle(1080x1920, as the reference;--scale 1.3333gives 2K). - If audio is muxed separately, use
-map 0:v:0 -map 1:a:0. - Run QA:
python3 scripts/short_qa.py final.mp4 out/<name>/qa stories/<name>/shots.json(black, white, frozen and pop frames, loudness, sheets). - Run
tools/measure.py oursonce more, then deliver with SendUserFile. - After delivery (kept from the original skill):
- add the topic to
references/TOPICS_DONE.md; - append an entry to
references/PAST_VIDEOS.md: date, title, what the user approved, the measured gaps that remain, weak spots, lessons, and the review-board line fromnode ~/video-editor/review-board/cli.mjs counts stories/<name>/review(notes per round, falling over time is the sign the learning works); - update the user's memory.
- add the topic to
- Render with
Look targets (measured: references/STYLE_BIBLE.md + original-skill/reports, corrected)
| Area | Target |
|---|---|
| Format | 1080x1920, 30 fps, 45-65 s; voice starts at frame 1-4; ends 0-0.5 s after the last word |
| Voice | 3.6-3.9 words/s; ≤8 pauses > 0.25 s; hook ends ≤6 s |
Sound (references/studies/sound.md, 19 videos) | -14 LUFS, LRA ~2, -2.2 dBTP; voice ~12 dB over whatever fills the gaps (gaps ~-30 dB); balance bass -5 / air -24 dB re total; near-mono (side ~28 dB down); steady sub from the music (0 dB speech-vs-gap change); a broadband whoosh rising ~20 dB (6-16 kHz) on every fast move and dissolve; music roughly 60-110 bpm with a light beat; never silent |
| Brightness / contrast | Mean luma ~0.46 (0.25-0.59 by video); luma std ~0.20; darker in the final third |
| Blacks / whites | p2 ~0.14 (lifted, no pure black; non-ink p5 ~0.23); p98 ~0.86 (highlights rolled off) |
| Colour | HSV saturation ~0.46; warmth (R-B) ~+0.04; orange + teal/blue are the two top hue families (day scenes ~48 % warm / 38 % cool by chroma) |
Colour by zone (references/studies/colour.md, 1,031 frames) | Highlights pale (luma ≥ .7: saturation ~.21, tint near neutral, faintly warm: Cb -.03, Cr +.01). Colour lives in the mids (saturation ~.44). Shadows faintly warm-magenta by day (Cb +.01, Cr +.03), blue at dusk (Cb +.05). Big bright surfaces (walls, facades, sky) are cream/pale, never saturated yellow. Day p50 ~.59, p98 ~.94; dusk p50 ~.30; night p50 ~.17 |
| Vignette | Corner/centre luma ~0.75 (grade strength 0.4-0.5 from radius ~0.28) |
| Light | Motivated sources only (window sun patches + shafts, bulbs, lamps); day for setup, sunset at the turn, night-blue for the reveal (references/studies/lighting.md) |
| Shade/lit ratio | interior .55-.75 (shade keeps the material's hue); day .45-.55; golden .15-.30, violet; night = a pool falling to .15-.25 within ~2 m |
| Exposure | interior p50 ~.33, sun patches 15-25 % of the frame; blue hour p50 ~.06, bright share ≤5 %; glow r10 60-140 px; contact blobs .5-.7 |
| Ink | Near-black world lines ~3-5 px at 1080 on silhouettes and creases; characters carry their own plum outline |
| Depth | Lens blur on ~1/3 of screen time: CU cInf 12-16 px, MS 8-12, wides 0; focus on the eyes; fog = horizon colour |
| Editing | New visual every ~2.2 s; hard cuts ~0; dissolves 4-8 f; whips 10-18 f with motion blur; camera moving in most frames (still share ~0.10) |
| Characters | ~2/3 white heads that take the colour of the LIGHT (lit .93-.98, shade .70-.85, lamp .75-.85 and warm, blue hour .50-.70 and blue); head gradient ≤.08; ~3.5 head-heights; limbs ~0.055 head width; ink-dot hands; no blush |
| Acting | 5-6 expression swaps per 10 s with 5-10° head re-tilts; pose tweens of 3-6 f; business loops on ~half the medium-shot characters; 3 f blinks; walk 11 f per step, 6 % bob |
Lip-sync (references/studies/lips.md, 14 quoted lines at 30 fps) | The speaking mouth changes shape every 1-2 f (~half of all frames), opens ~3.3 times a second (~3 opens per 4 syllables), in sync with the voice (0-1 f lead). Calm speakers: solid dark ovals ~¼ of the head wide (slit, flat, wide, round, tall). Gleeful / sneering speakers: teeth (open D + tongue, gritted). ~1 quote in 5 shows no moving mouth (speaker off screen or a reaction shot) |
| World | ≥8 prop types per wide frame; ink 2.5-3 px; line length ≥15 px per 10k px in the top quarter; facades in 3D relief; painted joints 3-5 px |
Captions (references/studies/captions.md, 19 videos at 30 fps) | On screen through all speech; ~0.84 captions per word (articles / short prepositions / "and" pair with the next word); ~3 swaps a second, 9 f median; swap on the word onset (±2 f); no pop, no colour; pure white heavy sans, glyphs ~42 px tall, centre y = 1351/1920; a dark outline ~6 px outside the letters plus a hard dark shadow down-right |
Engine reference (src/engine/)
video.ts: the VideoDef contract.Short.tsx:makeShort(def), with layers, transitions, post chain, captions and audio. Test toggles go in inputProps{post: {ink, lens, mblur, bloom, grade, captions, audio, dpr}}.World.ts: sky, world-fixed sun and shadows, fog, clouds and stars, card pool; calls build/stage/pose and returns the focus point and camera motion (motion-blur shutterSHUTTER= 0.1 f).camera.ts: glide, dolly, zoom, pushK, kicks,dofAt,durFor(glide length by travel),shiftShot,withNudges(explicit small reframes),withReframes(automatic reframes in long holds),makeCamAt(chained glides with per-shotchain, keydissolves exposed ascamAt.dissolves;camAt(id, f, before)evaluates the outgoing framing during a key dissolve). Blends are orbits around the look point.lipsync.ts:lipAt(words, f, span)word-synced mouth shapes,chewAt(k).lights.ts: PRESETS interior / morning / day / golden / night / street, with measured shade colours. Per preset:tintK/norm(head colour follows the light),poolK(practical pool strength),cardShadows.build.ts: the set kit (materials, painted textures, 3D facades, props, shafts, products, car).grade.ts: GradeEffect plus presets per light (curve, saturation by zone, Cb/Cr tints, vignette). interior, night and street are fitted to the reference's day / dusk+night scene classes (studies/colour.md); golden is half-fitted to keep golden-hour warmth. Re-fit withreferences/studies/scripts/runfit2.pyafter big lighting changes.- Post chain:
ink.ts(world outlines; NOINK layer for cards),lensblur.ts(thin-lens DOF),motionblur.ts(camera blur),shade.ts(two-tone world shading +untone),cardshade.ts(lit character cards). - Drawing kit:
toon.ts(Pen, characters, faces, hats, arms, props, effects),cards.ts(Pool, Actor, Fx, person, viewOf, blinks, contact shadows),paint.ts(painted textures),captions.tsx. - Order: ink → lens pre → lens main → camera blur → bloom (threshold 1.0, emissive only) → NEUTRAL tone map → grade, MSAA 4. Every pixel parameter is specified at 1080x1920 and scales with output height / 1920.
Depth of the study (what each number is based on)
- Script, editing, colour: frame-by-frame over all 19 shorts (references/STYLE_BIBLE.md, the measure baseline).
- Camera movement: a 30 fps similarity-model track (translation, zoom, roll) of 12 shorts plus our camera path projected from code (references/studies/camera.md).
- Colour and grade: a zone-by-zone colour profile of 1,031 reference frames split into day / dusk / night, and grade presets fitted with an exact copy of the shader (references/studies/colour.md).
- Lip-sync: 14 quoted lines tracked at 30 fps, plus filmstrips of the speaking mouths (references/studies/lips.md).
- Captions: every frame of all 19 videos, through a caption detector (position, size, outline, timing, grouping; references/studies/captions.md).
- Sound: all 19 videos from the original stereo audio: loudness, true peak, speech vs gaps, low end, balance, width, whooshes on camera moves (references/studies/sound.md).
- Study scripts for all of these: references/studies/scripts/ (README inside).
- World, characters + acting, lighting: three dedicated studies (references/studies/*.md):
- 203-1,020 reference frames per study;
- a 30 fps character track over 598 s of face-time;
- the other creator's reports verified, with corrections noted.
- Last check of the example against the baseline:
- brightness .49 vs .46; contrast .193 vs .196; p2 .155 vs .139; p98 .856 vs .864;
- saturation .423 vs .458; vignette .71 vs .75; 0 hard cuts; 3.75 vs 3.66 words/s; -14.0 vs -14.3 LUFS.
- Colour (preview v19, after the colour study): brightness .472 vs .455, warmth .090 vs .042, warm hue share .53 vs .52, vignette .747 vs .751. Interior highlights pale like the reference (saturation .21 vs .21). Mid saturation .39 vs .44 is the palette, not the grade (studies/colour.md).
- Camera (preview v18, after the camera study and re-key): still / creep / glide / fast 19.5 / 49.9 / 27.1 / 3.5 % vs 16.8 / 46.4 / 32.7 / 5.1, 7.4 vs 7.1 moves per 10 s; a new visual every 2.13 s vs 2.20.
- Warmth +.13 vs a mean of +.04 is the story's yellow store. It's inside the reference's per-video range (-.12 .. +.20), so pick a cooler palette for a cooler story.
Reference baseline (tools/measure.py over all 19 shorts, ~/video-editor/reference-study/measure/summary.json)
brightness 0.455 · contrast 0.196 · p2 0.139 · p98 0.864 · saturation 0.458 · warmth +0.042 · vignette 0.751 · new visual every 2.20 s · hard cuts 0 · camera still share 0.10 · 3.66 words/s · -14.3 LUFS. (Its "hook ends" mean is inflated by two transcripts whisper returned without punctuation: punctuate words.json before trusting that metric.)
Readability traps (walk this list on every stills sheet, with both CHECKS files)
From the original skill's verifier rounds and our own passes. Each one fooled a first look.
- One rope + a person reads as a hanging. A swing needs two ropes to a wide plank, with the kid between them and a frame whose legs clear the arc.
- Props closer than ~2 m to a lens become giant cut-off blobs at the frame edge. After moving any camera, re-check every prop near its new position.
- A cut-off foreground object must still read as what it is (a bush cut by the edge reads as a "green ball"). Make it recognisable or remove it.
- Period and country: flags, uniforms, money, signs and cars must fit the year and place. A wrong flag reads as another country.
- Signs lit from the side go grey-blue and unreadable: give sign faces the unlit
flatmaterial. - Diegetic text must show whole: a calendar date or newspaper headline cropped by the frame or by a push-in
fails. Widen the lens, mark the key
hold: true, and lettext()shrink a line to fit its paper. - Rim light goes on the inner edge of the head and torso only. An offset outer copy turns every hat into a picture frame.
- Rails and bars at chest height cut through bodies: raise the lens (~2.5 m) so they cross the legs.
- A lamp post, pole or foreground head in front of the speaker hides the line. A camera nudge must move toward the speaker, never slide a head over them.
- Camera inside a wall or window frame: a giant blurred blob. Keep lenses ≥0.3 m from geometry.
- Still holds read as frozen in QA (≥12 f): something in frame must move (a loop, a blink, a walk), or give the hold a slow push.
Gotchas
- Ink and blur: ink must be its own pass before the lens blur, or the blur wipes it. Cards, sky and effects live on the NOINK layer.
- Stills scale: R3F clamps dpr to ≥1, so
--scale 0.5stills still render a 1080 buffer. - Camera placement: a camera inside a wall or window frame shows a giant blurred blob. Keep lenses ≥0.3 m from geometry and ≥2 m from characters, or a head fills the frame. The lamp-post-in-front-of-the-hero trap is real: check every still.
- Facing surfaces: a plane rotated π about Y faces -z, and a texture on a floor plane needs rotation (-π/2, 0, π) to read from the front.
- Layering: a sign behind another surface is hidden. The calendar sat behind the pegboard and the frame showed a hammer.
- Iris maths: CSS
circle(r%)is relative to sqrt(w²+h²)/√2 (1557.6 px for 1080x1920). The ink ring radius in px is r × 15.576. - Shadow-casting sun: only the world-fixed sun casts shadows. For windows to cast patches, the ceiling and walls must be inside the shadow camera (shadowR ~22 indoors).
- Imports: JSON imports need
resolveJsonModule. Scratch.tsfiles insideout/break tsc. - Python audio: run the audio scripts with plain
python3from the project; the-Iflag hides user site-packages (numpy, scipy). - Deferred rendering: Remotion renders frames out of order in parallel tabs, so everything must be a pure function of the frame. No Math.random at render time, or seed it.
What changed vs the original skill
See references/original-skill/CORRECTIONS.md. In short:
- Pipeline: zero-hard-cut chained glides, motion blur, iris/dust/flash transitions, night/interior/street lighting, captions, white heads, -14 LUFS.
- Measured formula: the channel's script formula and pacing targets.
- Reuse: a generic engine (3 story files per video instead of 7) and a cue-driven mixer.
- Tooling: the measuring tool that was promised but not shipped; Mac support; pinned dependencies; a code-only world (no missing 3D models).
- Kept from the original:
- its reports and frames, in
references/original-skill/; - the script approval gate and "just do it" after it;
- "never narrate what the viewer sees";
- foreground framing (now counted on the reference);
- the verifier, as a fresh-eyes review;
- the readability traps;
- "lock the picture before the sound";
- delivery notes.
- its reports and frames, in
- Dropped on purpose:
- the Kenney 3D models (everything is built in code);
- Windows-only scripts;
- the sight-line culler (only the Kenney houses needed it; the stills check covers camera placement);
- the parallel writer-agent sandboxes and
bridge_merge.py: one writer, because parallel agents hit the usage limit. They live in the original repo if ever needed.