---
name: motion-stack
description: 用代码做动效短片的通用底座：按 brief 从 20 个开源库（Three.js、PixiJS、p5、Paper、Matter、Rapier、anime.js、tsParticles、OGL、GL Transitions、Tone.js、Chatterbox、faster-whisper、Remotion、HyperFrames、FFmpeg 等）里挑最小组合，写成「画面是时间的纯函数」的场景，用本地无头渲染器出 MP4（可带离线合成的音效），并自动写出可复现清单（库版本、字体、文件哈希、引擎版本、命令）。当用户说「做个动效 / motion 短片 / 物理动画 / 3D 动画视频 / 用 three.js 做个视频 / 用这套 motion stack」时使用。MV、知识视频、尺寸图动画走各自专用 skill。只在明确请求时触发，忽略顺带提及。
---

# Motion stack：从 brief 到可复现的成片

用中文交流。用户给方向和审片意见，你挑库、写场景、渲染、自检。

**这个 skill 要支撑的决定**：一个新的动效 brief，能不能不重写渲染器、只写一个场景文件就出片，并且两次渲染结果逐位一致。
**放弃条件**：到 2026-11-25 为止，除自带示例外没有一部片子用它做出来；或者连续两个项目都得改 `kit/render.mjs` 才能用。满足任一条就停用。每做完一部，在下方“做过的片子”表里记一行。

## 先分流

下表里的其他 skill 装了就用它们的现成流程；没装时，这些片子也能用本 skill 从头做。

| brief 是什么 | 用哪个 |
|---|---|
| 歌词 MV、有歌有角色的片子 | `ai-music-video`（已有成片的完整流程） |
| 已有 MV 要做“好玩版” | `mv-fun-pass` |
| 竖屏数学 / 概率科普 | `vibe-knowledge-video`（VibeFX 组件库） |
| 尺寸图、组件规格图的动画 | `dimensional-drawing-motion` |
| 一个仓库一段时间的 git 记录做成流程图短片 | `repo-film`（基于本 skill） |
| 其他代码动效：产品演示、物理、3D、粒子、数据动画、片头、转场 | 本 skill |

## 流程

1. **brief → 一句话 + 节拍表。** 写清时长、画幅（16:9 / 9:16 / 1:1）、帧率、每个节拍几秒发生什么，以及要不要声音。8–15 秒的片子最好控制；超过 30 秒先拆镜头。和用户确认节拍表再写代码，用户说“随便发挥”也要先给节拍表：创意由用户拍板，跳过这步做出的 GRAVITY 被评为“非常一般”。
2. **挑库，越少越好。** 查 `references/libraries.md`，一般 1 个画面库 + 0–1 个物理库 + 0–1 个声音库就够。20 个库功能大量重叠，不要为了用而用。许可证要看：Remotion 超过 3 人的公司需要买授权，p5 是 LGPL。
3. **建项目。** 复制 `examples/drop/` 到用户指定的目录，改 `package.json` 依赖（写死精确版本，不用 `^`），`npm install`。库从本地 `node_modules` 加载，不走 CDN；字体放进项目用 `@font-face` 引入。
4. **重的模拟先离线烘焙。** 上千个刚体时，用 Node 跑一次物理（`bake.mjs`），按片子帧率存成二进制轨迹，场景只读文件：8000 块 15 秒烘焙 40 秒，之后每次出图不再重算。
5. **写场景**（契约见下），并按 `references/determinism.md` 让每个库都做到“只看 t 画画面”。
6. **自检，再给用户看：**
   - `node $KIT/render.mjs scene.html --pure`：同一组时间点正序、倒序各画一遍，哈希必须一致。不一致说明某处依赖了上一帧，修好再往下。
   - `node $KIT/render.mjs scene.html --sheet 0.5,2,4,6 --out beats.png`：每个节拍出一帧拼成一张图，自己先看：构图、主体是否出画、文字是否被切、第一秒是否太乱。
   - **好看关卡**（技术通过不等于好看，逐条对着拼图答“是/否”，有“否”就改，不交）：
     1. 第 1 秒有钩子：一个清楚的主体 + 一次冲击（声音、震屏、扬尘）。
     2. 中段每 2 秒左右有新变化；没有超过 2 秒“什么都没发生”的段落。
     3. 结尾有揭示或反转，而且揭示之前确实藏住了（检查揭示前的每个节拍帧有没有提前露底）。
     4. 每个碰撞或转折都有反馈：声音、光、镜头至少一种。
     5. 缩到手机尺寸（360 px 宽）主体仍然认得出。
     6. 画面亮度：主体不发闷、白色不糊成一片（用保持色相的 `NeutralToneMapping`，辉光阈值放在主体最亮值之上）。
7. **出片。** `node $KIT/render.mjs scene.html --out out/film.mp4`。场景有 `audio()` 会自动混进去；外部音频用 `--audio file.wav`。只重渲一段用 `--from 2 --to 4`。
   - 声音**先于画面**生成：`audio()` 出错会在一帧都没渲之前停下（退出码 4）并说明原因；只要画面就加 `--no-audio`。
   - 出片后自动用两遍 loudnorm 把响度调到 -14 LUFS（真峰值 ≤ -1.5 dBTP，峰值受限时会停在 -14.x），调整前后的数值写进清单 `audio.loudness`。目标改用 `--lufs -16`，保持原样用 `--no-loudnorm`。
8. **交付前读清单** `out/film.mp4.manifest.json`：`scene_errors` 要为空，`audio.loudness.after_lufs` 在 -15 到 -13 之间，`remote_requests` 要为空（否则离线无法复现），`fonts` 要都是 `loaded`，`libraries` 里没有 `NOT INSTALLED`。交付时把 MP4、清单和项目目录一起给。

`KIT=<本 skill 目录>/kit`。第一次用先 `cd $KIT && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install && node render.mjs --check`。

## 场景契约

场景是一个 HTML 页面，设置 `window.motion`：

```js
window.motion = {
  width: 1920, height: 1080, fps: 30, duration: 6, seed: 7,
  async setup() {},   // 只跑一次：建舞台、加载素材、预算物理
  frame(t) {},        // 画出时间 t 的画面；只依赖 t，不依赖上一帧
  async audio() {},   // 可选：返回 AudioBuffer（如 Tone.Offline 的结果）或 { sampleRate, channels }
};
```

渲染器做的事：用本地 HTTP 服务打开页面（ES 模块和 import map 才能用）；冻结 `performance.now` / `Date.now` 到当前帧时间，并给 `Math.random` 设种子；WebGL 走 SwiftShader（软件渲染，换机器像素也一致）；逐帧截图送 ffmpeg；最后写清单。`--real-clock` 可关闭时钟冻结，但这样出的片子不保证可复现。

## 自带示例

`examples/drop/`：Three.js 画面 + Matter.js 物理（固定步长预算，逐帧查表）+ Tone.js 离线合成撞击声（和物理用同一张碰撞表，声画对齐）。6 秒，1080p，30fps，本机渲染约 18 秒。2026-10-26 实测：纯度检查通过，两次渲染的画面 MD5 和音频哈希完全一致。

## 做过的片子

| 片子 | 库 | 结果 |
|---|---|---|
| examples/drop（6 s） | Three.js + Matter.js + Tone.js | 画面、音频两次渲染逐位一致 |
| GRAVITY（12 s，2026-10-26） | OGL + Rapier 2D + Tone.js + Canvas 2D | 画面逐位一致；音频差 ≤1 LSB。第一次 `--pure` 就抓到 GPU 2D 画布的顺序依赖，改 CPU 光栅后通过。12 s 1080p 渲染 211 s（主要耗在 SwiftShader 着色器） |

| you-are-here 宣传片（26 s / 24 s，2026-11-02） | 纯 DOM + Canvas 2D + Tone.js | Keepline 一夜真实 git 分支长成流程图，收拢进项目地图的阻塞链；未改渲染器。暴露两处渲染器缺口：成片 -25.7 LUFS 太小、`audio()` 报错要等画面渲完才发现（10-30《年年有鱼》同样踩过）。11-02 已修：声音先渲、自动 -14 LUFS。这套做法另成 skill `repo-film` |

| 8000 + 1 / 14000 + 1（动漫人物立绘，15 s，2026-10-26；10-27 换第三张图，红外套版；后改整张图 + 矩形托盘 30001 块，片名“30000 + 1”；再改 60001 块 + 对焦一拍 + 9:16 竖版） | Three.js 实例化 + Rapier 3D 离线烘焙 + Tone.js | 8001 块落进圆盘（换成用户给的粉彩立绘后改为 14001 块 0.7 尺寸），镜头从背面低机位升起扫半圈，乱堆变成她的脸，最后一块白方块落进瞳孔成为高光。烘焙 40 s；见下方“错位拼图” |

## 错位拼图（乱堆从某个角度拼出图像）的做法

1. 先烘焙物理，用最终静止位置按“揭示镜头”投影给每块上色；颜色从第一帧起就固定，下落时看起来是随机的。
2. 分辨率由揭示镜头里能看到的方块数决定：头像类图像要 ≥ 40 块宽（36 块勉强、48 清楚）。4500 块散成平堆只有 26 块宽；加一个圆盘兜住、8000 块后约 45 块宽。
3. 方块堆偏平时，图像其实是顶面马赛克，从任何斜上方都看得出：揭示前镜头必须压到堆顶以下（仰角 ≤ 4°），并绕到图像背面（倒着的更难认），升机时扫半圈转正。
4. 要让某块落到图像的特定位置（如瞳孔高光），不要硬瞄：先烘焙看它停在哪，再把图像平移过去，让那个像素对准它；给它加线性阻尼（2.5）减少滑动。
5. 覆盖检测的格子要比投影后的方块大（1.5 倍），否则只标中心点会到处是洞。
6. 需要的分辨率取决于图像对比度：高对比的图（暗背景、深色线稿）45 块宽就够；粉彩、头发和皮肤颜色接近的图要 60 块以上。办法是缩小方块（0.7 倍、14000 块），而不是加大镜头距离。方块歪斜加阴影会给图像叠一层噪点，所以实际成片比像素预览更难认，要留余量。
7. 两个便宜的增强：原图先加深线稿（`edgedetect` → `dilation` → `negate`，再用 `multiply` 叠回原图，滤镜链全程用 `gbrp` 格式，否则颜色会丢；不要用 `split` 分两路，它的两路输出共用一个像素格式，一路转灰度会让另一路也变灰，改成同一张图读两次 `-i`）；揭示时把灯光铺平（环境光调高、主光调低），让每块方块尽量显出它本来的颜色。
8. **换图要重烘白块落点。** 图像按白块的落点摆放，所以白块落点决定了圆能有多大：高光在原图中间的，白块要落在堆的投影中心附近；高光偏一侧的，落点也要偏同一侧。做法：先不管白块，算出堆能完全覆盖的最大圆（圆心、半径 r），目标屏幕点 = 圆心 + 高光在原图里的归一化偏移 × 0.95r，取最后一帧离该点最近、位置最高的方块的世界坐标作为 `--catch` 重新烘焙（14001 块约 70 s）。第三张图（10-27）没重烘时圆只有堆的三分之一大，重烘后填满。
9. **先问裁切。** 用户给整张立绘时，默认裁到脸会丢掉他们在意的东西（10-27：裁掉了蓝天、黄领结、红制服，用户以为没用他们的图）。动手前问一句：拼整张（大色块好认，五官糊）还是裁脸（五官清楚）。整张竖图时拼图区域改成和原图同比例的矩形（场景里 `IA = 宽/高`，`r` 是半高），14001 块约 37×55 块，脸只剩几块但整体一眼能认出。探针开关 `PROBE` 打开时，场景会在控制台打印该图白块应落的位置。
10. **细节丢在哪（10-27 实测）。** 先把原图缩成同样格数的理想马赛克对照：37×55 格的理想马赛克已经能认出脸，成片却认不出，说明瓶颈不在块数，而在：①取色只取方块中心一个像素（线稿、高光变成脏点）→ 改成取方块覆盖那一片的平均色；②图像只占圆堆里的一小块 → 托盘做成和图同比例的矩形，长轴朝揭示镜头（`yaw = 方位角`），撒点均匀铺满托盘，堆成一块平板，几乎每块方块都进图；③明暗和雾把颜色压灰 → 揭示时在材质里把输出往方块本色混 80%（`onBeforeCompile` 替换 `opaque_fragment`，除以曝光值），同时把雾的起点推远；不再给原图加深线稿。三项加上 0.5 尺寸、30001 块之后，蓝天白云、眼睛、领结、远处城市都能看出来。平板堆的副作用：低机位能看到顶面，要把机位压到托盘边沿高度。
11. **对焦一拍：乱堆揭示后再把图“对上焦”（10-27）。** 同样格数下，乱堆成片明显比理想马赛克糊，原因是方块歪斜、互相叠压。揭示后约 1 秒，让每块转到离它最近的“与托盘对齐”的姿态（24 种立方体对称里挑四元数点积最大的），位置吸附到托盘坐标系的 `size` 网格上；同一格被占的往下沉一层（被上面盖住，看不见）。取色要按吸附后的位置算，白块的整个下落轨迹也加上同样的偏移。波纹从眼睛向外扩散（每块延迟按屏幕距离算）。只改场景，不用重烘。
12. **块数翻倍与竖版。** 60001 块、0.35 尺寸：烘焙 327 s（带白块 405 s），轨迹 379 MB，浏览器 7.8 s 读完没问题，第 6 秒就静止。线性细节约 1.4 倍（格数翻倍不是边长翻倍）。竖版用同一个场景文件：`scene-v.html` 是指向 `scene.html` 的软链接，场景按路径名切换 1080×1920；揭示机位姿态不变、只改 fov，所以白块落点和取色仍然对得上。其他镜头在竖版里用 `wide(fov)` 放大竖直视角。
13. **中段别空着。** 倾倒在 ~5.6 s 结束后，原来的低机位环绕一直到 8.5 s 都没有新信息。改成切到贴着表面倒退滑行（看得到大片颜色、认不出图），再从那里一路后拉升起到揭示机位，揭示就成了一个连续的“拉远看清”。
14. **声音不能只看峰值（10-27）。** 用 `--from 0 --to 0.1` 渲 3 帧，渲染器照样输出整段 WAV，几秒就能量一次：`ffmpeg -i x.wav -af ebur128=peak=true -f null -` 看整体响度和真峰值，再按每 0.5 s 算峰值 / RMS，找出哪一下独大、哪一段没声。这次查出开头闷响比其他声音高 8 dB 以上，新加的贴面滑行段几乎静音。`Tone.Limiter` 不带预读，挡不住瞬态，要直接压那个声源。改合成器参数时注释别插在 `})` 和 `.connect()` 中间，否则后半行被注释掉，那个声音就悄悄消失了。

15. **结尾要讲得清（10-27 用户反馈）。** 只在一只眼落一块白方块、画面又小，用户看不懂它是高光。改成两只眼各落一块（`bake.mjs --catch "x,y,z;x,y,z" --catch-t 12`，第二块按图放好后再吸到眼睛高光下最高那块的上面），落下时推近到双眼，最后拉回，原图从右侧滑入并排，标题居中在下方。推近和拉回不动机位，只缩放画面：`fov` 按倍数收窄，再用 `camera.setViewOffset` 把锚点（两眼中点）挪到目标位置，所以取色仍然对得上。推近后，正对视线方向的方块缝会透出黑地板，形成一条细竖线：网格列要以视线所在的平面（u = 0）为中心，对焦后方块放大 3.6% 贴紧。

## 没验过的

- Remotion、HyperFrames：有自己的渲染器，本 kit 不负责。用户要用时按它们的文档走，只借用本 skill 的节拍表和清单习惯。
- PixiJS、p5、Paper、tsParticles、GL Transitions：`references/determinism.md` 写了接法，但还没有实际出过片。第一次用哪个，就把结果补到该文件对应行。
- Chatterbox 配音、faster-whisper 字幕：都是 Python 工具，在场景外生成好 WAV / 字幕 JSON 再读进来；还没在本流程里跑过。
- 长片（> 30 秒）的渲染时间和内存。
