Files
html-explainer/scripts/render_video.mjs
T
MohZhuang 66b61002f2 chore: prepare html-explainer v1.2.2 for public release
First public release of the html-explainer skill: deterministic HTML-to-MP4 explainer video pipeline for AI coding agents.

Additions since v1.2.1:

- scripts/lint_frames.py: pre-render audit of the eight frame-contract rules
- scripts/peek_frame.mjs: single-scene screenshot preview (seconds, not minutes)
- scripts/check_integrity.py: version/catalog/count consistency + stray-CDN scan
- Bilingual README (README.md Chinese default, README.en.md English)
- LICENSE (MIT), THIRD_PARTY_NOTICES.md, licenses/Apache-2.0.txt
- CHANGELOG.md, CONTRIBUTING.md, .gitignore, .gitattributes
- GitHub CI workflow and issue/PR templates

Fixes and docs:

- document lint_frames.py and peek_frame.mjs, previously absent from the file table
- add the lint stage to the pipeline order
- remove references to private client projects so the skill can ship publicly
- record the full upstream attribution chain for the style catalog
- package_skill.py now excludes repo-hygiene files and keeps licence files
2026-09-21 12:46:23 +08:00

389 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* render_video.mjs —— html-explainer 的确定性 HTML→MP4 渲染器。
*
* 【血统】
* - 画面契约(HTML + GSAP 单文件场景、1920×1080、系统字体)来自 html-video
* 的帧形态;但它用「实时录制 + 裁引导期」,天然带一整类坑(__hvUnfreeze
* 起播、Google Fonts 6s 超时、字体 swap 中途换脸、leadInMs 不稳)。
* - 本渲染器改用 anything2explainer / Remotion 同款的**确定性逐帧渲染**:
* 暂停时间轴 → seek 到 t → 截图。没有任何实时录制环节,上述坑整类消除。
*
* 【每帧渲染管线】
* 1. tl.pause(t, false) GSAP 时间轴同步渲染到 t(inline style 立即生效;false = 放行回调)
* 2. getAnimations() seek CSS @keyframes 动画也确定性对齐(currentTime = t*1000)
* 3. __mgSubUpdate(t) 渲染器注入的字幕层按本场景块表硬切
* 4. __mgProgressUpdate() 渲染器注入的全局进度条按 (globalT/total) 填充
* 5. 双 rAF + screenshot 保证合成器已把本帧样式画出来再截图
*
* 【时长口径(继承并修正两级时钟)】
* - 场景时长 = 该段 MP3 的**容器时长**(tts_build.py 已裁首尾静音并回填),
* 段间 gap 由 timeline_build.py 显式插入 —— 音画天然对齐。
* - 总帧数 = round(total × fps),按场景累计舍入,不逐场景独立舍入。
*
* 用法(项目目录 = 含 project.json 的目录):
* node render_video.mjs <projectDir> [--out out/<slug>.mp4]
* [--preview 30] [--fps 30] [--concurrency 3] [--jpeg]
* [--scale 1] [--keep-frames]
*
* 环境解析顺序:
* 浏览器 env BROWSER_PATH → Chrome → Edge → playwright 自带 chromium
* ffmpeg env FFMPEG_PATH → PATH 上的 ffmpeg → python -c imageio_ffmpeg(venv)
* python env PY → PATH python(用来问 imageio-ffmpeg 要静态 ffmpeg)
*/
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILL_ROOT = path.resolve(__dirname, '..');
const require = createRequire(path.join(SKILL_ROOT, 'node', 'package.json'));
// ---------- 工具 ----------
const log = (msg) => process.stderr.write(`[render] ${msg}\n`);
const die = (msg) => { process.stderr.write(`[render] ✗ ${msg}\n`); process.exit(1); };
function parseArgs(argv) {
const a = { _: [] };
for (let i = 0; i < argv.length; i++) {
const t = argv[i];
if (t === '--out') a.out = argv[++i];
else if (t === '--preview') a.preview = parseFloat(argv[++i]);
else if (t === '--fps') a.fps = parseFloat(argv[++i]);
else if (t === '--concurrency') a.concurrency = parseInt(argv[++i], 10);
else if (t === '--jpeg') a.jpeg = true;
else if (t === '--scale') a.scale = parseFloat(argv[++i]);
else if (t === '--keep-frames') a.keepFrames = true;
else if (t === '--browser') a.browser = argv[++i];
else if (!t.startsWith('--')) a._.push(t);
}
return a;
}
// ---------- ffmpeg 解析(PATH → imageio-ffmpeg 静态二进制) ----------
function resolveFfmpeg() {
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) return process.env.FFMPEG_PATH;
const isWin = process.platform === 'win32';
const name = isWin ? 'ffmpeg.exe' : 'ffmpeg';
for (const d of (process.env.PATH || '').split(path.delimiter)) {
if (!d) continue;
const p = path.join(d, name);
try { if (fs.existsSync(p)) return p; } catch { /* ignore */ }
}
// 回退:问 python 的 imageio_ffmpeg 要(WorkBuddy 托管 venv 里有静态 ffmpeg)
const pyCandidates = [process.env.PY, 'python3', 'python'].filter(Boolean);
for (const py of pyCandidates) {
try {
const out = execFileSync(py, ['-c', 'import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (out && fs.existsSync(out)) return out;
} catch { /* next */ }
}
return null;
}
// ---------- 浏览器解析(Chrome → Edge → playwright chromium) ----------
function resolveBrowserExec() {
if (process.env.BROWSER_PATH && fs.existsSync(process.env.BROWSER_PATH)) return process.env.BROWSER_PATH;
const candidates = [];
if (process.platform === 'win32') {
for (const p of [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe'),
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
]) if (p) candidates.push(p);
} else if (process.platform === 'darwin') {
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/Applications/Chromium.app/Contents/MacOS/Chromium');
} else {
candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
'/usr/bin/microsoft-edge');
}
for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch { /* ignore */ } }
return null; // 走 playwright 自带 chromium(需 npx playwright install chromium)
}
// ---------- 注入页面的字幕层 / 进度条(渲染器全权管理,帧作者零负担) ----------
const OVERLAY_CSS = `
#mg-subs { position: fixed; inset: 0; pointer-events: none; z-index: 900; }
#mg-subs .mg-sub {
position: absolute; left: 50%; bottom: 96px; transform: translateX(-50%);
white-space: nowrap; text-align: center;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', 'Source Han Sans SC', sans-serif;
font-weight: 700; font-size: 44px; line-height: 1.2; color: #FFFFFF;
text-shadow:
4px 0 0 #000, -4px 0 0 #000, 0 4px 0 #000, 0 -4px 0 #000,
2.4px 2.4px 0 #000, -2.4px 2.4px 0 #000, 2.4px -2.4px 0 #000, -2.4px -2.4px 0 #000,
1.2px 1.2px 0 #000, -1.2px 1.2px 0 #000, 1.2px -1.2px 0 #000, -1.2px -1.2px 0 #000,
3.2px 1.06px 0 #000, -3.2px 1.06px 0 #000, 3.2px -1.06px 0 #000, -3.2px -1.06px 0 #000,
1.06px 3.2px 0 #000, -1.06px 3.2px 0 #000, 1.06px -3.2px 0 #000, -1.06px -3.2px 0 #000,
2.83px 2.83px 0 #000, -2.83px 2.83px 0 #000, 2.83px -2.83px 0 #000, -2.83px -2.83px 0 #000;
}
#mg-progress { position: fixed; left: 0; right: 0; bottom: 0; height: 12px;
z-index: 901; pointer-events: none; background: rgba(255,255,255,.10); }
#mg-progress .mg-bar { height: 100%; width: 0%;
background: var(--accent, #E4AE29); box-shadow: 0 0 12px var(--accent-glow, rgba(228,174,41,.55)); }
#mg-progress .mg-tick { position: absolute; top: 0; width: 2px; height: 100%;
background: rgba(255,255,255,.38); }
`;
function overlaySetupJs(blocks, fps, withProgress, chapterTicks) {
const blocksJs = JSON.stringify(blocks || []);
const ticksJs = JSON.stringify(chapterTicks || []);
return `(function () {
var css = document.createElement('style'); css.textContent = ${JSON.stringify(OVERLAY_CSS)};
document.head.appendChild(css);
var subEl = document.createElement('div'); subEl.id = 'mg-subs';
var subNode = document.createElement('div'); subNode.className = 'mg-sub';
subNode.style.display = 'none'; subEl.appendChild(subNode);
document.body.appendChild(subEl);
var proEl = null, bar = null;
${withProgress ? `
proEl = document.createElement('div'); proEl.id = 'mg-progress';
bar = document.createElement('div'); bar.className = 'mg-bar'; proEl.appendChild(bar);
${ticksJs}.forEach(function (p) {
var t = document.createElement('div'); t.className = 'mg-tick'; t.style.left = (p * 100) + '%';
proEl.appendChild(t);
});
document.body.appendChild(proEl);` : ''}
var BLOCKS = ${blocksJs}, FPS = ${fps}, cur = -1;
window.__mgSubUpdate = function (t) {
var f = t * FPS, idx = -1;
for (var i = 0; i < BLOCKS.length; i++) {
if (f >= BLOCKS[i].from - 1 && f <= BLOCKS[i].to) { idx = i; break; }
}
if (idx !== cur) {
if (cur >= 0) subNode.style.display = 'none';
if (idx >= 0) {
subNode.textContent = BLOCKS[idx].text;
subNode.style.fontSize = (BLOCKS[idx].size || 44) + 'px';
subNode.style.display = 'block';
}
cur = idx;
}
};
window.__mgProgressUpdate = function (p) {
if (bar) bar.style.width = (Math.max(0, Math.min(1, p)) * 100).toFixed(3) + '%';
};
})();`;
}
// 每帧 seek:GSAP + CSS @keyframes + 字幕 + 进度条 + 双 rAF。
// ★ 必须是**真正的函数**,不能写成字符串形式的箭头函数定义 ——
// playwright 对字符串只做表达式求值:`async (o) => {...}` 会被当成函数字面量
// **返回而不执行**(返回不可序列化 → undefined),导致每帧都是静止首帧。
// 这个坑极隐蔽:不报错、截图仍在写盘,成片却是"只有背景色 + CSS 动画"。
const frameSeek = async (o) => {
const { t, globalT, total } = o;
let tl = window.__tl || null;
if (!tl && window.__timelines) {
for (const k in window.__timelines) { if (window.__timelines[k]) { tl = window.__timelines[k]; break; } }
}
if (!tl) throw new Error('no GSAP timeline found (window.__tl)');
// ★ pause(atTime, suppressEvents):第二参 GSAP 默认 true,会**掐掉本次 seek 触发的回调**
// (onUpdate / onStart / onComplete)。此时用 onUpdate 写 textContent 做的数字滚动
// 在渲染器下永远停在初值——不报错、截图照写、成片数字不动。必须显式传 false。
// 实测见 lessons.md #27。
tl.pause(t, false); // GSAP:seek 即同步渲染 + 放行回调
if (document.getAnimations) { // CSS @keyframes:也确定性对齐
document.getAnimations().forEach((a) => {
try { a.pause(); a.currentTime = Math.round(t * 1000); } catch (e) { /* ignore */ }
});
}
if (window.__mgSubUpdate) window.__mgSubUpdate(t);
if (window.__mgProgressUpdate) window.__mgProgressUpdate(total ? (globalT / total) : 0);
await new Promise((r) => { requestAnimationFrame(() => { requestAnimationFrame(r); }); });
return tl.time();
};
// ---------- 主流程 ----------
async function main() {
const args = parseArgs(process.argv.slice(2));
const projectDir = path.resolve(args._[0] || '.');
const pjPath = path.join(projectDir, 'project.json');
if (!fs.existsSync(pjPath)) die(`没有 project.json:${pjPath}`);
const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8'));
const fps = args.fps || pj.fps || 30;
const W = pj.width || 1920, H = pj.height || 1080;
const order = pj.order || [];
if (!order.length) die('project.json 里没有 order(场景顺序)');
const layoutPath = path.join(projectDir, 'layout.json');
if (!fs.existsSync(layoutPath)) die(`没有 layout.json(先跑 timeline_build.py):${layoutPath}`);
const layout = JSON.parse(fs.readFileSync(layoutPath, 'utf8'));
let subs = { fps, segments: [] };
const subsPath = path.join(projectDir, 'subs.json');
if (fs.existsSync(subsPath)) subs = JSON.parse(fs.readFileSync(subsPath, 'utf8'));
// 缺帧 HTML 的场景直接报错(别静默跳过)
for (const id of order) {
if (!fs.existsSync(path.join(projectDir, 'frames', `${id}.html`)))
die(`缺场景帧:frames/${id}.html`);
}
// 渲染范围:正常全片;--preview N 只渲前 N 秒
const totalSec = order.reduce((s, id) => s + (layout[id]?.duration_sec || 0), 0)
+ (pj.gap || 0) * (order.length - 1);
const preview = args.preview && args.preview > 0 ? Math.min(args.preview, totalSec) : null;
const renderSec = preview != null ? preview : totalSec;
const totalFrames = Math.max(1, Math.round(renderSec * fps));
// 各场景的全局起止帧(累计舍入,保证全局对齐)
const scenes = [];
let cursorSec = 0;
for (const id of order) {
const dur = layout[id]?.duration_sec || 0;
const gf = Math.round(cursorSec * fps);
const gfEnd = Math.min(totalFrames, Math.round((cursorSec + dur + (pj.gap || 0)) * fps));
const n = Math.max(0, gfEnd - gf);
if (n > 0) scenes.push({ id, gf, n, globalStart: cursorSec, dur });
cursorSec += dur + (pj.gap || 0);
}
const framesDir = path.join(projectDir, 'render', 'frames');
fs.mkdirSync(framesDir, { recursive: true });
const outDir = path.join(projectDir, 'out');
fs.mkdirSync(outDir, { recursive: true });
const slug = pj.slug || 'video';
const outPath = args.out || path.join(outDir, preview != null ? 'preview.mp4' : `${slug}.mp4`);
const ffmpeg = resolveFfmpeg();
if (!ffmpeg) die('找不到 ffmpeg:装到 PATH,或设 FFMPEG_PATH,或 python 装好 imageio-ffmpeg');
// ---------- playwright ----------
let chromium;
try { ({ chromium } = require('playwright-core')); }
catch { die(`playwright-core 缺失:在技能目录 node/ 下 npm install playwright-core(见 setup_env.sh)`); }
const browserExec = args.browser || resolveBrowserExec();
const launchOpts = { viewport: { width: W, height: H }, deviceScaleFactor: args.scale || 1 };
if (browserExec) { launchOpts.executablePath = browserExec; log(`浏览器:${browserExec}`); }
else log('浏览器:用 playwright 自带 chromium(首次需 npx playwright install chromium)');
log(`项目:${projectDir}`);
log(`场景 ${scenes.length} 个 · 总时长 ${renderSec.toFixed(2)}s · ${totalFrames} 帧 @${fps}fps`);
log(`画布 ${W}x${H} · ${args.jpeg ? 'JPEG 草稿模式' : 'PNG 精细模式'} · 并发 ${args.concurrency || 3}`);
const browser = await chromium.launch(launchOpts);
// 章节刻度(可选:project.json.chapters = [{title, startSegment}])
const chapterTicks = (pj.chapters || [])
.map(c => { const i = order.indexOf(c.startSegment); return i > 0 ? (Math.round((layout[order[0]] ? 0 : 0) + order.slice(0, i).reduce((s, x) => s + (layout[x]?.duration_sec || 0) + (pj.gap || 0), 0)) / renderSec) : null; })
.filter(x => x != null);
// ---------- 逐场景渲染(可并发多个 page) ----------
const errors = [];
let doneFrames = 0;
const t0 = Date.now();
async function renderScene(sc) {
// ★ viewport 必须逐 page 指定(launch 的 viewport 不继承到 newPage)
const page = await browser.newPage({ viewport: { width: W, height: H } });
const htmlPath = path.join(projectDir, 'frames', `${sc.id}.html`);
try {
// __MG_RENDER__ 必须在任何页面脚本之前置位(帧据此跳过自动起播)
await page.addInitScript(`window.__MG_RENDER__ = true;`);
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'domcontentloaded' });
// 字体等待:系统字体即时就绪;@font-face 有 5s 硬顶(防 CDN 卡死整片)
await page.evaluate(`new Promise(function (resolve) {
var cap = setTimeout(resolve, 5000);
try {
var links = Array.prototype.slice.call(document.querySelectorAll('link[rel="stylesheet"]'));
Promise.all(links.map(function (l) {
return new Promise(function (r) {
l.addEventListener('load', r, { once: true });
l.addEventListener('error', r, { once: true });
try { if (l.sheet) r(); } catch (e) {}
});
})).then(function () {
var step = function () { requestAnimationFrame(function () { requestAnimationFrame(resolve); }); };
if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
document.fonts.ready.then(function () { clearTimeout(cap); step(); });
} else { clearTimeout(cap); step(); }
});
} catch (e) { clearTimeout(cap); resolve(); }
})`);
// 找时间轴(找不到 → 契约违规,报错而不是渲静止首帧)
const hasTl = await page.evaluate(`Boolean(window.__tl || (window.__timelines && Object.keys(window.__timelines).length))`);
if (!hasTl) throw new Error('帧里没有 GSAP 时间轴(window.__tl)—— 见 frame-contract.md');
// 注入字幕层 + 进度条
const seg = (subs.segments || []).find(s => s.id === sc.id);
const blocks = (seg?.blocks || []).map(b => ({ from: b.from, to: b.to, text: b.text, size: b.size }));
await page.evaluate(overlaySetupJs(blocks, fps, pj.progress !== false, chapterTicks));
// 逐帧 seek + 截图
for (let i = 0; i < sc.n; i++) {
const t = i / fps;
const globalT = sc.globalStart + t;
await page.evaluate(frameSeek, { t, globalT, total: renderSec });
const framePath = path.join(framesDir, `f_${String(sc.gf + i + 1).padStart(6, '0')}.${args.jpeg ? 'jpg' : 'png'}`);
await page.screenshot({ path: framePath, type: args.jpeg ? 'jpeg' : 'png', quality: args.jpeg ? 82 : undefined });
doneFrames++;
if (doneFrames % 300 === 0)
log(`进度 ${doneFrames}/${totalFrames} 帧 · ${(doneFrames / ((Date.now() - t0) / 1000)).toFixed(1)} 帧/秒`);
}
log(`✓ 场景 ${sc.id}:${sc.n} 帧`);
} catch (e) {
errors.push(`场景 ${sc.id}: ${e.message}`);
} finally {
await page.close();
}
}
// 场景级并发(默认 3;帧级不需要——同 page 顺序 seek)
const conc = Math.max(1, Math.min(args.concurrency || 3, scenes.length));
const queue = [...scenes];
const workers = Array.from({ length: conc }, () => (async () => {
while (queue.length && !errors.length) { const sc = queue.shift(); await renderScene(sc); }
})());
await Promise.all(workers);
await browser.close();
if (errors.length) die(`渲染失败:\n ${errors.join('\n ')}`);
log(`截图完成:${doneFrames} 帧,耗时 ${((Date.now() - t0) / 1000).toFixed(1)}s`);
// ---------- ffmpeg 合成 ----------
const audioPath = path.join(projectDir, 'audio', 'narration-full.mp3');
const hasAudio = fs.existsSync(audioPath);
const ext = args.jpeg ? 'jpg' : 'png';
const cmd = [
'-hide_banner', '-loglevel', 'error', '-y',
'-framerate', String(fps), '-i', path.join(framesDir, `f_%06d.${ext}`),
];
if (hasAudio) cmd.push('-i', audioPath);
cmd.push(
'-c:v', 'libx264', '-preset', args.jpeg ? 'veryfast' : 'medium',
'-crf', args.jpeg ? '23' : '18', '-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
);
if (hasAudio) cmd.push('-c:a', 'aac', '-b:a', '160k', '-shortest');
cmd.push(outPath);
log(hasAudio ? `合成:${totalFrames} 帧 + ${audioPath}` : '合成:无音轨(配音还没生成)');
execFileSync(ffmpeg, cmd, { stdio: 'inherit' });
log(`✓ 成片:${outPath}`);
log(` 帧目录:${framesDir}(--keep-frames 未指定且为完整渲时,保留供 QC)`);
if (!args.keepFrames && preview != null) {
// 预览模式顺手清掉草稿帧,避免占盘;完整渲保留(QC/封面要用)
for (const f of fs.readdirSync(framesDir)) fs.unlinkSync(path.join(framesDir, f));
}
}
main().catch(e => die(e.stack || String(e)));