Every edit in this pack starts here. You end with one folder that holds a cut take, its word timings, one face frame per video frame and the starter page.
Step 1: Ask for three things, then wait
- The recording. The raw phone or camera file. Vertical is best.
- The script, if there is one. The recording still wins: captions follow what was said.
- The look. Brand hex codes and a font. Default: white, ink
#1d1d1f, one accent#0071e3, SF Pro or Inter.
Step 2: Check the file before you touch it
ffprobe -v error -show_streams raw.mov | grep -E "codec_type|codec_name|width|height|start_time|rotat"
- A rotation tag means the picture needs turning. Bake it in on the first encode.
- Two audio streams (iPhone spatial audio) means you map only the first:
-map 0:a:0. - Audio that starts more than 0.02s after the picture will drift. Fix it with
-itsoffsetnow. - Width bigger than height and no rotation tag means a landscape file. Step 6 crops the middle out of it. Shoot one frame through that crop first and look at it, so the head sits in frame.
Step 3: Transcribe with word timings
Free and local with Whisper:
ffmpeg -y -i raw.mov -map 0:a:0 -ac 1 -ar 16000 audio.wav
pip install openai-whisper # once
whisper audio.wav --model small.en --word_timestamps True --output_format json
Step 4: Pick the takes, then cut on silence only
- Read the whole transcript. A re-recorded line near the end beats the first try. Keep the last complete take of each line.
- A take rejected out loud ("scratch that", "let me start again") goes. Coughs go. Laughter stays.
- Cut every pause down to 0.15-0.2s. No silence over 0.25s survives, except one planned beat before a reveal.
- Cut only where the sound is truly silent. Never cut on the transcript's word times: Whisper stretches words across pauses. Find the real silences on the audio:
ffmpeg -i audio.wav -af silencedetect=noise=-50dB:d=0.2 -f null - 2>&1 | grep silence_
If that finds nothing, raise the threshold (try -45dB). If it finds silence inside words, lower it. Check the tail of each word: an "s" can hiss 0.3s past where the transcript says the word ends.
- A filler word with no silence around it ("I'm, right, so") stays. You cannot cut it cleanly.
- Snap every cut edge to the frame grid (a whole number of 1/30s) before you trim. Float edges drift the lips by a few frames over a minute.
- Give every audio join a 30ms fade each side so nothing clicks. One fixed gain, never a normaliser.
Write the kept spans as
my-video/segments.json([[start, end], ...]in raw seconds) and build the cut with ffmpegtrim/atrimandconcat. Save the cut audio asmy-video/cut-audio.wav: the starter plays that file.
Step 5: Transcribe the CUT audio again
Run Step 3 on cut-audio.wav. Captions and every word-timed move use these timings. Raw timings drift after each cut.
Now read the kept words end to end. Nothing said twice, nothing that points at something the viewer has not heard yet. Whisper often merges a repeated line into one long word on the raw take, so a double only shows up here. Found one? Go back to Step 4, cut it, and run this step again until the text reads once.
Then fix the words Whisper got wrong by hand (names, product names, the comment keyword) and drop any zero-length word at the very end. Viewers read every caption.
Step 6: Set up the video folder
mkdir -p my-video && cp -R <this skill>/starter/. my-video/
ffmpeg -i cut.mp4 -vf "fps=30,scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920" -q:v 3 my-video/face/%06d.jpg
Then write my-video/words.js from the cut transcript ({ w, s, e } per word) and set duration and faceFrames in my-video/video.js. faceFrames must equal the number of jpgs in face/. If it is off by more than one, your cut edges were not on the frame grid.
The starter ships with demo beats and a demo hook. Replace them now: one face beat from 0 to the end, and your own hook lines (or hookEnd: 0 for none). Otherwise a demo card plays over your take.
Open my-video/index.html?render&t=3 in Chrome to see any frame. Open it without ?render and click to play it with sound.
How the starter works
index.htmlis the engine. Never edit it. It draws any moment fromseek(t)alone, so every frame renders the same way every time.video.jsis the edit. Three layouts per beat:face(full face),split(one object on top, face card below),full(graphic only).- Captions sit in one white pill at y 962 on every beat. Keep y 930-1040 clear of anything else. Captions stay hidden while the hook shows (
t < hookEnd). A time past the last beat shows the last beat. - The engine already defines
clamp,prog,easeOut,spring,wordAt,$andpad. Use them, never declare them again: a secondconst clampinvideo.jsstops the whole page withseek is not defined. Wrap your own helpers in a function or give them new names. wordAt('every')gives the first time a word is said. For a later one, pass the time to search from:wordAt('every', 5).- See any frame as a still before a full render:
node render.mjs --stills 0.5,3,5. - To edit one section of a long take, make a section folder: trim
cut-audio.wavto the section, copy its face frames renumbered from 000001, and shift its words so the section starts at 0. render.mjsshoots every frame and encodes an MP4 every platform accepts (npm i puppeteer, thennode render.mjs). Add--format linkedin,squareorwidefor the other sizes.
What I learned the hard way
- The recording is the source of truth, not the script. People change lines on camera. Build captions and on-screen words from the cut transcript.
- Ending a scene mid-word is the cut people hear first. End each beat about 0.1s after its phrase ends.
- Never trust a transcript at a join. Listen to every one.