Files
html-explainer/scripts/render_video.mjs
T
MohZhuang 3aec9f6701 feat(v1.2.3): 局部重渲开关、修片尾被截、QC 抽帧双判据
渲染器
- 新增 --only <场景id>:改一句文案只重渲这一场(14 场 6194 帧全渲 20 分钟 → 末场 78 秒)
- 新增 --mux-only:跳过截图,用现有帧重新合成
- 合成弃用 -shortest,改用 -af apad -t <renderSec>:音轨不足处补静音,
  成片长度由 layout 唯一决定(此前片尾留白连同末块字幕被按音轨长度切掉)

体检
- qc_check.py 抽帧判据改「体积 + 内容」双条件:体积低于中位数 35% 时再看
  色数与标准差,避免极简画面(片头淡入起点、纯文字面)稳定误报
- 时长差改列「音轨 / layout 期望 / 成片」三个口径,并把「成片比音轨短」定为故障

文档
- lessons.md 新增 35–45 条:入场动画的截止时刻、共享 kit.css、数字卷轴写法、
  结尾三类废句与「勾选框全空的待办清单」、极简帧信息重复、--only 的两个前提
- lessons.md 第 33 条同步修正:单场重渲已有开关、合成命令不再用 -shortest
- workflow-guide.md 新增「改一句文案之后」子流程

版本号同步至 1.2.3(SKILL.md / 双语 README / CHANGELOG / issue 模板)
2026-09-22 18:14:11 +08:00

429 lines
22 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 === '--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();
};
// ---------- 主流程 ----------
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}`);
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 } });
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)
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);
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 砍掉视频尾部。
// 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)));