1
0

render-video-seek.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. #!/usr/bin/env node
  2. /**
  3. * HTML animation → MP4 via deterministic frame-by-frame SEEK (Playwright + ffmpeg).
  4. *
  5. * 这是 render-video.js(Playwright recordVideo)的逐帧替代渲染器。技术内核借鉴
  6. * HeyGen HyperFrames(Apache 2.0)的「冻结时钟 + seek 到时间戳截图」思路,但不引入
  7. * 任何第三方包——只用本 skill 已有的 playwright + ffmpeg,runtime 中立。
  8. *
  9. * 相比 render-video.js 解决的三个死结(见 references/video-export.md §「seek 渲染」):
  10. * 1. 帧率不再被 Chromium headless compositor 锁死 25fps —— --fps 原生任意帧率
  11. * 2. 不再需要 convert-formats.sh 的 minterpolate 事后插帧(有 ghosting + macOS
  12. * QuickTime 兼容 bug,见 animation-pitfalls §14)—— 每帧都是真实 seek 画面
  13. * 3. 不录屏 → 无开头黑帧 → 不需要 --trim / --fontwait / __ready 偏移那套逻辑
  14. * 额外:seek 到时间戳截图,同输入同输出 deterministic(recordVideo 是实时录制非确定性)
  15. *
  16. * 前提:动画必须走 Stage 时钟(assets/animations.jsx 的 <Stage> 或 narration_stage.jsx
  17. * 的 <NarrationStage>),它们会响应 window.__seekRender 冻结自驱时钟、并暴露
  18. * window.__seek(t)。纯 CSS @keyframes / Lottie / 非 Stage 驱动的动画不吃 __seek,
  19. * 这类请继续用 render-video.js。
  20. *
  21. * Requires: global playwright (`npm install -g playwright`), ffmpeg on PATH.
  22. *
  23. * Usage:
  24. * NODE_PATH=$(npm root -g) node render-video-seek.js <html-file> \
  25. * [--duration=30] [--fps=60] [--width=1920] [--height=1080] \
  26. * [--concurrency=4] [--settle=2] [--keep-chrome]
  27. *
  28. * Output: next to the HTML file, same basename with .mp4 suffix.
  29. */
  30. const { chromium } = require('playwright');
  31. const path = require('path');
  32. const fs = require('fs');
  33. const { spawnSync } = require('child_process');
  34. function arg(name, def) {
  35. const p = process.argv.find(a => a.startsWith('--' + name + '='));
  36. return p ? p.slice(name.length + 3) : def;
  37. }
  38. function hasFlag(name) {
  39. return process.argv.includes('--' + name);
  40. }
  41. const HTML_FILE = process.argv[2];
  42. if (!HTML_FILE || HTML_FILE.startsWith('--')) {
  43. console.error('Usage: node render-video-seek.js <html-file>');
  44. console.error('Example: NODE_PATH=$(npm root -g) node render-video-seek.js my-animation.html --fps=60');
  45. process.exit(1);
  46. }
  47. const DURATION = parseFloat(arg('duration', '30'));
  48. const FPS = parseFloat(arg('fps', '60')); // 原生任意帧率,默认真 60fps
  49. const WIDTH = parseInt(arg('width', '1920'));
  50. const HEIGHT = parseInt(arg('height', '1080'));
  51. const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '4'))); // 并行 worker 数(每个一个 page)
  52. const SETTLE = Math.max(1, parseInt(arg('settle', '2'))); // seek 后等几个 rAF 再截图
  53. const READY_TIMEOUT = parseFloat(arg('readytimeout', '8'));
  54. const KEEP_CHROME = hasFlag('keep-chrome');
  55. const HTML_ABS = path.resolve(HTML_FILE);
  56. const BASENAME = path.basename(HTML_FILE, path.extname(HTML_FILE));
  57. const DIR = path.dirname(HTML_ABS);
  58. const TMP_DIR = path.join(DIR, '.seek-tmp-' + Date.now() + '-' + process.pid);
  59. const MP4_OUT = path.join(DIR, BASENAME + '.mp4');
  60. // 与 render-video.js 完全一致的 chrome 隐藏规则(保证两条链路出片外观一致)
  61. const HIDE_CHROME_CSS = `
  62. .no-record,
  63. .progress, .progress-bar,
  64. .counter, .tCur,
  65. .phases, .phase-label, .phase,
  66. .replay, button.replay,
  67. .masthead, .kicker, .title,
  68. .footer,
  69. [data-role="chrome"], [data-record="hidden"] {
  70. display: none !important;
  71. }
  72. `;
  73. const TOTAL_FRAMES = Math.round(FPS * DURATION);
  74. console.log(`▸ Seek-rendering: ${HTML_FILE}`);
  75. console.log(` size: ${WIDTH}x${HEIGHT} · ${FPS}fps · duration: ${DURATION}s · frames: ${TOTAL_FRAMES} · workers: ${CONCURRENCY}`);
  76. console.log(` output: ${MP4_OUT}`);
  77. // 在 page 上下文里运行:等 SETTLE 个 rAF(让 React/Babel commit + 布局稳定后再截图)
  78. async function waitRaf(page, n) {
  79. await page.evaluate((count) => new Promise(resolve => {
  80. let i = 0;
  81. const step = () => { i++; (i >= count) ? resolve() : requestAnimationFrame(step); };
  82. requestAnimationFrame(step);
  83. }), n);
  84. }
  85. // 一个 worker:开一个 page,goto,等 __seek 就绪,渲染分配给它的帧
  86. async function renderFrames(context, url, frames) {
  87. const page = await context.newPage();
  88. await page.goto(url, { waitUntil: 'load', timeout: 60000 });
  89. // Stage / NarrationStage 在 __seekRender 模式下会暴露 window.__seek 并冻结自驱时钟
  90. await page.waitForFunction(
  91. () => window.__ready === true && typeof window.__seek === 'function',
  92. { timeout: READY_TIMEOUT * 1000 },
  93. );
  94. for (const f of frames) {
  95. const t = f / FPS;
  96. await page.evaluate((tt) => window.__seek(tt), t);
  97. await waitRaf(page, SETTLE);
  98. await page.screenshot({
  99. path: path.join(TMP_DIR, 'frame-' + String(f).padStart(6, '0') + '.png'),
  100. clip: { x: 0, y: 0, width: WIDTH, height: HEIGHT },
  101. });
  102. }
  103. await page.close();
  104. }
  105. (async () => {
  106. fs.mkdirSync(TMP_DIR, { recursive: true });
  107. const browser = await chromium.launch();
  108. const url = 'file://' + HTML_ABS;
  109. const context = await browser.newContext({
  110. viewport: { width: WIDTH, height: HEIGHT },
  111. deviceScaleFactor: 1,
  112. });
  113. // 关键信号:__seekRender 让 Stage / NarrationStage 冻结 wall-clock rAF,改由外部 __seek 推帧
  114. // __recording 沿用,让 Stage 强制 loop=false(复用既有约定)
  115. await context.addInitScript(() => {
  116. window.__recording = true;
  117. window.__seekRender = true;
  118. });
  119. if (!KEEP_CHROME) {
  120. // 与 render-video.js 同款 chrome 隐藏(CSS + 固定栏启发式)
  121. await context.addInitScript(css => {
  122. const HIDE_MARK = 'data-video-hidden';
  123. function injectStyle() {
  124. const style = document.createElement('style');
  125. style.setAttribute('data-inject', 'render-video-chrome-hide');
  126. style.textContent = css;
  127. (document.head || document.documentElement).appendChild(style);
  128. }
  129. function hideChromeBars() {
  130. const vh = window.innerHeight;
  131. document.querySelectorAll('div, nav, header, footer, section, aside')
  132. .forEach(el => {
  133. if (el.hasAttribute(HIDE_MARK)) return;
  134. if (el.dataset.recordKeep === 'true') return;
  135. const s = getComputedStyle(el);
  136. if (s.position !== 'fixed' && s.position !== 'sticky') return;
  137. const r = el.getBoundingClientRect();
  138. if (r.height > vh * 0.25) return;
  139. const atBottom = r.bottom >= vh - 30;
  140. const atTop = r.top <= 30 && r.height < 80;
  141. if (!atBottom && !atTop) return;
  142. const txt = el.textContent || '';
  143. const hasBtn = !!el.querySelector('button, [role="button"]');
  144. const hasCtrls = /[⏸▶⏮⏭↻↺↩↪]|\d+\.\d+\s*s/.test(txt);
  145. if (hasBtn || hasCtrls) {
  146. el.style.setProperty('display', 'none', 'important');
  147. el.setAttribute(HIDE_MARK, '1');
  148. }
  149. });
  150. }
  151. const start = () => {
  152. injectStyle();
  153. hideChromeBars();
  154. const obs = new MutationObserver(hideChromeBars);
  155. obs.observe(document.body, { childList: true, subtree: true });
  156. setTimeout(() => obs.disconnect(), 6000);
  157. };
  158. if (document.readyState === 'loading') {
  159. document.addEventListener('DOMContentLoaded', start, { once: true });
  160. } else {
  161. start();
  162. }
  163. }, HIDE_CHROME_CSS);
  164. }
  165. // 把帧 round-robin 分给 CONCURRENCY 个 worker(每个 page 独立 window,seek 互不干扰)
  166. const buckets = Array.from({ length: CONCURRENCY }, () => []);
  167. for (let f = 0; f < TOTAL_FRAMES; f++) buckets[f % CONCURRENCY].push(f);
  168. console.log(`▸ Capturing ${TOTAL_FRAMES} frames across ${CONCURRENCY} workers…`);
  169. try {
  170. await Promise.all(buckets.map(b => b.length ? renderFrames(context, url, b) : Promise.resolve()));
  171. } catch (e) {
  172. const msg = String(e && e.message || e);
  173. if (/__seek|__ready/.test(msg)) {
  174. console.error('');
  175. console.error('✗ 动画没有暴露 window.__seek(或未就绪)。');
  176. console.error(' seek 渲染只支持走 Stage 时钟的动画(assets/animations.jsx 的 <Stage>');
  177. console.error(' 或 narration_stage.jsx 的 <NarrationStage>)。纯 CSS @keyframes / Lottie /');
  178. console.error(' 手写非 Stage 动画请改用 render-video.js。');
  179. console.error('');
  180. }
  181. await browser.close();
  182. fs.rmSync(TMP_DIR, { recursive: true, force: true });
  183. console.error(msg.slice(0, 500));
  184. process.exit(1);
  185. }
  186. await browser.close();
  187. const pngCount = fs.readdirSync(TMP_DIR).filter(f => f.endsWith('.png')).length;
  188. if (pngCount === 0) {
  189. console.error('✗ 没有截到任何帧');
  190. process.exit(1);
  191. }
  192. console.log(`▸ Captured ${pngCount}/${TOTAL_FRAMES} frames. Encoding H.264…`);
  193. // PNG 序列 → MP4。无 trim(本来就没黑帧),输入输出帧率都设 FPS。
  194. const ffmpeg = spawnSync('ffmpeg', [
  195. '-y',
  196. '-framerate', String(FPS),
  197. '-i', path.join(TMP_DIR, 'frame-%06d.png'),
  198. '-c:v', 'libx264',
  199. '-pix_fmt', 'yuv420p',
  200. '-crf', '18',
  201. '-preset', 'medium',
  202. '-r', String(FPS),
  203. '-movflags', '+faststart',
  204. MP4_OUT,
  205. ], { stdio: ['ignore', 'ignore', 'pipe'] });
  206. if (ffmpeg.status !== 0) {
  207. console.error('✗ ffmpeg failed:\n' + ffmpeg.stderr.toString().slice(-2000));
  208. process.exit(1);
  209. }
  210. fs.rmSync(TMP_DIR, { recursive: true, force: true });
  211. const mp4Size = (fs.statSync(MP4_OUT).size / 1024 / 1024).toFixed(1);
  212. console.log(`✓ Done: ${MP4_OUT} (${mp4Size} MB · ${FPS}fps native)`);
  213. })();