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:
1 parent
0d74a1097d
commit
d158afcd36
11 files changed
+393
-11
No files matched your search
@@ -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 比音轨多出
|
||||
|
||||
@@ -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 砍掉视频尾部。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user