@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +951 -0
- package/README.md +51 -8
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +4 -1
- package/package.json +5 -3
- package/schemas/film.schema.json +162 -2
- package/skills/video-studio/SKILL.md +211 -11
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +14 -0
- package/skills/video-studio/scripts/filmcheck.py +121 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +719 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +274 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +43 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +171 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
|
@@ -35,6 +35,7 @@ import importlib.util
|
|
|
35
35
|
import json
|
|
36
36
|
import os
|
|
37
37
|
import shutil
|
|
38
|
+
import subprocess
|
|
38
39
|
import sys
|
|
39
40
|
import time
|
|
40
41
|
from pathlib import Path
|
|
@@ -90,14 +91,22 @@ def _file_sha1(path):
|
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
def _frames_plan_stamp(tl, script, scenes_path, chunk):
|
|
93
|
-
"""分块计划的指纹:时长 / scenes.py 内容 /
|
|
94
|
+
"""分块计划的指纹:时长 / scenes.py 内容 / 剧本快照 / 时间轴内容任一变化,旧分块就画出错帧。
|
|
94
95
|
|
|
95
96
|
断点续跑只看"分块文件在不在"是危险的:改一句台词 -> 时间轴变了 -> 旧分块
|
|
96
97
|
仍然被跳过复用,最后拼出一部"画面和旁白对不上"的片子却不报错。
|
|
98
|
+
|
|
99
|
+
为什么不能只哈希 total/frames/fps:字幕窗口(voice_start/voice_end)与词级断点
|
|
100
|
+
(words_abs)都在 tl["scenes"] 里,而它们可以在 total 分毫不动的情况下变化
|
|
101
|
+
——逐句被 min_dur 夹住时,重新合成的 TTS 只改句内时长、不改段落总长。只比三个
|
|
102
|
+
标量会让这种情况静默复用旧分块(字幕按上一版的词断行画)。代价是每家分块计划
|
|
103
|
+
多一次 timeline.json 的 json.dumps(几十 KB,相对渲一帧可忽略)。
|
|
97
104
|
"""
|
|
98
105
|
body = json.dumps(dict(script), sort_keys=True, ensure_ascii=False, default=str)
|
|
106
|
+
tl_body = json.dumps(tl, sort_keys=True, ensure_ascii=False, default=str)
|
|
99
107
|
payload = dict(total=round(float(tl["total"]), 3), frames=int(tl["frames"]),
|
|
100
108
|
fps=int(tl["fps"]), chunk=int(chunk),
|
|
109
|
+
timeline=hashlib.sha1(tl_body.encode("utf-8")).hexdigest()[:16],
|
|
101
110
|
scenes=_file_sha1(scenes_path),
|
|
102
111
|
script=hashlib.sha1(body.encode("utf-8")).hexdigest()[:16])
|
|
103
112
|
return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
|
@@ -224,6 +233,53 @@ def load_script(p) -> dict:
|
|
|
224
233
|
return s
|
|
225
234
|
|
|
226
235
|
|
|
236
|
+
def _to_gif(mp4, gif, fps=12, width=640):
|
|
237
|
+
"""mp4 -> GIF(两遍调色板,避免"一屏噪点"的默认调色板)。
|
|
238
|
+
|
|
239
|
+
GIF 只用来"快速看一眼大概长什么样"(发给别人、贴到文档里),
|
|
240
|
+
不是交付物:它没有声音、色彩也被量化。真正的交付仍是 mp4。
|
|
241
|
+
"""
|
|
242
|
+
pal = gif + ".palette.png"
|
|
243
|
+
_run_ff = K._run if hasattr(K, "_run") else None
|
|
244
|
+
vf1 = "fps=%d,scale=%d:-1:flags=lanczos,palettegen=stats_mode=diff" % (fps, width)
|
|
245
|
+
vf2 = "fps=%d,scale=%d:-1:flags=lanczos,paletteuse=dither=bayer:bayer_scale=3" % (fps, width)
|
|
246
|
+
for args in ([K.FFMPEG, "-y", "-v", "error", "-i", str(mp4), "-vf", vf1, pal],
|
|
247
|
+
[K.FFMPEG, "-y", "-v", "error", "-i", str(mp4), "-i", pal,
|
|
248
|
+
"-lavfi", vf2, "-loop", "0", str(gif)]):
|
|
249
|
+
p = subprocess.run(args, capture_output=True)
|
|
250
|
+
if p.returncode != 0:
|
|
251
|
+
raise RuntimeError("GIF 生成失败: " + p.stderr.decode("utf-8", "replace")[:400])
|
|
252
|
+
try:
|
|
253
|
+
os.remove(pal)
|
|
254
|
+
except OSError:
|
|
255
|
+
pass
|
|
256
|
+
return gif
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _atomic_json_retry(path, obj, tries=3, sleep_s=0.1):
|
|
260
|
+
"""原子写 JSON + 有限退避(v0.8)。
|
|
261
|
+
|
|
262
|
+
Windows 上文件被杀毒/预览进程短暂占用时,一次写入失败会让**整片**跑挂。
|
|
263
|
+
只对"可重试的写状态"退避,不对内容生成盲目重试(那会掩盖真错误)。
|
|
264
|
+
"""
|
|
265
|
+
last = None
|
|
266
|
+
for i in range(max(1, int(tries))):
|
|
267
|
+
try:
|
|
268
|
+
d = os.path.dirname(os.path.abspath(str(path)))
|
|
269
|
+
if d:
|
|
270
|
+
os.makedirs(d, exist_ok=True)
|
|
271
|
+
tmp = str(path) + ".tmp"
|
|
272
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
273
|
+
json.dump(obj, fh, ensure_ascii=False, indent=1)
|
|
274
|
+
os.replace(tmp, str(path))
|
|
275
|
+
return True
|
|
276
|
+
except OSError as e: # noqa: BLE001
|
|
277
|
+
last = e
|
|
278
|
+
time.sleep(sleep_s * (i + 1))
|
|
279
|
+
print(" [WARN] 写 %s 失败(重试 %d 次):%s" % (path, tries, last))
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
227
283
|
def _need(cond, msg):
|
|
228
284
|
if not cond:
|
|
229
285
|
raise SystemExit("[前置产物缺失] " + msg)
|
|
@@ -277,6 +333,14 @@ def _theme_for(script):
|
|
|
277
333
|
plan = resolve_style(script)
|
|
278
334
|
if plan.get("legacy"):
|
|
279
335
|
return None
|
|
336
|
+
# plan["theme"] 是 stylekit 合并过 film.json 的 visual.theme_overrides 之后的最终主题,
|
|
337
|
+
# resolve_style 的对比度体检(theme_lint)用的就是它。这里必须用同一份:否则
|
|
338
|
+
# scenes 画背景吃 overrides、字幕底板/字色仍用基础主题——字幕压在同色背景上会看不清,
|
|
339
|
+
# 而体检还照报通过("门禁看着干净、其实没在查")。老 stylekit / 老计划没有 theme
|
|
340
|
+
# 字段时回落到基础主题,行为与本行改动前一致。
|
|
341
|
+
th = plan.get("theme")
|
|
342
|
+
if isinstance(th, dict):
|
|
343
|
+
return th
|
|
280
344
|
return _stylekit().THEMES[plan["theme_id"]]
|
|
281
345
|
|
|
282
346
|
|
|
@@ -338,6 +402,7 @@ def st_tts(script, work, force=False):
|
|
|
338
402
|
ensure_ascii=False, indent=1)
|
|
339
403
|
if force:
|
|
340
404
|
shutil.rmtree(os.path.join(work, "tts"), ignore_errors=True)
|
|
405
|
+
want_words = bool(script.get("words", True))
|
|
341
406
|
done = K.tts_batch(lines, work,
|
|
342
407
|
voice=script.get("voice", "zh-CN-YunxiNeural"),
|
|
343
408
|
rate=script.get("rate", "+0%"),
|
|
@@ -348,14 +413,50 @@ def st_tts(script, work, force=False):
|
|
|
348
413
|
[{"voice": r["voice"], "rate": r["rate"], "pitch": r["pitch"],
|
|
349
414
|
"volume": r.get("volume", "+0%"),
|
|
350
415
|
"persona": r.get("persona"), "tone": r.get("tone")} for r in vp],
|
|
351
|
-
trim_db=float(script.get("trim_db", -42.0))
|
|
416
|
+
trim_db=float(script.get("trim_db", -42.0)),
|
|
417
|
+
words=want_words,
|
|
418
|
+
backend=str((script.get("tts") or {}).get("backend") or "edge"))
|
|
352
419
|
json.dump(done, open(os.path.join(work, "tts.json"), "w", encoding="utf-8"),
|
|
353
420
|
ensure_ascii=False, indent=1)
|
|
354
421
|
total = sum(float(x["dur"]) for x in done)
|
|
355
422
|
print(" 语音总时长 %.2fs / %d 句" % (total, len(done)))
|
|
423
|
+
_write_words(work, done, want_words)
|
|
356
424
|
return done
|
|
357
425
|
|
|
358
426
|
|
|
427
|
+
def _write_words(work, done, want_words):
|
|
428
|
+
"""把逐句词级时间戳汇总成 work/words.json(v0.8)。
|
|
429
|
+
|
|
430
|
+
结构:{schema, enabled, lines:{<id>:{dur, n, covered, tail, mono, words:[...]}}, stats}
|
|
431
|
+
门禁与字幕都从这里读,不再各自去翻 *.mp3.json(单一事实来源)。
|
|
432
|
+
"""
|
|
433
|
+
if not want_words:
|
|
434
|
+
print(" 词级时间戳: 未启用(film.json 里 words=false)")
|
|
435
|
+
return None
|
|
436
|
+
lines = {}
|
|
437
|
+
n_words = 0
|
|
438
|
+
n_bad = []
|
|
439
|
+
for row in done or []:
|
|
440
|
+
ws = row.get("words") or []
|
|
441
|
+
summ = K.words_summary(ws, row.get("dur"))
|
|
442
|
+
lines[str(row["id"])] = dict(dur=round(float(row.get("dur") or 0.0), 4),
|
|
443
|
+
words=ws, **{k: v for k, v in summ.items() if k != "n"},
|
|
444
|
+
n=summ["n"])
|
|
445
|
+
n_words += len(ws)
|
|
446
|
+
if not ws:
|
|
447
|
+
n_bad.append(row["id"])
|
|
448
|
+
payload = dict(schema=1, enabled=True, lines=lines,
|
|
449
|
+
stats=dict(lines=len(lines), words=n_words, empty=n_bad))
|
|
450
|
+
p = os.path.join(work, "words.json")
|
|
451
|
+
json.dump(payload, open(p, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
452
|
+
if n_bad:
|
|
453
|
+
print(" [WARN] %d 句拿不到词级事件(%s);这些句的词级门禁会记 skipped"
|
|
454
|
+
% (len(n_bad), "、".join(n_bad[:6])))
|
|
455
|
+
else:
|
|
456
|
+
print(" 词级时间戳: %d 句 / %d 词 -> %s" % (len(lines), n_words, p))
|
|
457
|
+
return payload
|
|
458
|
+
|
|
459
|
+
|
|
359
460
|
# ---------------------------------------------------------------- 排版
|
|
360
461
|
def st_layout(script, work, lines=None):
|
|
361
462
|
lines = lines or json.load(open(os.path.join(work, "tts.json"), encoding="utf-8"))
|
|
@@ -378,6 +479,7 @@ def st_layout(script, work, lines=None):
|
|
|
378
479
|
for lid, v in tail_extra.items():
|
|
379
480
|
if lid in tail_map:
|
|
380
481
|
tail_map[lid] = float(v)
|
|
482
|
+
vp = K.video_plan(script) # 画幅/帧率:非法配置在这里就报错,不拖到渲帧
|
|
381
483
|
tl = K.build_timeline(lines,
|
|
382
484
|
lead=script.get("lead", pace["lead"]),
|
|
383
485
|
tail=script.get("tail", pace["tail"]),
|
|
@@ -385,9 +487,24 @@ def st_layout(script, work, lines=None):
|
|
|
385
487
|
tail_map=tail_map or script.get("tail_map"),
|
|
386
488
|
lead_map=lead_map,
|
|
387
489
|
hold=script.get("hold", pace["hold"]),
|
|
388
|
-
max_dur=script.get("max_dur")
|
|
490
|
+
max_dur=script.get("max_dur"),
|
|
491
|
+
fps=vp["fps"])
|
|
492
|
+
tl["video"] = vp
|
|
493
|
+
print(" 画幅: %d×%d @ %d fps(内部构图 %d×%d%s)"
|
|
494
|
+
% (vp["out_w"], vp["out_h"], vp["fps"], vp["w"], vp["h"],
|
|
495
|
+
",编码时 lanczos 缩放" if vp["scale"] else ""))
|
|
389
496
|
tl["title"] = script["title"]
|
|
390
497
|
tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
|
|
498
|
+
# v0.8:词级时间戳(句子时长仍是权威;词只用于字幕断行/锚点/门禁)
|
|
499
|
+
tl["words"] = bool(script.get("words", True))
|
|
500
|
+
wp = os.path.join(work, "words.json")
|
|
501
|
+
if tl["words"] and os.path.exists(wp):
|
|
502
|
+
try:
|
|
503
|
+
wj = json.load(open(wp, encoding="utf-8"))
|
|
504
|
+
tl["word_stats"] = wj.get("stats")
|
|
505
|
+
except Exception as e: # noqa: BLE001
|
|
506
|
+
print(" [WARN] words.json 读不了,词级能力本次不可用:%s" % e)
|
|
507
|
+
tl["words"] = False
|
|
391
508
|
tl["crf"] = script.get("crf", 18)
|
|
392
509
|
tl["preset"] = script.get("preset", "medium")
|
|
393
510
|
tl["style"] = {"style": plan["style"], "theme_id": plan["theme_id"],
|
|
@@ -514,7 +631,19 @@ def _st_cues_web(script, work):
|
|
|
514
631
|
"""调用 webengine.export_cues 导出页面提示点(engine=web 时)。"""
|
|
515
632
|
we = _opt_module("webengine", "浏览器逐帧渲染需要 webengine.py;只用 PIL 路线可以不改 engine")
|
|
516
633
|
entry = _web_entry(script)
|
|
517
|
-
|
|
634
|
+
tl = {}
|
|
635
|
+
try:
|
|
636
|
+
tp = os.path.join(str(work), "timeline.json")
|
|
637
|
+
if os.path.exists(tp):
|
|
638
|
+
tl = json.load(open(tp, encoding="utf-8"))
|
|
639
|
+
except Exception: # noqa: BLE001
|
|
640
|
+
tl = {}
|
|
641
|
+
style = _web_style_payload(we, script, tl)
|
|
642
|
+
vp = (tl.get("video") or {})
|
|
643
|
+
data = we.export_cues(entry, style=style,
|
|
644
|
+
width=int(vp.get("out_w") or K.W),
|
|
645
|
+
height=int(vp.get("out_h") or K.H),
|
|
646
|
+
fps=int(tl.get("fps") or K.FPS))
|
|
518
647
|
return data or {}
|
|
519
648
|
|
|
520
649
|
|
|
@@ -847,12 +976,64 @@ def st_audio(tl, work, script):
|
|
|
847
976
|
return out
|
|
848
977
|
|
|
849
978
|
|
|
979
|
+
# ---------------------------------------------------------------- 预检闸门
|
|
980
|
+
def _run_preflight(script, work, engine, tl=None, skip=False):
|
|
981
|
+
"""渲染前资源闸门(v0.8):缺字形 / 坏音频 / 缺字体,在渲第一帧之前就 fail。
|
|
982
|
+
|
|
983
|
+
为什么值得单独立一道闸:这类问题以前都是"片渲完、QA 才报"——870 帧白跑,
|
|
984
|
+
而且 reports 里只有一句"缺字形",说不清是哪个字段哪句台词。
|
|
985
|
+
三档语义:没装 preflight.py -> skipped(写明原因);有 error -> fail;
|
|
986
|
+
只有 warning -> pass(warning 仍进账本,不静默)。
|
|
987
|
+
"""
|
|
988
|
+
lg = _gate()
|
|
989
|
+
pf = _try_module("preflight")
|
|
990
|
+
if skip:
|
|
991
|
+
if lg is not None:
|
|
992
|
+
lg.bypass("渲染前资源预检(--no-preflight)", "本条不算通过,只记录")
|
|
993
|
+
print(" [preflight] --no-preflight:跳过资源预检(QC 报告里会留痕)")
|
|
994
|
+
return True
|
|
995
|
+
if pf is None:
|
|
996
|
+
if lg is not None:
|
|
997
|
+
lg.skip("G0", "渲染前资源预检", "没有 preflight.py(老版本工作目录,拷一份进来才能查)")
|
|
998
|
+
print(" [preflight] 没有 preflight.py,跳过资源预检(会记 skipped)")
|
|
999
|
+
return True
|
|
1000
|
+
try:
|
|
1001
|
+
res = pf.check(script, work=work, engine=engine, timeline=tl)
|
|
1002
|
+
except Exception as e: # noqa: BLE001
|
|
1003
|
+
if lg is not None:
|
|
1004
|
+
lg.fail("G0", "渲染前资源预检", "预检本身报错:%s" % e)
|
|
1005
|
+
print(" [FAIL][preflight] 预检自身出错:", e)
|
|
1006
|
+
return False
|
|
1007
|
+
for w in (res.get("warnings") or [])[:6]:
|
|
1008
|
+
print(" [WARN][preflight] %s" % (w.get("msg") if isinstance(w, dict) else w))
|
|
1009
|
+
errs = res.get("errors") or []
|
|
1010
|
+
st = (res.get("checks") or {})
|
|
1011
|
+
detail = " ".join("%s=%s" % (k, v.get("status")) for k, v in st.items()) or "-"
|
|
1012
|
+
if errs:
|
|
1013
|
+
if lg is not None:
|
|
1014
|
+
lg.fail("G0", "渲染前资源预检", "%d 处问题:%s" % (
|
|
1015
|
+
len(errs), ";".join((e.get("msg") if isinstance(e, dict) else str(e))
|
|
1016
|
+
for e in errs[:3])))
|
|
1017
|
+
print(" [FAIL][preflight] %d 处问题(下面是前几条):" % len(errs))
|
|
1018
|
+
for e in errs[:8]:
|
|
1019
|
+
msg = e.get("msg") if isinstance(e, dict) else str(e)
|
|
1020
|
+
where = (e.get("where") if isinstance(e, dict) else "") or ""
|
|
1021
|
+
print(" - %s%s" % ((where + ":") if where else "", msg))
|
|
1022
|
+
return False
|
|
1023
|
+
if lg is not None:
|
|
1024
|
+
lg.ok("G0", "渲染前资源预检", detail)
|
|
1025
|
+
print(" [preflight] %s" % detail)
|
|
1026
|
+
return True
|
|
1027
|
+
|
|
1028
|
+
|
|
850
1029
|
# ---------------------------------------------------------------- 渲染
|
|
851
1030
|
def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True):
|
|
852
1031
|
"""渲染 [f0, f1) 帧区间到 out_path。纯函数:同一帧号永远画出同一张图。"""
|
|
853
1032
|
scenes = tl["scenes"]
|
|
854
1033
|
total = tl["total"]
|
|
855
|
-
|
|
1034
|
+
_vp = K.video_plan(script)
|
|
1035
|
+
sink = K.FrameSink(out_path, fps=float(tl["fps"]), crf=crf, preset=preset,
|
|
1036
|
+
out_w=_vp["out_w"], out_h=_vp["out_h"])
|
|
856
1037
|
idx = 0
|
|
857
1038
|
t_start = time.time()
|
|
858
1039
|
last_rep = 0
|
|
@@ -868,8 +1049,9 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
|
|
|
868
1049
|
th = _theme_for(script) if _THEME_CACHE.get("k") != id(script) else _THEME_CACHE.get("th")
|
|
869
1050
|
if id(script) != _THEME_CACHE.get("k"):
|
|
870
1051
|
_THEME_CACHE.update(k=id(script), th=th)
|
|
871
|
-
|
|
872
|
-
|
|
1052
|
+
_hold = script.get("sub_max_hold")
|
|
1053
|
+
frame = K.draw_subtitle(frame, K.sub_at(scenes, t, _hold), theme=th,
|
|
1054
|
+
words=K.sub_words_at(scenes, t, _hold))
|
|
873
1055
|
fade = 1.0
|
|
874
1056
|
if t < 0.5:
|
|
875
1057
|
fade *= K.ease_out(t / 0.5)
|
|
@@ -1071,19 +1253,55 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1071
1253
|
return silent
|
|
1072
1254
|
|
|
1073
1255
|
|
|
1256
|
+
def _web_style_payload(we, script, tl=None):
|
|
1257
|
+
"""把风格包解析成 webengine 能吃的 __STYLE__ 载荷(v0.8)。
|
|
1258
|
+
|
|
1259
|
+
style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
|
|
1260
|
+
add_init_script 都不调,页面行为与 v0.7 逐字节一致(这是兼容性硬要求)。
|
|
1261
|
+
"""
|
|
1262
|
+
try:
|
|
1263
|
+
style_plan = resolve_style(script, verbose=False)
|
|
1264
|
+
except Exception as e: # noqa: BLE001
|
|
1265
|
+
print(" [WARN] web 风格包解析失败,页面用内置配色:%s" % e)
|
|
1266
|
+
return None
|
|
1267
|
+
if not style_plan or style_plan.get("legacy"):
|
|
1268
|
+
return None
|
|
1269
|
+
vp = (tl or {}).get("video") or {}
|
|
1270
|
+
try:
|
|
1271
|
+
return we.normalize_style(style_plan,
|
|
1272
|
+
width=int(vp.get("out_w") or K.W),
|
|
1273
|
+
height=int(vp.get("out_h") or K.H),
|
|
1274
|
+
fps=int((tl or {}).get("fps") or K.FPS))
|
|
1275
|
+
except Exception as e: # noqa: BLE001
|
|
1276
|
+
# 载荷不合法要**说出来**:静默回落会让"换了风格但页面没变"变成一个谜。
|
|
1277
|
+
print(" [WARN] web 风格载荷不可用(%s),页面用内置配色" % e)
|
|
1278
|
+
return None
|
|
1279
|
+
|
|
1280
|
+
|
|
1074
1281
|
def _st_frames_web(tl, work, script, jobs=1, resume=True):
|
|
1075
1282
|
"""浏览器逐帧渲染:交给 webengine.render_full(分块并行 + 断点续跑 + 缺口检测)。
|
|
1076
1283
|
|
|
1077
1284
|
页面的 window.DURATION / window.FPS 是切块与校验的唯一真值来源:
|
|
1078
1285
|
和 timeline.json 对不上就直接报错(十有八九是改了剧本没重新 layout)。
|
|
1286
|
+
v0.8:把风格包与画幅/帧率一起注入页面(window.__STYLE__),页面不再是深蓝橙写死。
|
|
1079
1287
|
"""
|
|
1080
1288
|
we = _opt_module("webengine",
|
|
1081
1289
|
"浏览器渲染需要 webengine.py(本技能 scripts/ 里自带;还需要 pip install playwright)")
|
|
1082
1290
|
entry = _web_entry(script)
|
|
1083
1291
|
silent = os.path.join(work, "silent.mp4")
|
|
1084
1292
|
web = script.get("web") or {}
|
|
1085
|
-
|
|
1086
|
-
|
|
1293
|
+
style = _web_style_payload(we, script, tl)
|
|
1294
|
+
vp = tl.get("video") or {}
|
|
1295
|
+
canvas_w = int(vp.get("out_w") or K.W)
|
|
1296
|
+
canvas_h = int(vp.get("out_h") or K.H)
|
|
1297
|
+
if style is not None:
|
|
1298
|
+
print(" 风格注入: %s(%dx%d @ %s fps)"
|
|
1299
|
+
% (style.get("style") or style.get("theme_id") or "custom",
|
|
1300
|
+
canvas_w, canvas_h, tl.get("fps")))
|
|
1301
|
+
|
|
1302
|
+
page_dur, page_fps = we.page_meta(entry, style=style,
|
|
1303
|
+
width=canvas_w, height=canvas_h,
|
|
1304
|
+
fps=int(tl["fps"]))
|
|
1087
1305
|
if abs(float(page_fps) - float(tl["fps"])) > 1e-6:
|
|
1088
1306
|
raise RuntimeError(
|
|
1089
1307
|
"页面 FPS=%s 与 timeline fps=%s 不一致。浏览器逐帧渲染的帧号就是 f/FPS,"
|
|
@@ -1103,7 +1321,9 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
|
|
|
1103
1321
|
chunk_s=float(web.get("chunk_s", 30.0)),
|
|
1104
1322
|
resume=bool(resume),
|
|
1105
1323
|
crf=int(script.get("crf", 17)),
|
|
1106
|
-
preset=str(script.get("preset", "slow"))
|
|
1324
|
+
preset=str(script.get("preset", "slow")),
|
|
1325
|
+
style=style, width=canvas_w, height=canvas_h,
|
|
1326
|
+
fps=int(tl["fps"]))
|
|
1107
1327
|
got = K.media_dur(str(out) if out else silent)
|
|
1108
1328
|
want = float(tl["frames"]) / float(tl["fps"])
|
|
1109
1329
|
if abs(got - want) > 0.5:
|
|
@@ -1134,7 +1354,12 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1134
1354
|
v = next((s for s in streams if s.get("codec_type") == "video"), {})
|
|
1135
1355
|
a = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
1136
1356
|
fmt = pr.get("format") or {}
|
|
1137
|
-
|
|
1357
|
+
# v0.8:容器门禁比对的是**交付规格**(timeline 里记的 video 块),不是引擎内部常量。
|
|
1358
|
+
# 老 timeline(升级前生成的)没有 video 字段 -> 回落到 1920x1080,行为不变。
|
|
1359
|
+
_vp = tl.get("video") or {}
|
|
1360
|
+
W = int(_vp.get("out_w") or K.W)
|
|
1361
|
+
H = int(_vp.get("out_h") or K.H)
|
|
1362
|
+
FPS = float(_vp.get("fps") or tl.get("fps") or K.FPS)
|
|
1138
1363
|
|
|
1139
1364
|
# ---- G4 容器:分辨率 / 帧率 / 音轨 / 时长 ----
|
|
1140
1365
|
try:
|
|
@@ -1194,6 +1419,9 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1194
1419
|
else:
|
|
1195
1420
|
lg.ok("G8", "首尾之外无长黑帧", "0 段")
|
|
1196
1421
|
|
|
1422
|
+
# ---- G10 词级时间戳(v0.8):覆盖率 / 单调 / 末词偏差 ----
|
|
1423
|
+
_word_gate(lg, tl, work)
|
|
1424
|
+
|
|
1197
1425
|
# ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
|
|
1198
1426
|
if deep is None:
|
|
1199
1427
|
lg.skip("G9", "帧级深检(闪烁/空窗/同步)", "没有 qa_frames.py 或深检失败")
|
|
@@ -1213,7 +1441,218 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1213
1441
|
(lg.ok if ok9 else lg.fail)("G9", "帧级深检(闪烁/空窗/同步超差)", detail9)
|
|
1214
1442
|
|
|
1215
1443
|
|
|
1216
|
-
|
|
1444
|
+
WORD_TAIL_MAX = 0.40 # 末词终点到句尾静音的上限(实测模板 0.07–0.12 s)
|
|
1445
|
+
WORD_START_MAX = 0.20 # 句首到首词的上限(裁剪把开头静音切掉后通常 ≤ 0.05 s)
|
|
1446
|
+
WORD_COVER_MIN = 0.45 # 词时长之和 / 句实测时长:中文有词间空隙,0.45 是"明显少了"的线
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _word_gate(lg, tl, work):
|
|
1450
|
+
"""词级时间戳门禁(v0.8)。
|
|
1451
|
+
|
|
1452
|
+
为什么值得单独一道闸:词级数据是"字幕按词折行 / cue 词锚点"的依据,
|
|
1453
|
+
但它来自一个**逆向接口**(edge-tts)。服务端一改,最可能的表现是"仍返回音频、
|
|
1454
|
+
但词事件为空或整体偏移"——不查就永远发现不了,而画面上只表现为字幕断行变丑。
|
|
1455
|
+
三档语义:没有启用 / 没有 words.json -> skipped(写明原因),不许冒充 pass。
|
|
1456
|
+
"""
|
|
1457
|
+
wp = os.path.join(str(work), "words.json")
|
|
1458
|
+
if not tl.get("words"):
|
|
1459
|
+
lg.skip("G10", "词级时间戳(字幕断行/词锚点依据)",
|
|
1460
|
+
"本片未启用(film.json words=false 或 words.json 缺失)")
|
|
1461
|
+
return
|
|
1462
|
+
if not os.path.exists(wp):
|
|
1463
|
+
lg.skip("G10", "词级时间戳(字幕断行/词锚点依据)", "没有 words.json(跑 --stage tts 生成)")
|
|
1464
|
+
return
|
|
1465
|
+
try:
|
|
1466
|
+
wj = json.load(open(wp, encoding="utf-8"))
|
|
1467
|
+
except Exception as e: # noqa: BLE001
|
|
1468
|
+
lg.fail("G10", "词级时间戳(字幕断行/词锚点依据)", "words.json 读不了:%s" % e)
|
|
1469
|
+
return
|
|
1470
|
+
lines = wj.get("lines") or {}
|
|
1471
|
+
empty = [lid for lid, r in lines.items() if not r.get("n")]
|
|
1472
|
+
n_words = sum(int(r.get("n") or 0) for r in lines.values())
|
|
1473
|
+
if not lines or n_words == 0:
|
|
1474
|
+
lg.skip("G10", "词级时间戳(字幕断行/词锚点依据)",
|
|
1475
|
+
"词事件为空(edge-tts 只回了音频;本次降级为等分/整句字幕)")
|
|
1476
|
+
return
|
|
1477
|
+
bad = []
|
|
1478
|
+
if empty:
|
|
1479
|
+
bad.append("%d 句没有词事件(%s)" % (len(empty), "、".join(empty[:4])))
|
|
1480
|
+
mono_bad = [lid for lid, r in lines.items() if not r.get("mono", True)]
|
|
1481
|
+
if mono_bad:
|
|
1482
|
+
bad.append("词偏移非单调:%s" % "、".join(mono_bad[:4]))
|
|
1483
|
+
tail_bad = [(lid, float(r.get("tail") or 0.0)) for lid, r in lines.items()
|
|
1484
|
+
if r.get("n") and float(r.get("tail") or 0.0) > WORD_TAIL_MAX]
|
|
1485
|
+
if tail_bad:
|
|
1486
|
+
bad.append("末词终点离句尾过远(>%.2fs):%s"
|
|
1487
|
+
% (WORD_TAIL_MAX, "、".join("%s %.2fs" % x for x in tail_bad[:3])))
|
|
1488
|
+
start_bad = [(lid, float(r.get("start") or 0.0)) for lid, r in lines.items()
|
|
1489
|
+
if r.get("n") and float(r.get("start") or 0.0) > WORD_START_MAX]
|
|
1490
|
+
if start_bad:
|
|
1491
|
+
bad.append("首词起点离句首太远(>%.2fs):%s"
|
|
1492
|
+
% (WORD_START_MAX, "、".join("%s %.2fs" % x for x in start_bad[:3])))
|
|
1493
|
+
cover_bad = []
|
|
1494
|
+
for lid, r in lines.items():
|
|
1495
|
+
dur = float(r.get("dur") or 0.0)
|
|
1496
|
+
if not r.get("n") or dur <= 0:
|
|
1497
|
+
continue
|
|
1498
|
+
cov = float(r.get("covered") or 0.0) / dur
|
|
1499
|
+
if cov < WORD_COVER_MIN:
|
|
1500
|
+
cover_bad.append((lid, cov))
|
|
1501
|
+
if cover_bad:
|
|
1502
|
+
bad.append("词覆盖率过低(<%.0f%%):%s"
|
|
1503
|
+
% (WORD_COVER_MIN * 100,
|
|
1504
|
+
"、".join("%s %.0f%%" % (l, c * 100) for l, c in cover_bad[:3])))
|
|
1505
|
+
tail_max = max([float(r.get("tail") or 0.0) for r in lines.values()] or [0.0])
|
|
1506
|
+
detail = "%d 句 / %d 词 · 末词偏差最大 %.2fs" % (len(lines), n_words, tail_max)
|
|
1507
|
+
if bad:
|
|
1508
|
+
lg.fail("G10", "词级时间戳(字幕断行/词锚点依据)", detail + ";" + ";".join(bad))
|
|
1509
|
+
else:
|
|
1510
|
+
lg.ok("G10", "词级时间戳(字幕断行/词锚点依据)", detail)
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
# ⚠ 阈值**只有一套**:权威定义在 qavmaf.py(VMAF_PASS=92 / VMAF_WARN=96,SSIM 仅诊断)。
|
|
1514
|
+
# 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"(CHANGELOG v0.4 D 节)。
|
|
1515
|
+
# 真实标定(qavmaf 作者实测,参考帧含字幕与片头片尾淡入淡出):
|
|
1516
|
+
# 模板片 crf17 VMAF 97.24(pass) / crf30 95.74(pass+warning) / 二次编码坏片 29.45(fail)
|
|
1517
|
+
# 想按片收紧或放宽就写 film.json 的 qa.vmaf.pass / qa.vmaf.warn,会透传给 qavmaf。
|
|
1518
|
+
def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
1519
|
+
"""全片 VMAF/SSIM 门禁(v0.8):参考帧由场景函数实时渲染,与成片逐帧比较。
|
|
1520
|
+
|
|
1521
|
+
为什么值得全片跑(用户明确要求"全片 + 阻断"):锐度比只在 4 个时刻采样,
|
|
1522
|
+
能发现"整体发虚",但发现不了"某 30 秒里字幕糊了 / 某段梯度出现色带"。
|
|
1523
|
+
VMAF 是唯一能覆盖整条时间轴的客观指标。
|
|
1524
|
+
|
|
1525
|
+
三档语义:
|
|
1526
|
+
* 没有 qavmaf.py / 没有 scenes.py(web 引擎)/ 显式 mode=off -> skipped,写明原因;
|
|
1527
|
+
* 分数低于 pass 阈值 -> fail(阻断交付);
|
|
1528
|
+
* 介于 pass 与 warn 之间 -> pass + warning(能出片,但建议降 CRF)。
|
|
1529
|
+
"""
|
|
1530
|
+
qv = _try_module("qavmaf")
|
|
1531
|
+
if mode == "off":
|
|
1532
|
+
lg.skip("G11", "全片 VMAF / SSIM 画质", "本次显式关闭(--vmaf off 或 qa.vmaf.mode=off)")
|
|
1533
|
+
return None
|
|
1534
|
+
if qv is None:
|
|
1535
|
+
lg.skip("G11", "全片 VMAF / SSIM 画质", "没有 qavmaf.py(拷一份进来才能做全片画质门禁)")
|
|
1536
|
+
return None
|
|
1537
|
+
if scenes_mod is None:
|
|
1538
|
+
lg.skip("G11", "全片 VMAF / SSIM 画质",
|
|
1539
|
+
"web 引擎/缺 scenes.py:没有可重渲的参考帧(VMAF 参考帧必须来自场景函数)")
|
|
1540
|
+
return None
|
|
1541
|
+
cfg = ((script or {}).get("qa") or {}).get("vmaf") if isinstance((script or {}).get("qa"), dict) else {}
|
|
1542
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
1543
|
+
m = str(mode or cfg.get("mode") or "full")
|
|
1544
|
+
kw = {}
|
|
1545
|
+
for key, ckey in (("vmaf_pass", "pass"), ("vmaf_warn", "warn"),
|
|
1546
|
+
("ssim_pass", "ssim_pass"), ("ssim_warn", "ssim_warn")):
|
|
1547
|
+
if cfg.get(ckey) is not None:
|
|
1548
|
+
kw[key] = float(cfg[ckey])
|
|
1549
|
+
# 抽样步长:qavmaf.report 的正式参数名是 sample_step(schema 里也是这个名字)。
|
|
1550
|
+
# 不传的话 mode=sample 永远按 qavmaf 内部默认步长 5 抽样——用户写了 sample_step
|
|
1551
|
+
# 也毫无效果("配置存在但没人读"正是最容易被忽略的那类静默缺陷)。
|
|
1552
|
+
if cfg.get("sample_step") is not None:
|
|
1553
|
+
kw["sample_step"] = max(1, int(cfg["sample_step"]))
|
|
1554
|
+
if cfg.get("ssim") and isinstance(cfg["ssim"], dict):
|
|
1555
|
+
for key, ckey in (("ssim_pass", "pass"), ("ssim_warn", "warn")):
|
|
1556
|
+
if cfg["ssim"].get(ckey) is not None:
|
|
1557
|
+
kw[key] = float(cfg["ssim"][ckey])
|
|
1558
|
+
print(" [vmaf] 模式=%s(参考帧=场景函数实时渲染,不是成片抽帧)" % m)
|
|
1559
|
+
try:
|
|
1560
|
+
res = qv.report(film, tl, scenes_mod, script, mode=m,
|
|
1561
|
+
out_json=os.path.join(str(work), "qa", "vmaf.json"), **kw)
|
|
1562
|
+
except Exception as e: # noqa: BLE001
|
|
1563
|
+
lg.fail("G11", "全片 VMAF / SSIM 画质", "门禁自身失败:%s" % e)
|
|
1564
|
+
print(" [FAIL][vmaf]", e)
|
|
1565
|
+
return None
|
|
1566
|
+
gate = (res or {}).get("gate") or {}
|
|
1567
|
+
v = (res or {}).get("vmaf") or {}
|
|
1568
|
+
s = (res or {}).get("ssim") or {}
|
|
1569
|
+
bits = []
|
|
1570
|
+
if v.get("score") is not None:
|
|
1571
|
+
bits.append("VMAF %.2f" % float(v["score"]))
|
|
1572
|
+
if s.get("score") is not None:
|
|
1573
|
+
bits.append("SSIM %.4f" % float(s["score"]))
|
|
1574
|
+
bits.append("%s 帧" % ((res or {}).get("frames_compared") or 0))
|
|
1575
|
+
detail = " · ".join(bits)
|
|
1576
|
+
if (res or {}).get("elapsed_s") is not None:
|
|
1577
|
+
detail += " · 用时 %.1fs" % float(res["elapsed_s"])
|
|
1578
|
+
st = str(gate.get("status") or "skipped")
|
|
1579
|
+
for w in (res or {}).get("warnings") or []:
|
|
1580
|
+
print(" [WARN][vmaf] %s" % w)
|
|
1581
|
+
for e in (res or {}).get("errors") or []:
|
|
1582
|
+
print(" [FAIL][vmaf] %s" % e)
|
|
1583
|
+
if st == "fail":
|
|
1584
|
+
lg.fail("G11", "全片 VMAF / SSIM 画质", detail + ";" + str(gate.get("detail") or ""))
|
|
1585
|
+
elif st == "skipped":
|
|
1586
|
+
lg.skip("G11", "全片 VMAF / SSIM 画质", str(gate.get("detail") or "本次没跑"))
|
|
1587
|
+
else:
|
|
1588
|
+
lg.ok("G11", "全片 VMAF / SSIM 画质", detail)
|
|
1589
|
+
print(" [vmaf] %s %s" % (st, detail))
|
|
1590
|
+
return res
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
# v0.8 实测标定(同一部模板片,29 s,faster-whisper small / CPU int8):
|
|
1594
|
+
# 干净成片:字符召回 0.828(专有名词 "DeepSeek 4.1 Flash" 被听成 DIXX/DeepSeek 变体,
|
|
1595
|
+
# 4 段全对上、缺句 0)—— 这不是流水线坏了,是 ASR 对中英混排专名的正常误差。
|
|
1596
|
+
# 所以默认线取 0.85 / 0.72:**低于 0.72 才说明"整句丢了/音轨跟剧本不是一套"**,
|
|
1597
|
+
# 0.72–0.85 之间给 warning(仍然出片,但提示可能串了缓存)。
|
|
1598
|
+
# 想更严就按片覆盖 film.json 的 qa.asr.recall_pass / recall_warn。
|
|
1599
|
+
QA_ASR_RECALL_PASS = 0.85
|
|
1600
|
+
QA_ASR_RECALL_WARN = 0.72
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
|
|
1604
|
+
"""ASR 回读门禁(v0.8):把成片音轨转回文字,和剧本逐句对账。
|
|
1605
|
+
|
|
1606
|
+
为什么值得:容器/响度/帧级质检都默认"音轨里念的就是剧本那几句"。TTS 缓存串了、
|
|
1607
|
+
换声线漏句、混音压没了、音轨是旧的——这些从波形和容器上都看不出来,只有转回
|
|
1608
|
+
文字对账才能发现。代价是每片多 4–6 s(small 模型,CPU),可关。
|
|
1609
|
+
"""
|
|
1610
|
+
qa = cfg if isinstance(cfg, dict) else {}
|
|
1611
|
+
a = qa.get("asr") if isinstance(qa.get("asr"), dict) else {}
|
|
1612
|
+
if a.get("enabled") is False:
|
|
1613
|
+
lg.skip("G12", "ASR 回读(原稿 vs 成片旁白)",
|
|
1614
|
+
"本次显式关闭(qa.asr.enabled=false)")
|
|
1615
|
+
return None
|
|
1616
|
+
qa_mod = _try_module("qaasr")
|
|
1617
|
+
if qa_mod is None:
|
|
1618
|
+
lg.skip("G12", "ASR 回读(原稿 vs 成片旁白)", "没有 qaasr.py(拷一份进来才能做回读)")
|
|
1619
|
+
return None
|
|
1620
|
+
kw = dict(recall_pass=QA_ASR_RECALL_PASS, recall_warn=QA_ASR_RECALL_WARN)
|
|
1621
|
+
if a.get("recall_pass") is not None:
|
|
1622
|
+
kw["recall_pass"] = float(a["recall_pass"])
|
|
1623
|
+
if a.get("recall_warn") is not None:
|
|
1624
|
+
kw["recall_warn"] = float(a["recall_warn"])
|
|
1625
|
+
if a.get("model"):
|
|
1626
|
+
kw["model_size"] = str(a["model"])
|
|
1627
|
+
print(" [asr] 回读成片音轨(模型 %s,CPU)" % (kw.get("model_size") or "small"))
|
|
1628
|
+
try:
|
|
1629
|
+
res = qa_mod.report(film, tl, script,
|
|
1630
|
+
out_json=os.path.join(str(work), "qa", "asr.json"), **kw)
|
|
1631
|
+
except Exception as e: # noqa: BLE001
|
|
1632
|
+
lg.fail("G12", "ASR 回读(原稿 vs 成片旁白)", "门禁自身失败:%s" % e)
|
|
1633
|
+
print(" [FAIL][asr]", e)
|
|
1634
|
+
return None
|
|
1635
|
+
gate = (res or {}).get("gate") or {}
|
|
1636
|
+
detail = "字符召回 %.3f(%s)" % (float((res or {}).get("recall") or 0.0),
|
|
1637
|
+
(res or {}).get("engine") or "-")
|
|
1638
|
+
if (res or {}).get("elapsed_s") is not None:
|
|
1639
|
+
detail += " · %.1fs" % float(res["elapsed_s"])
|
|
1640
|
+
for w in (res or {}).get("warnings") or []:
|
|
1641
|
+
print(" [WARN][asr] %s" % w)
|
|
1642
|
+
for e in (res or {}).get("errors") or []:
|
|
1643
|
+
print(" [FAIL][asr] %s" % e)
|
|
1644
|
+
st = str(gate.get("status") or "skipped")
|
|
1645
|
+
if st == "fail":
|
|
1646
|
+
lg.fail("G12", "ASR 回读(原稿 vs 成片旁白)", detail + ";" + str(gate.get("detail") or ""))
|
|
1647
|
+
elif st == "skipped":
|
|
1648
|
+
lg.skip("G12", "ASR 回读(原稿 vs 成片旁白)", str(gate.get("detail") or "本次没跑"))
|
|
1649
|
+
else:
|
|
1650
|
+
lg.ok("G12", "ASR 回读(原稿 vs 成片旁白)", detail)
|
|
1651
|
+
print(" [asr] %s %s" % (st, detail))
|
|
1652
|
+
return res
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
1217
1656
|
print("== QA")
|
|
1218
1657
|
th = _theme_for(script) if script is not None else None
|
|
1219
1658
|
shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
|
|
@@ -1238,8 +1677,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
1238
1677
|
try:
|
|
1239
1678
|
fr = scenes_mod.draw_frame(s, sec - s["start"], s["dur"], sec, ref_script,
|
|
1240
1679
|
int(round(sec * tl["fps"])))
|
|
1241
|
-
|
|
1242
|
-
|
|
1680
|
+
_hold = ref_script.get("sub_max_hold")
|
|
1681
|
+
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, _hold), theme=th,
|
|
1682
|
+
words=K.sub_words_at(tl["scenes"], sec, _hold))
|
|
1243
1683
|
refs.append((k, fr))
|
|
1244
1684
|
except Exception as e: # noqa: BLE001
|
|
1245
1685
|
print(" [WARN] 参考帧渲染失败:", e)
|
|
@@ -1253,9 +1693,13 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
1253
1693
|
print(" 锐度参考帧: 场景函数重渲染 t=%.2fs(第 %d 张)" % (ref_sec, k_ref + 1))
|
|
1254
1694
|
elif scenes_mod is None:
|
|
1255
1695
|
print(" [WARN] 没有场景函数参考帧(web 引擎或 scenes.py 缺失),锐度比门禁本次跳过")
|
|
1696
|
+
_vq = tl.get("video") or {}
|
|
1256
1697
|
rep = K.qa_report(film, expect_frames=tl["frames"], ref_img=ref_img,
|
|
1257
1698
|
ref_sec=ref_sec, expected_subtitles=len(tl["scenes"]),
|
|
1258
|
-
notes=os.path.join(work, "sub.srt"), theme=th
|
|
1699
|
+
notes=os.path.join(work, "sub.srt"), theme=th,
|
|
1700
|
+
expect_w=int(_vq.get("out_w") or K.W),
|
|
1701
|
+
expect_h=int(_vq.get("out_h") or K.H),
|
|
1702
|
+
expect_fps=float(_vq.get("fps") or tl.get("fps") or K.FPS))
|
|
1259
1703
|
pr = rep["probe"]
|
|
1260
1704
|
f = pr.get("format", {})
|
|
1261
1705
|
print(" 时长 %.2fs 大小 %.1f MB 码率 %.2f Mbps" % (
|
|
@@ -1303,6 +1747,10 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
1303
1747
|
lg = _gate()
|
|
1304
1748
|
if lg is not None:
|
|
1305
1749
|
_qa_gates(lg, rep, deep, tl, work)
|
|
1750
|
+
# 全片 VMAF/SSIM 放在最后跑:它最贵(约 1.2× 实时),前面有 fail 时可省下这十分钟。
|
|
1751
|
+
_qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=vmaf_mode)
|
|
1752
|
+
# ASR 回读最便宜(模板片 6 s),放最后跑:前面已经 fail 的片没必要再花这 6 秒。
|
|
1753
|
+
_qa_asr_gate(lg, film, work, script, tl, cfg=((script or {}).get("qa") or {}))
|
|
1306
1754
|
return errors
|
|
1307
1755
|
|
|
1308
1756
|
|
|
@@ -1364,8 +1812,10 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
1364
1812
|
if tuple(fr.size) != (K.W, K.H):
|
|
1365
1813
|
raise RuntimeError("场景 %s 在 t=%.2fs 返回 %dx%d,期望 %dx%d(stills 与 FrameSink 一样严格)"
|
|
1366
1814
|
% (sid, tg, fr.size[0], fr.size[1], K.W, K.H))
|
|
1367
|
-
|
|
1368
|
-
|
|
1815
|
+
_hold = script.get("sub_max_hold")
|
|
1816
|
+
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, _hold),
|
|
1817
|
+
theme=_theme_for(script),
|
|
1818
|
+
words=K.sub_words_at(tl["scenes"], tg, _hold))
|
|
1369
1819
|
p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
|
|
1370
1820
|
fr.save(p)
|
|
1371
1821
|
made.append(p)
|
|
@@ -1464,6 +1914,15 @@ def _finish_gates(a, persist=True):
|
|
|
1464
1914
|
print(" 账本:", gj)
|
|
1465
1915
|
if rep:
|
|
1466
1916
|
print(" 报告:", rep, " <- 交付时把这个文件一起给出")
|
|
1917
|
+
try:
|
|
1918
|
+
_run_dir, _err = GT.archive_run(a.work, lg, report_path=rep,
|
|
1919
|
+
artifacts=[str(a.out) if getattr(a, "out", "") else ""])
|
|
1920
|
+
if _run_dir:
|
|
1921
|
+
print(" 归档:", _run_dir, "(work/runs/%s/)" % lg.run_id)
|
|
1922
|
+
elif _err:
|
|
1923
|
+
print(" [WARN] 归档失败:", _err)
|
|
1924
|
+
except Exception as e: # noqa: BLE001
|
|
1925
|
+
print(" [WARN] 归档失败:", e)
|
|
1467
1926
|
if c[GT.SKIP]:
|
|
1468
1927
|
print(" 注意:有 %d 项本次**没有执行**(skipped != 通过)" % c[GT.SKIP])
|
|
1469
1928
|
if errs:
|
|
@@ -1474,6 +1933,150 @@ def _finish_gates(a, persist=True):
|
|
|
1474
1933
|
return 0
|
|
1475
1934
|
|
|
1476
1935
|
|
|
1936
|
+
# ---------------------------------------------------------------- 批量
|
|
1937
|
+
BATCH_MAX_FILMS = 100
|
|
1938
|
+
BATCH_MAX_BYTES = 1 << 20 # 1 MiB:清单文件本身不该是个大 JSON
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
def _load_batch(path):
|
|
1942
|
+
"""读批量清单 -> [job]。支持两种写法(都接受):
|
|
1943
|
+
|
|
1944
|
+
["a/film.json", "b/film.json"] # 简写:只给剧本
|
|
1945
|
+
{"films": [{"script": "a/film.json", "work": "a/work",
|
|
1946
|
+
"scenes": "a/scenes.py", "out": "out/a.mp4"}]}
|
|
1947
|
+
|
|
1948
|
+
限制来自 MoneyPrinterTurbo 的工程经验(B 线调研):批量最容易死在"第 7 条跑一半
|
|
1949
|
+
才发现清单写错",所以数量/体积先卡死,并且在**开跑之前**全部预检一遍。
|
|
1950
|
+
"""
|
|
1951
|
+
p = str(path)
|
|
1952
|
+
if not os.path.exists(p):
|
|
1953
|
+
raise SystemExit("[batch] 清单不存在:%s" % p)
|
|
1954
|
+
if os.path.getsize(p) > BATCH_MAX_BYTES:
|
|
1955
|
+
raise SystemExit("[batch] 清单 %.1f KB 超过 %d KB 上限:拆成几份跑"
|
|
1956
|
+
% (os.path.getsize(p) / 1024.0, BATCH_MAX_BYTES // 1024))
|
|
1957
|
+
try:
|
|
1958
|
+
data = json.load(open(p, encoding="utf-8"))
|
|
1959
|
+
except json.JSONDecodeError as e:
|
|
1960
|
+
raise SystemExit("[batch] 清单不是合法 JSON:%s" % e) from e
|
|
1961
|
+
rows = data.get("films") if isinstance(data, dict) else data
|
|
1962
|
+
if not isinstance(rows, list) or not rows:
|
|
1963
|
+
raise SystemExit("[batch] 清单必须是数组,或 {\"films\": [...]} 且非空")
|
|
1964
|
+
if len(rows) > BATCH_MAX_FILMS:
|
|
1965
|
+
raise SystemExit("[batch] %d 条超过 %d 条上限:拆成几份跑"
|
|
1966
|
+
% (len(rows), BATCH_MAX_FILMS))
|
|
1967
|
+
base = os.path.dirname(os.path.abspath(p))
|
|
1968
|
+
jobs = []
|
|
1969
|
+
for i, row in enumerate(rows):
|
|
1970
|
+
if isinstance(row, str):
|
|
1971
|
+
row = {"script": row}
|
|
1972
|
+
if not isinstance(row, dict) or not row.get("script"):
|
|
1973
|
+
raise SystemExit("[batch] 第 %d 条缺少 script 字段:%r" % (i + 1, row))
|
|
1974
|
+
|
|
1975
|
+
def _abs(v, default=""):
|
|
1976
|
+
if not v:
|
|
1977
|
+
return default
|
|
1978
|
+
v = str(v)
|
|
1979
|
+
return v if os.path.isabs(v) else os.path.normpath(os.path.join(base, v))
|
|
1980
|
+
|
|
1981
|
+
script = _abs(row["script"])
|
|
1982
|
+
stem = os.path.splitext(os.path.basename(script))[0]
|
|
1983
|
+
jobs.append(dict(
|
|
1984
|
+
script=script,
|
|
1985
|
+
scenes=_abs(row.get("scenes"), os.path.join(os.path.dirname(script), "scenes.py")),
|
|
1986
|
+
work=_abs(row.get("work"), os.path.join(base, "work_" + stem)),
|
|
1987
|
+
out=_abs(row.get("out"), os.path.join(base, "out", stem + ".mp4")),
|
|
1988
|
+
label=str(row.get("label") or stem),
|
|
1989
|
+
))
|
|
1990
|
+
return jobs
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
def run_batch(a):
|
|
1994
|
+
"""批量渲染:先全量预检 -> 逐条隔离执行 -> JSON 汇总 + 退出码。
|
|
1995
|
+
|
|
1996
|
+
失败隔离是这套流程的全部价值:一条剧本写错,不该让另外 9 条都不出片。
|
|
1997
|
+
但也**不静默**:预检阶段就失败的条目会在开跑前一次性报全,跑的过程中失败的
|
|
1998
|
+
条目会带着退出码与日志尾进汇总。
|
|
1999
|
+
"""
|
|
2000
|
+
jobs = _load_batch(a.batch)
|
|
2001
|
+
print("== Batch(%d 条)" % len(jobs))
|
|
2002
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
2003
|
+
fc = os.path.join(here, "filmcheck.py")
|
|
2004
|
+
checks = []
|
|
2005
|
+
bad = 0
|
|
2006
|
+
for j in jobs:
|
|
2007
|
+
row = dict(label=j["label"], script=j["script"], ok=False, errors=[], warnings=[])
|
|
2008
|
+
if not os.path.exists(j["script"]):
|
|
2009
|
+
row["errors"].append("剧本不存在:%s" % j["script"])
|
|
2010
|
+
elif os.path.exists(fc):
|
|
2011
|
+
p = subprocess.run([sys.executable, fc, j["script"], "--json"],
|
|
2012
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
2013
|
+
errors="replace")
|
|
2014
|
+
try:
|
|
2015
|
+
d = json.loads((p.stdout or "").strip() or "{}")
|
|
2016
|
+
except json.JSONDecodeError:
|
|
2017
|
+
d = {}
|
|
2018
|
+
row["ok"] = bool(d.get("ok", p.returncode == 0))
|
|
2019
|
+
row["errors"] = [e.get("msg") if isinstance(e, dict) else str(e)
|
|
2020
|
+
for e in (d.get("errors") or [])][:8]
|
|
2021
|
+
row["warnings"] = len(d.get("warnings") or [])
|
|
2022
|
+
else:
|
|
2023
|
+
row["ok"] = True # 没装校验器:不阻断,但汇总里会说
|
|
2024
|
+
row["warnings"] = -1
|
|
2025
|
+
if not row["ok"]:
|
|
2026
|
+
bad += 1
|
|
2027
|
+
checks.append(row)
|
|
2028
|
+
for r in checks:
|
|
2029
|
+
print(" %s %-12s %s" % ("OK " if r["ok"] else "FAIL", r["label"],
|
|
2030
|
+
";".join(r["errors"][:3]) if r["errors"] else ""))
|
|
2031
|
+
if bad:
|
|
2032
|
+
rep_p = os.path.join(os.path.dirname(os.path.abspath(jobs[0]["work"])), "batch-report.json")
|
|
2033
|
+
# 预检阶段 work/ 目录通常还不存在(子进程要跑起来才会建),这里必须先建父目录:
|
|
2034
|
+
# 否则"清单里有一条写错"这个**本来只想报错退出**的路径,会先抛 FileNotFoundError,
|
|
2035
|
+
# 用户看到的是"[ERROR] 文件/目录操作失败",既丢了"一条都没跑"的说明,也没有台账。
|
|
2036
|
+
os.makedirs(os.path.dirname(rep_p), exist_ok=True)
|
|
2037
|
+
json.dump(dict(phase="preflight", films=checks), open(rep_p, "w", encoding="utf-8"),
|
|
2038
|
+
ensure_ascii=False, indent=1)
|
|
2039
|
+
raise SystemExit("[batch] 预检有 %d 条不通过,**一条都没跑**(清单/剧本先修好):%s\n"
|
|
2040
|
+
" 明细:%s" % (bad, "、".join(r["label"] for r in checks if not r["ok"]),
|
|
2041
|
+
rep_p))
|
|
2042
|
+
print(" 预检全部通过,开始逐条渲染(单条失败不阻断其余)")
|
|
2043
|
+
|
|
2044
|
+
results = []
|
|
2045
|
+
for j in jobs:
|
|
2046
|
+
print("\n---- [batch] %s ----" % j["label"])
|
|
2047
|
+
cmd = [sys.executable, os.path.join(here, "render.py"),
|
|
2048
|
+
"--script", j["script"], "--scenes", j["scenes"],
|
|
2049
|
+
"--work", j["work"], "--out", j["out"], "--stage", "all"]
|
|
2050
|
+
cmd += ["--engine", a.engine] if a.engine else []
|
|
2051
|
+
cmd += ["--cost"] if a.cost else []
|
|
2052
|
+
cmd += ["--strict-gates"] if a.strict_gates else []
|
|
2053
|
+
t0 = time.time()
|
|
2054
|
+
p = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
|
|
2055
|
+
errors="replace")
|
|
2056
|
+
wall = time.time() - t0
|
|
2057
|
+
tail = ((p.stdout or "") + (p.stderr or "")).strip().splitlines()[-6:]
|
|
2058
|
+
results.append(dict(label=j["label"], script=j["script"], out=j["out"],
|
|
2059
|
+
work=j["work"], exit=p.returncode, ok=(p.returncode == 0),
|
|
2060
|
+
wall_s=round(wall, 1), tail=tail))
|
|
2061
|
+
print(" %s 用时 %.1fs -> %s" % ("OK" if p.returncode == 0 else "FAIL", wall, j["out"]))
|
|
2062
|
+
if p.returncode != 0:
|
|
2063
|
+
for ln in tail:
|
|
2064
|
+
print(" | " + ln)
|
|
2065
|
+
|
|
2066
|
+
okn = sum(1 for r in results if r["ok"])
|
|
2067
|
+
rep_p = os.path.join(os.path.dirname(os.path.abspath(jobs[0]["work"])), "batch-report.json")
|
|
2068
|
+
os.makedirs(os.path.dirname(rep_p), exist_ok=True)
|
|
2069
|
+
json.dump(dict(phase="render", films=checks, results=results,
|
|
2070
|
+
summary=dict(total=len(results), ok=okn, failed=len(results) - okn)),
|
|
2071
|
+
open(rep_p, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
2072
|
+
print("\n== Batch 汇总:%d/%d 成功,%d 失败" % (okn, len(results), len(results) - okn))
|
|
2073
|
+
print(" 台账:%s" % rep_p)
|
|
2074
|
+
for r in results:
|
|
2075
|
+
if not r["ok"]:
|
|
2076
|
+
print(" FAIL %-12s exit=%s" % (r["label"], r["exit"]))
|
|
2077
|
+
return 0 if okn == len(results) else 1
|
|
2078
|
+
|
|
2079
|
+
|
|
1477
2080
|
# ---------------------------------------------------------------- main
|
|
1478
2081
|
def main():
|
|
1479
2082
|
_setup_stdio()
|
|
@@ -1506,8 +2109,24 @@ def main():
|
|
|
1506
2109
|
help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
|
|
1507
2110
|
ap.add_argument("--cost", action="store_true",
|
|
1508
2111
|
help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
|
|
2112
|
+
# ---- v0.8 ----
|
|
2113
|
+
ap.add_argument("--batch", default="",
|
|
2114
|
+
help="批量清单(JSON 数组或 {\"films\":[...]},≤100 条):"
|
|
2115
|
+
"先全量预检,再逐条渲染,单条失败不阻断其余")
|
|
2116
|
+
ap.add_argument("--vmaf", default="", choices=("", "full", "sample", "off"),
|
|
2117
|
+
help="qa 阶段的全片 VMAF/SSIM 模式(缺省读 film.json 的 qa.vmaf.mode)")
|
|
2118
|
+
ap.add_argument("--no-preflight", action="store_true",
|
|
2119
|
+
help="跳过渲染前资源预检(会在 QC 报告里留痕,不算通过)")
|
|
2120
|
+
ap.add_argument("--preview", default="",
|
|
2121
|
+
help="只渲前 N 秒做预览片(出 out/preview/*.mp4 + .gif),不写交付报告")
|
|
2122
|
+
ap.add_argument("--run-id", default="",
|
|
2123
|
+
help="本次运行的标签(写进 QC 报告,便于对照多版本)")
|
|
1509
2124
|
a = ap.parse_args()
|
|
1510
2125
|
|
|
2126
|
+
if a.batch:
|
|
2127
|
+
_setup_stdio()
|
|
2128
|
+
return run_batch(a)
|
|
2129
|
+
|
|
1511
2130
|
# work/ 本来就不存在 = 这一跑自己产出全部中间物,别把"我还没跑到"当成"产物过时"。
|
|
1512
2131
|
work_pre_exists = os.path.isdir(a.work)
|
|
1513
2132
|
try:
|
|
@@ -1534,7 +2153,7 @@ def main():
|
|
|
1534
2153
|
web_entry = "" # 页面还没建:交给后面的 stage 报错,账本先照建
|
|
1535
2154
|
_LEDGER = GT.GateLedger(a.work, script, scenes_path=a.scenes,
|
|
1536
2155
|
script_path=a.script, engine=engine,
|
|
1537
|
-
web_entry=web_entry)
|
|
2156
|
+
web_entry=web_entry, run_id=a.run_id)
|
|
1538
2157
|
if a.no_gate:
|
|
1539
2158
|
_LEDGER.bypass("人工签核检查(--no-gate)", "本条不算通过,只记录")
|
|
1540
2159
|
print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
|
|
@@ -1563,11 +2182,18 @@ def main():
|
|
|
1563
2182
|
直接跳过检查连痕都没有——那就成了静默。
|
|
1564
2183
|
"""
|
|
1565
2184
|
if name:
|
|
2185
|
+
# 本阶段本轮一定真的会跑,所以必须进 pending:`--stage tts` 会顺带排轴、
|
|
2186
|
+
# `--stage stills` 会自动补 tts/layout,这些"顺手跑的下游"不在 _planned(stage)
|
|
2187
|
+
# 里;不加的话 --strict-stale 下刚写完的 tts.json 会把 layout 判成
|
|
2188
|
+
# "输入变了却还要用旧产物",把自动补前置这条路径整个堵死。
|
|
2189
|
+
pending = list(_planned(stage))
|
|
2190
|
+
if name not in pending:
|
|
2191
|
+
pending.append(name)
|
|
1566
2192
|
errs, _warns = SW.report(a.work, [name], strict=a.strict_stale,
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
2193
|
+
allow_stale=a.allow_stale,
|
|
2194
|
+
ledger=_LEDGER, sources=_sources(),
|
|
2195
|
+
self_built=not work_pre_exists,
|
|
2196
|
+
pending=pending)
|
|
1571
2197
|
if errs:
|
|
1572
2198
|
# --strict-stale 的承诺是"上游产物过时算失败"。stagewatch.report 只把
|
|
1573
2199
|
# 问题放进返回值(并往账本记一条 fail),它自己不抛异常——所以必须在这里
|
|
@@ -1672,6 +2298,56 @@ def main():
|
|
|
1672
2298
|
|
|
1673
2299
|
stage = a.stage
|
|
1674
2300
|
|
|
2301
|
+
# ---- preview:前 N 秒试片(v0.8)----
|
|
2302
|
+
if a.preview:
|
|
2303
|
+
try:
|
|
2304
|
+
secs = float(a.preview)
|
|
2305
|
+
except ValueError:
|
|
2306
|
+
raise SystemExit("[preview] 需要秒数,例如 --preview 15")
|
|
2307
|
+
if secs <= 0:
|
|
2308
|
+
raise SystemExit("[preview] 秒数必须 > 0")
|
|
2309
|
+
tl = ensure_tl()
|
|
2310
|
+
mod = load_scenes()
|
|
2311
|
+
pdir = os.path.dirname(os.path.abspath(a.out))
|
|
2312
|
+
os.makedirs(pdir, exist_ok=True)
|
|
2313
|
+
stem = os.path.splitext(os.path.basename(a.out))[0]
|
|
2314
|
+
out_mp4 = os.path.join(pdir, "preview", stem + "_preview.mp4")
|
|
2315
|
+
os.makedirs(os.path.dirname(out_mp4), exist_ok=True)
|
|
2316
|
+
n = min(int(round(secs * float(tl["fps"]))), int(tl["frames"]))
|
|
2317
|
+
print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
|
|
2318
|
+
sc2 = dict(script)
|
|
2319
|
+
sc2["_total"] = tl["total"]
|
|
2320
|
+
sc2.setdefault("_style", resolve_style(script))
|
|
2321
|
+
_render_range(tl, mod, sc2, 0, n, out_mp4, int(script.get("crf", 18)),
|
|
2322
|
+
str(script.get("preset", "medium")), progress=False)
|
|
2323
|
+
# 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
|
|
2324
|
+
# 注意:**必须先把音轨截到预览时长**——K.mux 会校验音视频时长差 < 0.05s,
|
|
2325
|
+
# 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"(v0.8 开发期实测)。
|
|
2326
|
+
audio = os.path.join(a.work, "audio.wav")
|
|
2327
|
+
if os.path.exists(audio):
|
|
2328
|
+
tmp = out_mp4 + ".mux.mp4"
|
|
2329
|
+
try:
|
|
2330
|
+
pcm, _sr = K.wav_read(audio)
|
|
2331
|
+
need = int(round(n / float(tl["fps"]) * float(_sr)))
|
|
2332
|
+
if need > 0:
|
|
2333
|
+
pcm = pcm[:need]
|
|
2334
|
+
p_audio = os.path.join(a.work, "preview_audio.wav")
|
|
2335
|
+
K.wav_write(p_audio, pcm)
|
|
2336
|
+
audio = p_audio
|
|
2337
|
+
K.mux(out_mp4, audio, tmp, loudnorm=False)
|
|
2338
|
+
os.replace(tmp, out_mp4)
|
|
2339
|
+
except Exception as e: # noqa: BLE001
|
|
2340
|
+
print(" [WARN] 预览片混音失败(保留无声版):%s" % e)
|
|
2341
|
+
gif = os.path.splitext(out_mp4)[0] + ".gif"
|
|
2342
|
+
try:
|
|
2343
|
+
_to_gif(out_mp4, gif, fps=min(10, int(tl["fps"])), width=480)
|
|
2344
|
+
except Exception as e: # noqa: BLE001
|
|
2345
|
+
print(" [WARN] GIF 生成失败(mp4 仍然可用):%s" % e)
|
|
2346
|
+
print(" 预览片:", out_mp4)
|
|
2347
|
+
if os.path.exists(gif):
|
|
2348
|
+
print(" GIF :", gif)
|
|
2349
|
+
return 0
|
|
2350
|
+
|
|
1675
2351
|
# ---- explain:只报"哪些阶段过时了",不跑任何 stage ----
|
|
1676
2352
|
if stage == "explain":
|
|
1677
2353
|
rows = SW.explain(a.work, sources=_sources())
|
|
@@ -1754,8 +2430,10 @@ def main():
|
|
|
1754
2430
|
_need(os.path.exists(os.path.join(a.work, "style.json")), "style.json 没写出来")
|
|
1755
2431
|
_LEDGER.ok("style", "风格包解析", str(script["_style"].get("style")))
|
|
1756
2432
|
_LEDGER.write_json()
|
|
1757
|
-
_finish_gates
|
|
1758
|
-
|
|
2433
|
+
# 不能无条件 return 0:--strict-gates 下 skipped 会让 _finish_gates 返回 1,
|
|
2434
|
+
# 账本里也可能已经有 fail —— 门禁结论必须落到退出码,否则 CI 把失败当成功。
|
|
2435
|
+
rc = _finish_gates(a)
|
|
2436
|
+
return 1 if (rc or _LEDGER.fails()) else 0
|
|
1759
2437
|
|
|
1760
2438
|
if stage in ("all", "tts"):
|
|
1761
2439
|
_t0 = _stage_begin("tts")
|
|
@@ -1826,8 +2504,10 @@ def main():
|
|
|
1826
2504
|
_stage_end("stills", _t0)
|
|
1827
2505
|
_LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
|
|
1828
2506
|
"%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
|
|
1829
|
-
|
|
1830
|
-
|
|
2507
|
+
# 0 张静帧 = fail 门禁:以前这里无条件 return 0,`--stills 写错 id` 会
|
|
2508
|
+
# "全部跳过 + 退出码 0",CI 看不出根本没出图。
|
|
2509
|
+
rc = _finish_gates(a)
|
|
2510
|
+
return 1 if (rc or _LEDGER.fails()) else 0
|
|
1831
2511
|
|
|
1832
2512
|
if stage == "qa":
|
|
1833
2513
|
# qa 不重渲、也不重新合成:缺成片就直接报错;缺 scenes.py 只丢参考帧、不阻断质检。
|
|
@@ -1835,7 +2515,7 @@ def main():
|
|
|
1835
2515
|
_need(os.path.exists(a.out), "没有成片 %s,先跑 --stage mux" % a.out)
|
|
1836
2516
|
mod = load_scenes(strict=False) if engine == "pil" else None
|
|
1837
2517
|
_t0 = _stage_begin("qa")
|
|
1838
|
-
errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script)
|
|
2518
|
+
errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script, vmaf_mode=a.vmaf)
|
|
1839
2519
|
_stage_end("qa", _t0)
|
|
1840
2520
|
_check_signoff("qa")
|
|
1841
2521
|
rc = _finish_gates(a)
|
|
@@ -1851,6 +2531,10 @@ def main():
|
|
|
1851
2531
|
# audio.wav 已经在前面阶段生成——否则 --stage frames 在空 work 目录会白渲全片再挂掉。
|
|
1852
2532
|
if stage == "all":
|
|
1853
2533
|
_need(os.path.exists(audio), "混音阶段异常:audio.wav 未生成")
|
|
2534
|
+
if not _run_preflight(script, a.work, engine, tl=tl,
|
|
2535
|
+
skip=bool(a.no_preflight)):
|
|
2536
|
+
print("\n[渲染前预检未过] 按上面逐条修;确认要硬跑加 --no-preflight(会留痕)")
|
|
2537
|
+
return 1
|
|
1854
2538
|
if engine == "pil":
|
|
1855
2539
|
mod = load_scenes()
|
|
1856
2540
|
_t0 = _stage_begin("frames")
|
|
@@ -1859,8 +2543,9 @@ def main():
|
|
|
1859
2543
|
_stage_end("frames", _t0, frames=int(tl["frames"]))
|
|
1860
2544
|
if stage == "frames":
|
|
1861
2545
|
_LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
|
|
1862
|
-
|
|
1863
|
-
|
|
2546
|
+
# 同上:strict-gates 下 skipped 也要反映到退出码(以前这里只看 fails())。
|
|
2547
|
+
rc = _finish_gates(a)
|
|
2548
|
+
return 1 if (rc or _LEDGER.fails()) else 0
|
|
1864
2549
|
if stage in ("all", "mux"):
|
|
1865
2550
|
# mux 只需要 silent.mp4 + audio.wav:不加载 scenes.py(缺场景模块也能合成)
|
|
1866
2551
|
_need(os.path.exists(silent), "没有 silent.mp4,先跑 --stage frames")
|
|
@@ -1871,11 +2556,13 @@ def main():
|
|
|
1871
2556
|
_check_signoff("mux")
|
|
1872
2557
|
if stage == "mux":
|
|
1873
2558
|
_LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
|
|
1874
|
-
|
|
1875
|
-
|
|
2559
|
+
# 同上:strict-gates 下 skipped 也要反映到退出码(以前这里只看 fails())。
|
|
2560
|
+
rc = _finish_gates(a)
|
|
2561
|
+
return 1 if (rc or _LEDGER.fails()) else 0
|
|
1876
2562
|
if stage == "all":
|
|
1877
2563
|
_t0 = _stage_begin("qa")
|
|
1878
|
-
errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script
|
|
2564
|
+
errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script,
|
|
2565
|
+
vmaf_mode=a.vmaf)
|
|
1879
2566
|
_stage_end("qa", _t0)
|
|
1880
2567
|
rc = _finish_gates(a)
|
|
1881
2568
|
if errs or rc:
|