动画视频制作
推荐 社区用一句话描述想法,AI 自动写分镜、画动画、配音乐,生成可直接发布的 1080p MP4 视频。
CowAgent v1.0.0
安装命令
$ cow skill install motion-video 终端输入或发送给 CowAgent 一键安装
内容创作
能做什么 告诉 AI 你想做什么视频,它会像导演一样先写分镜,再用代码逐帧绘制画面、合成配乐,最后发给你一个高清 MP4。整个过程不用剪辑软件,也不用准备素材。
适合的内容
- 知识科普、历史回顾、概念讲解类动画
- 产品介绍、功能发布、品牌宣传短片
- 数据可视化、流程演示
- 文字动效、片头片尾
不适合需要真人或实拍质感的画面。
怎么用 直接描述需求即可。写得越具体,效果越好,比如:画面比例(横屏 16:9、竖屏 9:16 或方形 1:1)、时长、风格、要讲的内容、配色,有没有自己的 Logo 或音乐。
示例:
- 「做一个 30 秒的 16:9 横屏动画,介绍我们的新产品,科技感,配上音乐」
- 「做一个动画,快速回顾中华五千年的历史,线稿风格,加合适的音乐」
不满意可以继续提修改意见,比如「节奏再快一点」「换成手绘风格」「结尾加上 Logo」。
推荐模型 推荐使用 Claude Opus 5.5。这个技能要求模型同时具备很强的代码能力和审美,目前只在 Claude Opus 5.5 上做过完整测试,效果稳定。其他模型未经测试,代码能力较弱的模型容易做成「会动的幻灯片」。
使用前请注意
- 最大执行步数:一个视频大约需要 30 步以上,请在设置中把「最大执行步数」调到 50,避免中途停下。
- 首次使用:AI 会自动检查并安装需要的组件(Python 和视频编码工具,约 40 MB),几分钟完成,不需要管理员权限。电脑上的 Chrome 或 Edge 浏览器会被直接用来渲染;两者都没有时,会额外下载约 100 MB 的浏览器组件。
---
name: motion-video
description: Make videos with code - animated explainers, motion graphics, product promos, kinetic typography, data stories, lyric/music videos - rendered to MP4 with music and sound effects. Use whenever the user asks to make, create or render a video, animation, motion graphic, promo, trailer or animated explainer (e.g. "做一个视频/动画/宣传片/科普视频"). Not for photorealistic footage of real people or animals.
---
# Motion Video
You are the director and the programmer. The video is one HTML file whose picture **and** sound are pure functions of time `t`. `scripts/render.py` drives a headless Chromium to draw every frame, synthesizes the audio offline and muxes an MP4 with ffmpeg. No Node.js, no network, no external JS libraries.
`<skill_dir>` below is the directory containing this SKILL.md; always call scripts by absolute path. Never write inside `<skill_dir>`: build each video in its own project directory in the working directory, e.g. `videos/<short-slug>/`.
## 0. Environment (once per machine)
Run the setup script once (shell timeout >= 900s; skip it if it already succeeded in this conversation):
- Windows (cmd): `"<skill_dir>\scripts\setup.cmd"`
- macOS / Linux: `sh <skill_dir>/scripts/setup.sh`
It finds or fetches everything below, verifies a real browser launch, and ends with `READY` and a line `PYTHON <path>`. Use exactly that interpreter for every later command - it is written `python` below, but on a machine that had no Python it is a private one that is not on PATH. If Python is already known to work, `python <skill_dir>/scripts/setup.py --install` does the same.
| Needs | How it is found or fetched |
|---|---|
| Python 3.8+, no packages | any Python on PATH; else the official embeddable Python (Windows, ~11 MB) or a standalone build (macOS/Linux, ~25 MB) |
| Chromium-family browser | installed Chrome or Edge (Edge ships with Windows), or one another app already downloaded; else a headless Chromium (~100-125 MB) |
| ffmpeg with libx264 | ffmpeg on PATH; else a static build (~21-30 MB) |
| CJK font (Linux only) | `fonts-noto-cjk` via apt/dnf when running as root |
Downloads go to `~/.motion-video` (no admin rights, nothing else on the system changes; delete the folder to uninstall), each from the fastest of the official servers and public mirrors. Only Linux system libraries and fonts need root; the script prints the exact `sudo` command when it is not root. Stop and ask the user only for that, or when the network blocks every source. Do not switch to another rendering stack.
## 1. Direct before you code - this is where quality comes from
Read `<skill_dir>/references/direction.md` and `<skill_dir>/references/styles.md` (once per conversation), then write `STORYBOARD.md` in the project dir:
- the one idea (feel / remember / visual world), format, rhythm, direction of travel
- a style preset with palette hex, fonts, texture stack, motion energy, sound
- a beat sheet where every beat has picture (BG / MG / FG), motion verbs + camera, exact text, an object-level transition, and a sound cue
Think on paper: start writing `STORYBOARD.md` soon and refine it with edits, instead of composing the whole film silently in your head first - a long silent deliberation is slow, and some networks drop a request that produces no output for about two minutes. The same goes for code: write a scene, then the next.
Decide yourself; do not interview the user about taste. Ask only for things only they can provide (brand assets, a product name, a song they want). Aim for a film someone would share, not an animated slide deck: a continuous visual metaphor, a moving camera, motivated transitions, texture, and one big moment set up by a beat of stillness. `<skill_dir>/references/craft.md` has the rules and the lazy defaults to avoid.
## 2. Build
Start the project from the template: `python <skill_dir>/scripts/render.py --new videos/<slug>`. It has two files:
- `motion.js` - the helper library: camera rig with parallax, film finish (`paper`, `vignette`, `grain`), `glow`, hand-drawn `sketch` lines with draw-on, `revealText`, `loadImage`, synthesized `music()` and `SFX`, the render contract and the live preview. Use it as is; do not rewrite or re-read it - the helper list is in `techniques.md`.
- `index.html` - a 12s demo film (palette `C`, `CUES`, `CAMERA`, shots, `draw(t)`, `__audio`) showing how the helpers combine. Replace its content with your film; keep the structure.
`<skill_dir>/references/techniques.md` has recipes for light, depth, brush strokes, morphs, transitions, 3D projection and sound.
Keep every single `write` call under ~400 lines: one huge tool call takes minutes to stream and a dropped connection loses all of it. For longer films keep palette, `CUES`, `CAMERA`, `draw` and `__audio` in `index.html` and put shots in `scenes/01-intro.js`, `scenes/02-....js` (plain `<script src>`, no modules), one write per file. Fix things afterwards with targeted edits; never rewrite a whole file.
Keep every scene's start in one master timeline object in `index.html`, and write every time inside a scene, captions included, relative to that start (`T.c3 + 4.5`, never `28.4`). Lengthening one scene then only means moving the later starts; everything after it, picture, captions and sound, shifts with them.
## 3. Self-review with a contact sheet
You cannot watch the video, but you can look at stills:
`python <skill_dir>/scripts/render.py videos/<slug>/index.html --sheet 12`
Then look at `preview/sheet.jpg` once with your image-viewing tool, and judge it against the checklist at the end of `craft.md` (overlapping or clipped text, contrast, empty frames, tofu boxes, off-canvas elements, whether each frame is dressed and reads as intended). Use explicit times (`--sheet 3.2,3.6,4.0`) to inspect a transition, `--shots 5.5` for a full-size frame. Fix and re-check at most twice; do not loop on taste.
## 4. Render
`python <skill_dir>/scripts/render.py videos/<slug>/index.html` (shell timeout >= 600s). About 1s of 1080p renders per 1-2s of wall time with 4 workers. Add `--motion-blur 4` to the final render when the film has fast moves (whip pans, slams, fast slides); it roughly doubles render time. For films over ~3 minutes, run it in the background and poll. Output: `videos/<slug>/index.mp4` (or `--out`), roughly 3-5 MB per 10s at the default quality; pass `--crf 18` only when the user wants a master-quality file.
## 5. Deliver
Send the MP4 to the user with your file-sending tool (or give its path if there is none), plus 2-3 lines: what the film shows, its length, the world/style you chose with two alternative directions they could ask for, and that the editable source is `index.html` (open it in a browser to preview with sound; space = pause, arrows = seek).
## Render contract (must hold, or frames will flicker or differ)
```js
window.VIDEO = { width: 1920, height: 1080, fps: 30, duration: 30 };
window.__ready = Promise; // resolve after fonts/images are loaded
window.__seek = (t) => draw(t); // draw the exact frame at t seconds; may be async
window.__audio = async (ctx) => {...}; // optional: schedule sound on an OfflineAudioContext
```
- Every pixel is computed from `t` alone: no `Date.now()`, `performance.now()`, timers, `requestAnimationFrame` state, CSS animations/transitions, or state carried between frames. Frames are rendered out of order by several workers.
- Randomness only through the seeded `rng(seed)` helper, generated once at load.
- No network: no CDN scripts, web fonts or remote images. Use the local font stacks (`SANS`, `SERIF`, `KAI`, `MONO`). Download images into the project dir first and load them with `loadImage('relative/path.png')`.
- Canvas 2D is the default. Inline SVG/DOM is fine if styles are set from `t` inside `__seek`. WebGL only if the environment check reported `webgl yes`.
- Keep one `CUES` object of named times and use it in both the scenes and `__audio`, so picture and sound stay locked together.
## Sound
The template synthesizes a music bed (pad, bass, plucks, soft kick) and effects (`SFX.pop`, `tick`, `whoosh`, `riser`, `boom`, `kick`) with Web Audio, rendered offline into the MP4. Adapt key, tempo, chords and timbre to the mood; put an effect on every major visual hit via `CUES`; a riser into the pre-climax hold; duck the bed under important moments; fade in and out. `render.py` prints the peak level - keep it below about -1 dBFS.
External audio (a song the user supplied, a narration file) is mixed with `--audio path[@start_seconds][*gain]`, repeatable:
`python <skill_dir>/scripts/render.py index.html --audio song.mp3*0.8 --audio [email protected]`
When a song drives the film, run `python <skill_dir>/scripts/render.py --beats song.mp3` first: it prints the tempo, the first beat and the bar starts. Land cuts and big hits on bar starts and smaller accents on beats (put them in `CUES`), save the strongest reveal for the song's drop, trim `VIDEO.duration` to a bar boundary, and pass `--no-synth-audio` if the template music should not play. Use only music the user supplied; never download copyrighted songs. Narration needs a TTS service the user has configured; without one, use on-screen text and say so.
## Cost control
- Plan once, write once, edit surgically. Do not read the HTML back after writing it, and do not cat render logs - `render.py` prints only what matters.
- One contact sheet per review round (one image look), not individual frames.
- Prefer one complete, polished 20-45s film over a long rough one, unless the user asks for length.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>Motion Video</title>
<style>
html, body { margin: 0; background: #000; overflow: hidden; }
canvas { display: block; }
body:not(.render) canvas { max-width: 100vw; max-height: 100vh; margin: auto; }
#hint { position: fixed; left: 12px; bottom: 10px; color: #aaa; font: 13px sans-serif; }
body.render #hint { display: none; }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="hint">click to play with sound · space pause · ←/→ seek</div>
<script>
// Format and palette first: motion.js reads them while loading.
const VIDEO = { width: 1920, height: 1080, fps: 30, duration: 12 };
// One palette for the whole film. Neutrals are tinted toward the accent, never pure #000/#fff.
const C = { bg: '#14120f', ink: '#efe6d4', dim: '#7a7062', accent: '#e9a23b', accent2: '#5b8c85' };
</script>
<script src="motion.js"></script>
<!-- Longer films: put each shot in scenes/NN-name.js and add a <script src> per file here. -->
<script>
// ===== Timeline: one source of truth shared by picture and sound =====
const BPM = 96, BEAT = 60 / BPM;
const CUES = {
lineIn: 0.3, title: 1.0, kicker: 1.8, titleOut: 3.9,
node1: 5.4, node2: 6.2, node3: 7.0, count: 6.4,
hold: 9.2, push: 9.6, end: 10.7,
};
// Camera keyframes in world coordinates (x, y = point at the screen centre, z = zoom).
// The film travels left -> right; the hold at 9.2-9.6 is the stillness before the push-in.
const CAMERA = [
{ t: 0, x: W / 2, y: H / 2, z: 1.0 },
{ t: 4.2, x: W / 2 + 40, y: H / 2, z: 1.04 },
{ t: 5.4, x: W / 2 + 980, y: H / 2, z: 1.0 },
{ t: 9.2, x: W / 2 + 1040, y: H / 2, z: 1.06 },
{ t: 9.6, x: W / 2 + 1040, y: H / 2, z: 1.06 },
{ t: 10.7, x: W / 2 + 1460, y: H / 2 - 60, z: 9 },
];
// Background shared by every shot: one ambient motion (slow drift); content never idles on its own.
const DUST = (() => { const r = rng(11); return Array.from({ length: 70 }, () => [r() * W * 2.4 - W * 0.2, r() * H, 1 + r() * 2.5, r()]); })();
function drawBackground(t) {
ctx.fillStyle = C.bg;
ctx.fillRect(0, 0, W, H);
const c = camAt(t);
const gx = W * 0.62 - (c.x - W / 2) * 0.15, gy = H * 0.42;
const grd = ctx.createRadialGradient(gx, gy, 0, gx, gy, H * 0.95);
grd.addColorStop(0, 'rgba(233,162,59,0.16)');
grd.addColorStop(1, 'rgba(233,162,59,0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, W, H);
withCamera(t, 0.35, () => {
ctx.strokeStyle = 'rgba(239,230,212,0.06)';
ctx.lineWidth = 2;
for (let x = -W; x < W * 3; x += 120) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (const [x, y, s, ph] of DUST) {
ctx.globalAlpha = 0.18 + 0.12 * noise1(t * 0.4 + ph * 10, 3);
ctx.fillStyle = C.ink;
ctx.beginPath(); ctx.arc(x + noise1(t * 0.2, ph * 100) * 20, y - t * 6 * s, s, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
});
}
// World layout: shot 1 around x = W/2, shot 2 around x = W/2 + 1000 (the camera travels right).
const NODES = [
{ x: W / 2 + 640, y: H / 2 - 180, label: '想法', cue: CUES.node1 },
{ x: W / 2 + 1040, y: H / 2 + 170, label: '代码', cue: CUES.node2 },
{ x: W / 2 + 1460, y: H / 2 - 60, label: '影片', cue: CUES.node3 },
];
const LINE = [[160, H / 2 + 120], [W / 2 - 200, H / 2 + 120], [W / 2 + 200, H / 2 + 120], [NODES[0].x, NODES[0].y]];
function drawWorld(t) {
// Shot 1: a line draws left -> right (the direction of travel) under the title; its tip is the carrier.
const lp = ease.inOut(prog(t, CUES.lineIn, 4.6));
sketch(ctx, LINE, t, { color: C.accent, width: 5, progress: lp, seed: 2 });
const tip = partial(resample(LINE), lp).slice(-1)[0];
if (tip && lp < 1) glow(g => { g.fillStyle = C.accent; g.beginPath(); g.arc(tip[0], tip[1], 10, 0, Math.PI * 2); g.fill(); }, { blur: 18 });
const exit = prog(t, CUES.titleOut, 0.6);
if (exit < 1) {
revealText('从一条线开始', W / 2, H / 2 - 40, prog(t, CUES.title, 1.1), { size: 150, weight: 800, font: SERIF, exit, stagger: 0.08 });
const kp = ease.out(prog(t, CUES.kicker, 0.8)) * (1 - ease.in(exit));
text('EVERY FRAME IS A FUNCTION OF TIME', W / 2, H / 2 + 200, { size: 26, weight: 500, font: MONO, color: C.dim, spacing: 8, alpha: kp });
}
// Shot 2: the carrier lands on node 1, then the diagram builds node by node (cause -> effect).
NODES.forEach((n, i) => {
const p = spring(prog(t, n.cue, 0.9));
if (p <= 0) return;
const ring = ease.out(prog(t, n.cue, 0.7));
sketch(ctx, circlePts(n.x, n.y, 92), t, { color: i === 2 ? C.accent : C.ink, width: 4, progress: ring, seed: 10 + i });
ctx.save();
ctx.globalAlpha = clamp(p);
ctx.fillStyle = i === 2 ? C.accent : C.accent2;
ctx.beginPath(); ctx.arc(n.x, n.y, Math.max(0, 70 * p), 0, Math.PI * 2); ctx.fill();
ctx.restore();
text(n.label, n.x, n.y + 2, { size: 52, weight: 700, color: C.bg, alpha: clamp(p * 1.5 - 0.5) });
text(`0${i + 1}`, n.x - 130, n.y - 100, { size: 22, font: MONO, color: C.dim, spacing: 4, alpha: ring });
if (i > 0) {
const a = NODES[i - 1];
const ang = Math.atan2(n.y - a.y, n.x - a.x), off = 100;
sketch(ctx, [[a.x + Math.cos(ang) * off, a.y + Math.sin(ang) * off], [n.x - Math.cos(ang) * off, n.y - Math.sin(ang) * off]], t,
{ color: C.ink, width: 3, progress: ease.inOut(prog(t, n.cue - 0.5, 0.6)), seed: 20 + i, alpha: 0.8 });
}
});
const cp = ease.out(prog(t, CUES.count, 2.4));
if (cp > 0) {
text(String(Math.round(cp * 30 * VIDEO.duration)).padStart(3, '0'), W / 2 + 640, H / 2 + 300, { size: 120, weight: 300, font: MONO, alpha: clamp(cp * 3) });
text('FRAMES, ONE SCRIPT', W / 2 + 640, H / 2 + 390, { size: 22, font: MONO, color: C.dim, spacing: 6, alpha: clamp(cp * 3) });
}
}
function drawEnd(t) {
// Shot 3: the push-in fills the screen with node 3's colour, which becomes the end card.
const fill = ease.inOut(prog(t, CUES.push + 0.7, 0.4));
if (fill <= 0) return;
ctx.fillStyle = C.accent;
ctx.globalAlpha = fill;
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
revealText('画面即代码', W / 2, H / 2, prog(t, CUES.end, 0.9), { size: 132, weight: 800, font: SERIF, color: C.bg, stagger: 0.07 });
}
function draw(t) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
drawBackground(t);
withCamera(t, 1, () => drawWorld(t));
drawEnd(t);
// Finishing stack, always last.
paper(0.22);
vignette(0.55);
grain(t, 0.08);
}
window.__audio = async (ac) => {
const master = ac.createGain();
master.gain.value = 0.8;
const comp = ac.createDynamicsCompressor();
master.connect(comp).connect(ac.destination);
const bed = ac.createGain();
bed.connect(master);
bed.gain.setValueAtTime(0, 0);
bed.gain.linearRampToValueAtTime(1, 1.5);
bed.gain.setValueAtTime(1, CUES.hold - 0.1);
bed.gain.linearRampToValueAtTime(0.25, CUES.hold + 0.2); // duck: stillness before the climax
bed.gain.setValueAtTime(0.25, CUES.end);
bed.gain.linearRampToValueAtTime(0, VIDEO.duration - 0.1);
music(ac, bed, 0, VIDEO.duration, { bpm: BPM });
SFX.whoosh(ac, master, CUES.lineIn);
SFX.boom(ac, master, CUES.title);
SFX.whoosh(ac, master, CUES.titleOut + 0.3, 0.9);
[CUES.node1, CUES.node2, CUES.node3].forEach((c, i) => SFX.pop(ac, master, c, 1 + i * 0.12));
for (let k = 0; k < 12; k++) SFX.tick(ac, master, CUES.count + k * 0.1);
SFX.riser(ac, master, CUES.push - 0.9, 1.5);
SFX.boom(ac, master, CUES.push + 0.6);
};
</script>
</body>
</html>
// motion.js - shared helpers for code-rendered video. Copy next to index.html; do not edit per film.
// index.html must define VIDEO and C (palette) before loading this file, and draw(t) / CAMERA / __audio after it.
/* global VIDEO, C, CAMERA, draw */
// ===== Stage =====
const canvas = document.getElementById('stage');
canvas.width = VIDEO.width;
canvas.height = VIDEO.height;
const ctx = canvas.getContext('2d');
const W = VIDEO.width, H = VIDEO.height;
window.VIDEO = VIDEO;
// Local system fonts only: the render must not depend on the network.
const SANS = '"PingFang SC","Hiragino Sans GB","Noto Sans CJK SC","Source Han Sans SC","Microsoft YaHei","WenQuanYi Zen Hei",sans-serif';
const SERIF = '"Songti SC","STSong","Noto Serif CJK SC","Source Han Serif SC","SimSun","Times New Roman",serif';
const KAI = '"Kaiti SC","STKaiti","KaiTi","AR PL UKai CN",' + SERIF;
const MONO = '"SF Mono","Menlo","Consolas","DejaVu Sans Mono",monospace';
// ===== Math: every value is a pure function of t =====
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const lerp = (a, b, p) => a + (b - a) * p;
const prog = (t, start, dur) => clamp((t - start) / dur); // 0..1 progress of a window
const ease = {
out: p => 1 - Math.pow(1 - p, 3),
in: p => p * p * p,
inOut: p => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2),
expo: p => (p >= 1 ? 1 : 1 - Math.pow(2, -10 * p)),
expoInOut: p => (p <= 0 ? 0 : p >= 1 ? 1 : p < 0.5 ? Math.pow(2, 20 * p - 10) / 2 : (2 - Math.pow(2, -20 * p + 10)) / 2),
back: p => 1 + 1.7 * Math.pow(p - 1, 3) + 0.7 * Math.pow(p - 1, 2), // mild overshoot, objects only
};
// Closed-form damped spring 0 -> 1 with a small overshoot (objects, not text).
const spring = (p, k = 7, z = 0.6) => (p <= 0 ? 0 : 1 - Math.exp(-z * k * p) * Math.cos(k * Math.sqrt(1 - z * z) * p));
// Seeded RNG (mulberry32). Never use Math.random().
function rng(seed) {
return () => {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
// Smooth 1D value noise in [-1, 1]: organic drift without state.
function noise1(x, seed = 0) {
const h = i => { const r = rng((i * 374761393 + seed * 668265263) | 0); r(); return r() * 2 - 1; };
const i = Math.floor(x), f = x - i, s = f * f * (3 - 2 * f);
return lerp(h(i), h(i + 1), s);
}
// ===== Camera with parallax =====
// CAMERA = [{t, x, y, z}, ...] in index.html; keys are eased with inOut between neighbours.
// depth 1 = world layer, 0 = locked to the screen (HUD), 0.2-0.5 = far background.
function camAt(t) {
const K = (typeof CAMERA !== 'undefined' && CAMERA.length) ? CAMERA : [{ t: 0, x: W / 2, y: H / 2, z: 1 }];
if (t <= K[0].t) return K[0];
for (let i = 0; i < K.length - 1; i++) {
const a = K[i], b = K[i + 1];
if (t > b.t) continue;
const p = (b.ease || ease.inOut)(prog(t, a.t, b.t - a.t));
return { x: lerp(a.x, b.x, p), y: lerp(a.y, b.y, p), z: lerp(a.z, b.z, p) };
}
return K[K.length - 1];
}
function withCamera(t, depth, fn) {
const c = camAt(t);
const cx = W / 2 + (c.x - W / 2) * depth, cy = H / 2 + (c.y - H / 2) * depth;
const z = 1 + (c.z - 1) * depth;
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.scale(z, z);
ctx.translate(-cx, -cy);
fn();
ctx.restore();
}
// ===== Film finish: grain, vignette, paper, glow (precomputed once, cheap per frame) =====
function offscreen(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
const GRAIN = (() => {
const tiles = [];
for (let k = 0; k < 6; k++) {
const c = offscreen(256, 256), g = c.getContext('2d'), img = g.createImageData(256, 256), r = rng(101 + k);
for (let i = 0; i < img.data.length; i += 4) {
const v = 128 + (r() + r() + r() - 1.5) * 120;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255;
}
g.putImageData(img, 0, 0);
tiles.push(ctx.createPattern(c, 'repeat'));
}
return tiles;
})();
// Film grain that refreshes `rate` times per second. 0.04-0.09 is the useful range.
function grain(t, amount = 0.07, rate = 12) {
const f = Math.floor(t * rate), r = rng(f + 5);
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = amount;
ctx.translate(-r() * 256, -r() * 256);
ctx.fillStyle = GRAIN[f % GRAIN.length];
ctx.fillRect(0, 0, W + 256, H + 256);
ctx.restore();
}
const VIGNETTE = (() => {
const c = offscreen(W, H), g = c.getContext('2d');
const grd = g.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.6);
grd.addColorStop(0, 'rgba(0,0,0,0)');
grd.addColorStop(1, 'rgba(0,0,0,1)');
g.fillStyle = grd; g.fillRect(0, 0, W, H);
return c;
})();
function vignette(strength = 0.5) {
ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = strength; ctx.drawImage(VIGNETTE, 0, 0); ctx.restore();
}
// Paper / print texture: blotches + fibres. 'overlay' on dark styles, 'multiply' (0.3-0.5) on light ones.
function makePaper(seed = 3) {
const c = offscreen(W, H), g = c.getContext('2d'), r = rng(seed);
g.fillStyle = '#808080'; g.fillRect(0, 0, W, H);
for (let i = 0; i < 70; i++) {
const x = r() * W, y = r() * H, rad = 80 + r() * 380, v = r() < 0.5 ? 0 : 255;
const grd = g.createRadialGradient(x, y, 0, x, y, rad);
grd.addColorStop(0, `rgba(${v},${v},${v},${0.05 + r() * 0.06})`); grd.addColorStop(1, `rgba(${v},${v},${v},0)`);
g.fillStyle = grd; g.fillRect(x - rad, y - rad, rad * 2, rad * 2);
}
g.lineWidth = 1;
for (let i = 0; i < 2600; i++) {
const x = r() * W, y = r() * H, a = r() * Math.PI, l = 4 + r() * 18, v = r() < 0.5 ? 40 : 220;
g.strokeStyle = `rgba(${v},${v},${v},${0.08 + r() * 0.1})`;
g.beginPath(); g.moveTo(x, y); g.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); g.stroke();
}
return c;
}
let PAPER = null;
function paper(amount = 0.25, mode = 'overlay') {
PAPER = PAPER || makePaper();
ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = mode; ctx.globalAlpha = amount; ctx.drawImage(PAPER, 0, 0); ctx.restore();
}
// Bloom: fn(g) draws into g; it is drawn blurred and added for the halo, then crisp on top.
const GLOW_LAYER = offscreen(W, H), GLOW_CTX = GLOW_LAYER.getContext('2d');
function glow(fn, { blur = 22, strength = 0.9 } = {}) {
const m = ctx.getTransform();
GLOW_CTX.setTransform(1, 0, 0, 1, 0, 0);
GLOW_CTX.clearRect(0, 0, W, H);
GLOW_CTX.setTransform(m);
fn(GLOW_CTX);
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = strength;
ctx.filter = `blur(${blur}px)`;
ctx.drawImage(GLOW_LAYER, 0, 0);
ctx.filter = 'none';
ctx.restore();
fn(ctx);
}
// ===== Hand-drawn lines: jittered points that "boil" (re-seed) a few times per second =====
function resample(pts, step = 24) {
const out = [];
for (let i = 0; i < pts.length - 1; i++) {
const [x0, y0] = pts[i], [x1, y1] = pts[i + 1], n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / step));
for (let k = 0; k < n; k++) out.push([lerp(x0, x1, k / n), lerp(y0, y1, k / n)]);
}
out.push(pts[pts.length - 1]);
return out;
}
// Polyline cut at progress p (0..1) of its length: the draw-on effect.
function partial(pts, p) {
if (p >= 1) return pts;
const seg = [], lens = [0];
for (let i = 1; i < pts.length; i++) lens.push(lens[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
const target = lens[lens.length - 1] * clamp(p);
for (let i = 0; i < pts.length; i++) {
if (lens[i] <= target) { seg.push(pts[i]); continue; }
const f = (target - lens[i - 1]) / (lens[i] - lens[i - 1] || 1);
seg.push([lerp(pts[i - 1][0], pts[i][0], f), lerp(pts[i - 1][1], pts[i][1], f)]);
break;
}
return seg;
}
function sketch(g, pts, t, { color = C.ink, width = 4, wobble = 2.2, boil = 10, seed = 1, passes = 2, progress = 1, alpha = 1 } = {}) {
const base = partial(resample(pts), progress);
if (base.length < 2) return;
const frame = Math.floor(t * boil);
g.save();
g.lineCap = g.lineJoin = 'round';
g.strokeStyle = color;
for (let pass = 0; pass < passes; pass++) {
const r = rng(seed * 7919 + frame * 31 + pass);
g.globalAlpha = alpha * (pass === 0 ? 1 : 0.45);
g.lineWidth = width * (pass === 0 ? 1 : 0.6);
g.beginPath();
base.forEach(([x, y], i) => {
const jx = x + (r() - 0.5) * wobble * 2, jy = y + (r() - 0.5) * wobble * 2;
i ? g.lineTo(jx, jy) : g.moveTo(jx, jy);
});
g.stroke();
}
g.restore();
}
function circlePts(cx, cy, rad, n = 48, start = -Math.PI / 2) {
return Array.from({ length: n + 1 }, (_, i) => [cx + Math.cos(start + (i / n) * Math.PI * 2) * rad, cy + Math.sin(start + (i / n) * Math.PI * 2) * rad]);
}
// ===== Typography =====
function text(str, x, y, { size = 64, weight = 600, color = C.ink, align = 'center', alpha = 1, font = SANS, spacing = 0, g = ctx } = {}) {
g.save();
g.globalAlpha *= alpha;
g.fillStyle = color;
g.font = `${weight} ${size}px ${font}`;
g.letterSpacing = `${spacing}px`;
g.textAlign = align;
g.textBaseline = 'middle';
g.fillText(str, x, y);
g.restore();
}
// Masked per-character rise: each glyph slides up out of a clip box, staggered. `exit` runs it in reverse.
function revealText(str, x, y, p, { size = 96, weight = 700, color = C.ink, font = SANS, align = 'center', stagger = 0.06, spacing = 0, exit = 0 } = {}) {
const chars = [...str];
ctx.save();
ctx.font = `${weight} ${size}px ${font}`;
ctx.letterSpacing = `${spacing}px`;
const widths = chars.map(ch => ctx.measureText(ch).width);
const total = widths.reduce((a, b) => a + b, 0);
let cx = align === 'center' ? x - total / 2 : align === 'right' ? x - total : x;
ctx.beginPath();
ctx.rect(cx - size, y - size * 0.75, total + size * 2, size * 1.5);
ctx.clip();
const span = 1 + stagger * (chars.length - 1);
chars.forEach((ch, i) => {
const local = ease.expo(clamp(p * span - i * stagger));
const out = ease.in(clamp(exit * span - i * stagger));
const dy = (1 - local) * size * 1.4 - out * size * 1.4;
if (local > 0 && out < 1) text(ch, cx, y + dy, { size, weight, color, font, align: 'left', spacing });
cx += widths[i];
});
ctx.restore();
}
// ===== Assets: images must be local files; render waits for every loadImage() =====
const ASSETS = [];
function loadImage(src) {
const img = new Image();
img.src = src;
ASSETS.push(img.decode());
return img;
}
// ===== Sound: synthesized offline on the same timeline as the picture =====
const NOTE = n => 440 * Math.pow(2, (n - 69) / 12);
function tone(ac, out, t, { freq = 440, dur = 0.3, type = 'sine', gain = 0.2, attack = 0.005, release = 0.25 } = {}) {
const o = ac.createOscillator(), g = ac.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t);
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(gain, t + attack);
g.gain.setValueAtTime(gain, t + Math.max(attack, dur - release));
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g).connect(out); o.start(t); o.stop(t + dur + 0.05);
}
function noiseBuf(ac, seconds, seed = 99) {
const b = ac.createBuffer(1, Math.ceil(ac.sampleRate * seconds), ac.sampleRate);
const d = b.getChannelData(0), r = rng(seed);
for (let i = 0; i < d.length; i++) d[i] = r() * 2 - 1;
return b;
}
const SFX = {
pop(ac, out, t, pitch = 1) {
const o = ac.createOscillator(), g = ac.createGain();
o.frequency.setValueAtTime(900 * pitch, t);
o.frequency.exponentialRampToValueAtTime(220 * pitch, t + 0.12);
g.gain.setValueAtTime(0.3, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.15);
o.connect(g).connect(out); o.start(t); o.stop(t + 0.2);
},
tick(ac, out, t) { tone(ac, out, t, { freq: 2400, dur: 0.04, type: 'square', gain: 0.03, release: 0.03 }); },
whoosh(ac, out, t, dur = 0.6) {
const s = ac.createBufferSource(), f = ac.createBiquadFilter(), g = ac.createGain();
s.buffer = noiseBuf(ac, dur);
f.type = 'bandpass'; f.Q.value = 1.2;
f.frequency.setValueAtTime(300, t);
f.frequency.exponentialRampToValueAtTime(4000, t + dur * 0.7);
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.22, t + dur * 0.5);
g.gain.linearRampToValueAtTime(0, t + dur);
s.connect(f).connect(g).connect(out); s.start(t); s.stop(t + dur);
},
riser(ac, out, t, dur = 1.5) {
const s = ac.createBufferSource(), f = ac.createBiquadFilter(), g = ac.createGain();
s.buffer = noiseBuf(ac, dur, 7);
f.type = 'bandpass'; f.Q.value = 4;
f.frequency.setValueAtTime(200, t);
f.frequency.exponentialRampToValueAtTime(6000, t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.18, t + dur);
g.gain.linearRampToValueAtTime(0, t + dur + 0.02);
s.connect(f).connect(g).connect(out); s.start(t); s.stop(t + dur + 0.05);
},
boom(ac, out, t) {
const o = ac.createOscillator(), g = ac.createGain();
o.frequency.setValueAtTime(110, t);
o.frequency.exponentialRampToValueAtTime(32, t + 0.9);
g.gain.setValueAtTime(0.6, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.1);
o.connect(g).connect(out); o.start(t); o.stop(t + 1.2);
},
kick(ac, out, t, gain = 0.35) {
const o = ac.createOscillator(), g = ac.createGain();
o.frequency.setValueAtTime(140, t);
o.frequency.exponentialRampToValueAtTime(45, t + 0.12);
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
o.connect(g).connect(out); o.start(t); o.stop(t + 0.35);
},
};
// A simple bed: sustained pad chords, bass on the root, an 8th-note pluck arpeggio, optional kick.
// chords are MIDI note lists, one per bar; pad: 'triangle' warm, 'sawtooth' brighter, 'sine' soft.
function music(ac, out, from, to, { bpm = 96, chords = [[57, 60, 64, 67], [53, 57, 60, 64], [48, 52, 55, 59], [55, 59, 62, 65]],
pad = 'triangle', kickFromBar = 1, kickUntil = Infinity, pluck = true } = {}) {
const beat = 60 / bpm;
for (let bar = 0, t = from; t < to; bar++, t += beat * 4) {
const ch = chords[bar % chords.length];
ch.forEach(n => tone(ac, out, t, { freq: NOTE(n), dur: beat * 4, type: pad, gain: pad === 'sawtooth' ? 0.018 : 0.035, attack: 0.5, release: 1.4 }));
tone(ac, out, t, { freq: NOTE(ch[0] - 24), dur: beat * 3.6, type: 'sine', gain: 0.14, attack: 0.02, release: 0.9 });
if (pluck) for (let i = 0; i < 8; i++) {
const tt = t + i * beat / 2;
if (tt < to) tone(ac, out, tt, { freq: NOTE(ch[(i * 3) % ch.length] + 12), dur: 0.25, type: 'sine', gain: 0.04, release: 0.2 });
}
if (bar >= kickFromBar) for (let i = 0; i < 4; i++) {
const tt = t + i * beat;
if (tt < to && tt < kickUntil) SFX.kick(ac, out, tt, 0.16);
}
}
}
// ===== Contract read by render.py =====
window.__ready = (async () => {
if (document.readyState === 'loading') await new Promise(r => addEventListener('DOMContentLoaded', r));
await document.fonts.ready;
await Promise.all(ASSETS);
})();
window.__seek = (t) => draw(t);
// ===== Live preview when index.html is opened directly in a browser =====
addEventListener('DOMContentLoaded', () => {
if (new URLSearchParams(location.search).has('render')) {
document.body.classList.add('render');
window.__ready.then(() => draw(0));
return;
}
let t = 0, playing = false, last = 0, audioCtx = null, src = null, buffer = null;
const tick = (now) => {
if (playing) { t += (now - last) / 1000; if (t >= VIDEO.duration) { t = 0; restartAudio(); } }
last = now; draw(t); requestAnimationFrame(tick);
};
async function restartAudio() {
if (!audioCtx || !window.__audio) return;
if (!buffer) {
const off = new OfflineAudioContext(2, Math.ceil(VIDEO.duration * 48000), 48000);
await window.__audio(off);
buffer = await off.startRendering();
}
if (src) src.stop();
src = audioCtx.createBufferSource(); src.buffer = buffer;
src.connect(audioCtx.destination); src.start(0, t);
}
addEventListener('click', () => {
if (!audioCtx) audioCtx = new AudioContext();
playing = true; restartAudio();
});
addEventListener('keydown', (e) => {
if (e.code === 'Space') { playing = !playing; playing ? restartAudio() : src && src.stop(); }
if (e.code === 'ArrowRight') { t = Math.min(VIDEO.duration, t + 2); if (playing) restartAudio(); }
if (e.code === 'ArrowLeft') { t = Math.max(0, t - 2); if (playing) restartAudio(); }
});
window.__ready.then(() => requestAnimationFrame((n) => { last = n; tick(n); }));
});
# Craft rules
Rules that make code-rendered video read as designed rather than generated. Break one only on purpose, for this content.
## Motion
- **Vector law across cuts.** If something exits right at speed, the next shot's subject enters from the left at a similar speed, on the same axis. Scale too: a push-in into an object continues as growth in the next shot, never a sudden zoom-out.
- **One direction of travel for the film** (usually left to right or into depth). Reverse it only to signal a turn in the story.
- **Carriers.** Every transition is carried by an object: a dot that keeps moving and becomes the next scene's node, a line that becomes a horizon, a shape that grows to fill the frame and becomes the next background. "Fade to next slide" is the last resort.
- **Cause and effect.** Things move because something moved them: a line arrives and the node lights up; a number lands and the bar grows. Stagger reactions 40-120ms behind their cause.
- **Timing.** Entrances 0.4-0.8s, exits about 75% of the entrance, group staggers 40-120ms with the whole group in under 0.5s, holds long enough to read (~3 CJK chars or 1.5 English words per second).
- **Easing.** Ease-out for arrivals, ease-in for departures, ease-in-out for camera and moves between two rest points, expo for snappy UI-like motion. Never linear except for constant drift. Springs and back-ease only on objects, with a small overshoot - never on text.
- **Stillness before the climax.** Freeze everything for 0.3-0.75s before the biggest hit; duck the music there too. Contrast is what makes the hit land.
- **No idle wobble on content.** Once a message element has landed it holds still (or rides the camera). Ambient motion belongs to the background layer, ideally one shared slow motion (drift, breathe).
- **Camera.** Every shot has a camera behavior: a slow push (2-6% over the shot), a pan that follows the action, a pull-back reveal. Camera moves ease in and out; whip pans are fast (0.3-0.5s) and pair well with `--motion-blur`.
- **Two or three transition types per film**, reused consistently. Ten different transitions read as a template demo.
- **Motion blur** only on fast moves (a whip pan, a slam, a fast slide). Text being read must land sharp.
## Composition
- Think in three layers per frame: background atmosphere, midground message, foreground details (labels, rules, tick marks, coordinates, small captions). Foreground details are what make a frame feel produced.
- Two focal points minimum, one dominant. Lead the eye; do not center everything with equal weight.
- Anchor to edges and thirds; use split layouts (visual left, text right) and structural lines (rules, dividers) that can animate (`scaleX` 0 -> 1).
- Fill the frame: hero type at 50-80% of the frame width, hero shapes big. Keep ~80px safe margins at 1080p.
- Video scale is not web scale: headlines 96-180px, body >= 40px, labels >= 22px, strokes >= 3px, decorative opacity >= 10%.
- Depth: parallax layers (background moves 0.2-0.5x the camera), atmospheric fade on far layers, soft shadows or glows to separate planes.
## Color
- Declare bg / fg / accent once (`C` in the template) and do not invent colors per element.
- Tint neutrals toward the accent; avoid pure #000 / #fff.
- One accent, used for the thing that matters in each frame. If everything is accent, nothing is.
- Muted is fine, flat is not: each frame needs one color that pulls the eye.
- No full-screen linear gradients on dark backgrounds (they band after H.264); use solid + radial glows.
- Light canvases need texture (paper, grain) and bolder strokes, or they look like blank slides.
## Typography
- Few words, big. One idea per frame. Max ~12 CJK chars per line, 2 lines per frame.
- Pair a display face with a text face: `SERIF` (Songti) heavy for editorial / cinematic / cultural titles, `SANS` for tech and UI, `KAI` for hand-written / literary accents, `MONO` letter-spaced for labels, numbers and metadata.
- Weight contrast: display 700-900 vs text 300-500.
- Latin uppercase labels get letter spacing (`spacing` 4-10px); CJK gets none or very little.
- Text arrives with intent: masked rise per character (`revealText`), type-on for code, counters for numbers. Never just fade in a paragraph.
## Lazy defaults (AI tells) - avoid unless the content demands them
- Purple-to-blue or cyan-on-dark gradients, neon glow on everything, gradient text.
- Random particle confetti, starfields, lens flares, light leaks, RGB split, glitch, camera shake.
- Bouncy / elastic easing on text; everything entering from `y + 30, opacity 0`.
- Identical card grids, left accent stripes on cards, centered bullet lists, "Thanks for watching".
- Emoji as illustrations, clip-art icons, stock-UI mockups with lorem ipsum.
- Title card -> bullet points -> outro structure.
## Review checklist (run on every contact sheet)
1. Does each frame read in one second? Is there one dominant focal point?
2. Any overlapping, clipped or off-canvas text? Tofu boxes (missing glyphs)?
3. Contrast: text readable against what is behind it in this exact frame?
4. Is the frame dressed: background atmosphere + message + foreground details? Or empty?
5. Does the first frame already have something moving and worth looking at?
6. Do consecutive shots obey the direction of travel? Is each transition carried by an object?
7. Is the texture stack visible (grain, paper, vignette) but not muddy?
8. Does the last frame rhyme with the first?
# Direction: turn the request into a director's brief
The user's prompt is a seed, not a spec. "做一个讲 Transformer 的视频" says nothing about the world the film lives in, how it moves, or what the viewer should feel. Those decisions are what separate a film from an animated slide deck, and they are yours to make. Expanding the prompt is never a pass-through: keep everything the user asked for, then add the concept, atmosphere, choreography and sound they did not think to ask for.
Do not interview the user about taste. Ask only for things only they have (brand assets, a product name you cannot guess, a song they want). Decide everything else, write it down, and build it.
## 1. Find the one idea
Answer three questions in one line each:
- **What should the viewer feel** at the end? (curious, impressed, calm, moved, fired up)
- **What should they remember**? One sentence. If it takes two, the film is two films.
- **What is the visual world**? One metaphor that carries the whole film and evolves with it.
Good worlds are concrete and continuous:
| Topic | Weak (slides) | Strong (a world) |
|---|---|---|
| How attention works | boxes labelled Q, K, V | words as lanterns on a dark river; attention is light reaching between them, brighter for stronger links |
| 5000 years of history | timeline with dates | one ink brush line that never lifts: it becomes a river, a wall, a palace roof, a rail track, a skyline |
| A SaaS product | feature list with icons | the user's messy desk that the product tidies, object by object, until one clean screen remains |
| Compound interest | rising bar chart | a single seed whose tree grows a branch per year; the camera keeps pulling back |
| Room to quarks | cut to cut zooms | one continuous push-in; each level's texture becomes the next level's background |
The motif must recur: the final shot should rhyme with the first (same shape, same position, transformed).
## 2. Choose format and rhythm
- Duration: 20-45s by default; a teaser 10-15s; an explainer 45-90s. Longer only on request.
- Aspect: 1920x1080; 1080x1920 for short-video platforms; 1080x1080 for feeds.
- Name the rhythm before detailing any scene, e.g.:
- explainer: `hook - setup - build - BUILD - payoff - button`
- promo / launch: `hook - PUNCH - proof - proof - PEAK - logo`
- story / history: `quiet open - rise - rise - turn - CLIMAX - echo of the opening`
- data story: `question - one number - context - the real number - so what`
- The hook lands in the first 1.5s (motion already happening at frame 0, never a fade from black that lasts more than 0.5s).
- Something new every 2-4s; one hold (0.3-0.75s of stillness) right before the biggest moment.
- If music drives it, put the cuts on the bar grid (`BPM`, `BEAT` in the template) and big hits on downbeats.
## 3. Choose a style and declare it
Pick one preset from `styles.md` (or blend two deliberately) and write down, before any code:
- palette: bg, fg, accent (+ at most 2 support colors) as hex
- type: display face + text face from the local font stacks (`SERIF`, `SANS`, `KAI`, `MONO`)
- texture stack: e.g. `paper 0.25 overlay + grain 0.07 + vignette 0.5`
- motion energy: calm / medium / high, the main ease, and the film's **direction of travel** (e.g. left to right)
- sound: tempo, mood, instrument color
## 4. Write the beat sheet
For every beat (2-6s each), fill all of these. If a cell is empty, the beat is not designed yet.
- **Picture**: what is on screen, in three depth layers - BG (atmosphere), MG (the message), FG (labels, rules, small details that make it feel produced).
- **Motion**: a verb for every element - draws on, slides in, rises from mask, snaps, counts up, unfolds, orbits, sinks. Name the ease and duration.
- **Camera**: push in / pull back / pan with direction / hold. Static frames are the exception.
- **Text**: the exact on-screen words (short; ~3 Chinese chars or 1.5 English words per second of hold).
- **Transition out**: object level, not "crossfade" - "the dot keeps moving right and becomes the first node", "camera pushes into the orange circle until it fills the frame".
- **Sound**: the cue that lands on the main hit (whoosh, pop, boom, tick run, riser into the hold).
## 5. Motifs and negatives
- 1-2 recurring motifs (a shape, a color, a line) that tie the beats together.
- A negative list for this film, on top of the defaults in `craft.md`, e.g. "no glow on text, no cards, no centered bullet lists".
## 6. Explainers people finish: story, numbers, live demos
The knowledge videos that get shared are not prettier slides; they are a story told with evidence. For any explainer longer than ~30s:
- **A narration track.** One subtitle line at the bottom center, one sentence per beat (8-18 CJK chars), written like someone telling a story, not like a textbook: concrete, second person, short. One keyword per line in the accent color. The picture shows, the line interprets; they never say the same thing. This line carries the film even with the sound off.
- **A thread, not a list.** Give the topic a protagonist and a question ("它只会说话,怎么就开始干活了?") and let each beat answer the previous beat's problem: it can only talk -> give it hands -> it forgets -> give it memory. Each new layer exists because the last one failed at something the viewer just saw.
- **Show the mechanism working.** Every concept gets a small live demo on screen, never a label: a probability table filling in, a counter running, a JSON call being typed and executed, a file appearing in a tree, a chart plotting point by point. If you cannot show it moving, you have not found the beat yet.
- **Numbers are characters.** Use 3-6 concrete numbers the viewer can hold onto (a step count, a token count, a time, a cost, a ratio). Real ones from the topic or the user's material; never invent statistics - if a number is illustrative, label it (`示意`). Counters roll with tabular `MONO` digits; the important number gets the hold before the climax.
- **Evolving objects.** Keep one diagram on screen and grow it across beats (the same box gains a hand, then a loop, then a shelf of skills) instead of cutting to a new slide per idea. The viewer watches the system being built.
- **Precision texture.** Tiny `MONO` annotations (units, axis names, file paths, step numbers, a source note) in corners and next to objects, at 30-45% opacity. They make a frame feel researched without demanding attention.
- **Chapters for 60s+.** A recurring chapter mark (number + one word) between acts, same position and motion every time.
- **An ending with a feeling.** After the payoff, one quieter line that turns the topic toward the viewer (a dedication, a question, a callback to the opening line). Not a summary list, not "thanks for watching".
## STORYBOARD.md format
Keep it compact; it is a production document, not prose.
```markdown
# <title> - <duration>s, 1920x1080@30
Feel: <one line> Remember: <one sentence> World: <the metaphor>
Rhythm: hook - setup - build - BUILD - payoff - button Direction of travel: left -> right
Style: <preset>; bg #.. fg #.. accent #..; display SERIF 800, text SANS 400; texture paper .25 + grain .07 + vignette .5
Sound: 96 BPM, warm minor pad + soft kick from bar 2; riser into the hold at 18.2s
Motifs: <...> Avoid: <...>
| t | picture (BG / MG / FG) | motion + camera | text | transition out | sound |
|---|---|---|---|---|---|
| 0.0-3.5 | ... | ... | ... | ... | ... |
```
## Worked example
Request: "帮我做一个视频,讲讲什么是 Transformer"
```markdown
# 注意力 - 36s, 1920x1080@30
Feel: "oh, that's actually simple" Remember: every word looks at every other word and decides whom to listen to
World: words are lanterns floating on a dark river; attention is light that reaches between them
Rhythm: hook - setup - build - BUILD - payoff - button Direction of travel: left -> right, then a final pull-back
Style: Luminous Geometry; bg #0d1117 fg #e8e2d0 accent #f2b544 support #4fb3a9; display SERIF 800, labels MONO
Texture: grain .06, vignette .6, glow on light beams only (never on text)
Sound: 84 BPM, Dm9 pad, sparse plucks; a soft chime per new link; riser into the hold at 27.4s, boom on the payoff
| t | picture | motion + camera | text | transition out | sound |
|---|---|---|---|---|---|
| 0-4 | river of dark water (BG), one lantern drifts in from left (MG), hairline horizon (FG) | lantern glides L->R, camera tracks it | 机器怎么读懂一句话? (rises from mask at 0.6s) | lantern stops; 6 more lanterns light up beside it | low pad in, chime |
| 4-10 | 7 lanterns, each holding one word of "猫 坐 在 垫子 上 因为 它 累了" | words type on inside lanterns, staggered 80ms | - | camera pushes toward "它" | ticks per word |
| 10-18 | beams of light from "它" to every other lantern, thickness = weight | beams draw on one by one, "猫" beam brightest | 它 → 猫 | camera rises above the river (pull back + tilt) | chime per beam, pitch by weight |
| 18-27 | every lantern sends beams to every other: a woven net of light | net builds in waves L->R; numbers (weights) flicker in MONO | 每个词都在看所有词 | net brightens, all motion stops (hold 0.5s) | riser |
| 27-33 | the net folds into one bright layer, stacks into 12 layers like floors of a tower | layers slam down on the beat, camera pulls back to reveal the tower | 这就是 Transformer | tower's top light becomes a single lantern | boom, pad swells |
| 33-36 | the single lantern drifts right, as in the opening | glide, fade of the river | Attention is all you need | - | pad tail |
Motifs: lanterns, warm light on dark water Avoid: boxes labelled Q/K/V, matrix grids, code on screen
```
# Prompts: what good video requests look like
Use this file two ways: to expand a thin request yourself (see `direction.md`), and to suggest better requests to the user when they ask how to get a better result or want a different version.
## Why one-liners can work
The best-known code-rendered films came from one sentence ("make a modern slick and punchy video for a startup that works on inference", "做一个动画,快速回顾中华五千年的历史。风格轻松有趣,动画格式为线稿,添加合适的音乐,请务必做到引人入胜"). They worked because the model was given high reasoning effort and full creative freedom, and it did the director's job itself: chose a world, storyboarded, built, looked at its own frames and fixed them. Treat every request as if it ended with "go all out, surprise me" - within the requested length.
## Anatomy of a strong request
A detailed request usually contains these parts. When the user leaves one out, you fill it in.
1. **Goal and audience** - what it is for, who watches it, where (feed, keynote, website hero).
2. **Format** - duration, aspect ratio, language of on-screen text.
3. **World / metaphor** - the visual idea that carries the film.
4. **Style references** - a named style, a film, a designer, an art movement, or reference images ("like a Swiss poster", "like a museum documentary", "line art").
5. **Structure** - number of beats and what each says, or a bar grid (e.g. "10 bars at 120 BPM, one move per bar").
6. **Ban list** - "no particle bursts, no RGB split, no lens flares, no neon glow, no bouncy easing, no camera shake".
7. **Assets** - logo, product screenshots, footage, music the user owns; brand colors and fonts.
8. **Sound** - mood and tempo, or a supplied track; narration if a TTS service is configured.
9. **Process** - storyboard first, probe frames before the full render.
## Fill-in templates (offer these to users)
Explainer / 科普:
```text
做一个 [40] 秒的科普动画,讲清楚 [主题]。观众是 [没有专业背景的大学生],看完要记住一句话:[核心结论]。
风格 [线稿手绘 / 电影感暗色 / 发光几何],画面用一个贯穿始终的比喻来讲,不要用 PPT 式的标题加要点。
配合适的音乐和音效,务必做到引人入胜。
```
Product / brand promo:
```text
为 [产品名] 做一支 [30] 秒宣传片,一句话卖点是 [卖点],目标用户是 [人群]。
品牌色 [#hex],Logo 在 [路径],截图在 [目录]。高级、克制、留白多;一个镜头只讲一件事;
节奏跟音乐的小节对齐,最后落在 Logo 和一句口号上。禁止:粒子爆炸、镜头抖动、霓虹光、弹跳缓动。
```
History / story:
```text
用 [60] 秒讲 [事件/人物] 的故事,按"平静开场 - 推进 - 转折 - 高潮 - 呼应开头"的结构,
画面风格 [水墨 / 线稿 / 电影感],要有镜头运动和有动机的转场,结尾画面要和开头呼应。
```
Data story:
```text
把这组数据做成 [30] 秒的数据动画:[数据或文件路径]。只突出一个最重要的数字:[数字及含义]。
瑞士海报风格,坐标轴先画出来,数据再生长,配节奏感强的音效。
```
Long explainer / 知识视频 (story + numbers):
```text
做一个 [90] 秒的知识科普视频,讲清楚 [主题],观众是 [小白]。
用一个主角和一个问题串起来,每一层概念都是为了解决上一层的问题;每个概念配一个真实运转的小演示(计数器、概率条、图表、代码执行),用 [3-5] 个真实数字。
底部一句一行讲故事式字幕,关键词高亮;终端数据风格(深色、单一荧光强调色、细网格、等宽小字标注)。结尾一句有情绪的话。
```
Single-look short pieces: pick a look from the "More looks" table in `styles.md` and fill one line, e.g.
```text
做一个 10 秒的 [3Blue1Brown 风格数学可视化],主题 [圆面积]:[深色背景,圆切成 8、16、32 份扇形,拼成长方形,公式逐项出现,定格在 πr²]。用代码渲染,导出视频。
```
Kinetic typography / quote / lyrics:
```text
把这段文字做成 [20] 秒的动态文字视频:[文字]。两种颜色,大字铺满画面,每个词踩在鼓点上,120 BPM。
```
## Phrases that measurably help
- "务必做到引人入胜 / go all out / surprise me" - permission to be ambitious.
- "像导演一样先写分镜,再写代码" - forces planning.
- "用一个贯穿始终的视觉比喻" - forces a world instead of slides.
- "镜头要运动,转场要有动机(match cut、推进进入物体)" - camera and transitions.
- A ban list - removes most of the template look.
- A bar grid with BPM - makes cuts feel edited.
## Revision requests
Change one thing per round and be specific; the source is code, so targeted edits are cheap:
- Pace: "整体慢 20%" / "第 2 个镜头停留再久一点" / "高潮前加 0.5 秒静止"
- Energy: "更有冲击力:镜头推进更快,加一个甩镜转场,加动态模糊"
- Look: "换成线稿手绘风格" / "加纸张纹理和颗粒" / "主色换成 #e63312"
- Content: "加一个讲价格的镜头" / "把第 3 句文案改成 ..."
- Sound: "音乐换成更安静的钢琴" / "每个数字出现时加音效"
## When the request is thin
Do not ask the user to fill a template. Expand it yourself (`direction.md`), build, and in the delivery message say in two or three lines which world and style you chose and offer two concrete alternative directions (e.g. "也可以换成水墨长卷式,或节奏更快的动态文字版").
# Style presets
Pick by what the viewer should feel, then by content. Each preset lists the decisions you would otherwise make by default (and get generic results). Hex values are starting points; keep the relationships. Font stacks refer to the template constants (`SERIF`, `SANS`, `KAI`, `MONO`) - local fonts only.
| Preset | Feel | Good for |
|---|---|---|
| Ink & Paper | warm, clever, hand-made | explainers, history, science for everyone, "line art" requests |
| Cinematic Dark | premium, dramatic | launches, brand films, trailers, keynotes |
| Luminous Geometry | wonder, intelligence | math, AI, physics, abstract ideas |
| Swiss Data | clear, confident | data stories, reports, business explainers, dev tools |
| Blueprint | precise, technical | engineering, how-it-works, architecture, hardware |
| Ink Wash | poetic, Chinese classical | culture, poetry, history, tea / craft brands |
| Kinetic Type | loud, rhythmic | announcements, quotes, lyric videos, social hooks |
| Picture Book | friendly, soft | kids, education, wellness, consumer apps |
| Risograph Print | playful, indie | events, posters-in-motion, creative brands |
| Terminal Data Story | smart, precise, quietly moving | AI / tech / science explainers with numbers, "how it really works" |
More looks with one-line recipes are at the end of this file.
---
## Terminal Data Story
The look of the best-performing tech explainers: a dark research terminal where every claim is shown as data.
- Palette, pick one:
- Warm amber (default for general audiences; cinematic and friendly): bg `#13110e`, fg `#efe8dc`, dim `#8c8478`, accent `#f2a649`, warn `#ff5a4e`, warm `#ffd8a8` (emotional ending only), hairlines `rgba(239,232,220,0.08)`.
- Acid lime (hacker / developer audience): bg `#0b0d0b`, fg `#e6ebe0`, dim `#7d857a`, accent `#c8f03c`, warn `#ff5a4e`, warm `#f2a93b`, hairlines `rgba(230,235,224,0.08)`.
- Beginner audiences: one idea on screen at a time, large type; never open with two dense panels side by side or a bare jargon title - open with a relatable moment, then name the concept with a plain-language gloss. Every narration line names its subject ("the model ...", "the agent ...") instead of "it".
- Type: narration line `SERIF` 600 ~44px bottom center with the keyword in accent; hero words `SERIF` 900 with a soft accent glow; all numbers, axes, labels and annotations `MONO` (tabular digits, letter-spaced labels at 30-45% opacity).
- Texture: faint full-frame grid (40-60px cells), `grain(t, 0.05)`, `vignette(0.55)`; `glow()` on the accent only (counters, the active bar, the hero word), never on body text.
- Drawing: thin 1.5-2px strokes; charts with real axes and tick labels that plot point by point; probability bars; big rolling counters; code/JSON typed on with a block cursor; HUD-like small panels with a 1px border and a `MONO` caption.
- Motion: calm and precise - type-on, count-up, draw-on, plot; camera slow push or still; one diagram evolves across beats instead of cuts.
- Transitions: the chart rescales and becomes the next chart; zoom into one data point; the hero word dissolves into particles of text; a hard cut to black and one word.
- Sound: sparse ambient piano or soft pulses at 70-90 BPM; ticks on counters and points; a sub swell on the reveal. If the user supplies a song, cut on its beats.
- Avoid: cards with shadows, icons, more than one bright accent at a time, glow on paragraphs, sci-fi HUD clutter that carries no information.
---
## Ink & Paper
- Palette: paper `#f3ecdd`, ink `#2b2620`, accent `#d4552b` (vermilion), support `#3f6f8f`.
- Type: display `SERIF` 800 or `KAI` 700; labels `SANS` 500 in ink.
- Texture: `paper(0.45, 'multiply')`, `grain(t, 0.05)`, `vignette(0.25)`. Light canvas, so strokes 3-6px.
- Drawing: everything is `sketch()` lines with boil (`boil: 8-12`, `wobble: 1.5-3`), drawn on with `progress`. Hatching for shading (parallel short sketch lines), flat accent fills slightly offset from their outlines (misregistered print look).
- Motion: medium energy; things draw themselves rather than fade; small squash on landings; camera slides across one long continuous drawing.
- Transitions: the line keeps going into the next scene; a drawn shape becomes the next frame; page-turn pans.
- Sound: plucked strings / marimba / light percussion, pencil-scratch noise on draws (short bandpassed noise).
- Avoid: glow, gradients, dark backgrounds, perfect geometric shapes.
## Cinematic Dark
- Palette: bg `#0e0d0c` (warm) or `#0a0e14` (cool), fg `#ece6da`, one accent: amber `#e9a23b`, or ember `#e2553b`, or ice `#8fc4e8`.
- Type: display `SERIF` 700-800 large with generous space, or `SANS` 300 thin with wide spacing; small `MONO` labels.
- Texture: `grain(t, 0.08)`, `vignette(0.6)`, radial key-light glow behind the subject, optional letterbox bars (2.39:1) for trailer moments.
- Light: `glow()` on light sources and the accent only; light sweeps across titles (moving gradient with `source-atop`); volumetric beams as blurred, additive triangles.
- Motion: slow and confident - long ease-in-out camera pushes, masked type reveals, 0.5-1s holds. One big moment, set up by stillness.
- Transitions: match cuts on shape, push-through into an object, cut to black on a beat then hard in.
- Sound: low drones, sub booms on cuts, risers, sparse piano; duck everything before the reveal.
- Avoid: busy layouts, many colors, bounce, fast cutting without breathing room.
## Luminous Geometry
- Palette: bg `#07080d`, fg `#e8e4f0`, accents glow additive: gold `#ffcf6b` + teal `#5ee0c8` (or a single hue family).
- Type: display `SERIF` 600 (math feels classical), formulas in `SERIF` italic, labels `MONO`.
- Texture: `grain(t, 0.06)`, `vignette(0.65)`; bloom on all lines via `glow()` or `globalCompositeOperation = 'lighter'` with thin bright strokes over wider dim ones.
- Drawing: precise curves, orbits, lattices, polar patterns, Lissajous figures, rotating 3D wireframes projected to 2D (`project([x, y, z])`), particles that follow fields (seeded, purposeful).
- Motion: smooth and continuous; structures assemble from points -> lines -> surfaces; slow rotation of everything in 3D.
- Transitions: zoom into one point of the structure, which becomes the next structure; symmetry reveals.
- Sound: soft pads, glassy plucks, chimes on each connection, sub swell on the reveal.
- Avoid: neon colors at full saturation everywhere; text with glow.
## Swiss Data
- Palette: bg `#f4f1ea` or `#111111`, fg `#141414` / `#f4f1ea`, accent pure and loud: `#e63312` or `#0055ff`.
- Type: `SANS` 800 huge numbers, `SANS` 400 labels, `MONO` for axes and units. Flush-left, strict grid.
- Texture: very light `paper(0.15, 'multiply')` on light, `grain(t, 0.04)`; no vignette or a subtle one.
- Drawing: 12-column grid (visible hairlines at low opacity), axes that draw first, bars that grow from the baseline, counters with tabular digits, annotations with leader lines.
- Motion: crisp `ease.expo` 0.35-0.5s; elements slide on grid lines; wipes reveal panels.
- Transitions: hard cuts on beats, panel wipes, a chart element scaling up to become the next frame.
- Sound: tight clicks, short sine blips per data point, minimal electronic beat.
- Avoid: drop shadows, 3D charts, rounded cards, more than one accent.
## Blueprint
- Palette: bg `#123a6b`, lines `#e6eef8`, accent `#ffb000`, faint grid `rgba(230,238,248,0.12)`.
- Type: `MONO` labels and dimensions, `SANS` 700 titles.
- Texture: grid of minor/major lines, `paper(0.2, 'overlay')`, `grain(t, 0.05)`.
- Drawing: exploded views, dimension lines with arrows, callouts, section hatching; parts slide apart and back together.
- Motion: mechanical - linear-ish constant moves with ease at ends, rotations about real pivots, step-by-step assembly.
- Transitions: zoom into a detail (callout circle becomes the next frame), sheet slides like paper.
- Sound: mechanical clicks, servo whirs (filtered saw sweeps), soft electronic pulse.
## Ink Wash (水墨)
- Palette: rice paper `#efe8d8`, ink `#1d1b19` with grays from dilution, seal red `#b8322a` (only for the seal / one key element).
- Type: `KAI` or `SERIF` for poetry; vertical text columns right-to-left for classical lines; a red seal stamp as signature.
- Texture: `paper(0.55, 'multiply')`, `grain(t, 0.04)`; ink bleed = the same shape drawn several times with increasing `ctx.filter = 'blur(..)'` and decreasing alpha.
- Drawing: brush strokes with varying width (draw a stroke as a filled polygon whose half-width follows pressure along the path); mountains as layered blurred silhouettes at decreasing contrast with distance.
- Motion: slow, breathing; ink spreads; mist (big blurred light shapes) drifts across; camera scrolls like a handscroll (horizontal pan).
- Transitions: ink spreads to cover the frame, mist wipes, the handscroll pans on.
- Sound: guqin / pipa style plucks (sine + fast decay with slight pitch bend), bamboo flute (sine with vibrato), wind noise.
- Avoid: saturated colors, hard geometric shapes, sans-serif text.
## Kinetic Type
- Palette: two colors only, maximum contrast: `#111` / `#f5f0e6`, or `#f5f0e6` / `#e8380d`. Invert on big beats.
- Type: `SANS` 900 or `SERIF` 900 filling 60-90% of the frame width; words are the visuals.
- Texture: `grain(t, 0.06)`; nothing else.
- Motion: on the beat grid; words slam, slide, stack, scale through the camera, split by masks; 1-3 words per beat.
- Transitions: hard cuts on downbeats, a letter grows until its counter (the hole in "O"/"口") becomes the next frame, color inversions.
- Sound: a beat is mandatory: kick + clap + bass; hits on every word.
- Avoid: more than two colors, small text, slow fades.
## Picture Book
- Palette: cream `#fbf3e4`, ink `#3b3340`, soft accents `#f2a65a`, `#7cc6b5`, `#e98a9b`, `#8fb3e8`.
- Type: `SANS` 700 rounded feel (large, friendly), `KAI` for story text.
- Texture: `paper(0.35, 'multiply')`, `grain(t, 0.05)`, soft shadows (offset darker shape, blurred).
- Drawing: rounded blobby shapes, characters built from circles and capsules with dot eyes, gentle outlines via `sketch()` with low wobble.
- Motion: squash and stretch, anticipation before jumps, follow-through on landing; springs allowed on objects; blinking eyes.
- Transitions: an object rolls/walks out of frame and pulls the next scene in; iris in/out on a character.
- Sound: ukulele / glockenspiel / xylophone patterns, pops and boings for actions.
## Risograph Print
- Palette: paper `#f2ede3`, inks `#ff48b0` (pink), `#0078bf` (blue), `#ffe800` (yellow); overlaps use `multiply`.
- Type: `SANS` 900 condensed feel, `MONO` labels.
- Texture: `paper(0.4, 'multiply')`; halftone dots for tones (pattern of circles scaled by value); each ink layer offset 2-4px (misregistration) and drawn with `multiply`.
- Motion: stepped animation at 12fps for a printed feel (`t = Math.floor(t * 12) / 12` for selected layers), punchy slides.
- Transitions: sheets slide in like paper, ink layers separate then re-register.
- Sound: lo-fi drums, tape-wobbled keys (slight detune LFO).
---
## More looks (one-line recipes)
Each is one coherent look; pick one, do not stack three. They pair well with short single-idea pieces (10-15s) or as the look of a whole explainer.
| Look | Recipe |
|---|---|
| Shadow puppet (皮影) | flat silhouettes with jointed limbs rotating at pins, warm backlight radial glow behind a paper screen, subtitles pop on the beat |
| Isometric flow | 2.5D isometric blocks and conveyors (`project()` with a fixed iso camera), numbered steps with leader-line labels, items travel the path |
| Scale comparison | objects side by side at true relative size, camera pulls back continuously until the first object is a dot; MONO size labels; one landing fact |
| Whiteboard sketch | white board, black marker `sketch()` lines drawing on, red only for the key emphasis, hand-lettered `KAI` captions |
| Character evolution (汉字演变) | one glyph morphing through oracle bone -> bronze -> seal -> regular script -> modern use, texture changes per era |
| Exploded view | an object's layers separate along one axis, camera orbits, dashed leader lines to `MONO` part labels (WebGL / three-like projection only if `webgl yes`) |
| Pixel tale | 16-bit pixel art (draw at 1/6 resolution, scale up with `imageSmoothingEnabled = false`), ~1.5s hard-cut panels, chiptune square-wave music |
| Picture-book explainer | crayon lines plus pencil hatching, jittery boil, paper texture, a character whose movement rides the beat |
| 3Blue1Brown math | dark bg, colored strokes, shapes cut and rearranged to prove the point (e.g. sectors -> rectangle), formula terms appear one by one |
| Ancient map | parchment, faint ink mountains, a vermilion route drawn in one stroke, mileage labels, a seal at the end |
| Kurzgesagt infographic | flat clean shapes, rounded geometry, saturated but harmonious palette, big numbers, steady zoom toward the end point |
| Flat motion graphics | solid colors, no outlines, cartoon shapes that squash and merge, two light/dark phases |
| Beat-drop typography | one word card per beat at 120 BPM, black bg and one hot color, the final line hammers in |
| Comic panels | black-and-white halftone with one red accent, panels hard-cut, speech balloons, the last panel lands the point |
| Horizontal timeline | camera travels along an axis, nodes scale up and light as they pass, big year numbers with one event line |
## Blending and custom styles
Blend at most two presets and say which aspects come from which (e.g. "Cinematic Dark palette and camera, Ink & Paper drawing"). For a brand, keep its colors and fonts exactly, then borrow texture and motion from the closest preset. Whatever you choose, write it into the storyboard header so every scene uses the same decisions.
# Techniques: texture, light, depth and motion in Canvas 2D
`motion.js` (copied with the template) already provides these - use them, do not rewrite them:
| Helper | Signature / notes |
|---|---|
| globals | `canvas`, `ctx`, `W`, `H`; fonts `SANS`, `SERIF`, `KAI`, `MONO` |
| math | `clamp(x, a, b)`, `lerp(a, b, p)`, `prog(t, start, dur)`, `ease.{out,in,inOut,expo,expoInOut,back}`, `spring(p, k, z)`, `rng(seed)`, `noise1(x, seed)` |
| camera | `CAMERA = [{t, x, y, z, ease?}]` (defined in index.html), `camAt(t)`, `withCamera(t, depth, fn)` |
| finish | `paper(amount, mode)`, `vignette(strength)`, `grain(t, amount, rate)`, `glow(g => ..., {blur, strength})`, `offscreen(w, h)`, `makePaper(seed)` |
| lines | `sketch(g, pts, t, {color, width, wobble, boil, seed, passes, progress, alpha})`, `resample(pts, step)`, `partial(pts, p)`, `circlePts(cx, cy, r, n)` |
| text | `text(str, x, y, {size, weight, color, align, alpha, font, spacing})`, `revealText(str, x, y, p, {size, weight, color, font, align, stagger, spacing, exit})` |
| assets | `loadImage(src)` - returns an `Image`; rendering waits until it is decoded |
| sound | `NOTE(midi)`, `tone(ac, out, t, {freq, dur, type, gain, attack, release})`, `noiseBuf(ac, sec)`, `SFX.{pop,tick,whoosh,riser,boom,kick}(ac, out, t, ...)`, `music(ac, out, from, to, {bpm, chords, pad, kickFromBar, kickUntil, pluck})` |
Everything below stays deterministic: values come from `t` and seeded `rng`, never from previous frames.
## Finishing stack (draw last, in this order)
```js
paper(0.25); // print texture: 'overlay' on dark, 'multiply' 0.3-0.5 on light
vignette(0.5); // 0.2-0.3 light styles, 0.5-0.7 cinematic
grain(t, 0.07); // 0.04-0.09; refreshes 12x/s like film
```
Optional color grade, before the finishing stack: `ctx.globalCompositeOperation = 'soft-light'; ctx.fillStyle = '#ffb36b'; ctx.globalAlpha = 0.12; ctx.fillRect(0, 0, W, H);` (warm), or a teal for night.
Letterbox for trailer moments: two `C.bg` bars of `(H - W / 2.39) / 2` that slide in with `ease.inOut`.
## Light
- **Bloom** on light sources: `glow(g => { g.strokeStyle = C.accent; g.lineWidth = 3; g.stroke(path); }, { blur: 20, strength: 0.9 })`. Never on body text.
- **Additive light**: `ctx.globalCompositeOperation = 'lighter'` for beams, sparks and overlapping glows; draw a wide dim stroke then a thin bright one.
- **Key light**: a radial gradient behind the subject, accent-tinted, 15-25% alpha, drifting slowly with `noise1`.
- **Light sweep** across a title:
```js
function sweep(drawShape, p, angle = -0.35) {
const x = lerp(-W * 0.3, W * 1.3, ease.inOut(p));
ctx.save();
drawShape(); // draw the title normally first
ctx.globalCompositeOperation = 'source-atop'; // light only lands on what was just drawn
const g = ctx.createLinearGradient(x - 160, 0, x + 160, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,0.65)'); g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.setTransform(1, angle, 0, 1, 0, 0); ctx.fillStyle = g; ctx.fillRect(-W, 0, W * 3, H);
ctx.restore();
}
```
For `source-atop` to only hit the title, render title + sweep into an offscreen layer and draw the layer onto the frame.
- **Volumetric beam**: a long triangle filled with a gradient fading away from the source, `'lighter'`, under `ctx.filter = 'blur(12px)'`.
## Depth
- **Parallax**: draw far layers with `withCamera(t, 0.3, ...)`, mid with `0.6`, subject with `1`, HUD/labels with `0`.
- **Atmospheric perspective**: far layers lighter/lower contrast (mix toward `C.bg`), smaller, slower.
- **Depth of field / rack focus**: `ctx.filter = \`blur(${px}px)\`` on the out-of-focus layer, animate `px` between 0 and 6-12 while moving focus.
- **Soft shadow**: `ctx.shadowColor = 'rgba(0,0,0,0.35)'; ctx.shadowBlur = 30; ctx.shadowOffsetY = 12;` on solid shapes only (expensive on text).
- **Simple 3D**: rotate and project points, draw back-to-front.
```js
function project([x, y, z], { rx = 0, ry = 0, fov = 900, cx = W / 2, cy = H / 2 } = {}) {
let [a, b, c] = [x, y * Math.cos(rx) - z * Math.sin(rx), y * Math.sin(rx) + z * Math.cos(rx)];
[a, c] = [a * Math.cos(ry) + c * Math.sin(ry), -a * Math.sin(ry) + c * Math.cos(ry)];
const s = fov / (fov + c);
return [cx + a * s, cy + b * s, s]; // s doubles as a depth cue for size / alpha
}
```
## Hand-made look
- **Boiling line art**: `sketch(ctx, pts, t, { boil: 10, wobble: 2 })`; lower `boil` (6-8) for a calmer look. Draw-on with `progress`.
- **Hatching** for shade: many short parallel `sketch` lines clipped to a shape (`ctx.save(); ctx.clip(shapePath); ...`).
- **Offset fills**: fill a shape with the accent shifted 4-8px from its outline, like misregistered print.
- **Brush stroke with pressure** (ink wash, calligraphy):
```js
function brush(pts, t, { width = 18, color = C.ink, progress = 1, seed = 1 } = {}) {
const P = partial(resample(pts, 6), progress), r = rng(seed), L = [], R = [];
P.forEach(([x, y], i) => {
const [nx, ny] = P[Math.min(i + 1, P.length - 1)], [px, py] = P[Math.max(i - 1, 0)];
const a = Math.atan2(ny - py, nx - px) + Math.PI / 2;
const k = i / Math.max(1, P.length - 1);
const w = width * Math.sin(Math.PI * Math.min(1, k * 1.2)) * (0.85 + r() * 0.3); // press in, lift out
L.push([x + Math.cos(a) * w / 2, y + Math.sin(a) * w / 2]); R.push([x - Math.cos(a) * w / 2, y - Math.sin(a) * w / 2]);
});
ctx.beginPath(); [...L, ...R.reverse()].forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.fillStyle = color; ctx.fill();
}
```
- **Ink bleed**: draw the same shape 3-4 times with growing `blur(2..14px)` and alpha 0.5 -> 0.1 under the crisp shape.
- **Halftone**: grid of dots whose radius follows a value field (e.g. a radial gradient), drawn in one ink with `multiply`.
- **Stepped animation** (print / stop-motion feel): use `const ts = Math.floor(t * 12) / 12;` for chosen layers.
## Text
- `revealText(str, x, y, p, {...})` - masked rise per character; pass `exit` for the reverse.
- **Type-on** (code, terminal): `str.slice(0, Math.floor(prog(t, t0, dur) * str.length))` plus a caret blinking on `Math.floor(t * 2) % 2`.
- **Counters**: `Math.round(lerp(a, b, ease.out(p)))`, `MONO` or tabular digits so the width does not jitter; tick sound every 0.08-0.12s while counting.
- **Highlight**: a marker-style rectangle that grows `scaleX` behind a word (draw before the text, accent at 35% alpha).
- **Split reveal**: clip the frame to the top half and bottom half and slide them apart to uncover the next title.
## Shapes and transitions
- **Morph** between two outlines: resample both to the same point count (`resample` then pick N evenly), then `lerp` point by point with `ease.inOut`.
- **Grow-to-fill carrier**: scale a shape from the current shot until it covers the frame (radius > hypot(W, H) / 2), then it *is* the next background (as in the template's ending).
- **Iris / mask wipe**: `ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r(t), 0, 2 * Math.PI); ctx.clip(); drawNextShot(); ctx.restore();`
- **Whip pan**: move the camera ~1 screen width in 0.3-0.5s with `ease.expoInOut`, render with `--motion-blur 4`.
- **Match cut**: end shot A on a shape (circle, line, word) at a known position; start shot B with a different object of the same shape at the same position.
- **Path follower** (carrier dot, comet, vehicle): `const tip = partial(resample(path), p).slice(-1)[0];` and orient it with the last segment's angle.
## Particles that look intentional
Seeded once at load, each with its own size, speed and phase; positions computed from `t` (`x = x0 + vx * t`, wrap with `%`). Give them a purpose - flowing along a path, attracted to a target (`lerp(start, target, ease.inOut(p_i))` with per-particle delays), or dust in a light beam. Vary size 3x and alpha with depth. 40-300 particles is plenty.
## Data
Axis and gridlines draw first (0.4s), then data: bars grow from the baseline with 60ms stagger, lines draw on with `partial`, points pop with a tiny spring. Label the one number that matters big; everything else small and dim. Keep units next to numbers.
## Images and real assets
Download into the project dir and load at the top level with `const photo = loadImage('assets/x.png');` (the render waits for it). Ken-Burns them (slow scale + pan via the camera), grade them toward the palette with a `'color'` or `'multiply'` overlay, and never stretch them.
## Motion blur
Render with `--motion-blur N` (3-5 sub-frames, 180-degree shutter): every moving thing gets a physically correct smear, including canvas drawing and camera moves. It costs about N/2 times the render time, so use it for the final render of films with fast moves, not for drafts. For a single fast object in a draft, a manual smear works: draw the object 4-6 times along its last 1/60s of motion at decreasing alpha.
## Sound design patterns
- Hits: `SFX.boom` on reveals, `SFX.pop` (pitch up per item) for items in a sequence, `SFX.tick` runs for counters, `SFX.whoosh` 0.1-0.2s before a fast move, `SFX.riser` into the hold before the climax.
- The bed: change `chords`, `BPM` and instrument shapes per mood (triangle pad = warm, sawtooth through a lowpass `BiquadFilter` = cinematic, sine plucks = gentle, square at low gain = retro). Add a kick from the second bar for energy, remove it under calm passages.
- Mix: bed peaks around -14 dBFS under effects; duck the bed 6-10 dB under the climax and under any narration; fade the last 1.5s.
�
��jdB � � � S r SSKrSSKrSSKrSSKrSSKrSSKrSSKrSSKrSSK r SSK
r
SSKJr \"