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 模板)
This commit is contained in:
MohZhuang committed 2026-09-22 18:14:11 +08:00
1 parent ae9612a185
commit 3aec9f6701
9 files changed
+304 -36

No files matched your search

+40 -14
View File
@@ -5,11 +5,12 @@ qc_check.py —— 成片体检 + 抽帧速览图。
检查项(全部来自两个血统项目的实战教训):
① 流:必须有 h264 video + aac audio 两条流
② 时长:成片 vs 音轨 vs layout.json 期望帧数 —— 差 >0.5s 判 FAIL
(差 ~0.9s×N 段 = 帧时长用了词边界而非容器时长的典型症状)
② 时长:成片 vs layout.json 的 video_duration_sec —— 差 >0.5s 判 FAIL
(差 ~0.9s×N 段 = 帧时长用了词边界而非容器时长的典型症状;
成片比 layout 短 ~0.5s = 合成时被 -shortest 按音轨长度截了,见 lessons #33)
③ 音量:成片 mean_volume 与源音轨差 >3dB 判 FAIL(静音轨/占位轨)
④ 抽帧:按字幕切换点 + 每场景中段抽帧,纯背景帧(体积过小)判 FAIL
(1920×1080 纯背景 PNG ≈ 90KB 上下;有内容通常 220KB+)
④ 抽帧:按字幕切换点 + 每场景中段抽帧;体积低于本片中位数 35% 且画面近乎
无内容(唯一色数 <300 且 sd <6)才判可疑 —— 极简页压缩后天然体积小
⑤ 速览图:6 列 contact sheet → out/qc_sheet.jpg(肉眼过一遍字幕带/构图)
用法:
@@ -117,10 +118,18 @@ def main() -> int:
report.append(f"- ✓ 与期望差 {d:.2f}s")
if os.path.exists(audio):
adur, _, _ = probe(ff, audio)
d = abs(vdur - adur)
report.append(f"- 音轨 {adur:.2f}s,成片差 {d:.2f}s")
if d > 0.5:
fails.append(f"音画时长差 {d:.2f}s —— 典型原因:帧时长用了词边界时长(每段少 ~0.86s)")
# 有符号差:正数 = 成片比音轨长。layout 的 video_duration 比音轨多出
# 一小段片尾留白(末块字幕的收尾余韵),成片略长于音轨是正常的;
# 反过来「成片比音轨短」才是故障——视频被 -shortest 截了尾部,见 lessons #33。
d = vdur - adur
if d >= -0.15:
report.append(f"- 音轨 {adur:.2f}s,成片 {vdur:.2f}s"
f"({d:+.2f}s,片尾留白,正常)")
else:
report.append(f"- 音轨 {adur:.2f}s,成片 {vdur:.2f}s"
f"(★ 成片比音轨短 {-d:.2f}s,末段画面被截)")
fails.append(f"成片比音轨短 {-d:.2f}s —— 合成时视频尾部被截"
f"(render_video.mjs 应改用 apad 补静音 + -t,见 lessons #33)")
# ③ 音量
sec("音量")
@@ -167,12 +176,29 @@ def main() -> int:
floor = max(12_000, int(med * 0.35))
report.append(f"- 抽了 {len(sizes)} 帧 → out/qc_frames/"
f"(本片抽帧体积中位数 {med // 1024}KB,可疑线 {floor // 1024}KB)")
for t, sz, _ in sizes:
flag = " ← 可疑" if sz < floor else ""
report.append(f" - {t:>6.2f}s {sz // 1024:>4}KB{flag}")
if sz < floor:
warns.append(f"{t}s 抽帧 {sz // 1024}KB,低于本片中位数 {med // 1024}KB 的 35%"
f" —— 疑似空画面(主角没渲出来?核对 out/qc_frames/ 该帧)")
for t, sz, p in sizes:
suspicious = sz < floor
note = ""
# 体积小 ≠ 空画面:极简页(片头淡入起点、纯底 + 单行大字)压缩后天然极小。
# 见 lessons #32。二次判定——真的去看画面里有没有内容。
if suspicious:
try:
from PIL import Image
import numpy as _np
a = _np.asarray(Image.open(p).convert("RGB").resize((320, 180)))
ncol = len(_np.unique(a.reshape(-1, 3), axis=0))
sd = float(a.std())
if ncol >= 300 or sd >= 6:
suspicious = False
note = f" (体积低但有内容:{ncol} 色 / sd {sd:.1f},判为极简画面)"
except Exception:
pass
flag = " ← 可疑" if suspicious else ""
report.append(f" - {t:>6.2f}s {sz // 1024:>4}KB{flag}{note}")
if suspicious:
warns.append(f"{t}s 抽帧 {sz // 1024}KB,低于本片中位数 {med // 1024}KB 的 35%,"
f"且画面近乎无内容 —— 疑似空画面/主角没渲出来"
f"(核对 out/qc_frames/ 该帧)")
else:
report.append("- 未能抽到任何帧")
+52 -12
View File
@@ -24,7 +24,15 @@
* 用法(项目目录 = 含 project.json 的目录):
* node render_video.mjs <projectDir> [--out out/<slug>.mp4]
* [--preview 30] [--fps 30] [--concurrency 3] [--jpeg]
* [--scale 1] [--keep-frames]
* [--scale 1] [--keep-frames] [--mux-only] [--only <场景id,场景id>]
*
* --mux-only:跳过截图,用 render/frames 里已有的帧直接重新合成
* (改合参 / 换音轨后不用重渲 20 分钟)
*
* --only coda:改完某一场的文案/动效后,只重渲这一场的帧,其余帧保持不动。
* 仅当被改场景是**最后一场**时安全;若它在中间且时长变了,
* 其后场景的帧号会整体前移,必须整片重渲。
* 变短后记得删掉尾部的过期帧,否则成片会拖长(脚本会提示实际帧数)。
*
* 环境解析顺序:
* 浏览器 env BROWSER_PATH → Chrome → Edge → playwright 自带 chromium
@@ -56,6 +64,8 @@ function parseArgs(argv) {
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);
}
@@ -275,7 +285,16 @@ async function main() {
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);
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 || [])
@@ -345,16 +364,30 @@ async function main() {
}
// 场景级并发(默认 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 ')}`);
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`);
log(`截图完成:${doneFrames} 帧,耗时 ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
// ---------- ffmpeg 合成 ----------
const audioPath = path.join(projectDir, 'audio', 'narration-full.mp3');
@@ -370,7 +403,14 @@ async function main() {
'-crf', args.jpeg ? '23' : '18', '-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
);
if (hasAudio) cmd.push('-c:a', 'aac', '-b:a', '160k', '-shortest');
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}` : '合成:无音轨(配音还没生成)');