@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.7.2
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/README.md +93 -18
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/index.js +20 -7
- package/package.json +13 -3
- package/schemas/film.schema.json +599 -0
- package/skills/video-studio/SKILL.md +252 -28
- package/skills/video-studio/assets/film.json +78 -24
- package/skills/video-studio/assets/scenes.py +1005 -42
- package/skills/video-studio/assets/web/engine.js +18 -1
- package/skills/video-studio/assets/web/film.js +29 -4
- package/skills/video-studio/assets/web/index.html +5 -0
- package/skills/video-studio/references/audio-variation.md +158 -0
- package/skills/video-studio/references/audio.md +96 -6
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +13 -1
- package/skills/video-studio/scripts/filmcheck.py +725 -0
- package/skills/video-studio/scripts/filmkit.py +953 -76
- package/skills/video-studio/scripts/gates.py +509 -0
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +942 -45
- package/skills/video-studio/scripts/scorekit.py +1887 -27
- package/skills/video-studio/scripts/selfcheck.py +648 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +405 -0
- package/skills/video-studio/scripts/stylekit.py +1012 -0
- package/skills/video-studio/scripts/voicekit.py +880 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +450 -0
- package/tools/estimate.py +657 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""render.py — 一条命令从剧本到成片(v0.
|
|
2
|
+
"""render.py — 一条命令从剧本到成片(v0.5.0)。
|
|
3
3
|
|
|
4
4
|
python render.py --script film.json --scenes scenes.py --out out/film.mp4 [--stage all]
|
|
5
5
|
|
|
6
6
|
阶段(每个 stage 只做自己的事,不顺手重渲全片):
|
|
7
|
-
|
|
7
|
+
style 解析风格包 -> work/style.json(画风/配乐/旁白/节奏一次定死,v0.5)
|
|
8
|
+
tts 逐句合成旁白 + 实测时长(带输入校验的缓存;按声线计划换人换语气)
|
|
8
9
|
layout 由实测时长排时间轴 -> timeline.json / sub.srt / sub.ass(含停留窗口 hold)
|
|
9
10
|
cues 画面提示点 -> work/cues.json(web 引擎从页面导出,pil 引擎从 film.json 生成)
|
|
10
11
|
score 程序化配乐 -> work/music.wav(scorekit;无则跳过)
|
|
@@ -14,10 +15,17 @@
|
|
|
14
15
|
mux 合成(视频流 copy,两遍 loudnorm)
|
|
15
16
|
qa 质检 + 抽帧 + 联系表 + 帧级深检(闪烁/空窗/音画同步)
|
|
16
17
|
stills 只渲染若干关键帧做母版预览(不生成视频)
|
|
18
|
+
accept 人工签核一道闸(--gate style|stills|facts|release),写 work/signoff.json
|
|
19
|
+
explain 打印"现在哪些 stage 是旧的、为什么、该跑什么"(--explain-stale 的入口)
|
|
17
20
|
|
|
18
|
-
依赖顺序:tts -> layout -> cues -> score -> sfx -> audio -> frames -> mux -> qa。
|
|
21
|
+
依赖顺序:style -> tts -> layout -> cues -> score -> sfx -> audio -> frames -> mux -> qa。
|
|
19
22
|
--stage all 按顺序跑完;单跑某个 stage 时,若前置产物缺失会明确报错并提示先跑哪个。
|
|
20
23
|
--engine 覆盖 film.json 的 engine:pil = Pillow 逐帧(默认,依赖最少);web = 无头浏览器逐帧。
|
|
24
|
+
|
|
25
|
+
v0.6.0 起每个 stage 都往「门禁账本」记结论(pass / fail / skipped 三档),并在
|
|
26
|
+
--stage qa(或 --stage all 收尾)写出 out/QC-REPORT.md:这是**机器可读的交付结论**,
|
|
27
|
+
取代"agent 在对话里复述质检结果"。人工签核走 --stage accept,绑内容指纹——
|
|
28
|
+
改了剧本 / scenes.py / 引擎,旧签核自动失效。
|
|
21
29
|
"""
|
|
22
30
|
from __future__ import annotations
|
|
23
31
|
|
|
@@ -36,8 +44,22 @@ import numpy as np
|
|
|
36
44
|
HERE = Path(__file__).resolve().parent
|
|
37
45
|
sys.path.insert(0, str(HERE))
|
|
38
46
|
import filmkit as K
|
|
47
|
+
import gates as GT
|
|
48
|
+
import stagewatch as SW
|
|
49
|
+
|
|
50
|
+
STAGES = ("style", "tts", "layout", "cues", "score", "sfx", "audio", "frames",
|
|
51
|
+
"mux", "qa", "stills", "accept", "explain", "all")
|
|
52
|
+
|
|
53
|
+
# _render_range 每帧都要主题:按 script 对象缓存一次,避免每帧重新解析(几百毫秒的浪费)
|
|
54
|
+
_THEME_CACHE = {}
|
|
55
|
+
|
|
56
|
+
# 本次运行的门禁账本(main() 里建;stage 函数通过它记账)
|
|
57
|
+
_LEDGER = None
|
|
58
|
+
|
|
39
59
|
|
|
40
|
-
|
|
60
|
+
def _gate():
|
|
61
|
+
"""取当前账本;被单测直接调用 stage 函数时返回 None(不记账,照常打印)。"""
|
|
62
|
+
return _LEDGER
|
|
41
63
|
|
|
42
64
|
|
|
43
65
|
def _setup_stdio():
|
|
@@ -121,6 +143,26 @@ def _web_entry(script):
|
|
|
121
143
|
return entry
|
|
122
144
|
|
|
123
145
|
|
|
146
|
+
def _web_where(entry):
|
|
147
|
+
"""报错里告诉用户 DURATION / FPS 到底写在哪:入口 HTML + 同目录脚本 + 一条能照抄的 grep。
|
|
148
|
+
|
|
149
|
+
只报入口 HTML 不够用:模板的 window.DURATION 写在 web/film.js 开头(engine.js 另有
|
|
150
|
+
兜底默认值),用户拿到 index.html 还是得全文翻。这里把候选文件一起列出来。
|
|
151
|
+
"""
|
|
152
|
+
d = os.path.dirname(entry)
|
|
153
|
+
try:
|
|
154
|
+
scripts = sorted(n for n in os.listdir(d)
|
|
155
|
+
if n.lower().endswith((".js", ".mjs", ".html")))
|
|
156
|
+
except OSError:
|
|
157
|
+
scripts = []
|
|
158
|
+
tail = ("要改的文件:%s(入口 HTML)" % entry)
|
|
159
|
+
if scripts:
|
|
160
|
+
tail += ";页面脚本在 %s%s" % (
|
|
161
|
+
d, "(%s)" % " / ".join(scripts) if len(scripts) <= 6 else "")
|
|
162
|
+
tail += "\n 查 DURATION / FPS 定义在哪:grep -n \"window.DURATION\\|global.DURATION\\|DURATION =\" %s" % d
|
|
163
|
+
return tail
|
|
164
|
+
|
|
165
|
+
|
|
124
166
|
# ---------------------------------------------------------------- 装载
|
|
125
167
|
def load_module(path: str, name="film_scenes"):
|
|
126
168
|
"""每次都用独立模块名 + 全新 module 对象,避免跨片污染(历史坑:渲染出上一部片)。"""
|
|
@@ -152,12 +194,18 @@ def load_script(p) -> dict:
|
|
|
152
194
|
for k in ("music", "sfx", "web"):
|
|
153
195
|
if s.get(k) is not None and not isinstance(s.get(k), dict):
|
|
154
196
|
raise SystemExit("film.json 的 %s 必须是对象(当前是 %s)" % (k, type(s[k]).__name__))
|
|
155
|
-
|
|
197
|
+
# v0.5:bpm 允许 "auto"(由 stylekit 按风格梯度 + 内容指纹决定)。
|
|
198
|
+
# 同时允许写在 music.bpm 里(优先级更高)。
|
|
199
|
+
for _k, _v in (("bpm", s.get("bpm")),
|
|
200
|
+
("music.bpm", (s.get("music") or {}).get("bpm"))):
|
|
201
|
+
if _v in (None, "") or (isinstance(_v, str) and _v.strip().lower() == "auto"):
|
|
202
|
+
continue
|
|
156
203
|
try:
|
|
157
|
-
if float(
|
|
204
|
+
if float(_v) <= 0:
|
|
158
205
|
raise ValueError("必须大于 0")
|
|
159
206
|
except (TypeError, ValueError) as e:
|
|
160
|
-
raise SystemExit("film.json 的
|
|
207
|
+
raise SystemExit("film.json 的 %s 必须是正数或 \"auto\"(收到 %r:%s)"
|
|
208
|
+
% (_k, _v, e)) from e
|
|
161
209
|
seen = set()
|
|
162
210
|
for i, ln in enumerate(lines):
|
|
163
211
|
if not isinstance(ln, dict):
|
|
@@ -181,10 +229,113 @@ def _need(cond, msg):
|
|
|
181
229
|
raise SystemExit("[前置产物缺失] " + msg)
|
|
182
230
|
|
|
183
231
|
|
|
232
|
+
# ---------------------------------------------------------------- 风格解析(v0.5.0)
|
|
233
|
+
def _stylekit():
|
|
234
|
+
"""惰性 import stylekit;缺文件时给一句能照着做的错,而不是 ModuleNotFoundError。"""
|
|
235
|
+
try:
|
|
236
|
+
import stylekit as ST
|
|
237
|
+
return ST
|
|
238
|
+
except Exception as e: # noqa: BLE001
|
|
239
|
+
raise SystemExit("[缺少 stylekit] v0.5 起 render.py 需要同目录的 stylekit.py"
|
|
240
|
+
"(skills/video-studio/scripts/stylekit.py):%s" % e)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def resolve_style(script, work=None, verbose=True):
|
|
244
|
+
"""解析风格计划并挂到 script["_style"] 上(同一进程内各 stage 共享)。
|
|
245
|
+
|
|
246
|
+
纯函数 + 落盘 work/style.json(审计用:用户能看到"为什么是这套风格")。
|
|
247
|
+
"""
|
|
248
|
+
if isinstance(script.get("_style"), dict):
|
|
249
|
+
return script["_style"]
|
|
250
|
+
ST = _stylekit()
|
|
251
|
+
plan = ST.resolve_script_style(script)
|
|
252
|
+
# 主题体检:坏主题现在拦,不要等渲完 300 帧。
|
|
253
|
+
# v0.7.2 修:以前只体检**基础主题**(ST.THEMES[theme_id]),而 scenes 实际用的是
|
|
254
|
+
# plan["theme"](= 基础主题 + film.json 的 visual.theme_overrides)。于是用 overrides
|
|
255
|
+
# 把 ink/bg 调成低对比度时,体检照样 PASS,渲完才发现字幕看不清。
|
|
256
|
+
# 实测:overrides ink=[30,30,30] / bg=[10,10,10] -> 基础主题 lint 为空,
|
|
257
|
+
# 最终主题 lint = ['ink 与背景对比度 1.09 < 7.0']。
|
|
258
|
+
bad = ST.theme_lint(plan.get("theme") or ST.THEMES[plan["theme_id"]])
|
|
259
|
+
if bad:
|
|
260
|
+
raise SystemExit("[主题体检失败] %s:%s" % (plan["theme_id"], ";".join(bad)))
|
|
261
|
+
script["_style"] = plan
|
|
262
|
+
if work and verbose:
|
|
263
|
+
path = os.path.join(work, "style.json")
|
|
264
|
+
try:
|
|
265
|
+
os.makedirs(work, exist_ok=True)
|
|
266
|
+
json.dump(plan, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
267
|
+
except Exception as e: # noqa: BLE001
|
|
268
|
+
print(" [WARN] style.json 落盘失败:%s" % e)
|
|
269
|
+
return plan
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _theme_for(script):
|
|
273
|
+
"""返回当前片的主题字典;legacy 返回 None(= v0.4 的硬编码外观,逐字节兼容)。"""
|
|
274
|
+
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
275
|
+
if plan is not None and plan.get("legacy"):
|
|
276
|
+
return None
|
|
277
|
+
plan = resolve_style(script)
|
|
278
|
+
if plan.get("legacy"):
|
|
279
|
+
return None
|
|
280
|
+
return _stylekit().THEMES[plan["theme_id"]]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _print_style(plan):
|
|
284
|
+
m = plan["music"]
|
|
285
|
+
v = plan["voice"]
|
|
286
|
+
print(" 风格: %s(%s)%s" % (plan["style"], plan["style_source"],
|
|
287
|
+
"" if plan["legacy"] else " / " + plan["style_name"]))
|
|
288
|
+
print(" 理由: %s" % ";".join(plan["style_reason"]))
|
|
289
|
+
print(" 主题: %s 版式: %s" % (plan["theme_id"],
|
|
290
|
+
",".join(sorted(set(plan["chapter_layout"].values())))
|
|
291
|
+
or plan["layout_default"]))
|
|
292
|
+
print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
|
|
293
|
+
% (m["style"], m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
|
|
294
|
+
if v["map"]:
|
|
295
|
+
print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
|
|
296
|
+
p = plan["pacing"]
|
|
297
|
+
print(" 节奏: lead %.2f / tail %.2f / min_dur %.1f / 条目步长 %.2f"
|
|
298
|
+
% (p["lead"], p["tail"], p["min_dur"], p["item_stagger"]))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def st_style(script, work):
|
|
302
|
+
print("== Style")
|
|
303
|
+
plan = resolve_style(script, work)
|
|
304
|
+
_print_style(plan)
|
|
305
|
+
return plan
|
|
306
|
+
|
|
307
|
+
|
|
184
308
|
# ---------------------------------------------------------------- TTS
|
|
309
|
+
def _voice_plan(script, plan):
|
|
310
|
+
"""声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走 v0.4 单声线路径。"""
|
|
311
|
+
try:
|
|
312
|
+
import voicekit as VK
|
|
313
|
+
except Exception as e: # noqa: BLE001
|
|
314
|
+
print(" [WARN] 没有 voicekit.py,本次旁白单声线(v0.4 行为):%s" % e)
|
|
315
|
+
return None
|
|
316
|
+
try:
|
|
317
|
+
vp = VK.voice_plan(script, plan)
|
|
318
|
+
except Exception as e: # noqa: BLE001
|
|
319
|
+
print(" [WARN] voicekit 解析失败,退回单声线:%s" % e)
|
|
320
|
+
return None
|
|
321
|
+
return vp
|
|
322
|
+
|
|
323
|
+
|
|
185
324
|
def st_tts(script, work, force=False):
|
|
186
325
|
lines = script["lines"]
|
|
326
|
+
plan = resolve_style(script, work)
|
|
187
327
|
print("== TTS (%d 句)" % len(lines))
|
|
328
|
+
if plan["legacy"]:
|
|
329
|
+
print(" 声线: v0.4 兼容(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
|
|
330
|
+
vp = None
|
|
331
|
+
else:
|
|
332
|
+
vp = _voice_plan(script, plan)
|
|
333
|
+
if vp:
|
|
334
|
+
import voicekit as VK
|
|
335
|
+
print(VK.describe(vp))
|
|
336
|
+
json.dump({"style": plan["style"], "policy": plan["voice"]["policy"], "lines": vp},
|
|
337
|
+
open(os.path.join(work, "voice_plan.json"), "w", encoding="utf-8"),
|
|
338
|
+
ensure_ascii=False, indent=1)
|
|
188
339
|
if force:
|
|
189
340
|
shutil.rmtree(os.path.join(work, "tts"), ignore_errors=True)
|
|
190
341
|
done = K.tts_batch(lines, work,
|
|
@@ -192,7 +343,12 @@ def st_tts(script, work, force=False):
|
|
|
192
343
|
rate=script.get("rate", "+0%"),
|
|
193
344
|
pitch=script.get("pitch", "+0Hz"),
|
|
194
345
|
workers=int(script.get("tts_workers", 4)),
|
|
195
|
-
trim=bool(script.get("trim_silence", True))
|
|
346
|
+
trim=bool(script.get("trim_silence", True)),
|
|
347
|
+
plan=None if vp is None else
|
|
348
|
+
[{"voice": r["voice"], "rate": r["rate"], "pitch": r["pitch"],
|
|
349
|
+
"volume": r.get("volume", "+0%"),
|
|
350
|
+
"persona": r.get("persona"), "tone": r.get("tone")} for r in vp],
|
|
351
|
+
trim_db=float(script.get("trim_db", -42.0)))
|
|
196
352
|
json.dump(done, open(os.path.join(work, "tts.json"), "w", encoding="utf-8"),
|
|
197
353
|
ensure_ascii=False, indent=1)
|
|
198
354
|
total = sum(float(x["dur"]) for x in done)
|
|
@@ -203,21 +359,46 @@ def st_tts(script, work, force=False):
|
|
|
203
359
|
# ---------------------------------------------------------------- 排版
|
|
204
360
|
def st_layout(script, work, lines=None):
|
|
205
361
|
lines = lines or json.load(open(os.path.join(work, "tts.json"), encoding="utf-8"))
|
|
362
|
+
plan = resolve_style(script, work)
|
|
206
363
|
print("== Layout")
|
|
364
|
+
pace = plan["pacing"]
|
|
365
|
+
# 逐句 lead/tail:voicekit 的 voice_plan 已经算好每句的停顿,这里只负责喂给时间轴。
|
|
366
|
+
# 旧项目(legacy)没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数,v0.4 行为。
|
|
367
|
+
vp = None
|
|
368
|
+
vp_path = os.path.join(work, "voice_plan.json")
|
|
369
|
+
if not plan["legacy"] and os.path.exists(vp_path):
|
|
370
|
+
try:
|
|
371
|
+
vp = json.load(open(vp_path, encoding="utf-8"))["lines"]
|
|
372
|
+
except Exception as e: # noqa: BLE001
|
|
373
|
+
print(" [WARN] voice_plan.json 读不了,逐句停顿退回全局值:%s" % e)
|
|
374
|
+
lead_map = {r["id"]: float(r["lead"]) for r in vp} if vp else None
|
|
375
|
+
tail_map = {r["id"]: float(r["tail"]) for r in vp} if vp else None
|
|
376
|
+
if vp and script.get("tail_map"):
|
|
377
|
+
tail_extra = script["tail_map"]
|
|
378
|
+
for lid, v in tail_extra.items():
|
|
379
|
+
if lid in tail_map:
|
|
380
|
+
tail_map[lid] = float(v)
|
|
207
381
|
tl = K.build_timeline(lines,
|
|
208
|
-
lead=script.get("lead",
|
|
209
|
-
tail=script.get("tail",
|
|
210
|
-
min_dur=script.get("min_dur",
|
|
211
|
-
tail_map=script.get("tail_map"),
|
|
382
|
+
lead=script.get("lead", pace["lead"]),
|
|
383
|
+
tail=script.get("tail", pace["tail"]),
|
|
384
|
+
min_dur=script.get("min_dur", pace["min_dur"]),
|
|
385
|
+
tail_map=tail_map or script.get("tail_map"),
|
|
386
|
+
lead_map=lead_map,
|
|
387
|
+
hold=script.get("hold", pace["hold"]),
|
|
212
388
|
max_dur=script.get("max_dur"))
|
|
213
389
|
tl["title"] = script["title"]
|
|
214
390
|
tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
|
|
215
391
|
tl["crf"] = script.get("crf", 18)
|
|
216
392
|
tl["preset"] = script.get("preset", "medium")
|
|
393
|
+
tl["style"] = {"style": plan["style"], "theme_id": plan["theme_id"],
|
|
394
|
+
"legacy": plan["legacy"],
|
|
395
|
+
"chapter_layout": plan["chapter_layout"],
|
|
396
|
+
"item_stagger": pace["item_stagger"]}
|
|
217
397
|
json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
|
|
218
398
|
ensure_ascii=False, indent=1)
|
|
219
399
|
n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
|
|
220
|
-
|
|
400
|
+
theme = (K._stylekit().THEMES[plan["theme_id"]] if not plan["legacy"] else None)
|
|
401
|
+
n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
|
|
221
402
|
print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
|
|
222
403
|
% (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
|
|
223
404
|
# 安全区自查:字幕一旦越界,mux 前就能发现
|
|
@@ -283,6 +464,19 @@ def st_cues(script, work, tl, engine="pil"):
|
|
|
283
464
|
except Exception as e: # noqa: BLE001
|
|
284
465
|
raise SystemExit("scenes.py 的 cues() 钩子报错:%s" % e) from e
|
|
285
466
|
n_hook = _extend_safe(got, "scenes.py")
|
|
467
|
+
# v0.7.2:web 引擎下要提醒"两套时间基准混用"。
|
|
468
|
+
# scenes.py 的 cues() 是按 **PIL 时间轴**(timeline.json 的段落与 visual_at)算的,
|
|
469
|
+
# 页面动画则按 **页面自己的时间轴**(window.DURATION)。两者合并进同一份 cues.json 后:
|
|
470
|
+
# - 音效会按 PIL 时间响(页面画面此刻可能正在另一幕);
|
|
471
|
+
# - 音画同步门禁能把这种配对识别为"跨基准"并跳过(qa_frames 的 src 过滤),不报假 FAIL,
|
|
472
|
+
# 但**声音与画面对不上的事实仍然存在**。
|
|
473
|
+
# 不静默:说清现象与两条改法,让用户自己选路线。
|
|
474
|
+
if engine == "web" and n_hook:
|
|
475
|
+
print(" [WARN] web 引擎下仍合并了 scenes.py 的 %d 条 cue —— 它们按 PIL 时间轴生成,"
|
|
476
|
+
"页面动画按页面时间轴播放,两者基准不同:音效可能响在错误的幕上。" % n_hook)
|
|
477
|
+
print(" 两种改法:① 走 PIL 路线(--engine pil)用 scenes.py 这套 cue;"
|
|
478
|
+
"② 走 web 路线就把 cue 写进页面(window.getCues())或 film.json 的 sfx.cues,"
|
|
479
|
+
"并删除 scenes.py 的 cues() 钩子。")
|
|
286
480
|
|
|
287
481
|
# web 引擎:从页面导出(需要浏览器)
|
|
288
482
|
n_web = 0
|
|
@@ -359,10 +553,58 @@ def st_score(script, work, tl):
|
|
|
359
553
|
s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
|
|
360
554
|
fixed.append(s2)
|
|
361
555
|
sections = fixed
|
|
556
|
+
plan_s = resolve_style(script)
|
|
557
|
+
mp = plan_s["music"]
|
|
362
558
|
t0 = time.time()
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
559
|
+
style_arg = None if plan_s["legacy"] else str(mp.get("style") or plan_s["style"])
|
|
560
|
+
kwargs = dict(bpm=float(mp["bpm"]), key=str(mp["key"] or "Dm"), anchors=anchors,
|
|
561
|
+
sections=sections, seed=int(mp["seed_effective"]))
|
|
562
|
+
# v0.7:内容指纹参与配乐结构变奏(和弦替换 / 动机级数 / 鼓型 ghost,见
|
|
563
|
+
# references/audio-variation.md §3.3)。只在非 legacy 时传——不传 fingerprint 的
|
|
564
|
+
# scorekit 认不出这个 kwarg,会走下面的 TypeError 回退链,照样出片只少变奏。
|
|
565
|
+
# legacy 必须不传:v0.4 的逐样本兼容是硬契约(selfcheck 20/26 条守着)。
|
|
566
|
+
fp_hex = ""
|
|
567
|
+
if not plan_s["legacy"]:
|
|
568
|
+
fp_hex = str((plan_s.get("fingerprint") or {}).get("full") or "")
|
|
569
|
+
kw_fp = dict(kwargs, fingerprint=fp_hex) if fp_hex else kwargs
|
|
570
|
+
# scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
|
|
571
|
+
# 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
|
|
572
|
+
# 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
|
|
573
|
+
# 四级回退:带指纹 -> 不带指纹 -> 只有 style/mood -> 最老的签名。
|
|
574
|
+
pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
|
|
575
|
+
"hold": float(plan_s["pacing"]["hold"])}
|
|
576
|
+
mood_arg = str(mp.get("mood") or "steady")
|
|
577
|
+
samples = report = None
|
|
578
|
+
# v0.7.2:多级签名兼容链以前对**任何** TypeError 都继续换签名试,最后统一报
|
|
579
|
+
# "不接受任何一种已知签名"。scorekit 内部真出 TypeError(例如某个参数是 None 被拿去做
|
|
580
|
+
# 算术)时,用户会照着"签名不兼容"的方向白找半天。这里区分两类:
|
|
581
|
+
# - 报错文本含 "unexpected keyword argument" -> 确实是不认识这个参数,换签名重试;
|
|
582
|
+
# - 其它 TypeError -> 是**内部**错误,带上签名与错误信息立刻报,别吞。
|
|
583
|
+
attempts = (
|
|
584
|
+
dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kw_fp),
|
|
585
|
+
dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kwargs),
|
|
586
|
+
dict(style=style_arg, mood=mood_arg, **kwargs),
|
|
587
|
+
dict(**kwargs),
|
|
588
|
+
)
|
|
589
|
+
for attempt in attempts:
|
|
590
|
+
try:
|
|
591
|
+
samples, report = sk.render(float(tl["total"]), **attempt)
|
|
592
|
+
break
|
|
593
|
+
except TypeError as err:
|
|
594
|
+
if "unexpected keyword argument" in str(err):
|
|
595
|
+
continue
|
|
596
|
+
raise SystemExit(
|
|
597
|
+
"[score] scorekit.render() 内部报错(不是签名不兼容,别去改调用参数):%s\n"
|
|
598
|
+
" 本次签名:%s\n 要看完整 traceback:set RENDER_DEBUG=1 后重跑"
|
|
599
|
+
% (err, ", ".join(sorted(k for k in attempt if k != "self")))
|
|
600
|
+
) from err
|
|
601
|
+
if samples is None:
|
|
602
|
+
raise SystemExit("[score] scorekit.render() 不接受任何一种已知签名,无法出配乐"
|
|
603
|
+
"(试过 %d 种:从最全的参数到最简)" % len(attempts))
|
|
604
|
+
if report and not plan_s["legacy"]:
|
|
605
|
+
print(" 配乐风格: %s / mood %s / %s BPM / %s"
|
|
606
|
+
% (report.get("style") or style_arg, mp.get("mood"),
|
|
607
|
+
report.get("bpm"), report.get("key")))
|
|
366
608
|
out = os.path.join(work, "music.wav")
|
|
367
609
|
sk.save(out, samples)
|
|
368
610
|
if report:
|
|
@@ -374,8 +616,82 @@ def st_score(script, work, tl):
|
|
|
374
616
|
return out
|
|
375
617
|
|
|
376
618
|
|
|
619
|
+
def _sfx_seed_context(script):
|
|
620
|
+
"""v0.7:算音效的内容派生上下文 (style, content_fp, key, enabled)。
|
|
621
|
+
|
|
622
|
+
legacy(没写 style 字段)时返回全 None -> CueBus 走旧的 seed_base + i 路径,
|
|
623
|
+
v0.4 及以前的片子逐样本不变。参考 references/audio-variation.md §3.2 / §4。
|
|
624
|
+
"""
|
|
625
|
+
plan = resolve_style(script)
|
|
626
|
+
sfx_cfg = script.get("sfx") or {}
|
|
627
|
+
want_content = bool(sfx_cfg.get("content_seed", True))
|
|
628
|
+
if plan.get("legacy") or not want_content:
|
|
629
|
+
return None, None, None
|
|
630
|
+
fp = (plan.get("fingerprint") or {}).get("full")
|
|
631
|
+
mus = plan.get("music") or {}
|
|
632
|
+
return str(plan.get("style")), str(fp) if fp else None, str(mus.get("key") or "Dm")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
|
|
636
|
+
"""段落边界前的转场音效自动让位(v0.7,契约 §4)。
|
|
637
|
+
|
|
638
|
+
配乐在段落交界自己已经有一段过门(scorekit 的 swell / riser / sub_boom),
|
|
639
|
+
sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
|
|
640
|
+
做法:对位于 [边界-window, 边界] 内的转场类 cue,在它的时长内乘一个 dB 增益包络。
|
|
641
|
+
只压转场类,不碰 impact / 提示音(那是"该响"的语义重音)。
|
|
642
|
+
"""
|
|
643
|
+
if not scenes or samples is None or not len(samples):
|
|
644
|
+
return samples
|
|
645
|
+
try:
|
|
646
|
+
import numpy as np
|
|
647
|
+
except Exception: # noqa: BLE001
|
|
648
|
+
return samples
|
|
649
|
+
x = np.array(samples, copy=True, dtype=np.float32)
|
|
650
|
+
n = len(x)
|
|
651
|
+
bounds = [float(s["start"]) for s in scenes if float(s.get("start", 0.0)) > 0.5]
|
|
652
|
+
if not bounds:
|
|
653
|
+
return x
|
|
654
|
+
kinds = 0
|
|
655
|
+
for c in cues or []:
|
|
656
|
+
if str(c.get("type")) not in ("whoosh", "riser", "reverse_swell"):
|
|
657
|
+
continue
|
|
658
|
+
t = float(c.get("t", -1.0))
|
|
659
|
+
if t < 0:
|
|
660
|
+
continue
|
|
661
|
+
near = any(abs(t - b) <= window for b in bounds)
|
|
662
|
+
if not near:
|
|
663
|
+
continue
|
|
664
|
+
dur = float(c.get("dur") or 1.5)
|
|
665
|
+
s = max(0, int(round(t * sr)))
|
|
666
|
+
e = min(n, int(round((t + dur) * sr)))
|
|
667
|
+
if e <= s:
|
|
668
|
+
continue
|
|
669
|
+
g = 10.0 ** (depth_db / 20.0)
|
|
670
|
+
k = min(80, max(8, int(0.01 * sr)))
|
|
671
|
+
ramp = min((e - s) // 2, k)
|
|
672
|
+
seg = x[s:e]
|
|
673
|
+
if ramp > 0:
|
|
674
|
+
env = np.full(e - s, g, dtype=np.float32)
|
|
675
|
+
env[:ramp] = np.linspace(1.0, g, ramp, dtype=np.float32)
|
|
676
|
+
env[-ramp:] = np.linspace(g, 1.0, ramp, dtype=np.float32)
|
|
677
|
+
seg = seg * env[:, None] if seg.ndim == 2 else seg * env
|
|
678
|
+
else:
|
|
679
|
+
seg = seg * g
|
|
680
|
+
x[s:e] = seg
|
|
681
|
+
kinds += 1
|
|
682
|
+
if kinds:
|
|
683
|
+
print(" 过门避让: %d 条转场音效在段落边界前 %.1fs 内 -%.0f dB(避免和配乐过门撞车)"
|
|
684
|
+
% (kinds, window, abs(depth_db)))
|
|
685
|
+
return x
|
|
686
|
+
|
|
687
|
+
|
|
377
688
|
def st_sfx(script, work, tl):
|
|
378
|
-
"""按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。
|
|
689
|
+
"""按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。
|
|
690
|
+
|
|
691
|
+
v0.7:非 legacy 时走内容派生 seed(跨片同一位置不再逐样本相同),
|
|
692
|
+
并把配乐调性透传给提示音配方(不再硬编码 Dm);
|
|
693
|
+
段落边界前的转场音效自动让位(不与配乐过门撞车)。
|
|
694
|
+
"""
|
|
379
695
|
print("== SFX")
|
|
380
696
|
cues_path = os.path.join(work, "cues.json")
|
|
381
697
|
if not os.path.exists(cues_path):
|
|
@@ -387,10 +703,12 @@ def st_sfx(script, work, tl):
|
|
|
387
703
|
print(" cues.json 里没有提示点,跳过")
|
|
388
704
|
return None
|
|
389
705
|
sfx_cfg = script.get("sfx") or {}
|
|
706
|
+
style, content_fp, key = _sfx_seed_context(script)
|
|
390
707
|
try:
|
|
391
708
|
samples = bus.build_audio(total=tl["total"],
|
|
392
709
|
base_db=sfx_cfg.get("base_db"),
|
|
393
|
-
default_gain_db=float(sfx_cfg.get("gain_db", 0.0))
|
|
710
|
+
default_gain_db=float(sfx_cfg.get("gain_db", 0.0)),
|
|
711
|
+
style=style, content_fp=content_fp, key=key)
|
|
394
712
|
except Exception as e: # noqa: BLE001
|
|
395
713
|
# 音效轨是"增强"不是"必需":sfxkit 缺失/配方不全时不要拖死整片,
|
|
396
714
|
# 但必须把原因喊出来(静默降级比报错更危险)。
|
|
@@ -399,6 +717,10 @@ def st_sfx(script, work, tl):
|
|
|
399
717
|
if os.path.exists(stale):
|
|
400
718
|
os.remove(stale)
|
|
401
719
|
return None
|
|
720
|
+
if not sfx_cfg.get("duck_transitions", True):
|
|
721
|
+
pass
|
|
722
|
+
else:
|
|
723
|
+
samples = _duck_transition_sfx(samples, bus.resolved(), tl.get("scenes") or [])
|
|
402
724
|
out = os.path.join(work, "sfx.wav")
|
|
403
725
|
peak = K.wav_write(out, samples)
|
|
404
726
|
print(" sfx.wav %.2fs 峰值 %.3f 声道 %d" % (
|
|
@@ -407,13 +729,18 @@ def st_sfx(script, work, tl):
|
|
|
407
729
|
|
|
408
730
|
|
|
409
731
|
def _voice_track(tl, script):
|
|
410
|
-
"""把逐句旁白铺成一条总线,返回 (n,) 单声道。
|
|
732
|
+
"""把逐句旁白铺成一条总线,返回 (n,) 单声道。
|
|
733
|
+
|
|
734
|
+
v0.5.0 修一个 v0.4 的静默 bug:voice_gain 原来在这里和 mix_film 里**各乘一次**
|
|
735
|
+
(净效果 voice_gain^2,默认 1.0 时看不出来)。现在全局增益只在 mix_film 乘一次,
|
|
736
|
+
这里只负责**逐句**增益(voicekit 的 gain_db,默认 0 dB -> 1.0,与 v0.4 一致)。
|
|
737
|
+
"""
|
|
411
738
|
bus = K.AudioBus(tl["total"])
|
|
412
739
|
for s in tl["scenes"]:
|
|
413
740
|
pcm = K.decode_pcm(s["path"])
|
|
414
|
-
|
|
741
|
+
gdb = float(s.get("gain_db", 0.0) or 0.0)
|
|
742
|
+
bus.place(pcm, s["voice_start"], gain=10.0 ** (gdb / 20.0),
|
|
415
743
|
fade_in=0.02, fade_out=0.06)
|
|
416
|
-
# 记录真实语音范围,给后续"停顿处补呼吸"之类的处理用
|
|
417
744
|
return bus.bus[: int(round(float(tl["total"]) * K.SR))].copy()
|
|
418
745
|
|
|
419
746
|
|
|
@@ -487,11 +814,33 @@ def st_audio(tl, work, script):
|
|
|
487
814
|
sfx = bus.bus[: int(round(total * K.SR))].copy()
|
|
488
815
|
print(" 音效: 旧版转场 whoosh(没有 cues.json)")
|
|
489
816
|
|
|
817
|
+
plan_s = resolve_style(script)
|
|
818
|
+
# 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
|
|
819
|
+
# 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
|
|
820
|
+
# v0.7:整体 +1 dB —— 旧值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
|
|
821
|
+
# 段落过门时两者在同一频段几乎等高,是"配乐和音效听起来像同一套"的混音侧成因。
|
|
822
|
+
duck_default = 4.0
|
|
823
|
+
if not plan_s["legacy"]:
|
|
824
|
+
duck_default = {"mono": 3.0, "lo-fi": 3.5, "magazine": 3.5,
|
|
825
|
+
"cinema": 5.5, "epic": 5.5}.get(plan_s["style"], 4.0)
|
|
490
826
|
samples = K.mix_film(voice, music, sfx, total=total,
|
|
491
827
|
voice_gain=float(script.get("voice_gain", 1.0)),
|
|
492
|
-
|
|
828
|
+
# v0.7.1 旁白优先:配乐基线从 -1.5 dB 降到 -12 dB。
|
|
829
|
+
# 视频以旁白为主,-1.5 dB 时背景与台词响度几乎等高,观众要费力分辨字;
|
|
830
|
+
# 12 dB 是"人声明显压住背景"的经验线(audio.md §2 的那条设计标准)。
|
|
831
|
+
music_gain_db=float(mus.get("gain_db", -12.0)),
|
|
493
832
|
sfx_gain_db=float((script.get("sfx") or {}).get("mix_gain_db", 0.0)),
|
|
494
|
-
duck_db=float(mus.get("duck_db",
|
|
833
|
+
duck_db=float(mus.get("duck_db", duck_default)),
|
|
834
|
+
# 旁白说话时再压背景(侧链):配乐 6 dB、音效 3 dB;
|
|
835
|
+
# 间隙自动恢复,避免整片背景被闷住。显式写 0 可关掉。
|
|
836
|
+
voice_duck_db=float(mus.get("voice_duck_db", 6.0)),
|
|
837
|
+
voice_duck_sfx_db=float(mus.get("voice_duck_sfx_db", 3.0)),
|
|
838
|
+
voice_thresh_db=float(mus.get("voice_thresh_db", -42.0)),
|
|
839
|
+
voice_floor_db=float(mus.get("voice_floor_db", -60.0)),
|
|
840
|
+
# SNR 兜底:混音后实测人声活跃区的人声/背景差,不足 12 dB 自动补压背景。
|
|
841
|
+
# 这是"画面/素材电平再离谱也不许盖住旁白"的最后一道闸;0 = 关。
|
|
842
|
+
guard_snr_db=float(mus.get("guard_snr_db", 12.0)),
|
|
843
|
+
guard_cap_db=float(mus.get("guard_cap_db", 30.0)))
|
|
495
844
|
out = os.path.join(work, "audio.wav")
|
|
496
845
|
peak = K.wav_write(out, samples)
|
|
497
846
|
print(" audio.wav %.2fs 峰值 %.3f 声道 %d" % (K.wav_dur(out), peak, 2))
|
|
@@ -516,14 +865,23 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
|
|
|
516
865
|
frame = mod.draw_frame(s, lt, s["dur"], t, script, fi)
|
|
517
866
|
if frame is None:
|
|
518
867
|
raise RuntimeError("场景 %s 在 t=%.2fs 返回 None" % (s.get("id"), t))
|
|
519
|
-
|
|
868
|
+
th = _theme_for(script) if _THEME_CACHE.get("k") != id(script) else _THEME_CACHE.get("th")
|
|
869
|
+
if id(script) != _THEME_CACHE.get("k"):
|
|
870
|
+
_THEME_CACHE.update(k=id(script), th=th)
|
|
871
|
+
frame = K.draw_subtitle(frame, K.sub_at(scenes, t, script.get("sub_max_hold")),
|
|
872
|
+
theme=th)
|
|
520
873
|
fade = 1.0
|
|
521
874
|
if t < 0.5:
|
|
522
875
|
fade *= K.ease_out(t / 0.5)
|
|
523
876
|
if t > total - 0.8:
|
|
524
877
|
fade *= max(0.0, (total - t) / 0.8)
|
|
525
878
|
if fade < 0.999:
|
|
526
|
-
|
|
879
|
+
# 浅色主题淡入淡出到纯黑会出现"闪一下黑",非常廉价。
|
|
880
|
+
# transition=wash 时淡向主题底色,观感是"渐显"而不是"黑场"。
|
|
881
|
+
wipe = (0, 0, 0)
|
|
882
|
+
if th is not None and str(th.get("transition") or "black") == "wash":
|
|
883
|
+
wipe = tuple(th["bg2"])
|
|
884
|
+
frame = K.Image.blend(K.Image.new("RGB", (K.W, K.H), wipe), frame, fade)
|
|
527
885
|
sink.write(frame)
|
|
528
886
|
if progress and fi - last_rep >= 300:
|
|
529
887
|
last_rep = fi
|
|
@@ -534,14 +892,56 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
|
|
|
534
892
|
return sink.close()
|
|
535
893
|
|
|
536
894
|
|
|
895
|
+
def _seg_usable(path, frames):
|
|
896
|
+
"""已有分块能不能复用:体积够 + 头部声明的帧数与计划一致。
|
|
897
|
+
|
|
898
|
+
为什么不能只看 size>1024:Ctrl-C / 杀进程留下的半截 mp4 也 >1024,被当成
|
|
899
|
+
"已存在"跳过后,拼接只在最后报一句"拼接后时长 0.07s != 预期 29.00s"。
|
|
900
|
+
v0.7.2 起分块先写 .part 再 os.replace,正常中断不会留下半截正式文件;这里
|
|
901
|
+
再补一道头部 nb_frames 比对,挡住"文件在/大小对但帧数不对"的异常(例如被
|
|
902
|
+
外部工具截断过)。
|
|
903
|
+
|
|
904
|
+
注意:**不做** -count_frames 逐帧核对。实测 390 帧的块要 3.5 s(只读头部
|
|
905
|
+
0.2 s),长片几十个块会让每次 --resume 多花几分钟;被截断的 mp4 头部仍写着
|
|
906
|
+
完整 nb_frames,逐帧核对才是唯一能查出来的手段,代价与收益不成比例——真遇到
|
|
907
|
+
这种情况由拼接后的时长校验兜底,并在报错里指明怎么重来。
|
|
908
|
+
"""
|
|
909
|
+
try:
|
|
910
|
+
if not os.path.exists(path) or os.path.getsize(path) <= 1024:
|
|
911
|
+
return False
|
|
912
|
+
info = K.probe(path) or {}
|
|
913
|
+
for st in info.get("streams") or []:
|
|
914
|
+
if st.get("codec_type") == "video" and st.get("nb_frames"):
|
|
915
|
+
return int(st["nb_frames"]) == int(frames)
|
|
916
|
+
# 读不出 nb_frames(老封装 / ffprobe 版本差异):退回到"体积够就信"
|
|
917
|
+
return True
|
|
918
|
+
except Exception: # noqa: BLE001
|
|
919
|
+
return False
|
|
920
|
+
|
|
921
|
+
|
|
537
922
|
def _render_chunk_worker(args):
|
|
538
923
|
"""分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
|
|
539
|
-
(tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset) = args
|
|
924
|
+
(tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args
|
|
540
925
|
tl = json.load(open(tl_path, encoding="utf-8"))
|
|
541
926
|
script = json.load(open(script_path, encoding="utf-8"))
|
|
542
927
|
script["_total"] = tl["total"]
|
|
928
|
+
# v0.6.0 修:JSON 快照会把元组变成 list(主题色是 (r,g,b) 元组),子进程里
|
|
929
|
+
# scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
930
|
+
# 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
|
|
931
|
+
# 既拿回元组,也保证子进程的主题与父进程逐字段一致。
|
|
932
|
+
script.pop("_style", None)
|
|
933
|
+
try:
|
|
934
|
+
script["_style"] = resolve_style(script, verbose=False)
|
|
935
|
+
except Exception as e: # noqa: BLE001
|
|
936
|
+
print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
|
|
937
|
+
script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
|
|
543
938
|
mod = load_module(scenes_path, "film_scenes_chunk_%d" % f0)
|
|
544
|
-
|
|
939
|
+
# 先写 .part 再改名:中途被 Ctrl-C / 被杀时,目标文件名对应的永远是一段**完整**
|
|
940
|
+
# 编码结果。否则半截 mp4 只要 >1024 字节就会被 --resume 当成"已存在"跳过,
|
|
941
|
+
# 拼接后时长对不上才报错(实测:截断到 60 KB 的块 -> 拼接后 0.07s vs 预期 29.00s)。
|
|
942
|
+
_render_range(tl, mod, script, f0, f1, part_path, crf, preset, progress=False)
|
|
943
|
+
os.replace(part_path, out_path)
|
|
944
|
+
return out_path
|
|
545
945
|
|
|
546
946
|
|
|
547
947
|
def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
@@ -550,6 +950,9 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
550
950
|
return _st_frames_web(tl, work, script, jobs=jobs, resume=resume)
|
|
551
951
|
script = dict(script)
|
|
552
952
|
script["_total"] = tl["total"] # 场景算全局进度条要用
|
|
953
|
+
# 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
|
|
954
|
+
# _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
|
|
955
|
+
script.setdefault("_style", resolve_style(script))
|
|
553
956
|
crf = int(script.get("crf", 18))
|
|
554
957
|
preset = script.get("preset", "medium")
|
|
555
958
|
total_frames = tl["frames"]
|
|
@@ -602,13 +1005,31 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
602
1005
|
|
|
603
1006
|
todo = []
|
|
604
1007
|
planned = set()
|
|
1008
|
+
# 上次被中断留下的半成品(编码中途的残片):不算分块,直接清掉,别让它占盘。
|
|
1009
|
+
# 名字带 _part_ 前缀,既不会被 stale 检测当成"不属于当前计划的旧分块",
|
|
1010
|
+
# 也不会被 _seg_usable 当成"已存在的分块"跳过。
|
|
1011
|
+
for n in os.listdir(seg_dir):
|
|
1012
|
+
if n.startswith("_part_"):
|
|
1013
|
+
try:
|
|
1014
|
+
os.remove(os.path.join(seg_dir, n))
|
|
1015
|
+
except OSError:
|
|
1016
|
+
pass
|
|
605
1017
|
for (f0, f1) in plan:
|
|
606
1018
|
seg = os.path.join(seg_dir, "seg_%06d_%06d.mp4" % (f0, f1))
|
|
607
1019
|
planned.add(os.path.basename(seg))
|
|
608
|
-
if resume and
|
|
1020
|
+
if resume and _seg_usable(seg, int(f1) - int(f0)):
|
|
609
1021
|
print(" skip 段 %d-%d(已存在)" % (f0, f1))
|
|
610
1022
|
continue
|
|
611
|
-
|
|
1023
|
+
if os.path.exists(seg):
|
|
1024
|
+
# 存在但不可用(半截 / 时长不符):删掉重渲,别让它混进拼接
|
|
1025
|
+
print(" [WARN] 段 %d-%d 已存在但帧数/时长对不上,重渲(%d 字节)"
|
|
1026
|
+
% (f0, f1, os.path.getsize(seg)))
|
|
1027
|
+
try:
|
|
1028
|
+
os.remove(seg)
|
|
1029
|
+
except OSError:
|
|
1030
|
+
pass
|
|
1031
|
+
part = os.path.join(seg_dir, "_part_" + os.path.basename(seg))
|
|
1032
|
+
todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part))
|
|
612
1033
|
|
|
613
1034
|
# 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
|
|
614
1035
|
stale = [n for n in os.listdir(seg_dir)
|
|
@@ -666,13 +1087,16 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
|
|
|
666
1087
|
if abs(float(page_fps) - float(tl["fps"])) > 1e-6:
|
|
667
1088
|
raise RuntimeError(
|
|
668
1089
|
"页面 FPS=%s 与 timeline fps=%s 不一致。浏览器逐帧渲染的帧号就是 f/FPS,"
|
|
669
|
-
"
|
|
670
|
-
%
|
|
1090
|
+
"两边必须同帧率:把页面里的 window.FPS 改成 %s(或重新 --stage layout 生成"
|
|
1091
|
+
"与页面一致的 timeline)。\n %s"
|
|
1092
|
+
% (page_fps, tl["fps"], tl["fps"], _web_where(entry)))
|
|
671
1093
|
if abs(float(page_dur) - float(tl["total"])) > 0.05:
|
|
672
1094
|
raise RuntimeError(
|
|
673
1095
|
"页面 DURATION=%.3fs 与 timeline 总时长 %.3fs 不一致(差 %.3fs)。\n"
|
|
674
|
-
" 页面里的时长必须等于 timeline.total
|
|
675
|
-
|
|
1096
|
+
" 页面里的时长必须等于 timeline.total;把页面里的 window.DURATION 改成 %.3f"
|
|
1097
|
+
"(跑过新的 --stage layout 之后,取 timeline.json 的 total)。\n %s"
|
|
1098
|
+
% (float(page_dur), float(tl["total"]), abs(float(page_dur) - float(tl["total"])),
|
|
1099
|
+
float(tl["total"]), _web_where(entry)))
|
|
676
1100
|
|
|
677
1101
|
out = we.render_full(entry, silent, float(tl["total"]),
|
|
678
1102
|
workers=max(1, int(jobs)),
|
|
@@ -699,8 +1123,99 @@ def st_mux(silent, audio, out, script):
|
|
|
699
1123
|
return out
|
|
700
1124
|
|
|
701
1125
|
|
|
1126
|
+
def _qa_gates(lg, rep, deep, tl, work):
|
|
1127
|
+
"""把 qa_report / 深检的结论拆成逐条门禁记进账本(pass / fail / skipped)。
|
|
1128
|
+
|
|
1129
|
+
为什么拆:只有一个"质检通过/失败"的总结论,读报告的人看不出"哪几项其实没执行"。
|
|
1130
|
+
这里每条都带实测值,skipped 必须写明"为什么没执行"。
|
|
1131
|
+
"""
|
|
1132
|
+
pr = rep.get("probe") or {}
|
|
1133
|
+
streams = pr.get("streams") or []
|
|
1134
|
+
v = next((s for s in streams if s.get("codec_type") == "video"), {})
|
|
1135
|
+
a = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
1136
|
+
fmt = pr.get("format") or {}
|
|
1137
|
+
W, H, FPS = K.W, K.H, K.FPS
|
|
1138
|
+
|
|
1139
|
+
# ---- G4 容器:分辨率 / 帧率 / 音轨 / 时长 ----
|
|
1140
|
+
try:
|
|
1141
|
+
rate = str(v.get("r_frame_rate") or "")
|
|
1142
|
+
num, _, den = rate.partition("/")
|
|
1143
|
+
got_fps = (float(num) / float(den or 1)) if num and den else 0.0
|
|
1144
|
+
except Exception: # noqa: BLE001
|
|
1145
|
+
got_fps = 0.0
|
|
1146
|
+
dur = float(fmt.get("duration") or 0)
|
|
1147
|
+
want_dur = float(tl["frames"]) / float(tl["fps"])
|
|
1148
|
+
detail = "%sx%s@%.3g / %s%s / %.2fs" % (
|
|
1149
|
+
v.get("width"), v.get("height"), got_fps,
|
|
1150
|
+
("h264" if v.get("codec_name") == "h264" else str(v.get("codec_name") or "?")),
|
|
1151
|
+
("+aac" if a else "(无音轨)"),
|
|
1152
|
+
dur)
|
|
1153
|
+
ok4 = (v.get("width") == W and v.get("height") == H
|
|
1154
|
+
and abs(got_fps - float(FPS)) <= 0.05 and a is not None
|
|
1155
|
+
and abs(dur - want_dur) <= 0.5)
|
|
1156
|
+
(lg.ok if ok4 else lg.fail)("G4", "容器层(分辨率/帧率/音轨/时长)", detail)
|
|
1157
|
+
|
|
1158
|
+
# ---- G5 响度 / 真峰 ----
|
|
1159
|
+
lo = rep.get("loudness") or {}
|
|
1160
|
+
if "I" in lo and "TP" in lo:
|
|
1161
|
+
i_l, tp = float(lo["I"]), float(lo["TP"])
|
|
1162
|
+
ok5 = (-19 <= i_l <= -12) and tp <= -1.0
|
|
1163
|
+
(lg.ok if ok5 else lg.fail)("G5", "响度 / 真峰(-16 LUFS / ≤ -1 dBTP)",
|
|
1164
|
+
"%.1f LUFS · %.1f dBTP" % (i_l, tp))
|
|
1165
|
+
else:
|
|
1166
|
+
lg.skip("G5", "响度 / 真峰", "ebur128 没测到 %s" % ("I" if "I" not in lo else "TP"))
|
|
1167
|
+
|
|
1168
|
+
# ---- G6 编码锐度比 ----
|
|
1169
|
+
q = rep.get("quality")
|
|
1170
|
+
if q and "ratio" in q:
|
|
1171
|
+
(lg.ok if q["ratio"] >= 0.85 else lg.fail)(
|
|
1172
|
+
"G6", "编码锐度比(文字发虚)", "%.3f(阈值 0.85)" % q["ratio"])
|
|
1173
|
+
else:
|
|
1174
|
+
why = "没装 cv2" if K._cv2() is None else "没有参考帧(scenes.py 缺失 / web 引擎)"
|
|
1175
|
+
lg.skip("G6", "编码锐度比(文字发虚)", why + ",本次没查")
|
|
1176
|
+
|
|
1177
|
+
# ---- G7 字幕条数(静默丢字幕的唯一检查点)----
|
|
1178
|
+
srt = os.path.join(str(work), "sub.srt")
|
|
1179
|
+
got = 0
|
|
1180
|
+
if os.path.exists(srt):
|
|
1181
|
+
got = len([b for b in open(srt, encoding="utf-8").read().strip().split("\n\n") if b.strip()])
|
|
1182
|
+
want = len(tl["scenes"])
|
|
1183
|
+
if got:
|
|
1184
|
+
(lg.ok if got == want else lg.fail)("G7", "字幕条数 = 剧本句数",
|
|
1185
|
+
"%d / %d" % (got, want))
|
|
1186
|
+
else:
|
|
1187
|
+
lg.skip("G7", "字幕条数 = 剧本句数", "没找到 sub.srt")
|
|
1188
|
+
|
|
1189
|
+
# ---- G8 黑帧 ----
|
|
1190
|
+
black_msgs = [w for w in (rep.get("warnings") or []) if "黑帧" in w]
|
|
1191
|
+
if black_msgs:
|
|
1192
|
+
# qa_report 只在"首尾之外有长黑帧"时写 warning,本身是 warning 级
|
|
1193
|
+
lg.skip("G8", "首尾之外无长黑帧", "检测到:" + black_msgs[0])
|
|
1194
|
+
else:
|
|
1195
|
+
lg.ok("G8", "首尾之外无长黑帧", "0 段")
|
|
1196
|
+
|
|
1197
|
+
# ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
|
|
1198
|
+
if deep is None:
|
|
1199
|
+
lg.skip("G9", "帧级深检(闪烁/空窗/同步)", "没有 qa_frames.py 或深检失败")
|
|
1200
|
+
else:
|
|
1201
|
+
nf = len(deep.get("flicker") or [])
|
|
1202
|
+
nb = len(deep.get("black_windows") or [])
|
|
1203
|
+
ns = len(deep.get("sync") or [])
|
|
1204
|
+
judged = (deep.get("sync_info") or {}).get("judged")
|
|
1205
|
+
sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
|
|
1206
|
+
ok9 = nf == 0 and nb == 0 and ns == 0
|
|
1207
|
+
detail9 = "闪烁 %d · 空窗 %d · 同步超差 %d(%s)" % (nf, nb, ns, sync_note)
|
|
1208
|
+
if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
|
|
1209
|
+
# 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
|
|
1210
|
+
lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
|
|
1211
|
+
detail9 + ";QA_REQUIRE_SYNC=1 要求同步必须被判过")
|
|
1212
|
+
else:
|
|
1213
|
+
(lg.ok if ok9 else lg.fail)("G9", "帧级深检(闪烁/空窗/同步超差)", detail9)
|
|
1214
|
+
|
|
1215
|
+
|
|
702
1216
|
def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
703
1217
|
print("== QA")
|
|
1218
|
+
th = _theme_for(script) if script is not None else None
|
|
704
1219
|
shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
|
|
705
1220
|
fracs = (0.08, 0.3, 0.55, 0.78)
|
|
706
1221
|
ref_script = dict(script) if script is not None else None
|
|
@@ -723,7 +1238,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
723
1238
|
try:
|
|
724
1239
|
fr = scenes_mod.draw_frame(s, sec - s["start"], s["dur"], sec, ref_script,
|
|
725
1240
|
int(round(sec * tl["fps"])))
|
|
726
|
-
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold"))
|
|
1241
|
+
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold")),
|
|
1242
|
+
theme=th)
|
|
727
1243
|
refs.append((k, fr))
|
|
728
1244
|
except Exception as e: # noqa: BLE001
|
|
729
1245
|
print(" [WARN] 参考帧渲染失败:", e)
|
|
@@ -739,7 +1255,7 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
739
1255
|
print(" [WARN] 没有场景函数参考帧(web 引擎或 scenes.py 缺失),锐度比门禁本次跳过")
|
|
740
1256
|
rep = K.qa_report(film, expect_frames=tl["frames"], ref_img=ref_img,
|
|
741
1257
|
ref_sec=ref_sec, expected_subtitles=len(tl["scenes"]),
|
|
742
|
-
notes=os.path.join(work, "sub.srt"))
|
|
1258
|
+
notes=os.path.join(work, "sub.srt"), theme=th)
|
|
743
1259
|
pr = rep["probe"]
|
|
744
1260
|
f = pr.get("format", {})
|
|
745
1261
|
print(" 时长 %.2fs 大小 %.1f MB 码率 %.2f Mbps" % (
|
|
@@ -784,6 +1300,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
784
1300
|
print(" -", e)
|
|
785
1301
|
else:
|
|
786
1302
|
print(" [PASS] 质检通过")
|
|
1303
|
+
lg = _gate()
|
|
1304
|
+
if lg is not None:
|
|
1305
|
+
_qa_gates(lg, rep, deep, tl, work)
|
|
787
1306
|
return errors
|
|
788
1307
|
|
|
789
1308
|
|
|
@@ -819,6 +1338,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
819
1338
|
print("== Stills")
|
|
820
1339
|
script = dict(script)
|
|
821
1340
|
script["_total"] = tl["total"]
|
|
1341
|
+
script.setdefault("_style", resolve_style(script))
|
|
822
1342
|
outdir = os.path.join(work, "stills")
|
|
823
1343
|
os.makedirs(outdir, exist_ok=True)
|
|
824
1344
|
by_id = {s["id"]: s for s in tl["scenes"]}
|
|
@@ -844,7 +1364,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
844
1364
|
if tuple(fr.size) != (K.W, K.H):
|
|
845
1365
|
raise RuntimeError("场景 %s 在 t=%.2fs 返回 %dx%d,期望 %dx%d(stills 与 FrameSink 一样严格)"
|
|
846
1366
|
% (sid, tg, fr.size[0], fr.size[1], K.W, K.H))
|
|
847
|
-
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, script.get("sub_max_hold"))
|
|
1367
|
+
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, script.get("sub_max_hold")),
|
|
1368
|
+
theme=_theme_for(script))
|
|
848
1369
|
p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
|
|
849
1370
|
fr.save(p)
|
|
850
1371
|
made.append(p)
|
|
@@ -856,6 +1377,103 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
856
1377
|
return made
|
|
857
1378
|
|
|
858
1379
|
|
|
1380
|
+
def _reload_ledger_from_disk(work, ledger):
|
|
1381
|
+
"""把 work/gates.json 里已有的门禁条目搬回内存账本(accept 这类独立进程用)。
|
|
1382
|
+
|
|
1383
|
+
只搬**内容指纹一致**的条目:指纹不同说明剧本/场景/引擎已经变过,那些结论属于
|
|
1384
|
+
上一版内容,搬进来等于拿旧结论冒充本版(比丢条目更糟)。搬不回来时明确说一句,
|
|
1385
|
+
让用户知道报告里的门禁矩阵为什么是空的(该重跑 --stage all)。
|
|
1386
|
+
"""
|
|
1387
|
+
p = os.path.join(str(work), "gates.json")
|
|
1388
|
+
if not os.path.exists(p):
|
|
1389
|
+
return 0
|
|
1390
|
+
try:
|
|
1391
|
+
with open(p, encoding="utf-8") as fh:
|
|
1392
|
+
d = json.load(fh)
|
|
1393
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
1394
|
+
print(" [WARN] 旧账本读不了(%s):报告里只会有本次的条目" % e)
|
|
1395
|
+
return 0
|
|
1396
|
+
if not isinstance(d, dict):
|
|
1397
|
+
return 0
|
|
1398
|
+
if str(d.get("contentHash") or "") != str(getattr(ledger, "content_hash", "") or ""):
|
|
1399
|
+
print(" [WARN] 旧账本的内容指纹与当前不一致(剧本/场景/引擎改过),"
|
|
1400
|
+
"不搬旧门禁结论:交付前请重跑 --stage qa")
|
|
1401
|
+
return 0
|
|
1402
|
+
n = 0
|
|
1403
|
+
for it in d.get("items") or []:
|
|
1404
|
+
if not isinstance(it, dict) or not it.get("id"):
|
|
1405
|
+
continue
|
|
1406
|
+
try:
|
|
1407
|
+
ledger.add(str(it["id"]), str(it.get("title") or ""),
|
|
1408
|
+
str(it.get("status") or ""), str(it.get("detail") or ""),
|
|
1409
|
+
at=it.get("at"))
|
|
1410
|
+
n += 1
|
|
1411
|
+
except (ValueError, TypeError) as e:
|
|
1412
|
+
print(" [WARN] 旧账本条目 %r 搬不回来:%s" % (it.get("id"), e))
|
|
1413
|
+
for b in d.get("bypassed") or []:
|
|
1414
|
+
if isinstance(b, dict) and b.get("what"):
|
|
1415
|
+
ledger.bypassed.append(dict(what=str(b["what"]), why=str(b.get("why") or ""),
|
|
1416
|
+
at=float(b.get("at") or time.time())))
|
|
1417
|
+
return n
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _report_path(a):
|
|
1421
|
+
"""QC 报告的唯一落点:**站在成片旁边**(--out 的目录),不猜 work 的兄弟目录。
|
|
1422
|
+
|
|
1423
|
+
v0.7.2 修:accept 分支以前用 dirname(abspath(work))/out/QC-REPORT.md,而 all/mux/qa
|
|
1424
|
+
用 dirname(abspath(out))。--out 指到任何非 out/ 的目录(deliver/、中文工作区/成品/…)
|
|
1425
|
+
时,签核更新的是另一份没人交付的文件,交付出去的那份仍是"未签核"——正好打穿
|
|
1426
|
+
"报告是交付凭据"这条承诺。两处必须走同一个函数。
|
|
1427
|
+
"""
|
|
1428
|
+
return os.path.join(os.path.dirname(os.path.abspath(str(a.out))), "QC-REPORT.md")
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
# 会写交付账本(work/gates.json + QC-REPORT.md)的阶段。中间产物阶段
|
|
1432
|
+
# (tts/layout/cues/score/sfx/audio)只做自己的事,不去覆盖上一轮完整跑出来的
|
|
1433
|
+
# 交付报告——否则 `--stage layout` 一次就会把「全片通过」的报告改写成「未判定」。
|
|
1434
|
+
_PERSIST_STAGES = ("style", "frames", "mux", "qa", "stills", "all", "accept")
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _finish_gates(a, persist=True):
|
|
1438
|
+
"""一次运行收尾:落 gates.json + 写 QC-REPORT.md(在成片旁边)+ 统一退出码。
|
|
1439
|
+
|
|
1440
|
+
persist=False:只打印本次门禁汇总与退出码,不动磁盘上的交付账本(中间阶段用)。
|
|
1441
|
+
"""
|
|
1442
|
+
lg = _LEDGER
|
|
1443
|
+
if lg is None:
|
|
1444
|
+
return 0
|
|
1445
|
+
errs = lg.exit_errors(strict=bool(getattr(a, "strict_gates", False)))
|
|
1446
|
+
gj = rep = ""
|
|
1447
|
+
if persist:
|
|
1448
|
+
# 交付账本只在交付阶段写;中间阶段(layout 等)写一次就会把上一轮
|
|
1449
|
+
# 「pass 8 / skipped 1」的完整报告换成「只有本阶段一条」的半成品。
|
|
1450
|
+
try:
|
|
1451
|
+
gj = lg.write_json()
|
|
1452
|
+
except Exception as e: # noqa: BLE001
|
|
1453
|
+
print("[WARN] gates.json 写入失败:", e)
|
|
1454
|
+
try:
|
|
1455
|
+
rep = GT.write_report(a.work, _report_path(a))
|
|
1456
|
+
except Exception as e: # noqa: BLE001
|
|
1457
|
+
print("[WARN] QC 报告写入失败:", e)
|
|
1458
|
+
c = lg.counts()
|
|
1459
|
+
print("")
|
|
1460
|
+
print("== 门禁汇总:pass %d · fail %d · skipped %d%s"
|
|
1461
|
+
% (c[GT.OK], c[GT.FAIL], c[GT.SKIP],
|
|
1462
|
+
"(--strict-gates:skipped 也算失败)" if getattr(a, "strict_gates", False) else ""))
|
|
1463
|
+
if gj:
|
|
1464
|
+
print(" 账本:", gj)
|
|
1465
|
+
if rep:
|
|
1466
|
+
print(" 报告:", rep, " <- 交付时把这个文件一起给出")
|
|
1467
|
+
if c[GT.SKIP]:
|
|
1468
|
+
print(" 注意:有 %d 项本次**没有执行**(skipped != 通过)" % c[GT.SKIP])
|
|
1469
|
+
if errs:
|
|
1470
|
+
print(" [FAIL]")
|
|
1471
|
+
for e in errs:
|
|
1472
|
+
print(" -", e)
|
|
1473
|
+
return 1
|
|
1474
|
+
return 0
|
|
1475
|
+
|
|
1476
|
+
|
|
859
1477
|
# ---------------------------------------------------------------- main
|
|
860
1478
|
def main():
|
|
861
1479
|
_setup_stdio()
|
|
@@ -872,8 +1490,26 @@ def main():
|
|
|
872
1490
|
ap.add_argument("--force-tts", action="store_true", help="丢弃 TTS 缓存重新合成")
|
|
873
1491
|
ap.add_argument("--force-music", action="store_true", help="丢弃配乐重出(score 阶段)")
|
|
874
1492
|
ap.add_argument("--stills", default="", help="stills 阶段用:逗号分隔的 id:秒")
|
|
1493
|
+
ap.add_argument("--gate", default="", choices=("",) + GT.GATE_ORDER,
|
|
1494
|
+
help="accept 阶段用:要签核哪一道闸(%s)" % " / ".join(GT.GATE_ORDER))
|
|
1495
|
+
ap.add_argument("--verdict", default="approved", choices=("approved", "rejected"),
|
|
1496
|
+
help="accept 阶段用:签核结论(rejected 视为没签)")
|
|
1497
|
+
ap.add_argument("--note", default="", help="accept 阶段用:签核备注(写进报告)")
|
|
1498
|
+
ap.add_argument("--by", default="", help="accept 阶段用:签核人(缺省 user)")
|
|
1499
|
+
ap.add_argument("--strict-gates", action="store_true",
|
|
1500
|
+
help="把 skipped 门禁也算失败(对齐 QA_REQUIRE_SYNC 语义)")
|
|
1501
|
+
ap.add_argument("--strict-stale", action="store_true",
|
|
1502
|
+
help="上游产物过时算失败(默认只告警)")
|
|
1503
|
+
ap.add_argument("--allow-stale", action="store_true",
|
|
1504
|
+
help="过时也照跑(会在 QC 报告里留痕)")
|
|
1505
|
+
ap.add_argument("--no-gate", action="store_true",
|
|
1506
|
+
help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
|
|
1507
|
+
ap.add_argument("--cost", action="store_true",
|
|
1508
|
+
help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
|
|
875
1509
|
a = ap.parse_args()
|
|
876
1510
|
|
|
1511
|
+
# work/ 本来就不存在 = 这一跑自己产出全部中间物,别把"我还没跑到"当成"产物过时"。
|
|
1512
|
+
work_pre_exists = os.path.isdir(a.work)
|
|
877
1513
|
try:
|
|
878
1514
|
os.makedirs(a.work, exist_ok=True)
|
|
879
1515
|
except OSError as e:
|
|
@@ -884,6 +1520,117 @@ def main():
|
|
|
884
1520
|
raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
|
|
885
1521
|
script["_engine"] = engine
|
|
886
1522
|
script["_scenes"] = str(Path(a.scenes).resolve())
|
|
1523
|
+
|
|
1524
|
+
# ---- 门禁账本(v0.6.0)----
|
|
1525
|
+
global _LEDGER
|
|
1526
|
+
# engine=web 时把入口页实际引用的同目录页面脚本一起纳入内容指纹:web 的画面逻辑在
|
|
1527
|
+
# web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效",正好违背
|
|
1528
|
+
# v0.6「签核绑内容指纹,改了画面代码旧签核自动失效」的承诺。
|
|
1529
|
+
web_entry = ""
|
|
1530
|
+
if engine == "web":
|
|
1531
|
+
try:
|
|
1532
|
+
web_entry = _web_entry(script) # 入口页不存在时这里会 SystemExit(本来就该早失败)
|
|
1533
|
+
except SystemExit:
|
|
1534
|
+
web_entry = "" # 页面还没建:交给后面的 stage 报错,账本先照建
|
|
1535
|
+
_LEDGER = GT.GateLedger(a.work, script, scenes_path=a.scenes,
|
|
1536
|
+
script_path=a.script, engine=engine,
|
|
1537
|
+
web_entry=web_entry)
|
|
1538
|
+
if a.no_gate:
|
|
1539
|
+
_LEDGER.bypass("人工签核检查(--no-gate)", "本条不算通过,只记录")
|
|
1540
|
+
print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
|
|
1541
|
+
|
|
1542
|
+
def _sources():
|
|
1543
|
+
"""源头输入:剧本与 scenes.py(改它们等于改了所有下游的依据)。"""
|
|
1544
|
+
return {"script": a.script, "scenes": a.scenes}
|
|
1545
|
+
|
|
1546
|
+
# 本次命令行**计划要跑**的 stage 集合:--stage all 展开成 10 个,单 stage 就是它自己。
|
|
1547
|
+
# 用途见 stagewatch.check 的 pending:本阶段马上要用新输入重算,那种"输入变了"不是过时。
|
|
1548
|
+
_ALL_ORDER = ("style", "tts", "layout", "cues", "score", "sfx", "audio",
|
|
1549
|
+
"frames", "mux", "qa")
|
|
1550
|
+
|
|
1551
|
+
def _planned(stage_name):
|
|
1552
|
+
if stage_name == "all":
|
|
1553
|
+
return list(_ALL_ORDER)
|
|
1554
|
+
return [stage_name] if stage_name in STAGES else []
|
|
1555
|
+
|
|
1556
|
+
def _stage_begin(name):
|
|
1557
|
+
"""stage 开始时查新鲜度并计时。返回 t0。
|
|
1558
|
+
|
|
1559
|
+
report() 自己负责打印 "[stale] <stage>: <原因>" 与"最小重跑"两行
|
|
1560
|
+
(v0.7.2 修:以前只调不接,用户只看到"最小重跑"不知道是哪个阶段、
|
|
1561
|
+
为什么过时,得上 --stage explain 才知道)。
|
|
1562
|
+
--allow-stale 也要调用:它承诺的是"放行并留痕"(QC 报告里记一条 skipped),
|
|
1563
|
+
直接跳过检查连痕都没有——那就成了静默。
|
|
1564
|
+
"""
|
|
1565
|
+
if name:
|
|
1566
|
+
errs, _warns = SW.report(a.work, [name], strict=a.strict_stale,
|
|
1567
|
+
allow_stale=a.allow_stale,
|
|
1568
|
+
ledger=_LEDGER, sources=_sources(),
|
|
1569
|
+
self_built=not work_pre_exists,
|
|
1570
|
+
pending=_planned(stage))
|
|
1571
|
+
if errs:
|
|
1572
|
+
# --strict-stale 的承诺是"上游产物过时算失败"。stagewatch.report 只把
|
|
1573
|
+
# 问题放进返回值(并往账本记一条 fail),它自己不抛异常——所以必须在这里
|
|
1574
|
+
# 真的失败。以前丢弃了返回值,于是 --stage layout --strict-stale 报完过时
|
|
1575
|
+
# 照跑、退出码 0,help 文本与行为对不上。
|
|
1576
|
+
#
|
|
1577
|
+
# 选择"立刻退出"而不是"跑完再在收尾处报":过时的输入会把旧产物掺进
|
|
1578
|
+
# 新结果(比如拿旧 cues.json 渲新时间轴的画面),跑完再报既已浪费渲染
|
|
1579
|
+
# 时间,产出的东西也不能用。--stage all 同理:一发现就停。
|
|
1580
|
+
# 不写 work/gates.json:那是**交付账本**(上一轮 --stage all 的完整
|
|
1581
|
+
# 结论)。中间阶段一次失败就把它覆盖成单条 fail,用户手里那份
|
|
1582
|
+
# QC-REPORT.md 会变成「未判定」,比留证据代价更大。失败证据在
|
|
1583
|
+
# stdout 与退出码里,够定位。
|
|
1584
|
+
# 原因行 report() 已经逐条打过了,这里只补"为什么停下 + 怎么办",
|
|
1585
|
+
# 不再把 errs 原样贴一遍。
|
|
1586
|
+
raise SystemExit(
|
|
1587
|
+
"[stale][strict] 上面 %d 处上游产物过时 -> 本阶段不跑,直接失败。\n"
|
|
1588
|
+
" (--strict-stale 下过时=失败;继续跑会把旧产物掺进新结果,"
|
|
1589
|
+
"白渲一遍还是错的。)\n"
|
|
1590
|
+
" 怎么办:\n"
|
|
1591
|
+
" 1) 照着每条下面的『最小重跑』先补上游,再重跑当前阶段\n"
|
|
1592
|
+
" 2) 确认旧产物可以接受:加 --allow-stale 显式放行"
|
|
1593
|
+
"(退出码 0,QC 报告里记 skipped)\n"
|
|
1594
|
+
" 3) 只想看现状不跑:python render.py --stage explain"
|
|
1595
|
+
% len(errs))
|
|
1596
|
+
return time.time()
|
|
1597
|
+
|
|
1598
|
+
def _stage_end(name, t0, **extra):
|
|
1599
|
+
"""stage 结束时落盘:manifest 快照 + 耗时台账。"""
|
|
1600
|
+
if not name:
|
|
1601
|
+
return
|
|
1602
|
+
try:
|
|
1603
|
+
SW.snapshot(a.work, name, _sources())
|
|
1604
|
+
except Exception as e: # noqa: BLE001
|
|
1605
|
+
print(" [WARN] manifest 快照失败:", e)
|
|
1606
|
+
wall = time.time() - t0
|
|
1607
|
+
if a.cost:
|
|
1608
|
+
try:
|
|
1609
|
+
GT.append_cost(a.work, name, wall, **extra)
|
|
1610
|
+
except Exception as e: # noqa: BLE001
|
|
1611
|
+
print(" [WARN] cost.jsonl 写入失败:", e)
|
|
1612
|
+
print(" [time] %s 用时 %.1fs%s" % (name, wall, "(已记 work/cost.jsonl)" if a.cost else ""))
|
|
1613
|
+
|
|
1614
|
+
def _check_signoff(where):
|
|
1615
|
+
"""交付前的签核检查:缺签只告警,--strict-gates 下算失败。"""
|
|
1616
|
+
if a.no_gate:
|
|
1617
|
+
return
|
|
1618
|
+
state, stale = GT.signoff_state(a.work, _LEDGER.content_hash)
|
|
1619
|
+
bad = [g for g, v in state.items() if v["status"] == GT.SKIP]
|
|
1620
|
+
rejected = [g for g, v in state.items() if v["status"] == GT.FAIL]
|
|
1621
|
+
for g in rejected:
|
|
1622
|
+
_LEDGER.fail("signoff:" + g, "人工签核:" + GT.GATES[g], state[g]["detail"])
|
|
1623
|
+
if bad:
|
|
1624
|
+
_LEDGER.skip("signoff", "人工签核(--stage accept 签核)",
|
|
1625
|
+
"未签/已失效:%s%s"
|
|
1626
|
+
% ("、".join(bad), ("(内容已变:%s)" % "、".join(stale)) if stale else ""))
|
|
1627
|
+
print(" [WARN] 人工签核未完成:%s" % "、".join(bad))
|
|
1628
|
+
print(" 交付前请 --stage accept --gate <名字>;"
|
|
1629
|
+
"--strict-gates 会把它算作失败")
|
|
1630
|
+
for g in bad:
|
|
1631
|
+
print(" %-8s %s" % (g, GT.GATES[g]))
|
|
1632
|
+
elif not rejected:
|
|
1633
|
+
_LEDGER.ok("signoff", "人工签核", "%d/4 道闸已签" % len(GT.GATE_ORDER))
|
|
887
1634
|
if a.force_music:
|
|
888
1635
|
plan, _why = K.music_plan(script, has_music_file=os.path.exists(os.path.join(a.work, "music.wav")))
|
|
889
1636
|
if plan in ("off", "bed", "file"):
|
|
@@ -924,48 +1671,162 @@ def main():
|
|
|
924
1671
|
return mod
|
|
925
1672
|
|
|
926
1673
|
stage = a.stage
|
|
1674
|
+
|
|
1675
|
+
# ---- explain:只报"哪些阶段过时了",不跑任何 stage ----
|
|
1676
|
+
if stage == "explain":
|
|
1677
|
+
rows = SW.explain(a.work, sources=_sources())
|
|
1678
|
+
bad = 0
|
|
1679
|
+
for r in rows:
|
|
1680
|
+
if r["issues"]:
|
|
1681
|
+
bad += 1
|
|
1682
|
+
print("%s%-8s %s" % ("!! " if r["issues"] else "OK ", r["stage"],
|
|
1683
|
+
r["at"] or "(无记录)"))
|
|
1684
|
+
for it in r["issues"]:
|
|
1685
|
+
print(" -", it["reason"])
|
|
1686
|
+
if it.get("rerun"):
|
|
1687
|
+
print(" 最小重跑:", it["rerun"])
|
|
1688
|
+
print()
|
|
1689
|
+
if bad:
|
|
1690
|
+
print("%d 个阶段过时。默认只告警;--strict-stale 让它失败。" % bad)
|
|
1691
|
+
return 1 if a.strict_stale else 0
|
|
1692
|
+
print("没有发现过时的阶段(按 work/manifest.json 判断)。")
|
|
1693
|
+
return 0
|
|
1694
|
+
|
|
1695
|
+
# ---- accept:人工签核一道闸(写 work/signoff.json,绑内容指纹)----
|
|
1696
|
+
if stage == "accept":
|
|
1697
|
+
if not a.gate:
|
|
1698
|
+
raise SystemExit("[accept] 必须指定要签哪一道闸:--gate %s"
|
|
1699
|
+
% " / ".join(GT.GATE_ORDER))
|
|
1700
|
+
if a.verdict == "approved":
|
|
1701
|
+
# 签核前必须能证明"真的看过了":证据产物不在就拒绝签(不然签核是橡皮图章)。
|
|
1702
|
+
# 证据可能在 work/(渲染产物),也可能在项目根(FACTS.md 是人写的输入),两处都找。
|
|
1703
|
+
proj = os.path.dirname(os.path.abspath(a.script))
|
|
1704
|
+
want = {"style": [os.path.join(a.work, "style.json"),
|
|
1705
|
+
os.path.join(a.work, "voice_plan.json")],
|
|
1706
|
+
"stills": [os.path.join(a.work, "qa", "sheet.png")],
|
|
1707
|
+
"facts": [os.path.join(proj, "FACTS.md"), os.path.join(a.work, "FACTS.md")],
|
|
1708
|
+
"release": [a.out]}.get(a.gate, [])
|
|
1709
|
+
# 每一组是"多选一":任一条路径存在就算有证据。
|
|
1710
|
+
missing = None
|
|
1711
|
+
if want and not any(os.path.exists(str(w)) for w in want):
|
|
1712
|
+
missing = "、".join(os.path.relpath(w, proj) if os.path.isabs(w) else w
|
|
1713
|
+
for w in want)
|
|
1714
|
+
if missing:
|
|
1715
|
+
raise SystemExit("[accept] 证据不存在,拒绝签核:先跑出要看的东西(缺 %s)"
|
|
1716
|
+
% missing)
|
|
1717
|
+
rec = GT.write_signoff(a.work, a.gate, _LEDGER.content_hash,
|
|
1718
|
+
verdict=a.verdict, by=a.by or "user",
|
|
1719
|
+
note=a.note,
|
|
1720
|
+
artifacts=[os.path.join(a.work, "gates.json")])
|
|
1721
|
+
print("[accept] %s = %s(内容指纹 %s,%s)"
|
|
1722
|
+
% (a.gate, rec["verdict"], _LEDGER.content_hash, rec["atText"]))
|
|
1723
|
+
if a.verdict == "rejected":
|
|
1724
|
+
print("[accept] 已记「否决」(不算签核通过);改了内容后需重新签。")
|
|
1725
|
+
# accept 是**独立进程**:本次账本是空的。直接 write_json 会把上一次 all/mux/qa
|
|
1726
|
+
# 记的 8 条门禁结论整个覆盖掉(报告变成 "pass 0 · skipped 1",交付凭据被打穿)。
|
|
1727
|
+
# 先把磁盘上的旧账本搬回来(内容指纹一致才搬),再追加签核状态。
|
|
1728
|
+
_reload_ledger_from_disk(a.work, _LEDGER)
|
|
1729
|
+
# 签核状态进账本:否则报告里的"人工签核"一行仍是上一次 all/mux 收尾时记的
|
|
1730
|
+
# skipped,用户签完再打开报告却看不到"已签"——签核就不算兑现。
|
|
1731
|
+
try:
|
|
1732
|
+
_check_signoff(stage)
|
|
1733
|
+
except Exception as e: # noqa: BLE001
|
|
1734
|
+
print(" [WARN] 签核状态没记进账本:", e)
|
|
1735
|
+
try:
|
|
1736
|
+
_LEDGER.write_json()
|
|
1737
|
+
except Exception as e: # noqa: BLE001
|
|
1738
|
+
print(" [WARN] gates.json 写入失败:", e)
|
|
1739
|
+
p_rep = GT.write_report(a.work, _report_path(a))
|
|
1740
|
+
print("[accept] QC 报告已更新:", p_rep)
|
|
1741
|
+
return 0
|
|
1742
|
+
|
|
1743
|
+
if stage in ("all", "style"):
|
|
1744
|
+
# --stage all 也要把 style 这一行记进 manifest:以前 _nm 在 all 下是 None,
|
|
1745
|
+
# 而 _stage_begin/_stage_end 对 None 是空操作 -> work/style.json 写出来了、
|
|
1746
|
+
# manifest.json 却从来没有 style 记录。于是之后任何一次 `--stage style` 都会
|
|
1747
|
+
# 命中「产物在但没有记录」的判据,报成 no-manifest(--stage all 的用户 100% 踩到)。
|
|
1748
|
+
_t0 = _stage_begin("style")
|
|
1749
|
+
resolve_style(script, a.work)
|
|
1750
|
+
_stage_end("style", _t0, n_tts=None)
|
|
1751
|
+
if stage == "style":
|
|
1752
|
+
print("== Style")
|
|
1753
|
+
_print_style(script["_style"])
|
|
1754
|
+
_need(os.path.exists(os.path.join(a.work, "style.json")), "style.json 没写出来")
|
|
1755
|
+
_LEDGER.ok("style", "风格包解析", str(script["_style"].get("style")))
|
|
1756
|
+
_LEDGER.write_json()
|
|
1757
|
+
_finish_gates(a)
|
|
1758
|
+
return 0
|
|
1759
|
+
|
|
927
1760
|
if stage in ("all", "tts"):
|
|
1761
|
+
_t0 = _stage_begin("tts")
|
|
928
1762
|
lines = st_tts(script, a.work, force=a.force_tts)
|
|
1763
|
+
_stage_end("tts", _t0, n_tts=len(lines) if isinstance(lines, list) else None)
|
|
1764
|
+
# 既有行为:单跑 tts 也会排一次轴(tts.json 的时长信息要用),这里一并计时。
|
|
1765
|
+
_t0 = _stage_begin("layout")
|
|
929
1766
|
tl = st_layout(script, a.work, lines)
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1767
|
+
_stage_end("layout", _t0)
|
|
1768
|
+
if stage == "layout":
|
|
1769
|
+
_t0 = _stage_begin("layout")
|
|
1770
|
+
_need(os.path.exists(tts_p), "没有 tts.json,先跑 --stage tts")
|
|
1771
|
+
tl = st_layout(script, a.work)
|
|
1772
|
+
_stage_end("layout", _t0)
|
|
934
1773
|
if stage in ("all", "cues"):
|
|
935
1774
|
tl = tl if stage == "all" else ensure_tl()
|
|
936
|
-
|
|
1775
|
+
_t0 = _stage_begin("cues")
|
|
1776
|
+
cues = st_cues(script, a.work, tl, engine=engine)
|
|
1777
|
+
_stage_end("cues", _t0,
|
|
1778
|
+
n_cues=(len((cues or {}).get("cues") or []) if isinstance(cues, dict) else None))
|
|
937
1779
|
if stage in ("all", "score"):
|
|
938
1780
|
tl = tl if stage == "all" else ensure_tl()
|
|
1781
|
+
_t0 = _stage_begin("score")
|
|
939
1782
|
st_score(script, a.work, tl)
|
|
1783
|
+
_stage_end("score", _t0)
|
|
940
1784
|
if stage in ("all", "sfx"):
|
|
941
1785
|
tl = tl if stage == "all" else ensure_tl()
|
|
1786
|
+
_t0 = _stage_begin("sfx")
|
|
942
1787
|
st_sfx(script, a.work, tl)
|
|
1788
|
+
_stage_end("sfx", _t0)
|
|
943
1789
|
if stage in ("all", "audio"):
|
|
944
1790
|
tl = tl if stage == "all" else ensure_tl()
|
|
945
1791
|
_need(all(s.get("path") for s in tl["scenes"]), "timeline 里没有旁白路径,先跑 --stage tts")
|
|
946
1792
|
missing = [s.get("id") for s in tl["scenes"] if not os.path.exists(str(s.get("path") or ""))]
|
|
947
1793
|
_need(not missing, "旁白文件不存在:%s(tts 目录被删/挪过?重新跑 --stage tts)"
|
|
948
1794
|
% (missing[:6],))
|
|
1795
|
+
_t0 = _stage_begin("audio")
|
|
949
1796
|
st_audio(tl, a.work, script)
|
|
1797
|
+
_stage_end("audio", _t0)
|
|
950
1798
|
|
|
951
1799
|
if stage == "stills":
|
|
952
1800
|
# 母版预览是"上手第一件事",可它需要 timeline。以前干净目录直接 SystemExit,
|
|
953
1801
|
# 而文档三步上手就把它排在第 4 步(照抄必失败)。这里自动补前置:
|
|
954
1802
|
# 缺 tts.json 就真合成一次旁白(要联网),缺 timeline 就排一次轴——
|
|
955
1803
|
# 但每一步都**明说**自己在补什么,不偷偷跑。
|
|
1804
|
+
# 自动补的这几步也要 _stage_begin/_stage_end:否则 manifest 里只有 stills 一条记录,
|
|
1805
|
+
# 下次 --stage tts 会看到 tts.json 在、账本里没有 tts 记录 -> 误报「接手旧 work」。
|
|
956
1806
|
if not os.path.exists(tts_p):
|
|
957
1807
|
print("[stills] 没有 tts.json,自动先跑 --stage tts(需要联网合成旁白)")
|
|
1808
|
+
_t0 = _stage_begin("tts")
|
|
958
1809
|
lines = st_tts(script, a.work, force=a.force_tts)
|
|
1810
|
+
_stage_end("tts", _t0, n_tts=len(lines) if isinstance(lines, list) else None)
|
|
1811
|
+
_t0 = _stage_begin("layout")
|
|
959
1812
|
tl_tmp = st_layout(script, a.work, lines)
|
|
1813
|
+
_stage_end("layout", _t0)
|
|
960
1814
|
tl = tl_tmp
|
|
961
1815
|
elif not os.path.exists(tl_p):
|
|
962
1816
|
print("[stills] 没有 timeline.json,自动先跑 --stage layout")
|
|
1817
|
+
_t0 = _stage_begin("layout")
|
|
963
1818
|
tl = st_layout(script, a.work)
|
|
1819
|
+
_stage_end("layout", _t0)
|
|
964
1820
|
else:
|
|
965
1821
|
tl = ensure_tl()
|
|
966
1822
|
mod = load_scenes()
|
|
967
1823
|
specs = [x.strip() for x in a.stills.split(",") if x.strip()] if a.stills else None
|
|
968
|
-
|
|
1824
|
+
_t0 = _stage_begin("stills")
|
|
1825
|
+
made = st_stills(tl, a.work, mod, script, specs)
|
|
1826
|
+
_stage_end("stills", _t0)
|
|
1827
|
+
_LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
|
|
1828
|
+
"%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
|
|
1829
|
+
_finish_gates(a)
|
|
969
1830
|
return 0
|
|
970
1831
|
|
|
971
1832
|
if stage == "qa":
|
|
@@ -973,8 +1834,12 @@ def main():
|
|
|
973
1834
|
tl = ensure_tl()
|
|
974
1835
|
_need(os.path.exists(a.out), "没有成片 %s,先跑 --stage mux" % a.out)
|
|
975
1836
|
mod = load_scenes(strict=False) if engine == "pil" else None
|
|
1837
|
+
_t0 = _stage_begin("qa")
|
|
976
1838
|
errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script)
|
|
977
|
-
|
|
1839
|
+
_stage_end("qa", _t0)
|
|
1840
|
+
_check_signoff("qa")
|
|
1841
|
+
rc = _finish_gates(a)
|
|
1842
|
+
return 1 if (errs or rc) else 0
|
|
978
1843
|
|
|
979
1844
|
if stage in ("all", "frames", "mux"):
|
|
980
1845
|
tl = tl if stage == "all" else ensure_tl()
|
|
@@ -988,24 +1853,56 @@ def main():
|
|
|
988
1853
|
_need(os.path.exists(audio), "混音阶段异常:audio.wav 未生成")
|
|
989
1854
|
if engine == "pil":
|
|
990
1855
|
mod = load_scenes()
|
|
1856
|
+
_t0 = _stage_begin("frames")
|
|
991
1857
|
silent = st_frames(tl, a.work, mod, script, jobs=a.jobs, resume=a.resume,
|
|
992
1858
|
engine=engine)
|
|
1859
|
+
_stage_end("frames", _t0, frames=int(tl["frames"]))
|
|
1860
|
+
if stage == "frames":
|
|
1861
|
+
_LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
|
|
1862
|
+
_finish_gates(a)
|
|
1863
|
+
return 0 if not _LEDGER.fails() else 1
|
|
993
1864
|
if stage in ("all", "mux"):
|
|
994
1865
|
# mux 只需要 silent.mp4 + audio.wav:不加载 scenes.py(缺场景模块也能合成)
|
|
995
1866
|
_need(os.path.exists(silent), "没有 silent.mp4,先跑 --stage frames")
|
|
996
1867
|
_need(os.path.exists(audio), "没有 audio.wav,先跑 --stage audio")
|
|
1868
|
+
_t0 = _stage_begin("mux")
|
|
997
1869
|
film = st_mux(silent, audio, a.out, script)
|
|
1870
|
+
_stage_end("mux", _t0)
|
|
1871
|
+
_check_signoff("mux")
|
|
1872
|
+
if stage == "mux":
|
|
1873
|
+
_LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
|
|
1874
|
+
_finish_gates(a)
|
|
1875
|
+
return 0 if not _LEDGER.fails() else 1
|
|
998
1876
|
if stage == "all":
|
|
1877
|
+
_t0 = _stage_begin("qa")
|
|
999
1878
|
errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script)
|
|
1000
|
-
|
|
1879
|
+
_stage_end("qa", _t0)
|
|
1880
|
+
rc = _finish_gates(a)
|
|
1881
|
+
if errs or rc:
|
|
1001
1882
|
print("\n[质检未过] 成片已生成但没有通过门禁,按上面 [FAIL] 逐条修。")
|
|
1002
1883
|
return 1
|
|
1003
|
-
|
|
1884
|
+
return 0
|
|
1885
|
+
# 所有 stage 统一收尾:落 gates.json + QC-REPORT.md,并按门禁结果给退出码。
|
|
1886
|
+
# v0.7.2 修:以前只有 style/frames/mux/qa 这几条分支自己调 _finish_gates,其余
|
|
1887
|
+
# (tts/layout/cues/score/sfx/audio/stills)落在这行 return 0——于是 ledger 里
|
|
1888
|
+
# 记的 fail 传不到退出码,"门禁失败 -> 非 0"这个承诺按 stage 不同行为不一样。
|
|
1889
|
+
# 默认路径不受影响:没有 fail(且没开 --strict-gates)时 _finish_gates 返回 0。
|
|
1890
|
+
rc = _finish_gates(a, persist=(stage in _PERSIST_STAGES))
|
|
1891
|
+
return 1 if (rc or _LEDGER.fails()) else 0
|
|
1004
1892
|
|
|
1005
1893
|
|
|
1006
1894
|
if __name__ == "__main__":
|
|
1007
1895
|
try:
|
|
1008
1896
|
sys.exit(main())
|
|
1897
|
+
except KeyboardInterrupt:
|
|
1898
|
+
# Ctrl-C:给「能不能续跑」的明确答复,而不是甩 traceback 或静默退出 130。
|
|
1899
|
+
# 分块渲染先写 _part_ 再改名,所以中断不会留下被 --resume 误认的半截分块;
|
|
1900
|
+
# 已完成的段下次加 --resume 直接复用,残留的 _part_ 会被自动清掉。
|
|
1901
|
+
_setup_stdio()
|
|
1902
|
+
print("\n[中断] 收到 Ctrl-C,本次运行已停止。", file=sys.stderr)
|
|
1903
|
+
print(" 已完成的产物都留在 work/ 里:分块渲染请加 --resume 续跑"
|
|
1904
|
+
"(半成品段不会被当成完成段复用)。", file=sys.stderr)
|
|
1905
|
+
sys.exit(130)
|
|
1009
1906
|
except RuntimeError as e:
|
|
1010
1907
|
# 领域错误(音视频时长不符 / 分块缺口 / 编码失败 / TTS 重试耗尽 …)给一句话,
|
|
1011
1908
|
# 不要甩 Python traceback;要完整栈就 set RENDER_DEBUG=1。
|