You write one JSON file, motion.json. Scripts validate it, preview it in a browser and render it to MP4. You never write animation code, and you never edit generated output.
<skill-dir> is the folder that contains this SKILL.md. Run every command from the project folder (where motion.json lives), using the full path to the scripts. Image paths resolve relative to the spec file. If the request is empty, ask what the video should say, how long it should be, and the size (default 1920x1080, 30 fps).
Audio is opt-in. Add audio tracks only when the prompt asks for sound (music, soundtrack, voiceover, narration, "read it aloud", a sound effect) or the user confirms when you ask. Never add audio to a prompt that does not mention it. When the prompt is about sound but unclear (for example "make it feel polished" with an mp3 in the folder), ask one question before writing the spec. Music and sound effects are files the user supplies; a voiceover can be synthesised with voice.mjs (workflow step 2) when the prompt asks for narration without giving a file.
First use (say what each step is before running it)
<skill-dir>/node_modulesmissing: runnpm ci --prefix <skill-dir>(animejs, vega-lite, playwright, fonts).- Chromium for frame capture: ask first (about 150 MB), then run
npx playwright install chromiuminside<skill-dir>. If the user declines, rendering falls back to installed Google Chrome. ffmpegnot on PATH: stop and give the install command (brew install ffmpegorsudo apt-get install ffmpeg, or setFFMPEG_PATH). Never install system packages yourself.- Voiceover requested and
<skill-dir>/node_modules/kokoro-jsmissing: ask first (about 250 MB of packages plus a 90 MB voice model downloaded once on first use, then offline), then runnpm install kokoro-js@1.2.1 --no-save --prefix <skill-dir>.npm ciremoves it again; reinstall whenvoice.mjsprints the hint. If the user declines, ask for a voice file instead.
Workflow
Run every step yourself, in this same turn, with your shell or file tools. Do not end your reply after showing or describing the JSON: keep going until validate prints valid and the output file exists.
- Write
motion.jsonin the project (shape below; full reference in<skill-dir>/references/schema.md; copy a file from<skill-dir>/references/examples/to start). Actually create the file: describing it or announcing a patch is not enough. If your file tool fails, write it from the shell (cat > motion.json <<'EOF'…EOF), thenls motion.jsonto confirm it exists before validating. - Voiceover (only when asked, see above): write the narration to
voice/<scene-id>.txt(plain spoken text, no markdown), thennode <skill-dir>/scripts/voice.mjs --text-file voice/<scene-id>.txt --out voice/<scene-id>.wav [--voice af_heart] [--speed 1]writes a 24 kHz wav and prints its length. Use--text-file, not a quoted argument: the shell would expand$4.2Mor backticks inside a double-quoted string and the clip would silently say something else. One clip per scene, scene-anchored in the spec ("scene": "<id>", "at": 0.3); keep each clip shorter than its scene (lengthen the scene if the printed duration does not fit). Voices:af_heart(default, warm US female),af_bella,am_michael(US male),bf_emma(UK female);--list-voicesprints all. Write the narration text yourself from the prompt; keep it short and spoken, no markdown. node <skill-dir>/scripts/validate.mjs motion.json— fix every line it prints (<json.path>: <message>, with did-you-mean) and re-run until it saysvalid. Exit 0 ok, 1 spec errors, 2 usage/IO error.- Self-check:
node <skill-dir>/scripts/render.mjs motion.json --contact-sheetwritesout/contact/<scene-id>.pngnext to the spec (one frame per scene, at 85% of its duration). Look at each PNG: clipped text, invisible elements, overlaps, wrong fonts. Fix the spec and repeat. node <skill-dir>/scripts/preview.mjs motion.json [--open]prints a localhost URL with scrubber, play/pause, scene markers, Space and arrow keys. The page reloads when the file changes. It plays the audio tracks in sync (approximate; M mutes); the MP4 timing is exact.node <skill-dir>/scripts/render.mjs motion.json [--out out/video.mp4] [--fps 30] [--size 1920x1080]writes the MP4 (H.264, yuv420p; AAC audio when the spec has tracks). It prints the path and frame count.
When asked for a change ("slow scene 2", "make the title blue"), edit only the fields the request names in motion.json, then validate again. Do not rewrite the spec from scratch.
When done, report the spec path, the MP4 path with its duration and size, and one line per scene.
Spec shape
{
"size": [1920, 1080], "fps": 30, "background": "#0b0d12",
"scenes": [
{ "id": "intro", "duration": 3, "transition": "fade",
"elements": [
{ "id": "title", "type": "text", "text": "Q3 revenue up 18%", "x": 960, "y": 480,
"fontSize": 96, "fontWeight": 700, "color": "#ffffff",
"keyframes": [ { "t": 0, "opacity": 0, "y": 520 },
{ "t": 0.6, "opacity": 1, "y": 480, "ease": "outCubic" } ] } ] } ],
"audio": [
{ "src": "music.mp3", "volume": 0.4, "fadeOut": 2 },
{ "src": "voice.wav", "scene": "intro", "at": 0.5 } ]
}
- Root:
size(default 1920x1080),fps(24, 25, 30, 60),background,scenes(1–50, total ≤ 120 s). Onlyscenesis required. - Audio track (optional root
audioarray, up to 20):src(relative to the spec, inside the spec folder; mp3, wav, m4a, aac, ogg or flac),at(seconds from the video start, default 0),scene(a sceneid;atis then relative to that scene's start, so lengthening an earlier scene keeps a voiceover aligned),duration(trim, seconds),volume(0–1, default 1),fadeIn/fadeOut(seconds).fadeOutanchors to thedurationtrim or the video end; a clip that ends earlier gets no fade. Noloop: a short music file just stops. - Scene:
id,duration(0.2–60 s),elements, optionalbackground,transition(noneorfade, a 0.4 s fade-in). Only one scene is visible at a time; scenes play back to back. - Every element:
id(unique in the whole spec),type,x,y(canvas pixels; the anchor is the element centre), optionalopacity,scale,rotate(degrees),keyframes. - Types:
text,counter(from,to,decimals,prefix,suffix; animatevalue),shape(rect,ellipse,line; a line is horizontal of lengthwidth, userotateto angle it; animatedraw0–1),image(srcrelative to the spec),chart(vegaLitewith inlinedata.values,width,height; animatereveal0–1 for a left-to-right wipe). - Keyframe:
t(seconds inside the scene, sorted, ≤ scene duration), optionalease, plus any animatable props for that type. Per property: it holds the first keyframe's value before it, interpolates to each later keyframe using that keyframe'sease, and holds the last value after. A property missing from a keyframe is interpolated across it. Frame i samples t = i/fps, so put the final keyframe at least one frame (1/fps s) before the scene end. A counter with novaluekeyframes counts from→to over min(2 s, 80% of the scene) with outCubic. - Eases:
linear, orin|out|inOut+Quad|Cubic|Quart|Back|Elastic|Bounce|Sine|Expo|Circ. PreferoutCubic/outQuadfor entrances.
Style defaults
Dark background, Inter for text, JetBrains Mono for "font": "mono", 1920x1080 sizes (titles 72–120 px, body 40–48 px, captions ≥ 28 px). Charts get a dark theme automatically; put a Vega-Lite config in the chart to override it. Keep entrances short (0.5–0.9 s), stagger elements by 0.2–0.4 s, and stay under 15 s unless asked. Fonts are bundled; never use CDN URLs. Charts must carry inline data (data.values, or datasets + data.name); any url in a chart spec is rejected. Colours must be hex, rgb()/hsl() or named.
Degraded mode
If Node, Chromium or ffmpeg cannot be installed here, still write and validate motion.json (validation needs only npm ci), then give the user the exact preview and render commands above to run themselves.
Voiceover and audio notes
What the scripts do, and why:
voice.mjsdisposes the ORT session (tts.model.dispose()) in afinally; on failure it setsprocess.exitCode(neverprocess.exit()) and lets the event loop drain. Exiting with a live session, or callingprocess.exit(), aborts with exit 134 /mutex lock failed. Keep it that way if you edit or wrap the script.- Long narration is synthesised sentence by sentence: the text is split into sentences capped at 300 characters, each goes through its own
tts.generate()call and the clips are joined, because Kokoro truncates one call at about 27 s. Pass the text with--text-file, never as a quoted argument. amixtakes the first input's sample rate and channel layout, so every track is normalised withaformat=sample_rates=48000:channel_layouts=stereobefore mixing and the encode pins-ac 2. Without it a mono 24 kHz voice listed first collapses stereo music.
Security
The preview server binds 127.0.0.1 only and accepts only local Host headers, and serves only this skill's own folders plus the image and audio files the spec names. The renderer aborts every non-local request and sends a strict CSP. The validator is not the offline guarantee; the CSP and the renderer's request abort (guardNetwork()) are. No network access at validate, preview or render time. The one exception is voice.mjs: its first run downloads the Kokoro voice model (about 90 MB) from huggingface.co into <skill-dir>/.cache/models, after which it is offline too.