Short vertical video ads are the cheapest reach a small product can buy, and the most annoying thing to make. A 15 second Instagram Reel needs a script with a hook, a character who looks the same in every shot, a voice, captions that match the voice, music that ducks under the voice, a sound on every tap, and three sizes for three feeds. Then someone asks to change one line.
I tried the obvious route first: text-to-video models. The clips looked good one at a time and fell apart as an ad. The character's face drifted between shots, on-screen text came out as nonsense letters, and every change meant paying to regenerate a whole clip and hoping. So I built reel-studio, a Claude Code skill that makes the whole reel with no video model anywhere. The images come from an image model, but all the motion is drawn in code, so changing one line and re-rendering takes about a minute and costs nothing.
The hook of a reel rebuilt for this post from the skill's saved library. Everything moving here is code: the drop, the shake, the mouth flaps, the captions.
Note: The reels in this post advertise CredibleAI, which runs a contractor's Facebook and Instagram every week from their real job photos, reviews and local news, with each post approved before it goes out. If you run a trade business or know someone who does, start the 7-day free trial at credibley.com.
What reel-studio is
reel-studio is a skill: a folder Claude Code loads when you ask for a reel, holding a SKILL.md with the method, some reference files, a few scripts and a small render engine. You type something like "make a 15 second Reel for CredibleAI with two characters" and Claude runs the whole production with you, stopping at every creative decision.
- Output: MP4s at 1080x1920 (9:16), plus 4:5 and 1:1 feed cuts from the same timeline, A/B hook variants, a no-captions cut, a cover image and the post caption with hashtags per platform.
- Scripts and images: ChatGPT through the Codex CLI, on the ChatGPT plan I already pay for. No API key.
- Voices and sound effects: ElevenLabs (Fish Audio as a fallback), always behind a credit estimate and an explicit OK.
- Music and stock effects: a local library first, then Envato Elements through Claude in Chrome.
- Motion: HTML and JavaScript, screenshotted frame by frame with Playwright and encoded with ffmpeg.
- Caption timing: whisper.cpp, locally, because Claude cannot hear audio.
- Size: about 2,600 lines including the reference docs. The engine is 550 lines and the renderer 289.
The whole skill is open source under MIT at github.com/mahmadmanzoor/reel-studio. Install it with one git clone into ~/.claude/skills/ and ask Claude Code for a reel. If it saves you time, a star on the repo is the best way to tell me it's worth building more of these.
mahmadmanzoor/reel-studio: Claude Code skill that makes Reels, TikTok and Shorts ads with characters, voiceover, music and captions. No video model: motion is drawn in code.
terminal
$ git clone https://github.com/mahmadmanzoor/reel-studio ~/.claude/skills/reel-studio
$ cd ~/.claude/skills/reel-studio && npm install && npx playwright install chromium
The pipeline: a pick at every stage
The rule at the top of SKILL.md is the one that makes the whole thing usable: "Every stage ends with the user's choice; never start the next stage until they have chosen." An agent that writes a script, draws images, buys voice lines and renders a video in one go will always get something wrong early, and you only find out after it has spent money on everything downstream.
Every arrow is a stop where you choose. The script round always offers five, the music round five tracks, each sound cue five candidates.
Everything chosen goes into one file, reel.json, the moment it is chosen. That file has a stage key, so if the session dies or I come back the next day, a new session reads it and carries on from the right stage. It also means the final reel is fully described by one JSON file plus the assets it points at.
reel.json
{
"title": "...",
"stage": "brief | style | scripts | sheet | images | voices | sfx | music | render | done",
"brief": { "product": "", "platform": "", "length": 15, "captions": true, "hookVariants": 2, "cta": "" },
"style": "humor skit",
"script": { "...the locked script JSON..." },
"characters": { "dale": { "sheet": "ref/dale.jpg", "voice": { "id": "", "name": "" } } },
"music": { "envato": "TRCFCAX", "title": "", "author": "" },
"timeline": { "...what the renderer draws..." }
}
Why no video model
A video model gives you pixels. You can't move a caption 40 pixels up, swap one line of dialogue, or make the 4:5 version, without generating again. What I wanted was closer to a motion graphics project file: a description of the reel that a program turns into frames, so every change is an edit and a re-render.
So the engine treats the reel as a pure function of time. The comment at the top of engine.js is the whole design:
engine/engine.js
/*
A reel is a pure function of time: window.seek(t) paints frame t from window.REEL (the timeline that
render.mjs builds from reel.json). No CSS transitions or animations, no timers, no Math.random, no state
carried between frames, so any frame can be drawn in any order by any number of browsers.
*/
Every animation is a function of t. Easing is computed, not left to CSS: there is a cubic Bezier solver for whips and glides and a damped spring for pops and slams, so a character dropping in overshoots and settles the same way every time.
engine/engine.js
/** Damped spring step response, 0 → 1 over x in 0..1. Lower z = more overshoot. */
function springer(z = 0.62, w = 11) {
const wd = w * Math.sqrt(1 - z * z);
const end = 1 - Math.exp(-z * w) * (Math.cos(wd) + (z * w / wd) * Math.sin(wd));
return (x) => {
if (x <= 0) return 0; if (x >= 1) return 1;
return (1 - Math.exp(-z * w * x) * (Math.cos(wd * x) + (z * w / wd) * Math.sin(wd * x))) / end;
};
}
Even the "random" parts are deterministic. The talking mouth flaps at an uneven rhythm so it doesn't look mechanical, but the rhythm comes from a seeded generator (seed = (seed * 16807) % 2147483647), so frame 212 is identical on every render.
The timeline: a scene is a list of layers
The timeline key is what gets drawn. Scenes run in order and each one has a duration; start times are computed. Inside a scene there are layers: a background, characters with poses, text, UI cards and real product screenshots. Here is the hook of the reel at the top of this post, trimmed:
reel.json (timeline.scenes[0])
{
"id": "hook", "dur": 2.4,
"bg": { "gradient": "radial-gradient(circle at 50% 35%, #fbe3c2, #e8a76a)", "move": "push" },
"shake": [{ "at": 0.15, "amount": 0.6 }],
"chars": [
{ "id": "dale", "x": 0.36, "base": 0.95, "h": 0.6, "sway": 1,
"poses": [{ "at": 0, "src": "images/dale-nailgun.png" }] },
{ "id": "homer", "x": 0.72, "base": 0.9, "h": 0.3, "enter": { "type": "drop", "at": 0, "d": 0.45 },
"poses": [{ "at": 0, "src": "images/homer-dive.png", "open": "images/homer-dive-open.png" }] }
],
"texts": [{ "at": 0, "text": "WRONG\n*TOOL!*", "style": "slam", "y": 0.09, "size": 120 }]
}
Positions are fractions of the frame, times are seconds from the scene's start, and *TOOL!* means "draw this word in the brand accent". A pose with an open image gets mouth flaps whenever that character's voice is loud. Voice lines and sound effects sit in their own arrays and point at a scene and a time:
reel.json (timeline)
"voice": [
{ "char": "homer", "file": "audio/vo/01.mp3", "words": "audio/vo/01.words.json",
"scene": "hook", "at": 0.25, "emphasis": { "tool": 1.5 } },
{ "char": "dale", "file": "audio/vo/03.mp3", "words": "audio/vo/03.words.json",
"scene": "gruff", "at": 0.1, "phrases": ["I fix roofs.", "I don't / write captions."] }
],
"sfx": [{ "file": "audio/sfx/LBTZJA6.wav", "scene": "hook", "at": 0.0, "gain": -6 }],
"music": { "file": "audio/music/A7UWZ9T.wav", "offset": 0, "gain": -8, "duck": 6 }
Because sound is attached to scenes rather than to absolute times, making a scene 0.3 seconds longer moves every later line and effect with it.
Rendering: Chromium as a frame printer
render.mjs opens the engine in headless Chromium through Playwright, steps through time, screenshots each frame and pipes the PNGs into ffmpeg. Because frames don't depend on each other, it opens four browsers and lets them race.
Picture and sound are built separately and muxed at the end.
Four browsers finish frames out of order, but ffmpeg needs them in order. The renderer keeps finished frames in a map keyed by frame number and only writes when the next one ffmpeg needs is ready:
render.mjs
const pending = new Map(); let next = 0, write = 0, flushing = false;
const flush = async () => {
if (flushing) return; flushing = true;
while (pending.has(write)) { const b = pending.get(write); pending.delete(write); if (!enc.stdin.write(b)) await once(enc.stdin, "drain"); write++; }
flushing = false;
};
await Promise.all(ws.map(async (w) => {
for (;;) {
const i = next++; if (i >= total) break;
pending.set(i, await grab(w, i / FPS)); await flush();
while (i - write > 48) await new Promise((r) => setTimeout(r, 4)); // don't run too far ahead
}
}));
grab seeks the page, waits two animation frames so layout has settled, and takes the screenshot directly over the Chrome DevTools Protocol (Page.captureScreenshot with optimizeForSpeed: true). The video is encoded with libx264 at CRF 17 and tagged bt709 so colours don't shift on phones.
On my M1 Pro MacBook the reel in this post, 17.9 seconds at 30 fps, rendered like this:
$ node render.mjs demo
picture: 537 frames in 26s
{
"file": ".../demo/out/reel.mp4",
"duration": 17.9,
"captions": true,
"captionGroups": 14,
"speakers": ["homer", "dale"],
"master": { "I": -14.1, "TP": -1.2 },
"final": { "I": -14.1, "TP": -1.1 }
}
The sound: measure after the encode, not before
Every voice clip is delayed to its line's time, the voices are mixed and gently compressed, and the compressed voice is split in two: one copy goes to the mix, the other becomes the key of a sidechain compressor on the music. The music dips whenever someone speaks and comes back in the gaps, with no volume keyframes to maintain.
render.mjs
if (vs.length) chains.push(`${vs.join("")}amix=inputs=${vs.length}:normalize=0:duration=first,atrim=0:${D},acompressor=threshold=0.08:ratio=3:attack=5:release=120:makeup=1,asplit=2[voice][key]`);
// ...
if (vs.length) chains.push(`[mus][key]sidechaincompress=threshold=0.02:ratio=${M.duck || 6}:attack=15:release=350:makeup=1[music]`);
Then the master goes to -14 LUFS, the loudness Instagram, TikTok and YouTube normalize to, with a true peak under -1 dB. The first version measured the peak on the WAV, saw -1 dB, and shipped. The final MP4 measured -0.8 dB, because AAC encoding adds inter-sample peaks the limiter never saw. Now the loop measures the file people will actually download:
render.mjs
// the true peak is measured on the encoded file; if AAC pushed it over -1 dB, lower the ceiling and mux again
for (let ceiling = -2; ; ceiling -= 0.75) {
rep = buildAudio(audio, ceiling);
ff(["-i", video, "-i", audio, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", ...]);
fin = loud(final); // ffmpeg ebur128=peak=true on the MP4
if (rep.silent || !(fin.TP > -1) || ceiling <= -4) break;
}
Tip: One more bug worth knowing if you build anything like this: an ffmpeg
apadwith no length on an input that feedsamixor a sidechain can make ffmpeg spin for over an hour. Every input here is padded and trimmed to exactly the reel length (apad=whole_dur=D,atrim=0:D) before it touches anything else.
Voices, mouths and captions
Claude can read a script but it cannot hear the voice it just paid for. Two small programs listen on its behalf.
The voice clip is the source of truth for both.
The mouth
Each speaking pose is drawn twice, closed and open. render.mjs decodes the clip to 16 kHz mono, measures RMS loudness for every video frame, and builds a string of 0s and 1s per character that the engine reads as it draws:
render.mjs
const peak = Math.max(...rms), gate = peak * 0.14;
const m = (mouth[v.char] ||= Array(N).fill("-"));
// the mouth opens and shuts at an uneven 2-4 frame rhythm while the voice is loud, and stays shut in pauses
let open = false, hold = 0;
rms.forEach((r, i) => {
const f = Math.round(at * FPS) + i; if (f < 0 || f >= N) return;
if (r < gate) { m[f] = "0"; open = false; hold = 0; return; }
if (hold-- <= 0) { open = !open; hold = 1 + Math.floor(rnd() * 3); }
m[f] = open ? "1" : "0";
});
It is not lip sync, and for cartoon characters it doesn't need to be. The mouth moves only while that character is talking, stops dead in the pauses, and never moves for the character who is listening.
The two poses the engine flips between. Only the mouth may differ, or the face jitters.
Getting the open pose right took a script of its own. The image model can make an open-mouth edit of a pose, but it moves other things a few pixels too, and flipping between the two images makes the eyes twitch. img.py remouth fixes that: it fills the closed mouth with the face colour and pastes in only the connected dark region that grew between the two poses, seeded from the new pixels, with its holes filled so the tongue comes along.
The captions
Captions are timed from the audio, not the script. words.py runs whisper.cpp locally with one word per segment, then aligns what Whisper heard to what the script says. The script wins on spelling (Whisper hears "credible AI", the script says "CredibleAI"); Whisper wins on timing.
scripts/words.py
said = re.sub(r"\[[^\]]*\]", " ", text).split() # drop delivery tags like [excited]
sm = difflib.SequenceMatcher(a=[norm(w["w"]) for w in heard], b=[norm(w) for w in said], autojunk=False)
timed = [None] * len(said)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal" or (tag == "replace" and i2 - i1 == j2 - j1):
for k in range(j2 - j1): timed[j1 + k] = (heard[i1 + k]["a"], heard[i1 + k]["b"])
elif tag == "replace": # e.g. "credible AI" heard for "CredibleAI": spread the heard span over the written words
a, b = heard[i1]["a"], heard[i2 - 1]["b"]
for k in range(j2 - j1): timed[j1 + k] = (a + (b - a) * k / (j2 - j1), a + (b - a) * (k + 1) / (j2 - j1))
The output for one line looks like this, and it is what drives the caption on screen:
audio/vo/03.words.json
[{"w": "I", "a": 0.11, "b": 0.16}, {"w": "fix", "a": 0.16, "b": 0.63}, {"w": "roofs.", "a": 0.63, "b": 1.62},
{"w": "I", "a": 1.62, "b": 1.68}, {"w": "don't", "a": 1.68, "b": 1.98}, {"w": "write", "a": 1.98, "b": 2.28},
{"w": "captions.", "a": 2.28, "b": 3.2}]
The renderer groups words into captions of up to three words or 20 characters, breaking at punctuation and at pauses over 0.35 seconds. A group never spans a cut, and it is gone two frames before its shot ends, so you never see a word flash across a transition. Words listed under emphasis grow and turn the accent colour when spoken.
Images through Codex, on a plan I already pay for
Scripts and images go through the Codex CLI signed in with ChatGPT, so they bill against the ChatGPT plan rather than an API key. The wrapper is 32 lines of zsh. For images it asks Codex to use its built-in image tool and copy the PNG to an exact path, then checks the file exists:
scripts/codex.sh
if [[ $mode == text ]]; then
codex exec -m "$model" --skip-git-repo-check -s read-only -C "$dir" -o "$out" - < "$prompt" > "$log" 2>&1
elif [[ $mode == image ]]; then
refs=()
for r in "$@"; do refs+=(-i "${r:A}"); done
{ cat "$prompt"; print "\n\nUse your built-in image generation tool. Then copy the generated PNG to exactly this path: $out\nReply with only that path."; } |
codex exec -m "$model" --skip-git-repo-check -s workspace-write -C "$dir" "${refs[@]}" - > "$log" 2>&1
fi
if [[ ! -s $out ]]; then
print -u2 "codex produced no $out — last lines of $log:"; tail -5 "$log" >&2; exit 1
fi
The prompt goes in on stdin because -i takes several files and would swallow a prompt passed as an argument. Text runs are read-only; only image runs get write access, and only to the project folder.
Character consistency comes from a reference sheet. Before any scene image exists, the skill makes one sheet per character and waits for approval. Every later pose is generated with that sheet attached as a reference image.
Dale's approved sheet. Every pose in the reel was drawn from this.
A few rules in SKILL.md came straight from bad renders:
- One pose per image, full size, transparent background. Pose grids come out around 500 px per cell and look soft at 1080 wide.
- Backgrounds separately, with nobody in them. Characters and backgrounds are separate layers, so the camera can push on the background while the characters stay put.
- Never an AI mockup of the product's screens. The demo uses the real screenshots you give it, or code-drawn notification and chat cards if you have none.
Money: estimates first, and a library so you pay once
Voice and sound effect generation costs ElevenLabs credits, so the skill has one rule it never bends: before any paid generation it shows exactly what it will generate, runs ElevenLabs with estimate_only: true, and waits for an explicit OK. Subagents are never allowed to spend.
The second half of that is the library. Every character (sheet, poses, voice), every generated voice line, every licensed track and effect is kept under library/ and checked before anything is paid for again. Voice lines are keyed by provider, voice, model and the exact text with its delivery tags:
$ node scripts/cast.mjs line get elevenlabs kSSJYNGo5Kev6qgwVloV eleven_v4 "[gruff] I fix roofs. I don't write captions."
If that voice has said that line before, the file comes back for free. The reel in this post was rebuilt entirely from the library: two saved characters, six saved voice lines, a licensed track and four licensed effects. It cost zero credits, and the only paid step in a normal run, the voices, was skipped because all six lines already existed.
Working in parallel without losing control
A run has a lot of independent work: three characters' images, a music shortlist, effect lookups, Whisper on each clip, several renders. The main session stays in charge of anything that needs a person or money and hands the rest out, each on the cheapest model that can do it well.
Cheap models for lookups, mid models for judgment, the big model only for the final cut.
The limits in that diagram are all things that broke. ElevenLabs returns 429 rate_limited if you send two calls at once (nothing is charged, but the call fails). Two renders running at the same time once wrote the same mix.wav, and one reel came out with the other reel's audio; the mix file is now named per output. And only one agent may drive Chrome at a time, because two agents clicking in the same Envato tab do not end well.
Checking the output before you see it
Rendering 18 seconds takes under half a minute, but looking at a whole video to find one bad frame is slow. The renderer has review modes. --stills scene makes a contact sheet with one frame from each shot, and --safe draws the areas Reels and TikTok cover with their own UI: the bottom 20% (caption and username), the right 14% (like, comment and share buttons) and the top 7%.
node render.mjs demo --stills scene: the whole reel at a glance.
--safe draws what Instagram and TikTok will cover. Nothing important goes in the red.
My first pass of this demo put the captions across Dale's face in three shots. The contact sheet showed it in one image, I moved the caption y per scene, and re-rendered the sheet in a few seconds.
Hook variants and feed sizes from one file
Ads are A/B tested on the first two seconds, so the timeline can carry alternative opening scenes. Scene 1 is hook 1 and hooks[0] is hook 2. A hook brings its own voice and effects, because swapping only the picture once left scene 1's line playing under the new opening.
node render.mjs demo --hook 2 renders the same reel with a different first scene.
Feed sizes come from the same timeline too. With --size 4:5 or --size 1:1 the engine remaps x into the safe width for that size (feeds have no button column), makes characters slightly bigger and text slightly smaller. Most reels need no overrides, and the ones that do get a small timeline.sizes patch for just that size.
Same timeline, three sizes, no manual layout.
terminal
node render.mjs <project> # out/reel.mp4
node render.mjs <project> --no-captions # out/reel-nocaptions.mp4
node render.mjs <project> --hook 2 # out/reel-hook2.mp4
node render.mjs <project> --size 4:5 # out/reel-4x5.mp4 (also 1:1)
node render.mjs <project> --cover # out/cover.png
node render.mjs <project> --stills scene --safe
node render.mjs <project> --frame 6.0 # one PNG at t = 6.0 s
A ready-made format: retro anime suspense
The newest addition is a reel type with fixed beats: a late-1970s TV anime suspense scene builds fake tension ("you won't believe what this does"), hard-cuts to a warm hand-held phone demo of the real product, and ends on a shocked face. The story changes every time; the structure, look, sound and motion rules don't.
Fixed formats are where an agent can be held to rules, so this one comes with a scaffold and a checker. The scaffold writes a timeline that already follows the format, plus the list of images to make:
$ node scripts/retro-scaffold.mjs reels/demo --cut core --app "Demo App" --accent "#0E9F6E"
scaffolded core cut, 10 scenes, 31.49 s: reels/demo
next: fill slots in reel.json, write the lines, then make the 19 images in SHOTS.md
The checker reads reel.json and fails the build on the mistakes that were corrected by hand in earlier reels. Every rule in it was a piece of feedback first:
scripts/retro-check.mjs
if (byId('phone')?.in) fail(`"phone" has an "in" transition: the reveal must be a hard cut`);
if (voice.some(v => v.scene === 'gulp')) fail(`"gulp" has speech: it must be silent`);
for (const s of scenes) {
for (const c of s.chars || [])
if (!c.still) fail(`"${s.id}": character "${c.id}" lacks still: true, idle motion reads as bubbling`);
if (s.punch?.length) fail(`"${s.id}": has punch-in zooms, add a real shot instead`);
}
if (t.captions?.on && t.captions.style !== 'steady') fail(`captions.style is "${t.captions.style}", use "steady"`);
Run against the fresh scaffold, before any images exist, it does what it should:
$ node scripts/retro-check.mjs reels/demo
FAIL 18 missing file(s): images/bg-world.png, images/listener-listen.png, ...
warn "device" has no tap markers yet
10 scenes, 31.49 s, reveal at 8.75 s: 1 failed, 1 warning(s)
This checks the rules, not the look. Still watch the frames and listen.
What it got wrong in real use
The skill keeps a LOG.md of every mistake from real runs. An entry moves into SKILL.md as a rule once the same thing happens twice. Some of the entries that changed how I build these tools:
-
"Why are the characters bubbling up and down?" No single frame looked wrong. The idle breathing, a talking bob and a slow camera push together made flat cutouts jitter once the video played. Characters in that format are now
still: trueand the checker enforces it. Single frames lie about motion. - Fast means more shots, not more camera. Asked for a faster cut, I added zooms on the talking shots and was told most of those frames were pointless. Seven real shots at the start fixed it.
- A second music track never blends. A heavier track for the opening fought the main groove at the cut. Extending the original track's tense intro, so its lift still landed on the reveal, worked.
- A stock track can pass every check and still be unavailable. The picked track showed "Subscribe to download" only at the last step. Now download availability is checked before a track is offered.
-
Whisper smeared a slow narrator. A deep, slow voice with long pauses got word timings that ran through the silences, so captions led the voice.
ffmpeg silencedetectshowed the real gaps. Slow reads get that check now. -
A silent no-op in Pillow.
ImageDraw.floodfillon an image made withImage.fromarraychanged nothing and raised nothing, so every pixel counted as inside the mouth..copy()first. If a fix appears to do nothing, count the changed pixels. -
Off-by-one in the hook numbering.
--hook 2first readhooks[1], so the first alternative hook could never be rendered. Scene 1 is hook 1 now, and the error message names the index.
Build the core yourself in 40 lines
You can install the full skill from the repo, but the idea that makes it work fits in two small files, and you can run it today. A page exposes seek(t), a script drives it with Playwright and pipes frames to ffmpeg. I ran exactly this to check it.
- Install Playwright and ffmpeg
Any recent Node works. ffmpeg comes from Homebrew on a Mac.
$ npm i playwright
$ npx playwright install chromium
$ brew install ffmpeg
- Write a scene that is a function of time
No CSS animations, no timers, no randomness. seek(t) sets every style from t alone.
<!doctype html>
<html><body style="margin:0;width:1080px;height:1920px;overflow:hidden;background:#f6d7b0;font:900 120px system-ui">
<div id="title" style="position:absolute;left:0;right:0;top:300px;text-align:center">WRONG TOOL!</div>
<div id="ball" style="position:absolute;width:220px;height:220px;border-radius:50%;background:#f28c28;left:430px"></div>
<script>
// A frame is a pure function of t: no timers, no Math.random, no state between frames.
const spring = (x, z = 0.45, w = 14) => x <= 0 ? 0 : 1 - Math.exp(-z * w * x) * Math.cos(w * Math.sqrt(1 - z * z) * x);
window.seek = (t) => {
const k = Math.min(1, t / 0.6);
title.style.transform = `scale(${0.4 + 0.6 * spring(k)})`;
ball.style.top = `${1500 - 900 * Math.abs(Math.sin(t * 2.4)) }px`;
};
window.seek(0);
</script></body></html>
- Render it with four browsers
Each browser takes the next frame number, seeks, screenshots, and hands the PNG to an in-order writer that feeds ffmpeg.
// node render.mjs scene.html out.mp4 3 -> 3 seconds at 30 fps, 4 browsers in parallel
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { pathToFileURL } from "node:url";
import path from "node:path";
const [page = "scene.html", out = "out.mp4", secs = "3"] = process.argv.slice(2);
const FPS = 30, total = Math.round(+secs * FPS), WORKERS = 4;
const enc = spawn("ffmpeg", ["-v", "error", "-y", "-f", "image2pipe", "-c:v", "png", "-framerate", String(FPS), "-i", "-",
"-c:v", "libx264", "-crf", "17", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out], { stdio: ["pipe", "inherit", "inherit"] });
async function open() {
const browser = await chromium.launch();
const p = await browser.newPage({ viewport: { width: 1080, height: 1920 } });
await p.goto(pathToFileURL(path.resolve(page)).href);
return { browser, p };
}
const ws = await Promise.all(Array.from({ length: WORKERS }, open));
// frames finish out of order; hold them until the next one ffmpeg needs is ready
const done = new Map(); let next = 0, write = 0;
async function flush() {
while (done.has(write)) { const b = done.get(write); done.delete(write); write++; if (!enc.stdin.write(b)) await once(enc.stdin, "drain"); }
}
await Promise.all(ws.map(async ({ p }) => {
for (let i; (i = next++) < total; ) {
await p.evaluate((t) => window.seek(t), i / FPS);
done.set(i, await p.screenshot({ type: "png" }));
await flush();
}
}));
enc.stdin.end(); await once(enc, "close");
await Promise.all(ws.map((w) => w.browser.close()));
console.log(`${out}: ${total} frames`);
- Run it
Three seconds of 1080x1920 video, 90 frames, in a little over two seconds on my machine.
$ node render.mjs scene.html out.mp4 3
out.mp4: 90 frames
$ ffprobe -v error -show_entries stream=width,height,nb_frames,r_frame_rate -of csv=p=0 out.mp4
1080,1920,30/1,90
From there, everything in reel-studio is layers on top: images as positioned <img> elements, an audio mix built separately in ffmpeg and muxed at the end, captions from Whisper timings, and a JSON file that describes it all so an agent can edit it. The important decision is the first one. Once a frame depends only on t, you can render in parallel, re-render one second to check it, and change any detail without starting over.
The code is all on GitHub: mahmadmanzoor/reel-studio. Clone it, make a reel for your own product, and if it helped, star the repo so other people building with Claude Code can find it. Issues and pull requests are welcome too.
If you want to see what these reels are selling, CredibleAI has a 7-day free trial: it turns a contractor's real jobs into a week of Facebook and Instagram posts. And if you build tools for coding agents, I wrote about how CLAUDE.md, skills and hooks fit together and how to make coding agents prove their work, which is where the review modes in this skill came from.
Originally published on my site. More posts and projects at mahmadmanzoor.com.











Top comments (0)