Back to skills
SKILL.md
html-to-video
ASecurity把 HTML/Canvas 动画逐帧渲染成 MP4(可配乐、可卡点、可出 GIF、可出透明层)。当用户说「用代码做视频」「HTML 转视频」「导出 MP4/GIF」「做个动态图解/宣传片/数据动画」「逐帧渲染」「换个风格」「卡点」「音乐配画面」时使用。自带渲染器、14 个版式模板(多镜头讲解片、竖屏 9:16、前后对比、流程图、字幕卡、引言卡、章节卡、界面演示)、12 种视觉风格(孔版印刷 / 剪纸 / 蓝图 / 粉笔 / 等距 / 霓虹 / 瑞士 / 水墨 / 水彩 / 铅笔 / 半调解说 / 黏土立体,一条命令切换)、节拍分析与卡点(--beats / --snap)、分镜预演、浏览器预览、环境自检、图标/人物/运镜素材库,支持透明输出、外部字幕时间轴与 JSON 镜头表,装好依赖后一条命令出片。
- 185 stars
- 0 votes
- 0 copies
- 0 views
- Added November 7, 2026
Works with
Security analysis
100/100Pro scans all 13 files and shows the line behind each finding
npx -y skills add wx4766/html-to-video --agent claude-codeAre you the author of html-to-video?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/wx4766-html-to-video)---
name: html-to-video
description: 把 HTML/Canvas 动画逐帧渲染成 MP4(可配乐、可卡点、可出 GIF、可出透明层)。当用户说「用代码做视频」「HTML 转视频」「导出 MP4/GIF」「做个动态图解/宣传片/数据动画」「逐帧渲染」「换个风格」「卡点」「音乐配画面」时使用。自带渲染器、14 个版式模板(多镜头讲解片、竖屏 9:16、前后对比、流程图、字幕卡、引言卡、章节卡、界面演示)、12 种视觉风格(孔版印刷 / 剪纸 / 蓝图 / 粉笔 / 等距 / 霓虹 / 瑞士 / 水墨 / 水彩 / 铅笔 / 半调解说 / 黏土立体,一条命令切换)、节拍分析与卡点(--beats / --snap)、分镜预演、浏览器预览、环境自检、图标/人物/运镜素材库,支持透明输出、外部字幕时间轴与 JSON 镜头表,装好依赖后一条命令出片。
agent_created: true
---
# html-to-video · 代码逐帧做视频
## 这套东西是什么
**不是 AI 生成视频**,是**用代码逐帧画视频**:
```
写 HTML/Canvas,把画面写成 frame(t) 纯函数
→ 无头浏览器逐帧 seek 到 t,截图
→ ffmpeg 编成 H.264 MP4
字幕 / 运镜 / 转场全在代码里,改代码重渲即可
```
优点:**确定性**(同输入同输出)、**可控到帧**、**中文文字完美**、**零费用**。
最适合做:讲解动画、动态图解、产品演示、数据可视化动画、宣传片片头。
不适合:实拍感/氛围镜头(那种走 VideoGen)。
**能拿它做什么**(`assets/templates/` 里 14 个模板直接改字就能出片):
| 模板 | 用途 | 出片 |
|---|---|---|
| `explainer.html` | **多镜头讲解片**:片头 / 人物+气泡 / 要点 / 条形对比 / 片尾,镜头带转场,可喂 JSON 镜头表;横屏恰好三条要点时自动并排成三栏,竖屏(1080×1920)另有上下版式 | `--duration=17.6` |
| `title-card.html` | 视频片头:主标题逐字弹入 + 副标题 | `--duration=6` |
| `bar-chart.html` | 条形图/排名:条形依次生长,数值同步滚动 | `--duration=6` |
| `line-chart.html` | 折线趋势:折线生长 + 数据点亮起 + 末点浮标 | `--duration=8` |
| `number-counter.html` | 大数字/KPI:从 0 滚到目标值 + 涨跌标签 | `--duration=7` |
| `timeline.html` | 时间线/流程:中轴生长,节点逐个点亮 | `--duration=7` |
| `vertical-card.html` | **竖屏 9:16 卡片**:大字标题 + 大数字,抖音/视频号/Reels 用 | `--duration=6 --width=1080 --height=1920` |
| `compare.html` | **前后对比**:左右两栏对照,结论条收口 | `--duration=7` |
| `flow-steps.html` | **流程图动画**:步骤依次点亮,当前步高亮 | `--duration=7` |
| `subtitle-card.html` | **逐句解说卡**:一句话一句地出,底部带进度条 | `--duration=8` + `--srt` |
| `vertical-subtitle.html` | **竖屏字幕卡**:关键词高亮,9:16 原生版式 | `--duration=8 --width=1080 --height=1920` + `--srt` |
| `quote-card.html` | **引言卡**:大引号 + 引文逐行推进 + 出处短线 | `--duration=7` |
| `chapter-card.html` | **章节卡**:序号水印 + 标题 + 章节进度点,切分长片 | `--duration=4` |
| `ui-showcase.html` | **界面演示**:窗口推入 + 缓推 + 两处聚光标注,可换真实截图(`CFG.shot`) | `--duration=8` |
每个模板**单文件自包含**,顶部有一个显眼的 `CFG` 配置块,改文案/数据即可 ——
不用懂 canvas 也能出片。**14 个模板全部支持换主题与 12 种视觉风格**(见「换配色」一节)。
想做别的,再看 `assets/animation-skeleton.html` 从零写。
**上面 14 个是「版式」。想要不同的「观感」,加一个 `--theme` 就有 12 种视觉语言**:
```bash
bash assets/render.sh assets/style-film.html --duration=13 --theme=ink # 水墨
bash assets/render.sh assets/style-film.html --duration=13 --theme=clay # 黏土立体
bash assets/render.sh assets/style-film.html --duration=13 --theme=watercolor # 水彩
```
版式 × 风格 = 14 × 12 种组合。想先挑风格,跑一次八宫格即可(见「风格」一节)。
**做「一支片子」而不是「一段动画」**:用 `assets/lib/scenes.js` 写多镜头 + 转场,
镜头表还能用 `--story=shots.json` 外置 —— LLM 写 JSON 比写几百行 canvas 代码可靠得多。
详见下面「多镜头与转场」一节。
### 尺寸:模板跟视口走,不用手改
14 个模板都按 `window.innerWidth/innerHeight` 设画布,所以**换任何尺寸都不裁切**:
```bash
# 横屏 1080p(默认)
bash assets/render.sh title-card.html --duration=6 --fps=60
# 竖屏 9:16
bash assets/render.sh title-card.html --duration=6 --fps=60 --width=1080 --height=1920
# 720p / 4K 同理
bash assets/render.sh title-card.html --duration=6 --fps=60 --width=1280 --height=720
```
模板内部用三档缩放把设计稿落到实际画布:
| 变量 | 含义 | 1920×1080 | 1080×1920 |
|---|---|---|---|
| `SX = W/1920` | 横向尺寸、左右边距 | 1 | 0.5625 |
| `SY = H/1080` | 纵向尺寸、行距 | 1 | 1.7778 |
| `SF = √(SX·SY)` | 字号、线宽、圆角、模糊 | 1 | 1 |
字号用面积等比(`SF`)而不是跟宽度走 —— 换成竖屏时字**不会变小**,只是版式铺开。
`1920×1080` 下三者都等于 1,所以**默认输出与旧版逐像素一致**(实测 PSNR = inf)。
**自己写的动画也照这个来**,否则改 `--width/--height` 会静默裁掉右下角:
```js
const cv = document.getElementById('stage');
cv.width = window.innerWidth; // ← 不要写死 1920
cv.height = window.innerHeight; // ← 不要写死 1080
```
渲染器现在会主动量一次画布尺寸,比视口小就明确告警(不再静默出半张图)。
## 先装依赖(只需一次)
渲染器需要 **Node 工作区**(内含 playwright + 完整版 ffmpeg)。一条命令搞定:
```bash
bash scripts/install-deps.sh # 工作区默认 ~/.workbuddy/binaries/node/workspace
bash scripts/install-deps.sh /path/to/workspace # 或自己指定
```
它会自动探测本机浏览器缓存、挑匹配的 playwright 版本、装完整版 ffmpeg,
并把工作区路径写进 `~/.workbuddy/.html2video-env`,之后 render.sh / smoke.sh / qa.sh 开箱即用。
手动装也行(工作区需含 `playwright` + `@ffmpeg-installer/ffmpeg`),然后自己写配置:
```bash
# ~/.workbuddy/.html2video-env
WB_NODE_WORKSPACE="<你的 node 工作区绝对路径>"
```
> 也可以用环境变量临时指定:`export WB_NODE_WORKSPACE="<路径>"`。
> **不要**把路径写进 `${VAR:-默认值}`——用户名若含撇号(如 `O'Brien`),
> bash 会把撇号当引号开始,报 `bad substitution`。
### 四条硬约束(踩过,别重犯)
1. **Playwright 版本必须和浏览器缓存匹配。**
`playwright-core@X` 只认自己在 `browsers.json` 里声明的 chromium build 号,
不匹配就报 "Executable doesn't exist"。
→ **别猜版本,用 `scripts/install-deps.sh`**,它会跑 `scripts/detect-env.js` 探测缓存后自动挑版本。
单独探测:`node scripts/detect-env.js`(输出 `RECOMMEND_PW=...`)。
已知映射:build 1148→1.49.1、1155→1.50.1、1169→1.52.0、1187→1.55.0、1208→1.58.0。
**注意**:某些网络环境下 chromium 下载会反复中断(表现为缓存目录里躺着半截的残缺目录)。
此时优先复用已有缓存而非升级——这正是探测脚本存在的理由。
2. **Playwright 自带的那个 ffmpeg 没用。**
`ms-playwright/ffmpeg-*/ffmpeg-win64.exe` 是 `--disable-everything` 编译的,
只有 VP8 编码 + webm 封装,**没有 H.264、没有 GIF、连 PNG 解码都没开**。
必须用 `@ffmpeg-installer/ffmpeg` 的完整版。
3. **ESM 不认 `NODE_PATH`。** `.mjs` 里 `import 'playwright'` 必然 `ERR_MODULE_NOT_FOUND`。
→ 辅助脚本一律写 **CJS(`.cjs` / `require`)**。
4. **PATH 不可靠。** 尤其在 Git Bash / Windows 上,塞进 PATH 的 Windows 风格路径会被转换搞坏。
→ **别靠 PATH 传 ffmpeg,用 `FFMPEG` 环境变量**(渲染器已支持)。
## 三种入口:CLI / 预览 / 自检
**统一命令**(`bin/h2v.js`,只是子命令到脚本的映射,不改任何默认行为):
```bash
npx html-to-video doctor # 环境自检:缺什么、怎么修(只读)
npx html-to-video templates # 14 个模板
npx html-to-video styles # 12 种风格
npx html-to-video new my-video --template=chapter-card
npx html-to-video preview my-video.html --duration=6 --open
npx html-to-video render my-video.html --duration=6 --fps=60
```
**预览**:`scripts/preview.js` 起本地 http(ESM/fetch 都能跑),把页面放进 iframe,
注入 `__seekRender` 冻结它自己的时钟,然后用滑块/空格/←→ 直接推 `__seek(t)`。
竖屏项目记得 `--size=1080x1920`,否则预览框是横的、模板按 16:9 排版,会误判成版式崩了。
**自检**:`scripts/doctor.js` 检查 Node / Playwright(含版本与浏览器缓存是否匹配)/
ffmpeg(是否含 libx264 与 ProRes)/ 中文字体 / 临时盘余量 / 工作区配置,
阻塞项给可执行修法;`--json` 给 CI 与 Agent 用。
## 出片流程
### 第 0 步(最省事的路):挑一个模板改字
```bash
cp assets/templates/bar-chart.html my-video.html
# 打开 my-video.html,只改顶部 CFG 里的 title / data / note
bash assets/render.sh my-video.html --duration=6 --fps=60
```
模板有**字幕安全区**意识:文字不会落在画面底部 15%(烧录字幕后会被盖住)。
自己做版式时也照这个来。
### 第 1 步:写动画 HTML(模板不够用时)
用 `assets/animation-skeleton.html` 当骨架。**核心契约**(渲染器靠它工作):
```js
// 必须暴露这两个
window.__seek = (t) => { /* 把 t 时刻的画面完整重绘一帧 */ };
window.__ready = true; // 就绪信号
// 渲染器会注入 window.__seekRender = true,
// 此时必须【冻结自驱时钟】,完全由外部 __seek 推帧(保证确定性)
if (window.__seekRender) { renderAt(0); window.__ready = true; }
else { /* 浏览器预览:requestAnimationFrame 自驱循环 */ }
```
**铁律:每一帧画面 = 时间 t 的纯函数。** 不要依赖「上一帧」的状态,
不要用 `Math.random()` / `Date.now()` / `performance.now()`——那会让同输入不同输出,
QA 里的「确定性」会亮红灯,而且多 worker 并行渲染会各渲各的。
**画布就是唯一输出**:`<canvas width=1920 height=1080>`,CSS 定位在 `left:0; top:0`,
body `margin:0`。渲染器用 `clip:{x:0,y:0,width:W,height:H}` 截左上角,所以画布必须正好铺满视口、不能居中。
(渲染器也支持 DOM/CSS/SVG 动画,同样只需暴露 `__seek`——但注意:DOM 动画每帧重排较慢,
纯 canvas 通常更稳更快。)
**禁止使用的 class 名**(渲染器注入的 CSS 会无条件隐藏它们,元素会凭空消失):
`.no-record .progress .progress-bar .counter .tCur .phases .phase-label .phase .replay .masthead .kicker .title .footer`
另外别给 `position:fixed/sticky` 的元素加 `<button>`,会被启发式规则当成播放控件隐藏。
(想让某块**出现在成片里**,给它加 `data-record-keep="true"` 就能豁免启发式隐藏。)
**中文字体**:`'"Microsoft YaHei", "PingFang SC", system-ui, sans-serif'`;等宽用 `'ui-monospace, Consolas, monospace'`。
### 第 2 步:冒烟测试(**别跳**)
先只截几帧,确认契约就绪、没控制台报错、画面没糊,再跑全量:
```bash
bash assets/smoke.sh animation.html
# SMOKE_T="0,1,2" SMOKE_W=1280 SMOKE_H=720 bash assets/smoke.sh animation.html
```
更快的迭代方式:**只渲一小段**,几秒就能看到动态效果。
```bash
bash assets/render.sh animation.html --from=3 --to=5 --fps=30 # 只渲 3s–5s
bash assets/render.sh animation.html --stills=1,3,5 # 只导出三张静帧
```
### 第 3 步:全量渲染
```bash
bash assets/render.sh animation.html --duration=12 --fps=60 --width=1920 --height=1080 --concurrency=4
```
常用参数:
| 参数 | 作用 |
|---|---|
| `--duration` / `--from` / `--to` | 时长 / 只渲某段(秒)。给了范围时输出名变成 `名.起-止s.mp4`,**不会覆盖成品** |
| `--stills=0.5,2.0` | 只导出这些时刻的 PNG,不编码 |
| `--fps` / `--width` / `--height` | 帧率与分辨率 |
| `--preset=<名>` | 按平台预设定尺寸:`douyin` / `tiktok` / `vertical`(1080×1920)、`xiaohongshu`(1080×1440)、`instagram`(1080×1350)、`square`(1080×1080)、`bilibili` / `youtube` / `landscape`(1920×1080) |
| `--presets=a,b,c` | **一条命令出全套比例**,每个比例一个成片(文件名自动加 `-比例名` 后缀)。跨平台发布用这个 |
| `--theme=<名>` | 换主题(`midnight` / `paper` / `neon`),覆盖模板里的 `CFG.theme`,不用改文件。名字打错会明确告警并回退 midnight |
| `--out=<文件>` | 指定成片路径(默认同目录同名)。一份 HTML 出多套配色时用它,否则后渲的会盖掉前一个;上级目录不存在会自动建 |
| `--json` | stdout 只输出一行结果 JSON(`output` / `duration` / `frames` / `warnings` 等),人读日志全部走 stderr。给 Agent 解析用 |
| `--concurrency` | 并行 worker 数(每个 worker 一个 Chromium 页面,注意内存)。给 `auto` 按 CPU 与内存自己挑 |
| `--audio=<file>` | 编码后自动混音(视频流直接 copy,不重编码)。**没给 `--duration` 时自动按音频长度出片**(向上取整到整帧,尾音不被削) |
| `--audio-fit=trim\|pad` | 音画长度不一致时怎么办:`trim` 按音轨收尾(默认)/`pad` 给音频补静音、保住视频长度 |
| `--beats` / `--beats=<json>` | 分析 `--audio` 的拍点(或用预计算的网格),注入 `window.__beats` |
| `--snap=beats` | 总时长吸到下一个拍点 |
| `--snap-shots` | 镜头表里每个镜头的时长量化到整拍 |
| `--bpm=<数>` / `--offset=<秒>` | 跳过估计,手工给 BPM / 首拍(纯配音硬卡点时用) |
| `--gop=<秒>` | 关键帧间隔,如 `--gop=1` 每秒一个关键帧,方便后期按秒 seek |
| `--keep-frames` | 成功也保留逐帧 PNG(换 CRF 重编码时不必重截) |
| `--tmpdir=<目录>` | 逐帧 PNG 放哪块盘(默认挨着 HTML)。1080p 一分钟约 2.4 GB,系统盘没余量时指到别的盘 |
| `--resume` | 上次渲到一半被打断?接着渲,已渲好的帧直接复用(参数和上次不一致会拒绝,见下) |
| `--alpha` | 透明背景输出(ProRes 4444 `.mov`),叠到实拍 / AI 生成画面上用,见下 |
| `--srt=<file>` | 外部字幕时间轴(SRT / VTT / JSON),注入 `window.__cues`,见下。同义参数 `--cues` |
| `--cue-log=<file>` | 导出 `frame,t,cue_index,cue_start,cue_end` CSV,用来核对字幕到底对不对轴 |
| `--story=<file>` | 镜头表 JSON,注入 `window.__story`,整份换掉剧本(见「多镜头与转场」) |
| `--scene-log=<file>` | 导出镜头时间轴 CSV;镜头表总时长与 `--duration` 不一致会告警 |
| `--gif[=fps,宽]` | 出片后顺手产 GIF(默认 15fps / 宽 960),两阶段 palette 优化,不重渲 |
| `--poster=<秒>` | 出片后顺手抽一张封面静帧 `.jpg`,发站 / 社媒用 |
| `--batch=<file.json>` | 一串任务按顺序渲,最后打汇总表;单个失败不连坐已渲好的,但整体非零退出 |
| `--headed` | 开着浏览器窗口渲(调试用;并发自动降到 1,免得弹一排窗口) |
| `--allow-errors` | 页面报错不判失败(默认:有报错 → 非零退出) |
| `--keep-chrome` | 不隐藏播放控件(调试用) |
| `--readytimeout=8` | 等 `__seek` 就绪的秒数(启动慢的大字体/大数据可调大) |
| `--no-serve` | 用 `file://` 加载页面(默认走本地 http server,见下) |
**ESM / fetch 项目直接能渲**。渲染器默认起一个只绑 `127.0.0.1` 的本地 http server 来加载页面 ——
`file://` 下 `<script type="module">` 和 `fetch()` 会被 CORS 拦死,用 Vite/esbuild 组织的动画、
读本地 JSON 的项目根本起不来;走 http 就没这些事。渲染开头会打印这个地址,排障时直接粘到浏览器里看控制台最方便。
服务器起不来会自动退回 `file://` 并提示(只警告,不判失败)。
- 输出与 HTML 同名的 `.mp4`
- 帧数 = `fps × duration`。**12s@60fps = 720 帧,实测约 6~7 分钟**(concurrency=4)
- 编码约 1 分钟(crf 18 / preset medium)
**渲染结束有页面报错会非零退出**(但成片照样产出,方便你核对)。
### 渲到一半断了,别从零开始
长片子(60fps × 30s = 1800 帧)跑到 80% 崩了,从零重来最伤。加 `--resume` 就接着渲:
```bash
bash assets/render.sh animation.html --fps=60 --duration=30 --resume
# ▸ resume: 已复用 1432 帧,还需渲 368 帧
```
规则三条,都是为了「续出来的片子和一次渲完的片子一模一样」:
- **帧先写 `.part` 再改名**。被 kill 的那一帧不会留下半个 PNG 冒充「已完成」,所以复用的帧都是真画完的。
- **参数变了直接拒绝**。上次 30fps、这次 60fps 会把两批帧混着编码,片子前后节奏不一致还不报错 ——
所以 `fps / 尺寸 / 范围 / 渲染模式` 任一不同就退出并打印「上次 vs 这次」,让你自己决定删不删。
- **帧号必须连续**。缺一张 ffmpeg 会静默提前收尾、成片莫名变短,所以编码前按 `[起,止)` 逐个核号,
有洞就报出来并提示加 `--resume` 补。
临时目录固定在 HTML 旁边的 `.seek-tmp-resume/`(不加 `--resume` 时仍是一次性目录,并发渲互不干扰)。
渲完自动清掉;中途想留着就配 `--keep-frames`。
这是故意的:页面报错常常意味着「整片黑屏 / 缺了半屏内容」,以前的版本会一路说成功,
直到你回抽帧才发现——现在它会在编码前就把报错打出来,并在最后判失败。
### 透明输出:把文字层叠到别人拍的画面上
AI 生成的背景 + 代码生成的文字层,是现在最省钱的组合(模型画文字必糊,代码画文字精确到像素)。
`--alpha` 就是为这条路准备的:
```bash
bash assets/render.sh animation.html --duration=6 --fps=30 --alpha
# → animation.mov(ProRes 4444 / yuva444p10le,背景透明)
```
**模板侧要配合一件事**:读到 `window.__transparent` 为真时跳过背景层绘制。
目前 `title-card`、`compare`、`flow-steps`、`subtitle-card`、`vertical-subtitle`、
`explainer` 这 6 个已经这样写,直接加 `--alpha` 就有透明效果;
其余 5 个是满版图表(条形 / 折线 / 大数字 / 时间线 / 竖屏卡),本来就要底色,加了也只会得到不透明的片子。
自己写的动画照这个来:
```js
const TRANSPARENT = !!window.__transparent;
function drawFrame(t) {
g.clearRect(0, 0, W, H);
if (!TRANSPARENT) {
// 底色 / 全屏渐变 / 光斑 —— 这些都要跳过
g.fillStyle = '#080A10';
g.fillRect(0, 0, W, H);
}
// 文字、图形照常画
}
```
**全屏的半透明光晕也要一起跳过**。哪怕它只有 5% 不透明度,叠到实拍上会让整片区域泛白,
剪辑师第一反应是「这素材脏」。实测:只跳底色、留着光斑时,四角 alpha=12(不是 0);
全跳之后四角 alpha=0、只有 3.7% 的像素不透明(全是文字)。
**体积代价**(960×540 / 2s / 20 帧实测):
| 输出 | 体积 | 用途 |
|---|---|---|
| H.264 `.mp4` | 118 KB | 成片直接发 |
| ProRes 4444 `.mov` | 760 KB(6.5×) | 拖进剪辑软件叠层 |
**只有 ProRes,没有 VP9**。试过 `--pix_fmt yuva420p` + `-auto-alt-ref 0`,文件确实从 7.8KB
涨到 16.8KB(alpha 数据写进去了),但 Chromium 播出来整帧 alpha 全 0 —— 连文字都透明;
换 lossless、固定码率、`-lag-in-frames 0` 都一样。既然没法自证正确,就不把它作为选项发布。
要小体积透明素材,用 ProRes 导入剪辑软件后再导 webm,那是那边的活。
### 配音对齐:先有声音,再定画面长度
真实工作流是「写文案 → 配音 → 配画面」,而不是先定 6 秒再想办法把配音塞进去。
所以**给了 `--audio` 又没给 `--duration` 时,视频按音频长度出片**:
```bash
bash assets/render.sh title-card.html --audio=voice.m4a --story=shots.json
# ▸ audio: 音频 12.48s,未指定 --duration → 视频按音频定长(12.5s,向上取整到整帧)
```
- **向上取整到整帧**,不是四舍五入:少半帧就会把最后一个字的尾音削掉。
- 显式给了 `--duration` 且与音频差超过 0.35s 会**明确告警**,说清差多少、伤在哪边。
旧版本这里会静默出一支短片 —— 画面后半段整段消失,日志却只有一句 `✓ Done`。
- `--audio-fit=pad`:音频比视频短时补静音,保住整段画面(默认 `trim` 按音轨收尾)。
注意:`trim` 下成片**不会精确等于音频秒数**。视频流是 `-c:v copy` 直接复制的,
ffmpeg 只能按帧包边界收尾,实测会多留 0.3–0.5s。想要毫秒级对齐得重编码,
那是拿画质换精度,不值得 —— 片尾多留几帧通常看不出来。
### 卡点:让切点落在拍上
「切点落在拍上」是片子显得专业的最短路径。手工对齐是数秒表挪关键帧,
换一首 BGM 全部重来;这里把拍点量出来,镜头时长直接量化到整拍:
```bash
bash assets/render.sh style-film.html --audio=bgm.mp3 --beats --snap=beats
# ▸ 节拍: 120.14 BPM · 20 个拍点 · 首拍 0.49s · 置信度 0.85
# ▸ 时长对齐拍点: 25.2s → 25.5s(+0.3s)
bash assets/render.sh style-film.html --story=shots.json --beats=beats.json --snap-shots
# ▸ 镜头对齐拍点(1 拍 = 0.499s):8 个镜头改了时长
# · hook 2.80s → 3.00s
# → 片长按镜头表定为 25.472s
```
规则(都是踩过才知道的):
- **改镜头,不改 BGM。** 配乐是骨架;除非你明确要裁音乐,否则画面去迁就拍子。
- **--snap-shots 会明说改了多少**,单镜变化 >25% 会警告 —— 节奏真的变了,导演得知道。
- **没给 `--duration` 时,片长跟着镜头表走**;给了就以你为准,只告警不偷偷改。
- **测不出稳定 BPM 就报警**,不硬卡。纯配音/环境音在物理上没有周期性重音,
这时候正确做法是按句子切(`--srt`),不是按拍切。
- 模板侧:`window.__beats` = `{ bpm, offset, beats[], downbeats[], confidence }`;
风格库给 `SV.pulse(t)`(拍点处 1、按 `decay` 衰减)与 `SV.beatPhase(t)`。
没有 `--beats` 时 `pulse()` 恒为 0,所以同一份模板加不加卡点都能跑。
单独跑分析器(零依赖,纯 ffmpeg + 算术):
```bash
node scripts/beats.js bgm.mp3 --json # {bpm, confidence, offset, beats:[...]}
node scripts/beats.js bgm.mp3 --out=beats.json # 存下来复用(同一 BGM 出多条片子)
```
精度:120BPM 鼓点实测 BPM 误差 <2、相位误差 ~9ms;分析 10 秒音频约 25ms。
### 一稿多发:一条命令出全套平台比例
同一条片子要发抖音、小红书、B 站,剪辑软件里得一个比例一个序列、导一次盯一次。
这里版式本来就是参数化的,多渲一遍只是多一次循环:
```bash
bash assets/render.sh bar-chart.html --duration=8 \
--presets=douyin,xiaohongshu,square --out=dist/chart.mp4
# → dist/chart-douyin.mp4 1080×1920
# dist/chart-xiaohongshu.mp4 1080×1440
# dist/chart-square.mp4 1080×1080
```
| 预设 | 尺寸 | 给谁用 |
|---|---|---|
| `douyin` / `tiktok` / `vertical` | 1080×1920 | 抖音、快手、视频号、Reels、Shorts |
| `xiaohongshu` / `xhs` | 1080×1440 | 小红书 3:4 |
| `instagram` / `ig` | 1080×1350 | IG 信息流 4:5 |
| `square` | 1080×1080 | 方版 1:1 |
| `bilibili` / `youtube` / `landscape` | 1920×1080 | 横屏 16:9 |
- 单个比例用 `--preset=xiaohongshu`;多个用 `--presets=a,b`(**至少两个**,只想要一个就用 `--preset`)。
- 预设名拼错会直接失败并列出可用名字 —— 不会悄悄按 1920×1080 渲一版比例错误的片子。
- **版式能不能撑住是另一回事**:模板跟视口自适应,但 9:16 与 16:9 的信息密度不同,
3:4 上字号偏大 / 内容偏挤要自己看一眼。`--presets` 只保证尺寸对,不保证构图好看。
- **竖屏底部那块留白是故意的**,不是排版出错:平台进度条和按钮压在那儿,
模板统一给烧录字幕留了 15% 安全区。
- 与 `--stills` 互斥(一个是出多片、一个是只截图);与 `--batch` 不能嵌套。
### 给 Agent 用:`--json`
这个工具的主力调用方是 AI Agent。人读 `✓ Done: xxx.mp4 (1.2 MB · 30fps · 12s)` 没问题,
Agent 只能拿正则去猜 —— 路径里带空格、中文、括号就崩。`--json` 把结果结构化:
```bash
bash assets/render.sh title-card.html --duration=6 --json
```
```json
{"ok":true,"exitCode":0,"input":"…/title-card.html","output":"…/title-card.mp4",
"width":1920,"height":1080,"fps":60,"duration":6,"frames":360,"bytes":2411724,
"gif":null,"poster":null,"warnings":[],"mode":"video","theme":null,"preset":null}
```
- **stdout 只有这一行**,进度与人读日志全部改道 stderr —— 直接 `JSON.parse(stdout)` 即可。
- `warnings` 是数组:参数冲突、音画长度不符、主题名不认识都进这里。空数组 = 干净。
- `duration` 是**成片实际时长**(ffmpeg 读回来的),不是计划值。
- `--presets` 时 `output` 变成数组,另附 `presets: [{name,width,height,output,ok,bytes}]`。
### 字幕挂外部时间轴:配音改了,字幕跟着走
字幕文案写在模板的 `CFG.lines` 里最省事,但一旦要配音,时间点就得跟着音频走 ——
总不能每改一句都回去手数秒数。所以支持直接喂一坨字幕文件:
```bash
bash assets/render.sh subtitle-card.html --duration=8 --srt=voice.srt --cue-log=cues.csv
```
- 支持 **SRT**(`HH:MM:SS,mmm`)、**WebVTT**(`MM:SS.mmm` 也行)、**JSON**(`[{start,end,text}]`)。
- 解析一次,注入 `window.__cues`,模板按当前帧的 `t` 自己去查该显示哪句。
模板里用 `--srt` 时**优先读 `__cues`,没有才回退 `CFG.lines`**,所以同一个模板两种用法都成立。
- `--cue-log` 输出 `frame,t,cue_index,cue_start,cue_end` 的 CSV。把「字幕时间对不对」
变成一个能算的数:拿每句的首帧时间减 SRT 的起点,就是误差。
实测 4 句 / 80 帧,每句误差 **0 ms**,帧覆盖数与理论值完全一致。
模板侧只要两行就能支持:
```js
const cues = (window.__cues && window.__cues.length) ? window.__cues : CFG.lines;
const cur = cues.find(c => t >= c.start && t < c.end) || cues[cues.length - 1];
```
**字幕别踩进底部 15%**。那是各平台的进度条 / 点赞按钮区,写进去等于白写。
`qa.js` 会提示「进字幕带」,四个新模板的竖屏版式都留出了这条安全带。
### 换配色:改一个字段,不用碰画布代码
`assets/templates/themes.js` 里有三套调色板,**14 个模板全部接入**;另有 12 套风格色板走 `lib/styles.js`。两种换法:
```bash
# ① 命令行换(推荐):不改任何文件,一次渲染一个主题
bash assets/render.sh bar-chart.html --duration=6 --theme=paper --out=bar-paper.mp4
# ② 改文件:在模板顶部 CFG 里写死
```
```js
const CFG = {
theme: 'paper', // midnight(默认,暗色)| paper(浅色)| neon(黑紫霓虹)
...
};
```
命令行优先于 `CFG.theme`(渲染器注入 `window.__h2vTheme`,模板读
`h2vTheme(window.__h2vTheme || CFG.theme)`)。所以同一份 HTML 可以批量出三套配色:
```json
[{ "html": "bar-chart.html", "duration": 6, "theme": "midnight", "out": "bar-dark.mp4" },
{ "html": "bar-chart.html", "duration": 6, "theme": "paper", "out": "bar-light.mp4" }]
```
批量里**必须配 `out`**:成片默认名是 `<HTML 名>.mp4`,同一个 HTML 渲两套配色
不换输出名的话,后一个会把前一个盖掉。
| 主题 | 观感 | 适合 |
|---|---|---|
| `midnight` | 近黑底 + 暖橙强调 | 通用,默认 |
| `paper` | 米白底 + 深墨文字 | 文档、教学、要打印或投影的场合 |
| `neon` | 黑紫底 + 紫/青霓虹 | 科技、发布会、社区宣传 |
要微调就传对象,缺的字段自动从 `midnight` 补:
```js
theme: { bg: '#0B1020', accent: '#22D3EE' }
```
**加主题不背「默认画面偷偷变了」的债**,靠一条约定:`themes.js` 里
`midnight` 只填 11 个基础字段,另外 5 个可选字段(`bgFar` / `accentDeep` /
`accentSoft` / `sweep` / `onAccent`)只给 `paper` 和 `neon` 填;
模板一律写成 `TH.accentDeep || '<历史常量>'`。于是默认主题走的还是原来那些常量,
换主题时才用新色。测试会盯住这条约定(midnight 填了可选字段就报错)。
**`themes.js` 是外部脚本,拷模板时连它一起拷**。只拷 HTML 会怎样:页面的兜底色板让画面
照常画出来,但 `<script src="./themes.js">` 会 404 —— 而渲染器把「页面报错」判为失败,
于是你会看到「Failed to load resource: 404」加一句渲染失败。**这不是画面坏了**,
把 `themes.js` 拷到同目录(或者加 `--allow-errors`)即可。
`test/templates-contract.test.mjs` 会检查「登记了主题能力就必须真的引入
themes.js,并且必须读 `window.__h2vTheme`」——后者保证命令行换主题不是空操作。
### 第 4 步:配乐 / 派生格式
渲染时加 `--audio=` 就能直接混音;需要更细的控制时用下面的命令:
```bash
FF="<node工作区>/node_modules/@ffmpeg-installer/win32-x64/ffmpeg.exe" # Windows
# 其它平台:FF="<node工作区>/node_modules/@ffmpeg-installer/ffmpeg"
# 混 BGM(0.8s 淡入 / 1.4s 淡出,视频流不重编码)
"$FF" -y -i animation.mp4 -i "$BGM" -filter_complex \
"[1:a]atrim=0:DUR,afade=t=in:st=0:d=0.8,afade=t=out:st=DUR-1.4:d=1.4,volume=0.5[a]" \
-map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -shortest -movflags +faststart animation-bgm.mp4
# GIF(两阶段 palette 优化,否则糊)
"$FF" -y -i animation.mp4 -vf "fps=15,scale=960:-1:flags=lanczos,palettegen=stats_mode=diff" palette.png
"$FF" -y -i animation.mp4 -i palette.png -lavfi "fps=15,scale=960:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" animation.gif
# 竖屏:改 HTML 画布尺寸后重渲,别用裁剪
```
### 第 5 步:自动化验收(**交付前跑**)
渲染器只保证「帧渲出来了」,不保证「画面是对的」。这一步把肉眼最容易漏的问题量化:
```bash
bash assets/qa.sh animation.html --duration=12 --fps=30
```
它做的事:
| 维度 | 量什么 |
|---|---|
| 确定性 | 抽 首/中/末 3 个时刻,各做两轮逐像素比对:同页面 seek 回去(验累积状态)+ 全新页面重渲(验随机初始化)。不一致 = 画面里混了随机数/时钟/累积状态 |
| 动感 | 相邻帧变化像素占比(运动%)、静止帧对比例、**最长连续静止秒数** |
| 流畅 | 相邻帧差的峰值/中位数,报「跳变」时刻 |
| 框景 | 从加载起 hook `fillText/strokeText` + DOM 文字行框 → 报**半截字 / 叠字 / 进字幕带 / 近空白** |
| 字体 | 声明的字体是否可用、是否悄悄回退到兜底字体(缺字形是静默失败) |
| 报错 | pageerror / console.error 清单(有则判失败,退出码非零) |
产物:`qa/qa.md`(表 + 线索清单)、`qa/qa.json`(原始数字)、`qa/<名>.heat.png`(运动热图)。
判据的脾气:**经验阈值,不是规则**。运动 0.5%–8% 多数好看;最长静止 ≥3s 会提示
(有意定格可以忽略);跳变 >0 先去看那一帧;框景线索只是「去看一眼」的提示,不让 QA 失败。
关键参数:`--safe-bottom=0.15`(画面底部这个比例内出现文字就警告,烧录字幕会盖住;`0` 关掉)、
`--skip=0.35`(跳过段首的镜头冲击)、`--samples=120`(采样帧数上限)。
**采样间隔要 ≤0.1s**,否则一段快速位移在相邻两帧之间看着就像「跳」——那是采样假象不是穿帮。
`qa.sh` 在间隔 >0.15s 时会提示该把 `--samples` 提到多少。(实测:同一段 title-card,
`--fps=6` 报 2 处跳变,`--fps=30` 报 0 处。)
> 已知盲区:画进离屏 canvas 再 `drawImage` 上来的文字追不到;被图形挡住的字查不到;
> 采样从 `--skip` 之后开始,开场瞬间的问题看不到。这些都在报告里说明了。
### 第 6 步:独立验证(别只看工具说成功)
```bash
# 从成片回抽帧,亲眼确认画面
"$FF" -y -ss 4.0 -i animation-bgm.mp4 -frames:v 1 -q:v 2 out/verify-4.0.jpg
# 校验规格
"$FF" -hide_banner -i animation-bgm.mp4 2>&1 | grep -E "Duration|Stream"
# 解码完整性(无输出即无错误)
"$FF" -v error -i animation-bgm.mp4 -f null -
```
⚠️ **验证渲染时务必换输出名或另建目录**。渲染器输出名固定为「HTML 同名 .mp4」,
拿真文件名跑一次短渲染验证,会把成品覆盖成短版。(踩过,只能重新编码。)
用 `--from/--to` 时输出名自带范围后缀,天然不会覆盖;也可以直接换目录。
## 动效库:assets/lib/motion.js
缓动、弹簧、步进、错开、拍号的纯函数库,和「冻结时钟 + `__seek(t)`」天然契合:
```html
<script src="../lib/motion.js"></script>
<script>
const p = MO.expoOut(MO.seg(t, 0.3, 1.4)); // 0..1 进度
const y = MO.lerp(80, 0, p); // 位移
const s = 1 + MO.settle(t - 1.2, 0.06); // 到位后的余振
</script>
```
选缓动的一句话:讲解类用 `MO.smooth` / `sineInOut`(两端都停稳);发布会用 `MO.appleOut` / `MO.snappy`;
动态大字用 `expoOut` + `MO.springHz`;纸片/拼贴按 `MO.step(t, 12)` 一拍二;逐项入场用 `MO.stagger`。
**别让画面「死」掉**:一段动效做完后如果还要停留好几秒,加一点持续微动
(慢速光扫、呼吸、漂浮 `MO.float`),否则观众会觉得视频卡住了。模板里都带了这一手。
## 多镜头与转场:assets/lib/scenes.js
一支能看的片子是「多镜头 + 转场」,不是几段动画硬拼。以前只能渲几段再拿 ffmpeg 拼
(还要自己算 offset);现在**一个 HTML 里写镜头表**,渲染器照旧只管按 `t` 推帧,
转场在页面内合成 —— 一条命令照样出片。
```html
<script src="../lib/scenes.js"></script>
<script>
const TL = SC.build([
{ id: 'hook', dur: 3.0, draw: (g, lt, c) => drawHook(g, lt, c) },
{ id: 'point', dur: 4.0, trans: { in: 'slide', dur: 0.5 }, draw: (g, lt, c) => drawPoint(g, lt, c) },
], { lead: 0.4, tail: 0.6 });
function frame(t) { SC.paint(g, TL, clamp(t, 0, TL.dur - 1e-4), { W, H, bg: TH.bg }); }
</script>
```
| 转场 | 观感 | 什么时候用 |
|---|---|---|
| `fade` | 交叉溶解 | **文字类片子的默认**,讲理、讲解、数据 |
| `dip` | 经底色过渡 | 两段情绪差得远;或两镜版式完全换掉 |
| `slide` | 下一镜整块推入 | 满幅图形 / 照片 / 色块之间 |
| `push` | 两镜贴着一起走 | 同上,更要方向感(流程往下走) |
| `rise` | 下一镜从下方升起 | 竖屏、解说卡(两镜共用左边距时才稳) |
| `zoom` | 变焦穿过 | 满幅画面之间,强调「一口气穿过去」 |
| `wipe` | 竖边扫擦 + 扫描亮线 | 科技感、数据揭示(满幅图形) |
| `cut` | 硬切 | 节奏紧、或转场时长给 0 |
**选型铁律:画面里有文字,就只用 `fade` / `dip`。** 带位移的转场会把上一镜的文字
从中间切一半推走,两镜标题还会在同一位置叠成乱码 —— 观感是「画面坏了」,不是
「镜头动了」。位移类转场只留给边缘没有语义的画面(满幅图形、照片、色块)。
五条约定(踩过的):
1. **每个镜头的 `draw` 必须把背景画满**。转场是按 alpha 叠加两张画,
镜头自己留透明,交叉溶解时会透出底色,看着像闪一下。
2. **镜头动画一律用局部时间 `lt`**,不要用全局 `t`;转场窗口里上一镜的 `lt`
正好走到它自己的末尾(不会越界),所以镜头代码不用为转场写特例。
3. **带转场的镜头,别在 `draw` 里再叠一层整体淡入**。转场已经在做透明度了,
两层同时压 alpha,前 0.3 秒会糊成一团。`ctx.trans.dur > 0` 就跳过自己那层。
4. **退场侧要先把内容撤干净**:转场窗口里 `ctx.role = { side:'a'|'b', p }`,
`side==='a'` 时把内容 alpha 按 `p` 收到 0(背景照画),`side==='b'` 的内容
晚 `0.22 × 转场时长` 起步。这样两镜内容永不同时在场 —— 这是文字类片子做溶解
不翻车的关键,标准写法见 `assets/templates/explainer.html` 的 `draw` 包装。
5. **转场是重叠的**:总时长 = Σ镜头时长 − Σ转场时长。加了转场,片子不会变长。
6. `SC.expose(TL)` 把镜头表挂到 `window.__sceneTable`,配渲染器 `--scene-log` 导出 CSV;
**镜头表总时长与 `--duration` 不一致会告警**(否则结尾静默多一截静止画面)。
镜头表可以整份外置,LLM 写 JSON 最省事:
```bash
bash assets/render.sh explainer.html --duration=17.6 --fps=30 --story=shots.json
```
```json
[{ "kind": "title", "id": "hook", "dur": 3.0, "title": "..." },
{ "kind": "talk", "id": "why", "dur": 3.5, "say": "...", "trans": { "in": "fade", "dur": 0.45 } }]
```
## 先看分镜,再渲全片(省时间的关键一步)
一支 60 秒 1080p 的片子逐帧渲要 3~10 分钟。渲完才发现「第 4 镜字太小」「转场太赶」,
改一轮又要几分钟。**分镜板**只取每镜中点渲一张静帧,十几秒出,一页看完:
```bash
node scripts/storyboard.js my.html # 4 列网格
node scripts/storyboard.js my.html --cols=2 --theme=chalk
node scripts/storyboard.js my.html --story=shots.json --width=1080 --height=1920
```
产出 `<名>.storyboard/`:每个镜头一张 PNG、一页带标注的 `storyboard.html`、一张整页截图
`storyboard.png`(贴给别人问「这样行不行」最方便)。
**什么时候必须先跑**:
- 镜头 >= 3 个,或总时长 >= 10 秒
- 改了版式/文案之后要重渲全片
- 用户还没看过这支片长什么样
**它靠什么知道镜头表**:页面里调过 `SC.expose(TL)` 就能读到 `window.__sceneTable`;
没调就按总时长均分(会提示)。所以写多镜头页面时,记得 expose 一句。
分镜板能看出的问题:镜头时长分配、层级(标题/正文/注释大小)、有没有哪一镜是空的、
风格与内容的匹配度。**看不出的**:动效手感、转场是否顺、音画是否对齐 —— 那些要出片或
`--stills` 抽帧连看。
## 风格:8 种视觉语言,一条命令换
模板决定**画什么**(标题 / 要点 / 条形图),风格决定**长什么样**(什么纸、什么墨、线多粗、
字多紧、入场怎么动、签名元素是什么)。`assets/lib/styles.js` 把这两层拆开了。
```bash
bash assets/render.sh assets/style-film.html --duration=13 --theme=chalk
bash assets/render.sh assets/style-film.html --duration=13 --theme=水墨 --width=1080 --height=1920
```
| id | 中文 | 一句话 | 适合 |
|---|---|---|---|
| `riso` | 孔版印刷 | 米白纸 + 三色网点(真实网角)+ 标题套印错位 | 观点、数据、独立杂志感(**默认**,辨识度最高) |
| `cutpaper` | 剪纸拼贴 | 四边手撕的纸片 + 锐利硬投影 + 两张纸叠着 | 故事、人物、教育 |
| `blueprint` | 工程蓝图 | 深蓝 + 双层格网 + 四角十字标记 + 等宽字 | 技术、架构、API |
| `chalk` | 黑板粉笔 | 墨绿板 + 双线抖动笔触 + 粉尘 + 楷体 | 教学、科普 |
| `isometric` | 等距线稿 | 白底 + 2:1 等距立体块 + 白面遮挡 | 机制、结构、流程 |
| `synthwave` | 霓虹复古 | 条纹太阳 + 透视地面网格 + CRT 扫描线 | 音乐、游戏、复古科技 |
| `swiss` | 瑞士网格 | 白底 + 超大字号 + 单一信号红 + 零圆角 | 品牌、观点、发布会 |
| `ink` | 水墨宣纸 | 宣纸纤维 + 三层洇墨 + 朱红印章色 | 国风、文化、历史 |
**中文名也认**(`--theme=水墨`、`--theme=剪纸`)。完整选择指南、每种风格的签名元素与禁忌
见 `assets/STYLES.md`。
两个现成入口:
```bash
# 八宫格:一秒一个风格,出 8 张全屏静帧,一眼挑风格
node scripts/render-video-seek.js assets/style-gallery.html --stills=0.5,1.5,2.5,3.5,4.5,5.5,6.5,7.5
# 一支 13 秒的风格短片(片头 / 要点 / 数据 / 片尾),支持 --story 外置文案
bash assets/render.sh assets/style-film.html --duration=13 --fps=60 --theme=riso
```
**自己写页面时,四条硬约定**(踩过,都是"画出来是黑的"这种):
1. `SV.card()` **必须用返回值排版**。它返回 `{x, y, w, h, ink}` —— 内容安全框。
每种风格能放字的地方不同(等距只给顶面、水墨只给内圈、瑞士要反白),
拿原始 box 硬排,字一定压边或消失。
2. **每个镜头的 `draw` 自己画满背景**(`SV.bg(...)`)。`SC.paint` 里 `paintShot()`
会先 `clearRect`,漏了这步背景就是黑的。
3. **`SV.overlay` 放在最外层**,在所有镜头之上。放镜头里,转场时扫描线会跟着淡入淡出。
4. 纹理噪点用 `SV.rng(seed)`、手抖用 `SV.boil(st, t, i, amp)`,**不要 `Math.random()`** ——
它们是纯函数,换机器渲出来一模一样。
**原来的 11 个模板已经全部接上风格**,不用改一行代码:
```bash
bash assets/render.sh assets/templates/bar-chart.html --theme=blueprint --duration=6
bash assets/render.sh assets/templates/explainer.html --theme=riso --duration=18
bash assets/render.sh assets/templates/vertical-card.html --theme=synthwave --width=1080 --height=1920
```
换的不只是配色,背景整层被换成该风格的纸纹 / 网点 / 格网 / 透视地面,
气泡、图标底这些可选色也跟着明暗走。机制只有一句:模板背景层判断 `TH.__style`,
有就走 `SV.bgCached(...)`。**`styles.js` 不需要在模板里引** —— 渲染器启动时注入,
所以模板可以单独拷走、也可以在临时目录里跑测试,不会因为相对路径 404 被判失败。
哪种模板配哪种风格顺手,见 `assets/STYLES.md` 末尾的匹配表。
### 镜头类型:8 种,一条 JSON 排顺序
`style-film.html` 内置 8 种镜头。给 `--story` 就是整份替换(给了 6 个镜头就渲 6 个,
不是只覆盖前 4 个):
| id | 用途 | 关键字段 |
|---|---|---|
| `hook` | 片头 | `kicker` / `lines[]` / `sub` / `badgeNum` / `badge` |
| `points` | 要点清单(2~4 条) | `head` / `items[{n,t,d}]` |
| `quote` | 引用(左侧粗引线 + 大字) | `text` / `by` |
| `steps` | 流程步骤(2~4 步,带箭头) | `head` / `items[{n,t}]` / `note` |
| `metric` | 一个大数字 + 对比条 | `head` / `value` / `unit` / `note` / `bars[{label,p}]` |
| `compare` | 前后对比 + 结论条 | `head` / `before{label,title,note}` / `after{...}` / `conclusion` |
| `timeline` | 时间线(节点上下交错) | `head` / `items[{n,t}]` |
| `outro` | 片尾 | `big` / `small` |
每个镜头都要 `dur`(秒)。8 个镜头 = 64 种组合(8 镜头 × 8 风格)。
```bash
# 现成示例:8 个镜头串成一支 26 秒的片子
node scripts/storyboard.js assets/style-film.html \
--story=assets/examples/story-8shots.json --cols=4 # 先看分镜板
bash assets/render.sh assets/style-film.html \
--story=assets/examples/story-8shots.json --theme=chalk --duration=26
```
**加新镜头类型**:写一个 `shotXxx(g2, lt, c)`,从 `SHOTS[c.index]` 取数据,
放进 `DRAW` 映射表。三条规矩:
- 开头必须 `backdrop(g2, lt, c)` + `chrome(g2, 1)`
- 所有卡片用 `SV.card()` 的返回值排版,字号用 `Math.min(理想值, cb.h * 比例)` 夹一道
- 入场用 `SV.clamp((t - 延迟) / 时长)` + `SV.enter(ST, p)`,别自己写缓动
## 运镜:assets/lib/camera.js
画面元素都到位之后还剩几秒 —— 那几秒最容易「死」,`qa.js` 会报「最长连续静止」。
加一点持续运镜能活过来,而且比堆装饰元素更不抢戏。
```js
const cam = CAM.breath(); // 呼吸:1.4% 的持续缩放,周期 4.2s
CAM.withCam(g, CAM.at(cam, t, W, H), W, H, () => { /* 画内容 */ });
```
| kind | 用途 |
|---|---|
| `breath()` | 极轻缩放,专门治「画面死了」 |
| `kenburns({from,to})` | 缓慢推拉 / 平移(照片、大图镜头) |
| `handheld` | 手持微抖:确定性伪噪声(**不是** `Math.random()`),多 worker 结果一致 |
| `shake` | 撞击式震动,会衰减到 0(强调、转场落点) |
| `stack([...])` | 多个运镜叠加:位移相加、缩放相乘 |
两条硬规矩:**运镜前先把底色铺满**(缩放/平移会露边);
**缩放小于 1 一定露边**,要拉远就把内容画得比视口大。
## 素材库:图标 / 人物 / 图形
想画图标、箭头、卡片、人物,不用找图、也不用从零写 path —— `assets/lib/` 里有三个零依赖库:
| 库 | 提供 |
|---|---|
| `shapes.js` | 40+ 线性图标(`SH.icon(g,'bolt',x,y,48,{color})`)、卡片 / 胶囊 / 箭头 / 正交连接线 / 虚线框 / 进度弧、网格 / 点阵 / 柔光 / 暗角 / 流动极光 / 胶片颗粒 |
| `figures.js` | 参数化扁平小人:`FIG.draw(g, FIG.preset('dev', {x,y,h,t,pose:'wave',mood:'happy'}))`,发型 / 表情 / 姿势 / 配色全可调,自带对话气泡 `FIG.say()`;`FIG.shade(hex, -0.14)` 取同色系深浅一档 —— 袖子和上衣必须差一档,否则手会像浮在衣服上的色块 |
| `camera.js` | 见上 |
**为什么是代码画的元件,不是预置图片素材包**:版权干净(没有一张外部图)、
风格必然统一(同一套圆角线宽、跟着主题走)、任意分辨率都清晰、不占体积。
**刻意不做**卡通人物位图包 / 标志性建筑照片包 —— 版权说不清、风格和用户自己的画面打架,
还会把仓库撑成几十 MB 的二进制库。要人物就用 `figures.js` 参数化生成。
```js
SH.icon(g, 'bolt', x, y, 48, { color: TH.accent, width: 2.5 });
SH.card(g, { x, y, w, h }, { fill: TH.panel, stroke: TH.faint, radius: 18 });
FIG.draw(g, FIG.preset('host', { x: W * 0.22, y: H * 0.84, h: H * 0.42, t, pose: 'wave' }));
```
## 中文字体与跨机器一致
默认用系统字体(`Microsoft YaHei` / `PingFang SC`)。**这意味着"确定性"只在单机成立**:
同一个 HTML 换台没有这款字体的机器(比如 Mac),字形就会变。
- 只是本机出片 → 不用管。
- 要跨机器/跨人复现 → 自己内嵌一份子集字体:
```css
@font-face { font-family: 'MyCJK'; src: url('fonts/subset.woff2') format('woff2'); font-display: block; }
```
子集化(只保留画面里真正用到的字,一个思源黑体 1.38MB → 约 300KB):
装 `fonttools` 后 `pyftsubset 源字体.ttf --text-file=<把文案导出成 txt> --flavor=woff2 --output-file=subset.woff2`。
**只统计会画到画面上的字**——把注释里的中文也算进去,子集会胖很多倍。
`qa.sh` 会检查字体可用性,并在「声明的字体其实没生效(悄悄回退了)」时给出警告。
## 渲染中途 ffmpeg 失败怎么办
**帧不会丢**:渲染器在编码失败时保留 `.seek-tmp-*/` 里的全部 PNG,并打印出可直接粘贴的编码命令。
修好 ffmpeg 后手动编码即可,不必重截 720 帧:
```bash
"$FF" -y -framerate 60 -start_number 0 -i .seek-tmp-*/frame-%06d.png -c:v libx264 -pix_fmt yuv420p \
-crf 18 -preset medium -r 60 -movflags +faststart animation.mp4
```
顺带:`.seek-tmp-*/` 有 720 张 PNG,**约 316 MB**。成功渲染后会自动删;加 `--keep-frames`
可以主动留住(换画质重编码时省一次渲染)。
> 某些沙箱/托管环境给 `fs.rmSync` 套了「批量删除保护」,一次删几十个文件会被拦下。
> 这时渲染器会提示「逐帧目录没能自动删除」并给出路径,**成片不受影响**——手动删那个目录即可。
> 这也是为什么有时会看到残留的 `.seek-tmp-*` 目录。
## 文件说明
| 文件 | 作用 |
|---|---|
| `scripts/render-video-seek.js` | 渲染器内核(冻结时钟 + 逐帧 seek 截图 + ffmpeg 编码 + 报错判定),自包含 |
| `scripts/qa.js` | 自动化验收(确定性/动感/流畅/框景/字体/报错),纯 Node 零新依赖 |
| `scripts/lib/hide-chrome.cjs` | 「隐藏播放控件」的共享规则,渲染器与验收器用同一份,规则不会漂移 |
| `scripts/lib/local-server.cjs` | 本地 http 静态服务器(只绑 127.0.0.1),解锁 ESM / fetch;零新依赖 |
| `scripts/detect-env.js` | 探测本机浏览器缓存,输出该装哪个 playwright 版本 |
| `scripts/install-deps.sh` | 一键装依赖并写配置(探测 → 装 → 落地配置文件) |
| `scripts/bench.js` | 性能基线:量 `s/帧` 并估算长片耗时,产出 `bench/bench.md` |
| `assets/render.sh` | 全量渲染包装器,自动解析 node 工作区与 ffmpeg 路径 |
| `assets/smoke.sh` / `smoke.cjs` | 冒烟测试:验证 `__seek` 契约 + 试截关键帧 |
| `assets/qa.sh` | 验收包装器,环境解析同上 |
| `assets/animation-skeleton.html` | 动画骨架模板,已实现完整渲染契约 |
| `scripts/lib/cues.cjs` | 字幕时间轴解析(SRT / VTT / JSON),会自动排序并修掉时长非正、首尾重叠的 cue |
| `scripts/lib/qa-rules.cjs` | 验收判据的纯函数(框景 / 持续 / 分块 / 统计),可脱离浏览器单测 |
| `assets/templates/*.html` | 11 个开箱即用模板(多镜头讲解片/片头/条形图/折线/大数字/时间线/竖屏卡/前后对比/流程图/解说卡/竖屏字幕卡),改 CFG 即用 |
| `assets/templates/themes.js` | 三套内置主题(midnight / paper / neon),新模板用它换配色 |
| `assets/lib/motion.js` | 动效时间库(缓动/弹簧/步进/错开),纯函数、零依赖 |
| `assets/lib/scenes.js` | 多镜头时间轴 + 8 种转场(cut/fade/dip/slide/push/rise/zoom/wipe),纯函数、零依赖 |
| `assets/lib/camera.js` | 运镜(推拉 / 呼吸 / 手持微抖 / 撞击震动),确定性伪噪声,不用随机数 |
| `assets/lib/shapes.js` | 图形元件:40+ 线性图标 / 卡片 / 箭头 / 正交连接线 / 背景纹理 |
| `assets/lib/figures.js` | 参数化人物(发型 / 表情 / 姿势 / 配色)+ 对话气泡 |
| `assets/lib/styles.js` | **风格库**:12 种视觉语言(孔版印刷/剪纸/蓝图/粉笔/等距/霓虹/瑞士/水墨/水彩/铅笔/半调/黏土),`SV.bg` / `SV.text` / `SV.card` / `SV.bar` / `SV.overlay` / `SV.boil` / `SV.BEAT` / `SV.pulse` |
| `assets/STYLES.md` | 风格选择指南:每种风格的签名元素、适用场景、禁忌、加新风格的字段清单 |
| `assets/style-film.html` | **风格短片**:片头/要点/数据/片尾 4 镜,8 风格通用,支持 `--story` 与横竖屏 |
| `assets/style-gallery.html` | 八宫格风格样张,第 k 秒一个风格,配 `--stills` 一次出 8 张静帧 |
| `scripts/storyboard.js` | **分镜预演**:每镜中点一张静帧 → 一页分镜板 HTML + 整页截图,渲全片前先确认 |
### 渲得有多慢:自己量一次,别信文档
不同机器差 3–5 倍(CPU、内存、画面复杂度都影响),所以这里不写「约几分钟」:
```bash
npm run bench # 两个页面 × 两档分辨率 × 两种并发 → bench/bench.md
node scripts/bench.js --frames=60 --html=assets/templates/explainer.html
```
表里给 `s/帧`(已把并发算进去)和「10s@60fps 预估」。**并发不是越大越好**:
超过 CPU 核数之后页面之间抢 CPU,`s/帧` 不降反升,表上能直接看出来。
## 设计品味另找专业分工
本技能只负责「环境 + 出片链路 + 质量门」。分镜方法、镜头语言、视觉风格属于设计范畴,
交给专门的设计类技能处理,别在这里重复造。
Files in this skill
- CHANGELOG.md
- CONTRIBUTING.md
- README.en.md
- SKILL.md
- assets/STYLES.md
- assets/animation-skeleton.html
- assets/qa.sh
- assets/render.sh
- assets/smoke.cjs
- assets/smoke.sh
- assets/style-film.html
- assets/style-gallery.html
- bin/h2v.js
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.