Video
Make videos entirely in code from a directed brief - narrated explainers with animated diagrams (Manim), walkthroughs of a real app screen for product demos and training, launch films that mix kinetic type, real UI and clips, and beat-synced UI motion loops (HTML + springs, rendered with Playwright and ffmpeg). Writes the script, records an ElevenLabs voice, times every motion to the narrator's words, burns in captions, scores it, and reviews its own frames like an animator (contact sheets, onion skins, speed graphs). Use when the user asks for an explainer video, animated diagram, product demo, app walkthrough, training or onboarding video, launch or feature video, social video, UI animation or motion design. Not for AI-generated footage (Sora, Veo, Runway) or for editing camera footage.
- 1Download the zip. It unpacks to a folder named
videowith aSKILL.mdinside. - 2Claude app or Cowork: open Settings, then Capabilities, then Skills, and upload the zip. Claude Code: unzip it into
~/.claude/skills/(or.claude/skills/inside a project). - 3Ask for it. Describe the task in plain words, or run
/videoin Claude Code. EditSKILL.mdto make it yours.
curl -L -o video.zip https://www.allbookedpodcast.com/skills/video.zip && unzip -o video.zip -d ~/.claude/skills
What the skill does
Every video here is drawn in code. The result is one MP4 (H.264, AAC at −16 LUFS) with a soft captions track, plus a sidecar .srt. <skill> is this skill's folder: the directory that contains this file.
Before you start
- Check the machine once:
bash <skill>/scripts/setup.sh. It installs Playwright and Chromium into a private cache folder. It also checks ffmpeg, Node, uv, Manim and the ElevenLabs key. Fix anything marked ✗ before you render. - Read the user's preferences, if they exist:
VIDEO.mdin the current project, then~/.config/video-skill/preferences.md. The project file wins. Preferences can set the voice, languages, output folder, brand colors and fonts, delivery steps, and whether to make a video on their own initiative. They change defaults. They never relax the privacy and accuracy rules below. - Pick a folder: the one the user names, else
videos/<slug>/in the current directory. In a git repository, addmedia/,render/,sfx/,qa/,out/andaudio/narration.wavto.gitignore.
Make a video when the user asks for one, or when their preferences say to. Do not make one on your own initiative otherwise: narration costs the user ElevenLabs credits.
Pick the kind
Each kind names its engine; Choosing an engine covers GSAP, Remotion, HyperFrames and plain HTML.
- Explainer. How a system, process or idea works: narrated diagrams in Manim, in the style of 3Blue1Brown, with every animation timed to the narrator's words. Build it with Explainer (Manim). Copy from
examples/explainer/. - Walkthrough. A real screen used step by step, with a pointer that clicks each control as the narrator names it. Use it for product demos, feature tours, training and onboarding. A step list on the left and the app on the right work well for training; a full-frame app works for demos. Build it with UI motion (HTML). Copy from
examples/walkthrough/. - Launch film. A short narrated or music-driven piece for a launch or a social post: kinetic type, a redrawn UI, real clips of other videos (
frames()), burned-in captions and a music bed, all in one HTML page. Change scenes withtransitions.js, and give it a film finish if it suits (Transitions and the film finish). Copy fromexamples/transitions/. For a longer film, write one scene module per narration scene. - Loop. No voice. One shape morphs through 6–12 UI states and the last frame matches the first, for social posts and feature teasers. Each change lands on a beat, but not on every beat: change state about every 4–8 beats (2–4 s at 120 BPM), so each state holds long enough to read. Use the beats in between for the pointer's approach and small reactions. Build it with UI motion (HTML). Copy from
examples/loop/.
Pick the frame for where the video will play:
| Frame | Size | Use |
|---|---|---|
| 16:9 | 1920x1080 | YouTube, docs, README, slides, training sites |
| 1:1 | 1080x1080 | LinkedIn and other feeds (fills more of a phone screen) |
| 4:5 | 1080x1350 | Instagram and LinkedIn feeds |
| 9:16 | 1080x1920 | Reels, Shorts, TikTok. Keep text inside the middle 80%, clear of app buttons |
Feeds autoplay without sound, so a feed video must make sense from its pictures and captions alone.
For an explainer or social short that should look made by hand, draw the HTML page with a style pack: riso print, pixel art or cut paper (Style packs). Each pack brings its own LOOK, MOTION and BAN lines for the brief. Never use one for a product walkthrough.
Direct it
Write brief.md in the project folder before you animate, from the directed brief template:
- GOAL, TIME, OUTPUT and a shot list with frame ranges;
- LOOK, MOTION, CAMERA, SOUND, BAN and QA.
Every adjective in the request becomes a number, using the reference's tables. Start BAN with the reference's list, and add every default the model used anyway after the first render. Follow the reference's render contract. Show the brief to the user first when the request is open-ended.
Keep a log.md beside it: one entry per review round, with notes tied to frame numbers. Most finished pieces take 2–4 rounds.
Pace
Slow down. Agent-made animation is usually too fast and shows too much at once. Read animation principles before you plan, and review the draft against it. In short: one change at a time, each settled state held at least 1.5 s plus reading time, and more time for bigger moves.
Shared steps
- Research. Read the real sources: the code, the docs, the data. Every number, name and example on screen, and every fact the narrator says, must be true. Use the product's real labels and a realistic example with sample data. Never show passwords, keys, access codes or personal data; mask a code as
• • • •, and use made-up names. - Direct. Write
brief.mdand startlog.md(Direct it). - Script. Write
narration.json(format below) in plain writing: short sentences, one idea each, the same word for the same thing. Plan one visual per sentence. Narration runs about 130 words per minute. A full explainer usually has 6–10 scenes of 15–30 seconds; a short one, likeexamples/explainer, can have 4. A walkthrough opens with the situation, does the task step by step, and ends with a one-line recap. - Narrate.
uv run --script <skill>/scripts/narrate.py <project>. It records one unedited ElevenLabs take per scene, transcribes each take, and retries below a 0.93 match, because a take sometimes repeats or drops a phrase. Re-runs only record scenes whose text changed. The key comes fromELEVENLABS_API_KEYor a lineELEVENLABS_API_KEY=...in~/.config/elevenlabs/env. Never print it. Each take uses the user's ElevenLabs credits, so say roughly how much narration you will record. - No key, or still working on timing? Add
--scratch. The computer's own voice (macOSsay, espeak-ng on Linux) reads each scene for free, with the same files and word timings, so every later step works unchanged. A later run without--scratchreplaces those takes, andmux.pyandassemble.pywarn while any remain. See Scratch voice. - Review. Look at frames before you change anything, and give yourself one or two notes per round, tied to frame numbers (review loop).
node <skill>/scripts/review.mjsmakes: sheet: a labeled contact sheet of a page or MP4;onion: an onion skin of one move;graph: a speed graph of an element, with a soft or hard start and stop;holds: holds per scene;audit: runs checks of the brief's numbers that the page declared withcheck(), on every frame they name (--greppicks checks by name). It exits 1 on a failure;board: a numbered storyboard of the page's shots, or 2–4 full-size look frames (--look), for the user to approve by panel number;lint: finds text that is clipped, outside the safe area, over other text, under 28 px, or under 4.5:1 contrast with the real pixels behind it. It exits 1 on a finding that is not allowed (storyboard and lint).cuts: the motion on each side of every cut and transition, with a strip of frames across each. It warns on a speed jump above 3× or a reversal.
Run --help for the options. When something breaks, see When things break.
- Deliver. Put the MP4 and
.srtin the project'sout/folder, or where the user's preferences say. Re-rendering replaces the project's ownout/files; when you copy a video anywhere else, never overwrite an existing file (cp -n). Report the path, the length and the voice, and anything you simplified. For a walkthrough, say that the screen is a redraw with sample data.
{"title": "How a request finds a server", "voice_id": "JBFqnCBsd6RMkjVDRZzb", "model_id": "eleven_v4",
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
"scenes": [{"id": "s01", "text": "You type an address and press Enter. ..."}]}
Optional fields: language (ISO 639-1, default en; it labels the caption track) and, for HTML videos, timeline ({"gap": 1.0, "lead_in": 1.0, "tail": 2.0} in seconds: the silence between, before and after scenes).
Voices: George (JBFqnCBsd6RMkjVDRZzb, a warm storyteller) is the default. Calm alternatives: Daniel onwK4e9ZLuTAKqWW03F9, Alice Xb7hH8MSUJpSbSDYk0k2. Any voice in the user's ElevenLabs library works; ask before using a cloned voice of a real person. Keep one voice per series. If the user's plan has no access to eleven_v4, set model_id to a model it has, such as eleven_multilingual_v2.
Sound
- Picture first. Feeds autoplay muted, so the picture and burned-in captions (
captions()) must tell the story alone. - Music. Use the user's licensed track, or generate a royalty-free bed on the beat grid:
uv run --script <skill>/scripts/bed.py --out bed.wav --bpm 120 --bars 16 --mood calm|drive --beats beats.js. Itsbeats.jsis exact, sobeat(n)lines up with every kick. For a loop, add--loopto bothbed.pyandmux.py: the bed repeats without fades, and sounds that ring past the end wrap to the start. Most phone speakers play little below about 100 Hz, so for feeds, prefer the calm mood or keep the bed's weight above that. - UI sounds come from
sfx().mux.pyplaces each one a frame after its picture (--sfx-lag 1), because sound that arrives before its picture feels wrong sooner than sound that is slightly late. - Loudness. Mix to −14 LUFS for social video (
mux.py --social), and −16 for training and the web (the default). True peak stays at or below −1 dBTP. - The model can't hear, so measure.
uv run --script <skill>/scripts/check_sync.py out/<Title>.mp4 --sfx render/video.sfx.jsonreports each sound's offset in frames, the loudness, the peak, and the share of energy below 60 Hz, which phone speakers can't play.
Export
uv run --script <skill>/scripts/export.py prepares the finished video for where it will be posted:
--platform linkedin|x|instagram|reels|youtube|web --out post.mp4: a web-ready MP4 that every phone plays, with a warning if the frame doesn't suit the platform;--gif loop.gif: one palette for the whole clip, at 25 fps (exact in GIF timing);--xchecks X's 350-frame limit;--poster 12.5 --out poster.jpg: a poster frame.
Other languages
Make each language its own project: <slug> for the first language and <slug>.<lang> (for example <slug>.es) for each translation. You write the translation; no tool does it.
- Translate
narration.json. Keep the scene ids, set"language", choose a native voice for that language, and say exactly what the original says. Copy anytimelineblock. - Record it with
narrate.py. - Copy the animation (
video.pyorindex.html) and translate every on-screen string. Keep an app's labels as the app shows them in that language; if the app is only in English, keep its labels in English so viewers recognize them. - Change every cue to the words the narrator now says at that moment. Cues ignore case, accents and punctuation, in any alphabet. Languages written without spaces (Chinese, Japanese, Thai) get one timing per phrase, so cue by phrase or by scene start.
- Render and check it like the original. Translations often run 10–20% longer, so check that each state still holds long enough to read, and that longer text still fits its box.
- Link shared pictures instead of copying them:
ln -s ../<slug>/assets <slug>.es/assets.
Explainer (Manim)
- Research, script and narrate (shared steps).
- Animate. Write
video.py: oneNarratedsubclass per scene withSCENE_ID, usingself.play_at("cue words", ...)so each animation starts as the narrator says those words, andself.finish()at the end. Usekitfor the shared palette, font (Nunito Sans, bundled), cards, nodes and graphs. Follow animation principles; in Manim that means:
- Stage one idea at a time. Cue each element on its own words; do not fade in a title, cards and arrows with one
play_at. Bring in a group withLaggedStart(..., lag_ratio=0.15). Draw an arrow only after both of its ends are on screen. - Show change by moving or transforming what is already on screen, not by fading in a new picture. Moves follow an arc: put
path_arc=0.4on the animation itself (mob.animate(path_arc=0.4).move_to(...)orTransform(a, b, path_arc=0.4)), not onplay_at. - Keep
play_at's 1 s default for fades and small changes. Userun_time=1.5to2for moves across the frame and for transforms.
- Draft render and check. Render at 480p, then build a contact sheet per scene and look at every one: overlaps, text off screen, arrows over labels, empty stretches, facts on screen that the narration contradicts, several things appearing at once, and states that change before they can be read.
- Final render and assemble. Render at 1080p30, then run
assemble.py. It pads narration to each scene, joins the scenes, normalizes loudness, and adds captions. - Deliver (shared steps).
export EXPLAINER_DIR="$PWD" PYTHONPATH="<skill>/scripts"
uv run -q --python 3.12 --with manim==0.21.0 manim -ql --disable_caching -a video.py # draft, 480p15
mkdir -p qa && for f in media/videos/video/480p15/S*.mp4; do
ffmpeg -v error -y -i "$f" -vf "fps=1/3,scale=427:240,tile=3x3" -frames:v 1 "qa/$(basename "$f" .mp4).png"; done
uv run -q --python 3.12 --with manim==0.21.0 manim --resolution 1920,1080 --frame_rate 30 --disable_caching -a video.py
uv run --script <skill>/scripts/assemble.py . --videos media/videos/video/1080p30 --out "out/<Title>.mp4"
Manim keeps the frame 8 units tall, so a 9:16 frame (--resolution 1080,1920) is only 4.5 units wide: lay it out vertically.
Gotchas:
- Avoid
MathTex,Tex,DecimalNumberandIntegerunless LaTeX is installed (setup.shreports it). Count with aValueTrackerand analways_redrawText. - Cues search forward from the previous cue. Do not cue the same word twice for one moment; a hyphenated word ("eighty-nine") can be cued by either part.
- Draw lines behind cards with
kit.card(..., solid=True)andset_z_index, or the lines cross the text. path_arctilts text when the same animation also scales it. Move first and scale after, or move along a custom arc path.GrowArrowfails on aCurvedArrow. UseArrow(start, end, path_arc=...)instead.AddTextLetterByLetterhides letters by opacity, so the text keeps its full width while it types. A caret must follow the last visible letter.FadeTransformbetween two different texts garbles the words halfway. To change a header, fade the old one out upward and the new one in.- Manim's
add_soundneeds pydub, which does not work on Python 3.13+. Audio is added inassemble.pyinstead. - Some ffmpeg builds have no
drawtext; put all on-screen text in Manim. - A 4-minute 1080p render takes a minute or two. Iterate on drafts freely.
UI motion (HTML)
One HTML page draws every frame as a pure function of time (window.seek(t)). Playwright screenshots it, ffmpeg blends subframes for motion blur and joins the chunks, and mux.py adds the voice, UI sounds, an optional music bed and captions.
Look:
- One shape, no cuts. Each state is the same container changing size, corner radius and color while its content swaps with a quick blur (
swap,presence). - A pointer (or a touch dot for phone screens) triggers every change with real taps and drags.
- Springs throughout with only slight overshoot (
SPRING.*, or a damping ratio of 0.8–0.85 for objects and 1.0 for text). Choose the spring by the size of the change:snappyonly for presses and toggles,smoothfor content swaps and short moves,gentlefor layout changes, andcamera(or slower) for zooms. The camera zooms so each state fills the frame, and moves only after the content has settled. - A light, warm-gray canvas, the app's own colors, and one UI font.
assets/fontsbundles Geist (geist-wght-normal.woff2, full Latin plus symbols such as → and ≤) and Geist Mono for code and numbers. Geist has no Greek, so add Inter's Greek subset under the same family name; the browser then draws characters such as ζ from it: ``css @font-face { font-family: "Geist"; src: url("/_skill/assets/fonts/geist-wght-normal.woff2"); font-weight: 100 900; } @font-face { font-family: "Geist"; src: url("/_skill/assets/fonts/inter-greek-wght-normal.woff2"); font-weight: 100 900; unicode-range: U+0370-03FF; }`` - Banned: bouncy easing, particle bursts, glows, gradients on UI chrome, mixed icon stroke widths, dead time, anything that looks like a template. A hold on a settled state that the viewer is reading is not dead time. The film finish (grain, vignette, light sweeps, a LUT) is for launch and social films only, never walkthroughs or training.
- For a real product, redraw the real screen from its source code or screenshots: the same words, colors and layout. Read the component and copy its strings and hex colors. Use sample data and made-up names.
Pipeline:
- Research (shared steps). Read the real screen's code or screenshots and the docs for the facts.
- Plan. List the states and when each happens (word cues or beat numbers), and check the list against animation principles. Show the plan to the user before animating if the request is open-ended. For a clear request, go ahead.
- For sign-off before narration and full renders, build the key poses at planned times. Render look frames with
review.mjs board index.html --look. Then list the shots inwindow.SHOTS = [{t, title, note, sound}]and render the numbered storyboard withreview.mjs board index.html. The user approves or rejects each panel by its number (storyboard and lint).
- Audio first.
- Walkthrough: write
narration.jsonand runnarrate.py(shared steps), thenuv run --script <skill>/scripts/timeline.py .. This writesaudio/narration.wavandwords.js(global word times). - Loop with music:
uv run --script <skill>/scripts/beats.py track.mp3 --out beats.js, then usebeat(n)for every action. Start onBEATS.first(a downbeat). Use only a track the user has the right to use (their own, or royalty-free with a license that allows it). - Silent loop: no audio step. Define
window.BEATS = {bpm: 120, first: 0.5}in the page and usebeat(n)the same way.
- Build
index.html. Loadwords.jsorbeats.js, thenimport {...} from "/_skill/runtime/motion.js". Build all tracks once at load, measure element positions with the camera at identity, then definewindow.seek(t)and finallywindow.VIDEO = {width, height, duration, stills: [...], cuts: [...]}. - Check stills.
node <skill>/scripts/review.mjs sheet index.html --out qa/sheet.png --at stills(or--frames 12,--from,--to) renders a labeled contact sheet. Look at every cell: clipping, overlaps, unreadable text, empty or gray frames, anything off the cue. Then runreview.mjs lint index.html, which measures what the eye misses at thumbnail size. Write your notes inlog.mdbefore you change code. - Draft.
node <skill>/scripts/render.mjs index.html --out qa/draft.mp4 --fps 30 --sub 1 --scale 0.5 --debugtakes about 10 seconds per minute of video on a fast machine.--debugprints the frame, time and current cue on every frame. Graph each hero move (review.mjs graph index.html --select "#card" --from 3 --to 5), check the onion skin of anything that travels, and runreview.mjs holds. - Final.
node <skill>/scripts/render.mjs index.html --out render/video.mp4(60 fps, 4 subframes; about 100 seconds per minute of 1080p). It also writesrender/video.sfx.json. - Mix.
uv run --script <skill>/scripts/mux.py render/video.mp4 --out "out/<Title>.mp4" --narration audio/narration.wav --words words.js --sfx-dir sfx. Add--music bed.wavfor a bed that ducks under the voice, and--socialfor −14 LUFS. A silent loop needs only--sfx-dir sfx, or--music. The output gets soft captions and a sidecar.srtwhen there is narration. Then runcheck_sync.py(Sound). - Deliver (shared steps), with
export.pyfor each platform.
motion.js in brief:
track(v).to(t, value, spring): each change adds one closed-form spring, so values stay pure functions of time even when targets change mid-motion. Read a track withx(t)..set(t, v)jumps..drag(t0, t1, fn)follows the pointer directly; the next spring starts from the release point and carries its velocity.swap(t, at)gives{out, in}looks with separate exit and enter timing.presence(t, on, off)fades an element in and out. Apply looks withstyle(el, look, css).cue("phrase")returns the time just before the narrator says the phrase. Cues search forward, like the explainer'splay_at, and a missing cue throws.pointer(x, y):.tap(t, x, y, sound),.moveTo(t, x, y),.drag(t0, t1, path),.appear(t),.hide(t). Moves travel on a gentle arc unless you pass a spring, and taps declare sounds automatically.sfx(t, kind)declares a UI sound (tap,key,toggle,whoosh,success,pop;sfx.pysynthesizes the kit, so there is nothing to license).mux.pyplaces each sound's measured peak on its time.- For a "liquid" indicator (tab, toggle, language pill), give each edge its own track: the leading edge gets a faster spring than the trailing edge.
- Springs take
{duration, damping}, where damping is the damping ratio ζ (1 = no bounce, 0.8 premium), or the older{duration, bounce}(bounce = 1 − ζ). - Randomness and loops.
rand(seed)gives a seeded random function; draw from it at load, never insideseek.phase(t, period)is a 0–1 loop phase.loopNoise(t, period, seed)is smooth noise that repeats exactly, for ambient drift. - Captions.
captions(el, t)burns in the caption line fromwindow.CAPTIONS, whichtimeline.pywrites intowords.jswith the same line breaks as the.srt. Words get the classescaption-past,caption-activeandcaption-upcoming. Theme them with--caption-size,--caption-color,--caption-accentand--caption-bg. - Clips.
frames(img, {dir, fps, count})plays an image sequence fromclip.py(uv run --script <skill>/scripts/clip.py video.mp4 --out clip --fps 30) frame-exactly. It returns a function of time that returns a promise;seekmust return or await it. Chromium in the renderer cannot decode H.264, so never use a<video>element. - Transitions.
transitions.jschanges scene roots:scenes([root, {at, kind}, root, ...])returnsfilm(t)andfilm.cuts. The kinds are cut, dissolve, push, zoom-through, whip, wipe, light-leak and morph.finish(el, t, {grain, vignette, sweeps})adds the film finish. See Transitions and the film finish. - Audit.
check(name, at, fn)declares a test of the brief's numbers, at a time, over[t0, t1]or on"all"frames. Two helpers measure layout:box(el)andoverlap(a, b). For example,check("caption band clear", "all", () => overlap("#title", "#cap") === 0).review.mjs auditruns every check. - Preview and debug, in any page.
?t=12.5shows one moment, and?debug=1adds a frame, time, scene and cue overlay.cue()recordswindow.CUE_TIMESfor the review tools.
Gotchas:
- Never put
will-changeon anything the camera scales, or text renders blurry. - No CSS transitions, animations, timers or state carried between frames. The renderer splits time into chunks across several pages, so every frame must be computable on its own.
progress()treats an infinite end as "never", sopresence(t, on, Infinity)stays visible. Do not passNaNto styles; aNaNopacity silently renders at full opacity.- Pages load over the renderer's local server, because ES modules do not load from
file://.node <skill>/scripts/render.mjs index.html --serveserves the page for a browser preview (?t=12.5,?debug=1). If a page fails to load, the renderer prints its script errors and failed URLs. - Put the camera inside a clipped stage when there is a side panel, and keep the zoomed focus inside a phone's edges, or a zoom shows the canvas past the screen's edge.
- For a seamless loop, the last frame must match the first, including the pointer's position and speed. Leave about 1.5 seconds after the final morph for springs to settle.
- Playwright is loaded only from
$PLAYWRIGHT_DIR, the skill's cache folder (setup.shinstalls it there), or the global npm root, never from the current folder. Where Playwright cannot download its browser (some cloud sandboxes), setCHROMIUM_PATHto an installed Chromium.RENDER_WORKERSlimits the parallel pages on a busy machine. - List hard cuts in
VIDEO.cuts(times in seconds). Motion blur then never blends the last frame of one shot into the first of the next; without it, the frame on each cut is a quarter new shot over three quarters old. A transition is not a hard cut: listfilm.cuts, never the transitions. - Motion blur covers 40% of each frame interval (
--shutter 0.4), and subframes are averaged in linear light, like a film camera's shutter (--blur gammafor the old look). A full shutter ghosts text into doubled copies during fast camera moves.
More skills
Conduit workspace
Build, audit, or improve a Conduit AI guest-messaging workspace (knowledge base, agent persona, guardrails, Conduit agent skills, custom tools, tests) so the AI resolves more conversations without a…
Guest review reply
Drafts public replies to vacation-rental guest reviews on Airbnb, Vrbo, Google, or Booking.com.
Listing description refresh
Rewrites a vacation-rental listing for Airbnb, Vrbo, Booking.com, or a direct-booking site, including the title, summary, the space, guest access, neighborhood, and other things to note, within each…