Ver código fonte

feat(video-export): 新增逐帧 seek 渲染器(真 60fps·确定性·无黑帧)

走 Stage 时钟的动画可改用 render-video-seek.js 逐帧 seek 导出,免插帧、
无黑帧,适合 B站作品集级交付。

- scripts/render-video-seek.js:新增逐帧 seek 渲染脚本
- scripts/render-narration.sh:加 --seek / --seek-fps 选项接入解说流水线
- assets/animations.jsx · narration_stage.jsx:配合 seek 渲染的时钟改造
- references/video-export.md · animation-pitfalls.md:补充 seek 渲染说明
- SKILL.md:导出能力表登记 render-video-seek.js 用法
- package.json / package-lock.json:补齐依赖声明

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alchain 2 semanas atrás
pai
commit
90be99260b

+ 2 - 1
SKILL.md

@@ -636,6 +636,7 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
 8. **总结**:极简,只说caveats和next steps。
 9. **(默认)导出视频 · 必带 SFX + BGM**:动画 HTML 的**默认交付形态是带音频的 MP4**,不是纯画面。无声版本等于半成品——用户潜意识感知「画在动但没声音响应」,廉价感的根源就在这里。流水线:
    - `scripts/render-video.js` 录 25fps 纯画面 MP4(只是中间产物,**不是成品**)
+   - 需要**真 60fps / 确定性 / B站作品集交付**且动画走 Stage 时钟时,改用 `scripts/render-video-seek.js --fps=60`(逐帧 seek,免插帧、无黑帧,详见 `references/video-export.md`)
    - `scripts/convert-formats.sh` 派生 60fps MP4 + palette 优化 GIF(视平台需要)
    - `scripts/add-music.sh` 加 BGM(6 首场景化配乐:tech/ad/educational/tutorial + alt 变体)
    - SFX 按 `references/audio-design-rules.md` 设计 cue 清单(时间轴 + 音效类型),用 `assets/sfx/<category>/*.mp3` 37 个预制资源,按配方 A/B/C/D 选密度(发布 hero ≈ 6个/10s,工具演示 ≈ 0-2个/10s)
@@ -749,7 +750,7 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
 | **按输出类型查场景模板**(封面/PPT/信息图) | `references/scene-templates.md` |
 | 输出完后验证 | `references/verification.md` + `scripts/verify.py` |
 | **设计评审/打分**(设计完成后可选) | `references/critique-guide.md`(5 维度评分+常见问题清单) |
-| **动画导出MP4/GIF/加BGM** | `references/video-export.md` + `scripts/render-video.js` + `scripts/convert-formats.sh` + `scripts/add-music.sh` |
+| **动画导出MP4/GIF/加BGM** | `references/video-export.md` + `scripts/render-video.js`(默认25fps)/ `scripts/render-video-seek.js`(真60fps·确定性·无黑帧,走Stage时钟时用)+ `scripts/convert-formats.sh` + `scripts/add-music.sh` |
 | **动画加音效SFX**(苹果发布会级,37个预制) | `references/sfx-library.md` + `assets/sfx/<category>/*.mp3` |
 | **动画音频配置规则**(SFX+BGM双轨制、黄金配比、ffmpeg模板、场景配方) | `references/audio-design-rules.md` |
 | **Apple画廊展示风格**(3D倾斜+悬浮卡片+缓慢pan+焦点切换,v9实战同款) | `references/apple-gallery-showcase.md` |

+ 8 - 0
assets/animations.jsx

@@ -189,6 +189,14 @@
     }, [width, height]);
 
     useEffect(() => {
+      // Seek-render mode (render-video-seek.js sets window.__seekRender): freeze the
+      // self-driven clock and let the external renderer advance each frame via
+      // window.__seek(t). No rAF self-drive here — every frame is a deterministic seek.
+      if (typeof window !== 'undefined' && window.__seekRender) {
+        window.__ready = true;
+        window.__seek = (t) => setTime(Math.min(t, duration - 0.001));
+        return;
+      }
       if (!playing) return;
       let cancelled = false;
       let last = null;

+ 6 - 0
assets/narration_stage.jsx

@@ -100,6 +100,12 @@ const NarrationStageLib = (() => {
     React.useEffect(() => {
       let raf;
       if (recording) {
+        // Seek-render(render-video-seek.js 注入 window.__seekRender):冻结自驱时钟,
+        // 由外部 window.__seek(t) 逐帧推进。每帧都是确定性 seek,不起 rAF。
+        if (typeof window !== 'undefined' && window.__seekRender) {
+          window.__seek = (t) => setTime(Math.min(t, timeline.totalDuration));
+          return;
+        }
         // 录视频模式:rAF wall-clock 自驱动从 0 开始
         // 兼容 render-video.js(它依赖动画自然推进 + window.__seek 复位)
         let startedAt = null;

+ 56 - 0
package-lock.json

@@ -0,0 +1,56 @@
+{
+  "name": "huashu-design",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "playwright": "^1.59.1"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+      "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.59.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+      "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    }
+  }
+}

+ 5 - 0
package.json

@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "playwright": "^1.59.1"
+  }
+}

+ 22 - 0
references/animation-pitfalls.md

@@ -360,6 +360,27 @@ bash convert-formats.sh input.mp4 --minterpolate
 
 这条坑的隐蔽性在于——**没有 bug 报警**。只有人眼或 OCR 能发现。
 
+## 17. 离线/无 CDN 的真·自包含 —— React/Babel 全内联,且引擎也要 transpile
+
+**踩的坑(2026-05 觅游宣传动画)**:动画 HTML 用 `<script src="https://unpkg.com/react...">` + `<script src=".../@babel/standalone">` 走 CDN。本机有全局代理,Playwright 录制时 chromium 连 unpkg / Google Fonts 全部 `net::ERR_CONNECTION_CLOSED`:
+
+1. React/ReactDOM 没加载 → `window.React undefined`
+2. Babel 没加载 → `<script type="text/babel">` 里的 JSX 当普通 JS 跑 → `Unexpected token '<'`
+
+修了 React/Babel 后又踩第二个坑:**把 `animations.jsx` 引擎当普通 `<script>` 内联,依然报 `Unexpected token '<'` → `window.Animations is undefined`**。根因:**`animations.jsx` 引擎本身含 JSX**(`Stage`/`Sprite` 组件 `return (<div>...)`),它原设计是用 `<script type="text/babel">` 由 Babel 转译加载的。只 transpile 了 app 代码、忘了 transpile 引擎 → 引擎那段 JSX 没被编译。
+
+**规则**(要做「双击即开 / 离线 / 能被 Playwright 录」的真自包含单文件时):
+
+- **React + ReactDOM 本地内联**:`curl` 下载 `react.production.min.js`(~10KB)+ `react-dom.production.min.js`(~131KB)到本地,inline 进 `<script>`,不走 CDN
+- **构建期 Babel 预编译,运行期不带 Babel**:用 `@babel/standalone`(下载一次,仅构建用)在 node 里 `Babel.transform(src,{presets:['react']}).code`,把 JSX → `React.createElement`。**app 和 `animations.jsx` 引擎两段都要过 transform**——引擎含 JSX,漏了它必报 `Unexpected token '<'`
+- **字体改系统字体**:Google Fonts CDN 同样会被代理掐断。中文动画用 `'PingFang SC'`(sans)/ `'Songti SC'`(serif)系统字体,不依赖网络。`document.fonts.ready` 对系统字体立即 resolve,录制不卡
+- **base64 内联图片素材**:`<img src="png/x.png">` 相对路径在 `file://` 能渲染,但要真便携(移动文件不丢图)就 base64 data URL 内联;背景大图先转 JPEG 压一下再 base64
+- **构建模板化**:HTML 模板留 `__REACT__/__REACTDOM__/__ASSETS__/__ENGINE__` token + 一段 `type="text/jsx-source"` 的 app 源码,node 构建脚本读 token 注入(vendor 原样、引擎+app 过 Babel)→ 写出最终单文件。改动画只改模板重跑构建
+
+**验证**:Playwright `page.evaluate(()=>({React:typeof window.React, Animations:typeof window.Animations}))`——两个都该是 `object`。任一 `undefined` → 对应 `<script>` 抛了错(多半是没 transpile 的 JSX)。
+
+**和坑 #15 的关系**:#15 讲「单文件别用 `src=` 外链 `.jsx`(file:// CORS)」;本坑更进一步——连 React/Babel/字体的**远程 CDN 在受限网络下也会断**,要做到真自包含必须全内联 + 构建期 transpile。
+
 ## 快速自查清单(开工前 5 秒)
 
 - [ ] 每个 `position: absolute` 的父元素都有 `position: relative`?
@@ -378,3 +399,4 @@ bash convert-formats.sh input.mp4 --minterpolate
 - [ ] 涉及具体品牌(Stripe/Anthropic/Lovart/...):走完了「品牌资产协议」(SKILL.md §1.a 五步)?有没有写 `brand-spec.md`?
 - [ ] 单文件交付的 HTML:`animations.jsx` 是内联的,不是 `src="..."`?(file:// 下 external .jsx 会 CORS 黑屏)
 - [ ] 跨 scene 出现的元素(chapter 标签/水印/scene 编号)没有硬编码颜色?在每个 scene 底色下都可见?
+- [ ] 要离线/真自包含:React+ReactDOM 本地内联、**app 和 `animations.jsx` 引擎都过 Babel transpile**、字体用系统字体?(见坑 #17;引擎含 JSX,漏 transpile 必报 `Unexpected token '<'`)

+ 23 - 0
references/video-export.md

@@ -107,6 +107,29 @@ bash /path/to/claude-design/scripts/convert-formats.sh <input.mp4> [gif_width] [
 - 1280 —— 更清晰但文件更大
 - 600 —— Twitter/X 优先加载
 
+### 4. `render-video-seek.js` — 真 60fps / 确定性渲染(推荐高质量交付)
+
+`render-video.js` 的 recordVideo 路径有三个固有限制:帧率被 Chromium compositor 锁死 25fps、开头有加载黑帧需 trim、60fps 只能靠事后 minterpolate 插帧(有 ghosting + macOS QuickTime 兼容 bug,见 `animation-pitfalls.md §14`)。需要**真 60fps、确定性输出、或交付 B站/作品集**时,改用 seek 渲染。
+
+它逐帧 seek 到时间戳截图、再用 ffmpeg 把 PNG 序列编码成 MP4。技术内核借鉴 HeyGen HyperFrames(Apache 2.0)的「冻结时钟 + seek 截图」思路,但不引入任何第三方包——只用本 skill 已有的 playwright + ffmpeg,runtime 中立。
+
+```bash
+NODE_PATH=$(npm root -g) node /path/to/claude-design/scripts/render-video-seek.js <html文件> --fps=60
+```
+
+参数:`--duration` · `--fps`(默认 60)· `--width` · `--height` · `--concurrency`(默认 4 个 worker 并行)· `--settle`(seek 后等几个 rAF 再截图,默认 2,重 layout 动画可调高)· `--keep-chrome`。输出与 HTML 同目录、同名 `.mp4`。
+
+正面解决 recordVideo 三死结:
+- **真原生任意帧率**:`--fps=60` 出真 60fps(每帧都是真实 seek 画面),不再经 `convert-formats.sh` 的 minterpolate 插帧,绕开 ghosting + macOS 兼容 bug
+- **无开头黑帧**:不录屏,根本没有加载期黑帧,不需要 `--trim` / `--fontwait`
+- **确定性**:seek 到时间戳截图,同输入同输出,不受机器负载/丢帧影响
+
+**适用边界(重要)**:只支持走 Stage 时钟的动画——`assets/animations.jsx` 的 `<Stage>` 或 `narration_stage.jsx` 的 `<NarrationStage>`,它们会响应 `window.__seekRender` 冻结自驱时钟并暴露 `window.__seek(t)`。纯 CSS `@keyframes` / Lottie / 手写非 Stage 动画不吃 `__seek`,这类继续用 `render-video.js`(脚本检测不到 `__seek` 会报错并提示)。
+
+**代价**:逐帧截图,长视频总耗时可能比 recordVideo 实时录更久(靠 `--concurrency` 多 worker 缓解);大量临时 PNG 占盘,渲染前建议关其他大内存 App。
+
+**二选一策略**:默认仍用 `render-video.js`(零风险、覆盖所有动画类型);需要真 60fps / 确定性 / 高质量交付、且动画走 Stage 时钟时,用 `render-video-seek.js`。带解说的长动画用 `render-narration.sh --seek` 一键走 seek 渲染 + 混音。
+
 ## 完整流程(标准推荐)
 
 用户说「导出视频」后:

+ 20 - 5
scripts/render-narration.sh

@@ -19,6 +19,8 @@
 #   --bgm-volume=<0-1>    BGM 静态音量,默认 0.18
 #   --no-ducking          关 sidechain ducking
 #   --keep-silent         保留中间产物(无声 MP4),便于 debug
+#   --seek                用 render-video-seek.js 逐帧 seek 渲染(真 60fps·确定性·无黑帧)
+#   --seek-fps=<n>        seek 渲染帧率,默认 60,需配合 --seek
 #   --out=<path>          输出路径,默认 <html-basename>-narrated.mp4
 #   --width=<px>          视频宽度(默认 1920)
 #   --height=<px>         视频高度(默认 1080)
@@ -39,6 +41,8 @@ BGM=""
 BGM_VOLUME="0.18"
 NO_DUCKING=""
 KEEP_SILENT=""
+USE_SEEK=""
+SEEK_FPS="60"
 OUT=""
 WIDTH="1920"
 HEIGHT="1080"
@@ -51,6 +55,8 @@ for arg in "$@"; do
     --bgm-volume=*)  BGM_VOLUME="${arg#*=}" ;;
     --no-ducking)    NO_DUCKING="--no-ducking" ;;
     --keep-silent)   KEEP_SILENT="1" ;;
+    --seek)          USE_SEEK="1" ;;
+    --seek-fps=*)    SEEK_FPS="${arg#*=}" ;;
     --out=*)         OUT="${arg#*=}" ;;
     --width=*)       WIDTH="${arg#*=}" ;;
     --height=*)      HEIGHT="${arg#*=}" ;;
@@ -104,11 +110,20 @@ echo "════════════════════════
 
 # ── Step 1: 录无声 MP4 ──────────────────────
 echo ""
-echo "▸ Step 1/2 · 录制 HTML 动画 (无声)"
-NODE_PATH=$(npm root -g) node "$SCRIPT_DIR/render-video.js" "$HTML_ABS" \
-  --duration="$RECORD_DURATION" \
-  --width="$WIDTH" \
-  --height="$HEIGHT"
+if [ -n "$USE_SEEK" ]; then
+  echo "▸ Step 1/2 · 逐帧 seek 渲染 HTML 动画 (无声 · ${SEEK_FPS}fps 确定性)"
+  NODE_PATH=$(npm root -g) node "$SCRIPT_DIR/render-video-seek.js" "$HTML_ABS" \
+    --duration="$RECORD_DURATION" \
+    --fps="$SEEK_FPS" \
+    --width="$WIDTH" \
+    --height="$HEIGHT"
+else
+  echo "▸ Step 1/2 · 录制 HTML 动画 (无声)"
+  NODE_PATH=$(npm root -g) node "$SCRIPT_DIR/render-video.js" "$HTML_ABS" \
+    --duration="$RECORD_DURATION" \
+    --width="$WIDTH" \
+    --height="$HEIGHT"
+fi
 
 if [ ! -f "$SILENT_MP4" ]; then
   echo "✗ 无声 MP4 没生成: $SILENT_MP4" >&2

+ 238 - 0
scripts/render-video-seek.js

@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+/**
+ * HTML animation → MP4 via deterministic frame-by-frame SEEK (Playwright + ffmpeg).
+ *
+ * 这是 render-video.js(Playwright recordVideo)的逐帧替代渲染器。技术内核借鉴
+ * HeyGen HyperFrames(Apache 2.0)的「冻结时钟 + seek 到时间戳截图」思路,但不引入
+ * 任何第三方包——只用本 skill 已有的 playwright + ffmpeg,runtime 中立。
+ *
+ * 相比 render-video.js 解决的三个死结(见 references/video-export.md §「seek 渲染」):
+ *   1. 帧率不再被 Chromium headless compositor 锁死 25fps —— --fps 原生任意帧率
+ *   2. 不再需要 convert-formats.sh 的 minterpolate 事后插帧(有 ghosting + macOS
+ *      QuickTime 兼容 bug,见 animation-pitfalls §14)—— 每帧都是真实 seek 画面
+ *   3. 不录屏 → 无开头黑帧 → 不需要 --trim / --fontwait / __ready 偏移那套逻辑
+ *   额外:seek 到时间戳截图,同输入同输出 deterministic(recordVideo 是实时录制非确定性)
+ *
+ * 前提:动画必须走 Stage 时钟(assets/animations.jsx 的 <Stage> 或 narration_stage.jsx
+ * 的 <NarrationStage>),它们会响应 window.__seekRender 冻结自驱时钟、并暴露
+ * window.__seek(t)。纯 CSS @keyframes / Lottie / 非 Stage 驱动的动画不吃 __seek,
+ * 这类请继续用 render-video.js。
+ *
+ * Requires: global playwright (`npm install -g playwright`), ffmpeg on PATH.
+ *
+ * Usage:
+ *   NODE_PATH=$(npm root -g) node render-video-seek.js <html-file> \
+ *     [--duration=30] [--fps=60] [--width=1920] [--height=1080] \
+ *     [--concurrency=4] [--settle=2] [--keep-chrome]
+ *
+ * Output: next to the HTML file, same basename with .mp4 suffix.
+ */
+
+const { chromium } = require('playwright');
+const path = require('path');
+const fs = require('fs');
+const { spawnSync } = require('child_process');
+
+function arg(name, def) {
+  const p = process.argv.find(a => a.startsWith('--' + name + '='));
+  return p ? p.slice(name.length + 3) : def;
+}
+function hasFlag(name) {
+  return process.argv.includes('--' + name);
+}
+
+const HTML_FILE = process.argv[2];
+if (!HTML_FILE || HTML_FILE.startsWith('--')) {
+  console.error('Usage: node render-video-seek.js <html-file>');
+  console.error('Example: NODE_PATH=$(npm root -g) node render-video-seek.js my-animation.html --fps=60');
+  process.exit(1);
+}
+
+const DURATION    = parseFloat(arg('duration', '30'));
+const FPS         = parseFloat(arg('fps', '60'));      // 原生任意帧率,默认真 60fps
+const WIDTH       = parseInt(arg('width', '1920'));
+const HEIGHT      = parseInt(arg('height', '1080'));
+const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '4')));  // 并行 worker 数(每个一个 page)
+const SETTLE      = Math.max(1, parseInt(arg('settle', '2')));        // seek 后等几个 rAF 再截图
+const READY_TIMEOUT = parseFloat(arg('readytimeout', '8'));
+const KEEP_CHROME = hasFlag('keep-chrome');
+
+const HTML_ABS = path.resolve(HTML_FILE);
+const BASENAME = path.basename(HTML_FILE, path.extname(HTML_FILE));
+const DIR      = path.dirname(HTML_ABS);
+const TMP_DIR  = path.join(DIR, '.seek-tmp-' + Date.now() + '-' + process.pid);
+const MP4_OUT  = path.join(DIR, BASENAME + '.mp4');
+
+// 与 render-video.js 完全一致的 chrome 隐藏规则(保证两条链路出片外观一致)
+const HIDE_CHROME_CSS = `
+  .no-record,
+  .progress, .progress-bar,
+  .counter, .tCur,
+  .phases, .phase-label, .phase,
+  .replay, button.replay,
+  .masthead, .kicker, .title,
+  .footer,
+  [data-role="chrome"], [data-record="hidden"] {
+    display: none !important;
+  }
+`;
+
+const TOTAL_FRAMES = Math.round(FPS * DURATION);
+
+console.log(`▸ Seek-rendering: ${HTML_FILE}`);
+console.log(`  size: ${WIDTH}x${HEIGHT} · ${FPS}fps · duration: ${DURATION}s · frames: ${TOTAL_FRAMES} · workers: ${CONCURRENCY}`);
+console.log(`  output: ${MP4_OUT}`);
+
+// 在 page 上下文里运行:等 SETTLE 个 rAF(让 React/Babel commit + 布局稳定后再截图)
+async function waitRaf(page, n) {
+  await page.evaluate((count) => new Promise(resolve => {
+    let i = 0;
+    const step = () => { i++; (i >= count) ? resolve() : requestAnimationFrame(step); };
+    requestAnimationFrame(step);
+  }), n);
+}
+
+// 一个 worker:开一个 page,goto,等 __seek 就绪,渲染分配给它的帧
+async function renderFrames(context, url, frames) {
+  const page = await context.newPage();
+  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
+
+  // Stage / NarrationStage 在 __seekRender 模式下会暴露 window.__seek 并冻结自驱时钟
+  await page.waitForFunction(
+    () => window.__ready === true && typeof window.__seek === 'function',
+    { timeout: READY_TIMEOUT * 1000 },
+  );
+
+  for (const f of frames) {
+    const t = f / FPS;
+    await page.evaluate((tt) => window.__seek(tt), t);
+    await waitRaf(page, SETTLE);
+    await page.screenshot({
+      path: path.join(TMP_DIR, 'frame-' + String(f).padStart(6, '0') + '.png'),
+      clip: { x: 0, y: 0, width: WIDTH, height: HEIGHT },
+    });
+  }
+  await page.close();
+}
+
+(async () => {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+
+  const browser = await chromium.launch();
+  const url = 'file://' + HTML_ABS;
+
+  const context = await browser.newContext({
+    viewport: { width: WIDTH, height: HEIGHT },
+    deviceScaleFactor: 1,
+  });
+
+  // 关键信号:__seekRender 让 Stage / NarrationStage 冻结 wall-clock rAF,改由外部 __seek 推帧
+  // __recording 沿用,让 Stage 强制 loop=false(复用既有约定)
+  await context.addInitScript(() => {
+    window.__recording = true;
+    window.__seekRender = true;
+  });
+
+  if (!KEEP_CHROME) {
+    // 与 render-video.js 同款 chrome 隐藏(CSS + 固定栏启发式)
+    await context.addInitScript(css => {
+      const HIDE_MARK = 'data-video-hidden';
+      function injectStyle() {
+        const style = document.createElement('style');
+        style.setAttribute('data-inject', 'render-video-chrome-hide');
+        style.textContent = css;
+        (document.head || document.documentElement).appendChild(style);
+      }
+      function hideChromeBars() {
+        const vh = window.innerHeight;
+        document.querySelectorAll('div, nav, header, footer, section, aside')
+          .forEach(el => {
+            if (el.hasAttribute(HIDE_MARK)) return;
+            if (el.dataset.recordKeep === 'true') return;
+            const s = getComputedStyle(el);
+            if (s.position !== 'fixed' && s.position !== 'sticky') return;
+            const r = el.getBoundingClientRect();
+            if (r.height > vh * 0.25) return;
+            const atBottom = r.bottom >= vh - 30;
+            const atTop = r.top <= 30 && r.height < 80;
+            if (!atBottom && !atTop) return;
+            const txt = el.textContent || '';
+            const hasBtn = !!el.querySelector('button, [role="button"]');
+            const hasCtrls = /[⏸▶⏮⏭↻↺↩↪]|\d+\.\d+\s*s/.test(txt);
+            if (hasBtn || hasCtrls) {
+              el.style.setProperty('display', 'none', 'important');
+              el.setAttribute(HIDE_MARK, '1');
+            }
+          });
+      }
+      const start = () => {
+        injectStyle();
+        hideChromeBars();
+        const obs = new MutationObserver(hideChromeBars);
+        obs.observe(document.body, { childList: true, subtree: true });
+        setTimeout(() => obs.disconnect(), 6000);
+      };
+      if (document.readyState === 'loading') {
+        document.addEventListener('DOMContentLoaded', start, { once: true });
+      } else {
+        start();
+      }
+    }, HIDE_CHROME_CSS);
+  }
+
+  // 把帧 round-robin 分给 CONCURRENCY 个 worker(每个 page 独立 window,seek 互不干扰)
+  const buckets = Array.from({ length: CONCURRENCY }, () => []);
+  for (let f = 0; f < TOTAL_FRAMES; f++) buckets[f % CONCURRENCY].push(f);
+
+  console.log(`▸ Capturing ${TOTAL_FRAMES} frames across ${CONCURRENCY} workers…`);
+  try {
+    await Promise.all(buckets.map(b => b.length ? renderFrames(context, url, b) : Promise.resolve()));
+  } catch (e) {
+    const msg = String(e && e.message || e);
+    if (/__seek|__ready/.test(msg)) {
+      console.error('');
+      console.error('✗ 动画没有暴露 window.__seek(或未就绪)。');
+      console.error('  seek 渲染只支持走 Stage 时钟的动画(assets/animations.jsx 的 <Stage>');
+      console.error('  或 narration_stage.jsx 的 <NarrationStage>)。纯 CSS @keyframes / Lottie /');
+      console.error('  手写非 Stage 动画请改用 render-video.js。');
+      console.error('');
+    }
+    await browser.close();
+    fs.rmSync(TMP_DIR, { recursive: true, force: true });
+    console.error(msg.slice(0, 500));
+    process.exit(1);
+  }
+
+  await browser.close();
+
+  const pngCount = fs.readdirSync(TMP_DIR).filter(f => f.endsWith('.png')).length;
+  if (pngCount === 0) {
+    console.error('✗ 没有截到任何帧');
+    process.exit(1);
+  }
+  console.log(`▸ Captured ${pngCount}/${TOTAL_FRAMES} frames. Encoding H.264…`);
+
+  // PNG 序列 → MP4。无 trim(本来就没黑帧),输入输出帧率都设 FPS。
+  const ffmpeg = spawnSync('ffmpeg', [
+    '-y',
+    '-framerate', String(FPS),
+    '-i', path.join(TMP_DIR, 'frame-%06d.png'),
+    '-c:v', 'libx264',
+    '-pix_fmt', 'yuv420p',
+    '-crf', '18',
+    '-preset', 'medium',
+    '-r', String(FPS),
+    '-movflags', '+faststart',
+    MP4_OUT,
+  ], { stdio: ['ignore', 'ignore', 'pipe'] });
+
+  if (ffmpeg.status !== 0) {
+    console.error('✗ ffmpeg failed:\n' + ffmpeg.stderr.toString().slice(-2000));
+    process.exit(1);
+  }
+
+  fs.rmSync(TMP_DIR, { recursive: true, force: true });
+
+  const mp4Size = (fs.statSync(MP4_OUT).size / 1024 / 1024).toFixed(1);
+  console.log(`✓ Done: ${MP4_OUT} (${mp4Size} MB · ${FPS}fps native)`);
+})();