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 段补齐 + 版本徽章同步。
This commit is contained in:
MohZhuang committed 2026-09-23 16:41:48 +08:00
1 parent 0d74a1097d
commit d158afcd36
11 files changed
+393 -11

No files matched your search

+39
View File
@@ -60,6 +60,26 @@ def mean_volume(ff, path):
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],
@@ -116,6 +136,25 @@ def main() -> int:
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 比音轨多出
+43 -4
View File
@@ -62,6 +62,10 @@ function parseArgs(argv) {
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;
@@ -214,6 +218,32 @@ const frameSeek = async (o) => {
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));
@@ -283,7 +313,9 @@ async function main() {
log(`项目:${projectDir}`);
log(`场景 ${scenes.length} 个 · 总时长 ${renderSec.toFixed(2)}s · ${totalFrames} 帧 @${fps}fps`);
log(`画布 ${W}x${H} · ${args.jpeg ? 'JPEG 草稿模式' : 'PNG 精细模式'} · 并发 ${args.concurrency || 3}`);
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) {
@@ -309,6 +341,8 @@ async function main() {
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__ 必须在任何页面脚本之前置位(帧据此跳过自动起播)
@@ -350,7 +384,7 @@ async function main() {
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 });
await captureFrame({ page, cdp, outPath: framePath, args, W, H });
doneFrames++;
if (doneFrames % 300 === 0)
log(`进度 ${doneFrames}/${totalFrames} 帧 · ${(doneFrames / ((Date.now() - t0) / 1000)).toFixed(1)} 帧/秒`);
@@ -398,11 +432,16 @@ async function main() {
'-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', args.jpeg ? 'veryfast' : 'medium',
'-crf', args.jpeg ? '23' : '18', '-pix_fmt', 'yuv420p',
'-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 砍掉视频尾部。
+4 -1
View File
@@ -338,7 +338,10 @@ async def run(args) -> int:
return 1
if provider == "volcano":
voice = args.voice or pj.get("voice") or tts_volcano.DEFAULT_VOICE
# project.json 里存的是显示名(「解说小明 2.0」),但 speaker 字段只认 ID。
# 在入口就解析成 ID,后面日志/清单里也就是可直接复制的真 ID。
voice = tts_volcano.resolve_voice_token(
args.voice or pj.get("voice") or "", tts_volcano.DEFAULT_VOICE)
rate = args.rate or pj.get("rate") or 0
else:
voice = args.voice or pj.get("voice") or DEFAULT_VOICE_ZH
+7
View File
@@ -433,6 +433,13 @@ def synthesize(text: str, voice: str = DEFAULT_VOICE, rate: int = 0,
text = (text or "").strip()
if not text:
raise VolcanoTTSError("文本为空")
# ★ 音色解析必须在这里再兜一次:`speaker` 字段只认 ID,
# 而 project.json 里存的是显示名(如「解说小明 2.0」)。
# CLI 路径调过 resolve_voice_token,但 tts_build.py 是直接把 project.json 的
# voice 传进来的 —— 少这一步就会拿到服务端的
# `55000000 resource ID is mismatched with speaker related resource`,
# 报错信息完全看不出是「名字没解析」(2026-09-23 实测踩到)。
voice = resolve_voice_token(voice, DEFAULT_VOICE)
cred = load_credentials(project_dir)
last: Exception | None = None