代码渲染动画
核心理念(Addy Osmani, LinkedIn 2026-09-24):编程模型读取源内容(文章、规格说明),并编写 JavaScript 代码来绘制每一帧及音效。交付物是一个程序,因此任何时刻都可以被编辑、重新计时、重新渲染或重新格式化。Osmani 未公开其提示语或工具。此技能的设计源自开源项目 riso-windowseat(MIT 许可证,仅作参考,请勿复制其说明):https://github.com/sevenevesai/riso-windowseat 。此外,它也借鉴了 HeyGen HyperFrames (https://hyperframes.heygen.com/guides/pipeline) 和 Remotion 的一些想法。
基本原则:以不低于 4 次/秒的采样率逐帧审查;绝不声称执行了未实际运行的检查;精准修复缺陷。
约定(一切皆以此为基础)
每个作品是一个独立的 index.html,包含一个 <canvas>,该 canvas 从 URL 中读取 ?w=&h= 参数,并暴露以下接口:
window.__anim = {
duration, fps, width, height, canvas,
ready, // 在字体/资源加载完毕且 seek(0) 绘制完成之前为 false
seek(t), // 同步绘制时间 t 处的精确帧
renderAudio(), // Promise<base64 WAV>,48 kHz 立体声,时长严格等于 duration
marks, // 可见事件(弹出、切换)的时间点 - 音效必须落在这些点上
shots, // [{id,start,end}] - 验证时会检查边界
};
不变规则:
seek(t)是 t 的纯函数。屏幕上的每个值都由 t 计算得到:prog(t,a,b)、缓动函数、pop(t,at)。帧之间不携带状态,不使用requestAnimationFrame时序,不使用Date,不进行物理步进,除非从 0 开始重新计算或使用封闭形式。- 渲染时绝不调用
Math.random()。请使用rngFor('stable-key')(基于 mulberry32 的种子随机数生成器),并在加载时通过固定键创建随机布局。更改键会改变输出结果,因此在批准后将键冻结。 - 一个时间线对象 (
TL) 包含所有场景范围和事件时间。画面和配乐都从这个对象读取。这就是同步的实现方式:音效在画面弹出的相同数字时间点被调度。 - 在
ready=true之前等待字体加载完成(document.fonts.load('700 40px "DejaVu Sans"'))。否则,前几帧会使用后备字体渲染。通过fc-list : family(Linux/macOS)或C:\Windows\Fonts(Windows;Segoe UI、Bahnschrift、Consolas 和 Georgia 始终存在,DejaVu 则不一定)检查已安装的字体。如果设计需要特定字体,请将其作为 base64 @font-face 嵌入,并以相同方式加载。 - 作品中不能包含网络调用。将所有资源作为 data URI 嵌入。
可编辑元素(用于影片修复)
review/film-fix.html 允许审阅者直接移动和缩放元素,并将结果写回 index.html。希望使用此功能的影片(示例影片使用了此功能;请从该处复制)需要添加:
- 一行
const TUNE = /*TUNE*/{}/*END-TUNE*/;,键值按格式再按元素 ID 组织:{"16x9":{"title":{"dx":0,"dy":0,"s":1}}}。之所以按格式键控,是因为每种宽高比有自己的布局。dx/dy 为画布像素。该工具只重写两个标记之间的文本。 - 在每个主要元素周围使用
E(id, [x,y,w,h], draw)。它会在TUNE[fmt][id]中查找,然后保存上下文,按 dx/dy 平移,按 s 围绕盒子中心缩放,调用draw()并恢复上下文。它还会将{id, box}推入__anim.elements,其中 box 是画布像素坐标下的变换后盒子(来自ctx.getTransform(),因此摄像机移动会被考虑在内)。seek(t)在每帧开始时清空此列表。 __anim.elements、__anim.format(16x9、9x16、4x5、1x1)和__anim.tune(用于实时 TUNE 对象的 getter/setter,供工具的实时预览使用)。- 规则:ID 在整个影片中唯一;无随机性;在 TUNE 为空时,E 不应用任何变换,因此帧保持字节级一致。通过在添加 E 前后计算帧哈希来证明这一点,然后运行
verify.mjs。
没有使用 E 的影片仍然可以在 film-fix 中工作:其“微调”模式会对一个区域进行框选,并将拖拽或缩放转化为供 AI 使用的数值修复。
项目设置
需要 Node 18+、ffmpeg(libx264、aac、blackdetect、freezedetect、ebur128)以及本地 Chrome/Chromium。工具链会在 Windows、macOS 和 Linux 的常见安装位置查找 Chrome;设置 CHROME 环境变量可覆盖此路径。qa.sh 需要 bash(Windows 上使用 Git Bash),并会选择一个系统字体用于时间码显示;设置 QA_FONT 环境变量可覆盖。不会在系统中安装任何全局包;puppeteer-core 是唯一的 npm 依赖。
SKILL_DIR=<此技能文件夹的路径>
mkdir -p anim && cp -r "$SKILL_DIR/harness" anim/harness
cd anim/harness && npm install && cd ..
mkdir -p works/<名称>
cp "$SKILL_DIR/example/browser-10s/index.html" works/<名称>/index.html # 从可工作的示例开始
# 所有工具命令都在 anim/ 目录下运行;输出文件位于 out/ 目录
工具命令
| 命令 | 功能 | 成功信号 |
|---|---|---|
node harness/verify.mjs works/<n>/index.html --format 16x9 | 在采样点和镜头边界处向前、向后和冷跳查找,然后比较像素哈希值 | verify: OK, 退出码 0 |
node harness/shoot.mjs works/<n>/index.html --format 9x16 --times 1.5,3.4 或 --range 3:4:0.1 --out out/stills | 在精确时间点生成原生 Canvas PNG | 查看图片 |
node harness/audio.mjs works/<n>/index.html --twice | 仅渲染配乐(秒级),检查精确时长和运行间的确定性 | 无 FAIL。Chrome Web Audio 在两次运行之间可能相差 1 LSB;容差为 1 |
node harness/render.mjs works/<n>/index.html --format 16x9 --fps 30 --out out/<n>-16x9.mp4 | 逐帧查找并将 PNG 通过管道送入 ffmpeg(libx264 crf18, yuv420p, +faststart),然后将 renderAudio 作为 AAC 复用 | 打印帧数和时间。页面错误时退出码为 2。--from/--to 或 --silent 可渲染不带音频的范围 |
node harness/lint.mjs works/<n>/index.html --all-formats | 对页面本身进行质量门禁检查(16:9 + 9:16;使用 --format 可指定单一格式):文本大小、安全区域、文本重叠、静态拉伸、空白帧、镜头列表、音效是否落在标记处、屏幕上的字数 | lint: OK (0 FAIL), 退出码 0 |
node harness/review.mjs works/<n>/index.html --format 16x9 --out out/review-16x9, 然后 --check out/review-16x9/REVIEW.md | 每个镜头生成一张联系页(第一帧、6 个均匀间隔的帧和最后一帧,带烧录的时间码)、lint JSON 以及 REVIEW.md,其中包含针对每个镜头的固定问题。--check 在答案为空白或 TODO、评分低于 4、lint 失败、镜头部分被删除或影片自联系页生成后发生更改时失败 | review check: OK, 退出码 0 |
bash harness/qa.sh out/<n>-16x9.mp4 4 | ffprobe、blackdetect、freezedetect、EBU R128 响度、音频/视频时长、以 N 次/秒采样并烧录时间码的联系页 | 参见 QA 部分 |
格式:16x9 1920x1080、9x16 1080x1920、4x5 1080x1350、1x1 1080x1080。默认情况下,帧以 JPEG q95 质量送入 ffmpeg,在 1080p 下约为 16 fps,因此 40 秒的片段每个格式大约需要 60-75 秒。--png 是无损的,但速度大约慢 4 倍。
长渲染:代理的 shell 调用可能会超时(通常为 120 秒),并连带终止其子进程。要么提高调用的超时时间,要么分离运行完整渲染并轮询日志(Linux/macOS 示例如下;Windows 上请使用后台 shell 或 Start-Process):
setsid nohup bash -c 'node harness/render.mjs works/<n>/index.html --format 16x9 --out out/<n>-16x9.mp4; echo RENDER_DONE' > out/render.log 2>&1 < /dev/null & disown
tr '\r' '\n' < out/render.log | tail -2
另外,请将编写作品文件的 heredoc 与运行它的命令分开,以防止超时导致文件丢失。
完成标准
具体命令在 AGENTS.md(单一来源,与其他编码工具共享)中:在每个格式中运行 verify、audio --twice、lint --all-formats(0 FAIL)、review.mjs 然后通过 review.mjs --check、渲染、QA。切勿在门禁检查失败时声称完成,也切勿为了通过某项检查而削弱门禁或编辑工具阈值。
工作流程
- 简报。 源内容是什么、目标观众、时长、格式、是否需要旁白。从源文章中提取关键主张。每个场景都必须展示正在表述的内容。
先讲故事,再保证正确性。 工具链能使影片正确,但不能使其吸引人。在制作故事板之前,先回答简报中的四个问题:
- 角色是谁?在每个场景中他们感受到什么?
- 用什么日常物品来代表每个想法?(原作使用了带蜡封的信、沙盒和一座由房屋组成的城镇。)
- 是什么持续存在的 HUD 将各个场景串联起来?通常是一个章节拨盘和一个打字机风格的标题。
- 最后一帧如何循环回到第一帧? 一部不具备以上任何要素的影片,尽管能通过所有门禁检查,但观众仍会匆匆划过。
- 在写代码之前先写
STORYBOARD.md。 每行对应一个场景:时间范围 | 屏幕内容 | 动作 | 音频提示。列出每个事件时间(这些将成为TL和marks)。每几秒规划一次切换或新的运动,避免静态拉伸。将过渡规划为推入或重叠,切勿让帧变为空白。除非得到批准,否则不要使用渐隐至黑屏的结尾。 - 时间线模块,然后每个场景一个
sceneN(t)函数(关于 t 的纯函数),最后是一个合成器,负责绘制背景、活动场景和过渡。 - 先处理最难的帧。 构建密度最大的时刻,在每个格式中
shoot它,查看它,修复它。然后再逐步构建其它部分。 - verify 每个格式,audio --twice,然后 render 每个格式。
- QA(如下),精准修复,仅重新渲染需要修改的部分,重新 QA。
- 交付 MP4 文件作为附件,并附上 QA 结果和已知的弱点。
使用旁白
先处理 TTS,然后获取单词级别的时间戳(使用任何能返回单词级时间的工具转录 WAV),然后根据单词设置 TL。场景、弹出和卡拉 OK 字幕高亮都基于单词时间点。规则:在框内显示卡拉 OK 式字幕,仅一个字幕轨道,说出数值时在屏幕上显示,通过听觉检查专有名词。将旁白 WAV 作为 data URI 嵌入,并在 renderAudio() 内部进行混音(使用 ctx.decodeAudioData 解码,在时间 0 调度),或者使用 ffmpeg 复用并在配乐下进行闪避处理。标准化整个视频中旁白的响度。
从单一时间线实现多格式(非裁剪)
- 相同的
TL,相同的场景,不同的布局。计算portrait = H > W、S = min(W,H)/1080(字体和形状缩放比例)、M = 0.06*min(W,H)(安全边距)。按宽高比布局:16:9 中的行在 9:16 中变为列,树形结构获得更窄的展开,等等。 - 文本:自适应缩小(
measureText循环,对比W-2M),确保没有任何内容被边缘裁剪。尺寸基于S计算。在竖屏模式下,使用更大的字体并填满高度。 - 不要将“竖屏”视为一种情况。4:5(1080x1350)比 9:16 短得多。示例中 9:16 的列布局在 4:5 比例下运行时,像素网格和标签行会从底部被推出并重叠在一起。根据宽高比(
H/W:约 0.56、1.25、1.78)或根据可用高度进行布局,并对交付的每个格式执行shoot。 - 在检查边距时,记住弹出时的过冲(easeOutBack ~1.1x)和“热”缩放效果。使用
shoot验证每个格式密度最高的帧。
程序化音效 (OfflineAudioContext)
new OfflineAudioContext(2, 48000*duration, 48000),构建音频图,await ctx.startRendering(),编码为 16 位 PCM WAV,返回 base64(请参阅示例中的wavB64)。- 示例中的模式:每个场景一个背景和弦(失谐的正弦/三角波对,慢起音,在切换处重叠),在每个
marks事件处一个拨弦音(三角波 + 2 个泛音,5 毫秒起音,指数衰减),用于打字效果的种子噪音爆发点击声,用于撞击效果的音高下降重击声,以及主总线上的 DynamicsCompressor。 - 噪声来自种子随机数生成器,绝不要使用 Math.random。
- 响度:对于社交媒体推送,目标综合响度约为 -14 LUFS,峰值低于 -1 dBFS。原始文章测得 -14.3 LUFS;捆绑的示例较安静,为 -17.2 LUFS,峰值为 -1.4。通过在页面中设置主增益来控制,使其保留在代码中。
- 对于比特级精确的音频,使用普通 JS 将样本合成为 Float32Array(使用封闭形式的包络累加振荡器),而不要使用 Web Audio 节点。
- 如果需要更丰富的音乐 API,Tone.js 的
Tone.Offline(https://tonejs.github.io/docs/14.9.17/functions/Offline.html) 是一个选项。由于作品无网络访问权限,因此必须内联嵌入。
QA(审查是我们的责任,不是观众的)
自动门禁检查,全部针对最终 MP4 文件:
- 每个格式的 verify 通过。页面错误 = 失败。
- ffprobe:预期尺寸、30/1 fps、nb_frames = 时长 x fps、音频时长 = 视频时长。
- blackdetect:无(除非有批准的戏剧性黑屏节拍)。freezedetect(>=2 秒静态):无。
- ebur128:综合响度和真实峰值在目标范围内。剧集间保持一致。
Lint(质量门禁)
node harness/lint.mjs works/<n>/index.html --all-formats [--json out/lint.json] 每 0.1 秒(--step)对作品进行一次 seek,并从外部包裹 fillText/strokeText,因此它无需修改即可在任何遵循约定的作品上运行。在以下情况下失败:
- text-size:静态文本小于 min(W,H) 的 2.6%(1080p 下为 28 像素);小于 3.3%(36 像素)为 WARN。因缩放至 0.5 以下而缩小的文本被视为概览地图,仅发出警告。
- safe-area:静态文本距离边缘在 4% 以内;4-6% 为 WARN。
- text-overlap:两个不同的可见字符串在 0.2 秒或更长时间内共享较小框面积的 15% 以上。更短的交叉为 WARN。
- static-stretch:超过 3 秒无可见变化(2 秒为 WARN)。empty-frame:任何接近均匀的帧,包括第一帧和最后一帧。
- shots-coverage:
shots中存在间隙或重叠,或某个镜头短于 1 秒。audio-sync:在 ±40 毫秒内具有声音起始点的marks占比低于 70%(90% 为 PASS);峰值超过 -1 dBFS 为 WARN。density:竖屏模式下屏幕上超过 18 个单词,或横屏模式下超过 30 个单词(仅 WARN)。
在您交付的每个格式中,lint 必须以 0 FAIL 通过,作品才算完成。它只能看到在作品自身 canvas 上使用 fillText/strokeText 绘制的文本,因此形状碰撞和绘制到离屏 canvas 的文本仍需人工视觉审查。example/browser-10s 在所有三种格式中均通过;example/browsers-40s 在门禁引入之前制作,因此未能通过(标签大小为 17-28 像素,9:16 格式中一个标签被裁剪,文本
…
[已截断以进行翻译]