motion-stack · git:20261102.d483374 · 2026-11-02 · sha256 8f7d9051738dac2c

motion-stack git:20261102.d483374A

Immutable. This exact content never changes and is served at /api/v1/blob/8f7d9051738dac2c.

---
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 秒)的渲染时间和内存。