Files
html-explainer/scripts/render_video.mjs
T
MohZhuang d158afcd36 release: v1.3.1 —— 火山音色名解析修复 + 渲染器照片帧提速 5–13 倍
修复
- tts_build.py 走火山引擎时,project.json 里的中文音色名没被解析成音色 ID,
  合成恒失败并返回 code=55000000(报错完全看不出是「名字没解析」)。
  双层兜底:tts_volcano.synthesize() 入口解析 + tts_build.py 读配置后解析。

新增
- render_video.mjs:`--png-fast`(CDP optimizeForSpeed,逐像素无损,4.4×)、
  `--jpeg-quality N`、`--crf N`、`--preset <名>`。最后一对把「帧容器」与
  「成片码率」解耦(旧 --jpeg 会连带降 crf23/veryfast,那是草稿语义)。
  默认行为完全不变。
- qc_check.py:新增实测视频流帧数校验。原先只比容器时长,而容器时长会被音轨撑起
  来,帧序列缺口会导致视频流被截断却仍显示完整长度(假通过,见 lessons #57)。

文档
- lessons.md 新增 #62–#69:信息层色值契约与绘制层排查、语速标定绑定引擎与音色、
  以及「PNG 编码才是瓶颈、且对并发完全免疫」的完整实测与判定路径。
- SKILL.md / references/workflow-guide.md:补截图模式选用判据(帧里有满幅照片就换模式)。
- CHANGELOG 1.3.1 段补齐 + 版本徽章同步。
2026-09-23 16:41:48 +08:00

468 lines
24 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] [--mux-only] [--only <场景id,场景id>]
*
* --mux-only:跳过截图,用 render/frames 里已有的帧直接重新合成
* (改合参 / 换音轨后不用重渲 20 分钟)
*
* --only coda:改完某一场的文案/动效后,只重渲这一场的帧,其余帧保持不动。
* 仅当被改场景是**最后一场**时安全;若它在中间且时长变了,
* 其后场景的帧号会整体前移,必须整片重渲。
* 变短后记得删掉尾部的过期帧,否则成片会拖长(脚本会提示实际帧数)。
*
* 环境解析顺序:
* 浏览器 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 === '--jpeg-quality') a.jpegQuality = parseInt(argv[++i], 10);
else if (t === '--png-fast') a.pngFast = true;
else if (t === '--crf') a.crf = parseInt(argv[++i], 10);
else if (t === '--preset') a.preset = argv[++i];
else if (t === '--scale') a.scale = parseFloat(argv[++i]);
else if (t === '--keep-frames') a.keepFrames = true;
else if (t === '--mux-only') a.muxOnly = true;
else if (t === '--only') a.only = String(argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
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();
};
// ---------- 截图:三种模式(这一层是本渲染器最容易踩的性能坑) ----------
// PNG(playwright 默认)对**照片满幅帧**极慢:实测 1920×1080 照片帧 582ms/张,
// 而纯色帧只要 45ms —— 差 13 倍。因为 PNG 是无损 deflate,高熵照片内容既压不小
// 也压不快,且这段编码在**浏览器进程内串行**:并发 1/3/6 路实测总吞吐
// 1.80 / 1.86 / 1.87 帧/秒 —— 挂并发等于白挂,`--concurrency` 对帧数毫无帮助。
// ★ --png-fast:走 CDP 的 optimizeForSpeed(zlib 最快档、不做自适应滤波),
// 实测 132ms/张,仍是**逐像素无损**(对 playwright PNG:PSNR 99dB、最大差 0),
// 代价是体积 +22%。照片多的片子选它。
// ★ --jpeg:q82 41ms/张(13×);q95 52ms/张,PSNR 41.7dB(已低于 x264 crf18
// 自身的失真水平,成片看不出),体积仅 1/5。要极致速度就它。
async function captureFrame({ page, cdp, outPath, args, W, H }) {
if (cdp) {
const opts = { format: 'png', optimizeForSpeed: true, captureBeyondViewport: false };
const sc = args.scale || 1;
if (sc !== 1) opts.clip = { x: 0, y: 0, width: W, height: H, scale: sc };
const r = await cdp.send('Page.captureScreenshot', opts);
fs.writeFileSync(outPath, Buffer.from(r.data, 'base64'));
return;
}
await page.screenshot({
path: outPath,
type: args.jpeg ? 'jpeg' : 'png',
quality: args.jpeg ? (args.jpegQuality || 82) : undefined,
});
}
// ---------- 主流程 ----------
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`);
const shotMode = args.pngFast ? 'PNG 速度优先(CDP,无损)'
: args.jpeg ? `JPEG q${args.jpegQuality || 82}` : 'PNG 精细模式';
log(`画布 ${W}x${H} · ${shotMode} · 并发 ${args.concurrency || 3}`);
let browser = null;
if (!args.muxOnly) {
browser = await chromium.launch(launchOpts);
} else {
// 只重新合成:不启动浏览器,直接用 render/frames 里已有的帧
const have = fs.existsSync(framesDir) ? fs.readdirSync(framesDir).filter(f => /^f_\d+\.(png|jpg)$/.test(f)).length : 0;
log(`--mux-only:跳过截图,用现有 ${have}/${totalFrames} 帧重新合成`);
if (have === 0) die('render/frames 里没有帧,无法只合成');
if (have < totalFrames) log(`⚠ 帧数不足(${have}/${totalFrames}),成片会短于 layout`);
}
// 章节刻度(可选: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 } });
// --png-fast:走 CDP 截图(见 captureFrame 注释)
const cdp = args.pngFast ? await page.context().newCDPSession(page) : null;
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 captureFrame({ page, cdp, outPath: framePath, args, W, H });
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)
if (!args.muxOnly) {
// --only a,b:只重渲指定场景的帧(其余场景的帧保持不动)。
// ★ 只允许改「帧号全在片尾、不影响其他场景偏移」的场景(通常是最后一场);
// 若被改场景变短,其后所有场景的帧号都会前移,必须整片重渲。
const only = args.only && args.only.length ? args.only : null;
if (only) {
const unknown = only.filter((id) => !scenes.some((s) => s.id === id));
if (unknown.length) die(`--only 里这些场景不存在:${unknown.join(', ')}`);
const firstIdx = Math.min(...only.map((id) => order.indexOf(id)));
if (firstIdx < order.length - 1)
log(`⚠ --only 包含非末场场景(${order[firstIdx]})——若其时长变化,其后场景帧号会整体前移,请整片重渲`);
log(`--only:只渲 ${only.join(', ')},跳过其余 ${scenes.length - new Set(only).size} 个场景`);
}
const queue = only ? scenes.filter((s) => only.includes(s.id)) : [...scenes];
const conc = Math.max(1, Math.min(args.concurrency || 3, queue.length));
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);
// 中间帧格式与最终编码质量**解耦**:--jpeg 只换帧容器,不代表要降码率。
// (旧行为里 --jpeg 连带降成 crf23/veryfast,是"草稿"语义;成片用 --crf 18 覆盖。)
const crf = args.crf != null ? args.crf : (args.jpeg ? 23 : 18);
const preset = args.preset || (args.jpeg ? 'veryfast' : 'medium');
cmd.push(
'-c:v', 'libx264', '-preset', preset,
'-crf', String(crf), '-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
);
log(`编码:libx264 · preset ${preset} · crf ${crf}`);
if (hasAudio) {
cmd.push('-c:a', 'aac', '-b:a', '160k');
// 音轨短于视频时补静音,而不是用 -shortest 砍掉视频尾部。
// layout 的 video_duration_sec 含片尾留白(末块字幕的收尾余韵),
// -shortest 会按音轨长度截视频,把这段留白连同最后一块字幕一起切掉
// (实测:片尾字幕从设计的 1.5s 缩到 1.16s)。改用 apad 补静音 + -t 锁视频长度。
cmd.push('-af', 'apad', '-t', renderSec.toFixed(3));
}
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)));