Files
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

279 lines
12 KiB
Python
Raw Permalink 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 python
# -*- coding: utf-8 -*-
"""
qc_check.py —— 成片体检 + 抽帧速览图。
检查项(全部来自两个血统项目的实战教训):
① 流:必须有 h264 video + aac audio 两条流
② 时长:成片 vs layout.json 的 video_duration_sec —— 差 >0.5s 判 FAIL
(差 ~0.9s×N 段 = 帧时长用了词边界而非容器时长的典型症状;
成片比 layout 短 ~0.5s = 合成时被 -shortest 按音轨长度截了,见 lessons #33)
③ 音量:成片 mean_volume 与源音轨差 >3dB 判 FAIL(静音轨/占位轨)
④ 抽帧:按字幕切换点 + 每场景中段抽帧;体积低于本片中位数 35% 且画面近乎
无内容(唯一色数 <300 且 sd <6)才判可疑 —— 极简页压缩后天然体积小
⑤ 速览图:6 列 contact sheet → out/qc_sheet.jpg(肉眼过一遍字幕带/构图)
用法:
python qc_check.py --project . [--samples 12]
产出:
out/qc_report.md + out/qc_frames/*.jpg + out/qc_sheet.jpg
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
def ffmpeg_exe() -> str:
from shutil import which
p = which("ffmpeg")
if p:
return p
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def probe(ff, path):
"""返回 (duration_sec, streams[], 容器行列表)。"""
r = subprocess.run([ff, "-hide_banner", "-i", path],
capture_output=True, text=True, errors="replace")
lines = r.stderr.splitlines()
dur = 0.0
for line in lines:
if "Duration:" in line:
t = line.split("Duration:")[1].split(",")[0].strip()
h, m, s = t.split(":")
dur = int(h) * 3600 + int(m) * 60 + float(s)
streams = [l.strip() for l in lines if "Stream #" in l]
return dur, streams, lines
def mean_volume(ff, path):
r = subprocess.run([ff, "-hide_banner", "-i", path, "-af", "volumedetect",
"-f", "null", "-"], capture_output=True, text=True, errors="replace")
for line in r.stderr.splitlines():
if "mean_volume:" in line:
return float(line.split("mean_volume:")[1].replace("dB", "").strip())
return None
def count_video_frames(ff, path):
"""实测**视频流真实帧数**。
为什么不能只看 Duration:容器时长会被音轨撑起来。若帧序列中间有缺口,
ffmpeg 的 image2 序列会在缺口处停下、**退出码仍是 0**,于是视频流只有前半段,
而容器 Duration 依旧显示完整长度 —— 这个假通过真的发生过,见 lessons #57。
"""
r = subprocess.run([ff, "-hide_banner", "-i", path,
"-map", "0:v:0", "-c", "copy", "-f", "null", "-"],
capture_output=True, text=True, errors="replace")
n = None
for line in r.stderr.splitlines():
if line.startswith("frame="):
try:
n = int(line.split("frame=")[1].strip().split()[0])
except (IndexError, ValueError):
pass
return n
def extract_frame(ff, video, sec, out_png):
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y",
"-i", video, "-ss", f"{sec:.3f}", "-frames:v", "1", out_png],
capture_output=True)
return r.returncode == 0 and os.path.exists(out_png)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project", default=".")
ap.add_argument("--samples", type=int, default=12)
args = ap.parse_args()
proj = os.path.abspath(args.project)
pj = json.load(open(os.path.join(proj, "project.json"), encoding="utf-8"))
slug = pj.get("slug") or "video"
ff = ffmpeg_exe()
video = os.path.join(proj, "out", f"{slug}.mp4")
audio = os.path.join(proj, "audio", "narration-full.mp3")
layout = os.path.join(proj, "layout.json")
subs = os.path.join(proj, "subs.json")
report, fails, warns = [], [], []
def sec(title):
report.append(f"\n## {title}\n")
if not os.path.exists(video):
print(f"✗ 找不到成片 {video}(先渲染)", file=sys.stderr)
return 1
# ① 流
sec("流")
vdur, streams, _ = probe(ff, video)
has_v = any("Video:" in s and "h264" in s for s in streams)
has_a = any("Audio:" in s and "aac" in s for s in streams)
for s in streams:
report.append(f"- `{s}`")
(report if has_v else fails).append("✓ 视频流 h264" if has_v else "video 流缺失或非 h264")
if not has_a:
warns.append("无 aac 音轨(还没配音?)")
report.append(f"- 成片时长 {vdur:.2f}s")
# ② 时长对齐
sec("时长对齐(两级时钟口径)")
expect = None
if os.path.exists(layout):
lay = json.load(open(layout, encoding="utf-8"))
expect = lay.get("_total", {})
report.append(f"- layout 期望:视频 {expect.get('video_duration_sec')}s / "
f"{expect.get('total_frames')} 帧 · 音轨 {expect.get('audio_duration_sec')}s")
d = abs(vdur - float(expect.get("video_duration_sec", vdur)))
if d > 0.5:
fails.append(f"成片与期望时长差 {d:.2f}s(>0.5s)—— 查帧时长口径是否用了容器时长")
else:
report.append(f"- ✓ 与期望差 {d:.2f}s")
# ★ 帧数实测(Duration 会被音轨撑大,掩盖视频流被截断 —— 见 lessons #57)
exp_frames = expect.get("total_frames")
if exp_frames:
got = count_video_frames(ff, video)
if got is None:
warns.append("无法实测视频流帧数(跳过帧数校验)")
elif int(got) != int(exp_frames):
fails.append(
f"成片实测 {got} 帧 ≠ layout 期望 {exp_frames} 帧 —— 视频流被截断。"
f"注意容器时长仍会显示正常,别被它骗了(见 lessons #57)。"
f"先量帧序列有没有缺口:"
f"`ls render/frames/ | sed 's/f_0*\\([0-9]*\\)\\.png/\\1/' | awk "
f"'NR==1{{p=$1-1}} {{if($1!=p+1) print \"缺口: \"p\" -> \"$1; p=$1}} "
f"END{{print \"末帧: \"p}}'`;"
f"有缺口就 `cp f_<洞后一帧>.png f_<洞帧>.png` 补齐,再 --mux-only")
report.append(f"- ✗ 实测视频流 {got} 帧 / 期望 {exp_frames} 帧 —— 被截断")
else:
report.append(f"- ✓ 实测视频流 {got} 帧 = 期望 {exp_frames} 帧")
if os.path.exists(audio):
adur, _, _ = probe(ff, audio)
# 有符号差:正数 = 成片比音轨长。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("音量")
if os.path.exists(audio):
mv_v, mv_a = mean_volume(ff, video), mean_volume(ff, audio)
report.append(f"- 成片 mean_volume {mv_v} dB · 源 {mv_a} dB")
if mv_v is not None and mv_a is not None and abs(mv_v - mv_a) > 3:
fails.append(f"音量差 {abs(mv_v - mv_a):.1f} dB(静音轨或占位轨?)")
# ④ 抽帧(字幕切换点 + 场景中段)
sec("抽帧体检")
qc_dir = os.path.join(proj, "out", "qc_frames")
os.makedirs(qc_dir, exist_ok=True)
times = []
if os.path.exists(subs):
sd = json.load(open(subs, encoding="utf-8"))
pts = sorted({round(b['global_start_sec'] + 0.25, 2)
for s in sd['segments'] for b in s['blocks']})
# ★ 全片均匀取 N 个(不是前 N 个):块数多于抽样数时按步长挑,
# 否则 QC 只覆盖前 ~1/4 片,后半段场景(本例 world→outro)抽不到。
# 见 lessons.md #29。
if len(pts) > args.samples:
step = len(pts) / args.samples
pts = [pts[int(i * step)] for i in range(args.samples)]
times = pts
if not times:
times = [round(vdur * (i + 1) / (args.samples + 1), 2) for i in range(args.samples)]
sizes = []
for i, t in enumerate(times):
if t >= vdur - 0.1:
continue
p = os.path.join(qc_dir, f"q{i:02d}_{int(t * 1000):07d}ms.jpg")
if extract_frame(ff, video, t, p):
sz = os.path.getsize(p)
sizes.append((t, sz, p))
# 阈值必须随主题底色自适应:暗底主题压缩后体积天然很小,
# 用固定 100KB 会把「黑底 + 稀疏文字」的合规画面全判成空画面(假阳性)。
# 做法:取本片所有抽帧体积的中位数作参考,低于中位数 35% 的帧才是可疑帧。
# 若全部帧都极暗(中位数本身就小),再叠加一个绝对下限兜底。
if sizes:
med = sorted(s for _, s, _ in sizes)[len(sizes) // 2]
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, 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("- 未能抽到任何帧")
# ⑤ contact sheet
sec("速览图")
sheet = os.path.join(proj, "out", "qc_sheet.jpg")
try:
from PIL import Image
imgs = [(t, p) for t, _, p in sizes]
if imgs:
cols = 6
rows = (len(imgs) + cols - 1) // cols
tw, th = 480, 270
sheet_im = Image.new("RGB", (cols * tw, rows * th), (16, 16, 16))
for i, (t, p) in enumerate(imgs):
im = Image.open(p).resize((tw, th))
sheet_im.paste(im, ((i % cols) * tw, (i // cols) * th))
sheet_im.save(sheet, quality=85)
report.append(f"- out/qc_sheet.jpg({rows}×{cols},时间点见 qc_frames 文件名)")
except Exception as e:
warns.append(f"速览图失败:{e}")
# 汇总
head = [f"# QC 报告 · {slug}", f"成片:{video}", f"时长 {vdur:.2f}s"]
rep = "\n".join(head + report)
if fails:
rep += "\n\n## FAIL\n" + "\n".join(f"- ✗ {f}" for f in fails)
if warns:
rep += "\n\n## WARN\n" + "\n".join(f"- ⚠ {w}" for w in warns)
if not fails and not warns:
rep += "\n\n## 结论:全部通过 ✓(速览图仍需肉眼过一遍字幕带与构图)"
open(os.path.join(proj, "out", "qc_report.md"), "w", encoding="utf-8", newline="\n").write(rep)
print(rep)
return 1 if fails else 0
if __name__ == "__main__":
raise SystemExit(main())