@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.6.0
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 +62 -12
- 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/package.json +11 -3
- package/schemas/film.schema.json +528 -0
- package/skills/video-studio/SKILL.md +166 -14
- package/skills/video-studio/assets/film.json +84 -23
- package/skills/video-studio/assets/scenes.py +892 -34
- package/skills/video-studio/references/audio.md +29 -1
- 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/requirements.txt +11 -0
- package/skills/video-studio/scripts/filmcheck.py +656 -0
- package/skills/video-studio/scripts/filmkit.py +682 -58
- package/skills/video-studio/scripts/gates.py +446 -0
- package/skills/video-studio/scripts/render.py +562 -34
- package/skills/video-studio/scripts/scorekit.py +1642 -25
- package/skills/video-studio/scripts/selfcheck.py +346 -0
- package/skills/video-studio/scripts/stagewatch.py +295 -0
- package/skills/video-studio/scripts/stylekit.py +924 -0
- package/skills/video-studio/scripts/voicekit.py +872 -0
- package/tools/ci_check.py +339 -0
- package/tools/estimate.py +578 -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():
|
|
@@ -152,12 +174,18 @@ def load_script(p) -> dict:
|
|
|
152
174
|
for k in ("music", "sfx", "web"):
|
|
153
175
|
if s.get(k) is not None and not isinstance(s.get(k), dict):
|
|
154
176
|
raise SystemExit("film.json 的 %s 必须是对象(当前是 %s)" % (k, type(s[k]).__name__))
|
|
155
|
-
|
|
177
|
+
# v0.5:bpm 允许 "auto"(由 stylekit 按风格梯度 + 内容指纹决定)。
|
|
178
|
+
# 同时允许写在 music.bpm 里(优先级更高)。
|
|
179
|
+
for _k, _v in (("bpm", s.get("bpm")),
|
|
180
|
+
("music.bpm", (s.get("music") or {}).get("bpm"))):
|
|
181
|
+
if _v in (None, "") or (isinstance(_v, str) and _v.strip().lower() == "auto"):
|
|
182
|
+
continue
|
|
156
183
|
try:
|
|
157
|
-
if float(
|
|
184
|
+
if float(_v) <= 0:
|
|
158
185
|
raise ValueError("必须大于 0")
|
|
159
186
|
except (TypeError, ValueError) as e:
|
|
160
|
-
raise SystemExit("film.json 的
|
|
187
|
+
raise SystemExit("film.json 的 %s 必须是正数或 \"auto\"(收到 %r:%s)"
|
|
188
|
+
% (_k, _v, e)) from e
|
|
161
189
|
seen = set()
|
|
162
190
|
for i, ln in enumerate(lines):
|
|
163
191
|
if not isinstance(ln, dict):
|
|
@@ -181,10 +209,108 @@ def _need(cond, msg):
|
|
|
181
209
|
raise SystemExit("[前置产物缺失] " + msg)
|
|
182
210
|
|
|
183
211
|
|
|
212
|
+
# ---------------------------------------------------------------- 风格解析(v0.5.0)
|
|
213
|
+
def _stylekit():
|
|
214
|
+
"""惰性 import stylekit;缺文件时给一句能照着做的错,而不是 ModuleNotFoundError。"""
|
|
215
|
+
try:
|
|
216
|
+
import stylekit as ST
|
|
217
|
+
return ST
|
|
218
|
+
except Exception as e: # noqa: BLE001
|
|
219
|
+
raise SystemExit("[缺少 stylekit] v0.5 起 render.py 需要同目录的 stylekit.py"
|
|
220
|
+
"(skills/video-studio/scripts/stylekit.py):%s" % e)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def resolve_style(script, work=None, verbose=True):
|
|
224
|
+
"""解析风格计划并挂到 script["_style"] 上(同一进程内各 stage 共享)。
|
|
225
|
+
|
|
226
|
+
纯函数 + 落盘 work/style.json(审计用:用户能看到"为什么是这套风格")。
|
|
227
|
+
"""
|
|
228
|
+
if isinstance(script.get("_style"), dict):
|
|
229
|
+
return script["_style"]
|
|
230
|
+
ST = _stylekit()
|
|
231
|
+
plan = ST.resolve_script_style(script)
|
|
232
|
+
# 主题体检:坏主题现在拦,不要等渲完 300 帧
|
|
233
|
+
bad = ST.theme_lint(ST.THEMES[plan["theme_id"]])
|
|
234
|
+
if bad:
|
|
235
|
+
raise SystemExit("[主题体检失败] %s:%s" % (plan["theme_id"], ";".join(bad)))
|
|
236
|
+
script["_style"] = plan
|
|
237
|
+
if work and verbose:
|
|
238
|
+
path = os.path.join(work, "style.json")
|
|
239
|
+
try:
|
|
240
|
+
os.makedirs(work, exist_ok=True)
|
|
241
|
+
json.dump(plan, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
242
|
+
except Exception as e: # noqa: BLE001
|
|
243
|
+
print(" [WARN] style.json 落盘失败:%s" % e)
|
|
244
|
+
return plan
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _theme_for(script):
|
|
248
|
+
"""返回当前片的主题字典;legacy 返回 None(= v0.4 的硬编码外观,逐字节兼容)。"""
|
|
249
|
+
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
250
|
+
if plan is not None and plan.get("legacy"):
|
|
251
|
+
return None
|
|
252
|
+
plan = resolve_style(script)
|
|
253
|
+
if plan.get("legacy"):
|
|
254
|
+
return None
|
|
255
|
+
return _stylekit().THEMES[plan["theme_id"]]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _print_style(plan):
|
|
259
|
+
m = plan["music"]
|
|
260
|
+
v = plan["voice"]
|
|
261
|
+
print(" 风格: %s(%s)%s" % (plan["style"], plan["style_source"],
|
|
262
|
+
"" if plan["legacy"] else " / " + plan["style_name"]))
|
|
263
|
+
print(" 理由: %s" % ";".join(plan["style_reason"]))
|
|
264
|
+
print(" 主题: %s 版式: %s" % (plan["theme_id"],
|
|
265
|
+
",".join(sorted(set(plan["chapter_layout"].values())))
|
|
266
|
+
or plan["layout_default"]))
|
|
267
|
+
print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
|
|
268
|
+
% (m["style"], m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
|
|
269
|
+
if v["map"]:
|
|
270
|
+
print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
|
|
271
|
+
p = plan["pacing"]
|
|
272
|
+
print(" 节奏: lead %.2f / tail %.2f / min_dur %.1f / 条目步长 %.2f"
|
|
273
|
+
% (p["lead"], p["tail"], p["min_dur"], p["item_stagger"]))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def st_style(script, work):
|
|
277
|
+
print("== Style")
|
|
278
|
+
plan = resolve_style(script, work)
|
|
279
|
+
_print_style(plan)
|
|
280
|
+
return plan
|
|
281
|
+
|
|
282
|
+
|
|
184
283
|
# ---------------------------------------------------------------- TTS
|
|
284
|
+
def _voice_plan(script, plan):
|
|
285
|
+
"""声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走 v0.4 单声线路径。"""
|
|
286
|
+
try:
|
|
287
|
+
import voicekit as VK
|
|
288
|
+
except Exception as e: # noqa: BLE001
|
|
289
|
+
print(" [WARN] 没有 voicekit.py,本次旁白单声线(v0.4 行为):%s" % e)
|
|
290
|
+
return None
|
|
291
|
+
try:
|
|
292
|
+
vp = VK.voice_plan(script, plan)
|
|
293
|
+
except Exception as e: # noqa: BLE001
|
|
294
|
+
print(" [WARN] voicekit 解析失败,退回单声线:%s" % e)
|
|
295
|
+
return None
|
|
296
|
+
return vp
|
|
297
|
+
|
|
298
|
+
|
|
185
299
|
def st_tts(script, work, force=False):
|
|
186
300
|
lines = script["lines"]
|
|
301
|
+
plan = resolve_style(script, work)
|
|
187
302
|
print("== TTS (%d 句)" % len(lines))
|
|
303
|
+
if plan["legacy"]:
|
|
304
|
+
print(" 声线: v0.4 兼容(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
|
|
305
|
+
vp = None
|
|
306
|
+
else:
|
|
307
|
+
vp = _voice_plan(script, plan)
|
|
308
|
+
if vp:
|
|
309
|
+
import voicekit as VK
|
|
310
|
+
print(VK.describe(vp))
|
|
311
|
+
json.dump({"style": plan["style"], "policy": plan["voice"]["policy"], "lines": vp},
|
|
312
|
+
open(os.path.join(work, "voice_plan.json"), "w", encoding="utf-8"),
|
|
313
|
+
ensure_ascii=False, indent=1)
|
|
188
314
|
if force:
|
|
189
315
|
shutil.rmtree(os.path.join(work, "tts"), ignore_errors=True)
|
|
190
316
|
done = K.tts_batch(lines, work,
|
|
@@ -192,7 +318,12 @@ def st_tts(script, work, force=False):
|
|
|
192
318
|
rate=script.get("rate", "+0%"),
|
|
193
319
|
pitch=script.get("pitch", "+0Hz"),
|
|
194
320
|
workers=int(script.get("tts_workers", 4)),
|
|
195
|
-
trim=bool(script.get("trim_silence", True))
|
|
321
|
+
trim=bool(script.get("trim_silence", True)),
|
|
322
|
+
plan=None if vp is None else
|
|
323
|
+
[{"voice": r["voice"], "rate": r["rate"], "pitch": r["pitch"],
|
|
324
|
+
"volume": r.get("volume", "+0%"),
|
|
325
|
+
"persona": r.get("persona"), "tone": r.get("tone")} for r in vp],
|
|
326
|
+
trim_db=float(script.get("trim_db", -42.0)))
|
|
196
327
|
json.dump(done, open(os.path.join(work, "tts.json"), "w", encoding="utf-8"),
|
|
197
328
|
ensure_ascii=False, indent=1)
|
|
198
329
|
total = sum(float(x["dur"]) for x in done)
|
|
@@ -203,21 +334,46 @@ def st_tts(script, work, force=False):
|
|
|
203
334
|
# ---------------------------------------------------------------- 排版
|
|
204
335
|
def st_layout(script, work, lines=None):
|
|
205
336
|
lines = lines or json.load(open(os.path.join(work, "tts.json"), encoding="utf-8"))
|
|
337
|
+
plan = resolve_style(script, work)
|
|
206
338
|
print("== Layout")
|
|
339
|
+
pace = plan["pacing"]
|
|
340
|
+
# 逐句 lead/tail:voicekit 的 voice_plan 已经算好每句的停顿,这里只负责喂给时间轴。
|
|
341
|
+
# 旧项目(legacy)没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数,v0.4 行为。
|
|
342
|
+
vp = None
|
|
343
|
+
vp_path = os.path.join(work, "voice_plan.json")
|
|
344
|
+
if not plan["legacy"] and os.path.exists(vp_path):
|
|
345
|
+
try:
|
|
346
|
+
vp = json.load(open(vp_path, encoding="utf-8"))["lines"]
|
|
347
|
+
except Exception as e: # noqa: BLE001
|
|
348
|
+
print(" [WARN] voice_plan.json 读不了,逐句停顿退回全局值:%s" % e)
|
|
349
|
+
lead_map = {r["id"]: float(r["lead"]) for r in vp} if vp else None
|
|
350
|
+
tail_map = {r["id"]: float(r["tail"]) for r in vp} if vp else None
|
|
351
|
+
if vp and script.get("tail_map"):
|
|
352
|
+
tail_extra = script["tail_map"]
|
|
353
|
+
for lid, v in tail_extra.items():
|
|
354
|
+
if lid in tail_map:
|
|
355
|
+
tail_map[lid] = float(v)
|
|
207
356
|
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"),
|
|
357
|
+
lead=script.get("lead", pace["lead"]),
|
|
358
|
+
tail=script.get("tail", pace["tail"]),
|
|
359
|
+
min_dur=script.get("min_dur", pace["min_dur"]),
|
|
360
|
+
tail_map=tail_map or script.get("tail_map"),
|
|
361
|
+
lead_map=lead_map,
|
|
362
|
+
hold=script.get("hold", pace["hold"]),
|
|
212
363
|
max_dur=script.get("max_dur"))
|
|
213
364
|
tl["title"] = script["title"]
|
|
214
365
|
tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
|
|
215
366
|
tl["crf"] = script.get("crf", 18)
|
|
216
367
|
tl["preset"] = script.get("preset", "medium")
|
|
368
|
+
tl["style"] = {"style": plan["style"], "theme_id": plan["theme_id"],
|
|
369
|
+
"legacy": plan["legacy"],
|
|
370
|
+
"chapter_layout": plan["chapter_layout"],
|
|
371
|
+
"item_stagger": pace["item_stagger"]}
|
|
217
372
|
json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
|
|
218
373
|
ensure_ascii=False, indent=1)
|
|
219
374
|
n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
|
|
220
|
-
|
|
375
|
+
theme = (K._stylekit().THEMES[plan["theme_id"]] if not plan["legacy"] else None)
|
|
376
|
+
n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
|
|
221
377
|
print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
|
|
222
378
|
% (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
|
|
223
379
|
# 安全区自查:字幕一旦越界,mux 前就能发现
|
|
@@ -359,10 +515,31 @@ def st_score(script, work, tl):
|
|
|
359
515
|
s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
|
|
360
516
|
fixed.append(s2)
|
|
361
517
|
sections = fixed
|
|
518
|
+
plan_s = resolve_style(script)
|
|
519
|
+
mp = plan_s["music"]
|
|
362
520
|
t0 = time.time()
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
521
|
+
style_arg = None if plan_s["legacy"] else str(mp.get("style") or plan_s["style"])
|
|
522
|
+
kwargs = dict(bpm=float(mp["bpm"]), key=str(mp["key"] or "Dm"), anchors=anchors,
|
|
523
|
+
sections=sections, seed=int(mp["seed_effective"]))
|
|
524
|
+
# scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
|
|
525
|
+
# 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
|
|
526
|
+
# 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
|
|
527
|
+
pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
|
|
528
|
+
"hold": float(plan_s["pacing"]["hold"])}
|
|
529
|
+
try:
|
|
530
|
+
samples, report = sk.render(float(tl["total"]), style=style_arg,
|
|
531
|
+
mood=str(mp.get("mood") or "steady"),
|
|
532
|
+
music_plan=mp, pace=pace, **kwargs)
|
|
533
|
+
except TypeError:
|
|
534
|
+
try:
|
|
535
|
+
samples, report = sk.render(float(tl["total"]), style=style_arg,
|
|
536
|
+
mood=str(mp.get("mood") or "steady"), **kwargs)
|
|
537
|
+
except TypeError:
|
|
538
|
+
samples, report = sk.render(float(tl["total"]), **kwargs)
|
|
539
|
+
if report and not plan_s["legacy"]:
|
|
540
|
+
print(" 配乐风格: %s / mood %s / %s BPM / %s"
|
|
541
|
+
% (report.get("style") or style_arg, mp.get("mood"),
|
|
542
|
+
report.get("bpm"), report.get("key")))
|
|
366
543
|
out = os.path.join(work, "music.wav")
|
|
367
544
|
sk.save(out, samples)
|
|
368
545
|
if report:
|
|
@@ -407,13 +584,18 @@ def st_sfx(script, work, tl):
|
|
|
407
584
|
|
|
408
585
|
|
|
409
586
|
def _voice_track(tl, script):
|
|
410
|
-
"""把逐句旁白铺成一条总线,返回 (n,) 单声道。
|
|
587
|
+
"""把逐句旁白铺成一条总线,返回 (n,) 单声道。
|
|
588
|
+
|
|
589
|
+
v0.5.0 修一个 v0.4 的静默 bug:voice_gain 原来在这里和 mix_film 里**各乘一次**
|
|
590
|
+
(净效果 voice_gain^2,默认 1.0 时看不出来)。现在全局增益只在 mix_film 乘一次,
|
|
591
|
+
这里只负责**逐句**增益(voicekit 的 gain_db,默认 0 dB -> 1.0,与 v0.4 一致)。
|
|
592
|
+
"""
|
|
411
593
|
bus = K.AudioBus(tl["total"])
|
|
412
594
|
for s in tl["scenes"]:
|
|
413
595
|
pcm = K.decode_pcm(s["path"])
|
|
414
|
-
|
|
596
|
+
gdb = float(s.get("gain_db", 0.0) or 0.0)
|
|
597
|
+
bus.place(pcm, s["voice_start"], gain=10.0 ** (gdb / 20.0),
|
|
415
598
|
fade_in=0.02, fade_out=0.06)
|
|
416
|
-
# 记录真实语音范围,给后续"停顿处补呼吸"之类的处理用
|
|
417
599
|
return bus.bus[: int(round(float(tl["total"]) * K.SR))].copy()
|
|
418
600
|
|
|
419
601
|
|
|
@@ -487,11 +669,18 @@ def st_audio(tl, work, script):
|
|
|
487
669
|
sfx = bus.bus[: int(round(total * K.SR))].copy()
|
|
488
670
|
print(" 音效: 旧版转场 whoosh(没有 cues.json)")
|
|
489
671
|
|
|
672
|
+
plan_s = resolve_style(script)
|
|
673
|
+
# 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
|
|
674
|
+
# 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
|
|
675
|
+
duck_default = 3.0
|
|
676
|
+
if not plan_s["legacy"]:
|
|
677
|
+
duck_default = {"mono": 2.0, "lo-fi": 2.5, "magazine": 2.5,
|
|
678
|
+
"cinema": 4.5, "epic": 4.5}.get(plan_s["style"], 3.0)
|
|
490
679
|
samples = K.mix_film(voice, music, sfx, total=total,
|
|
491
680
|
voice_gain=float(script.get("voice_gain", 1.0)),
|
|
492
681
|
music_gain_db=float(mus.get("gain_db", -1.5)),
|
|
493
682
|
sfx_gain_db=float((script.get("sfx") or {}).get("mix_gain_db", 0.0)),
|
|
494
|
-
duck_db=float(mus.get("duck_db",
|
|
683
|
+
duck_db=float(mus.get("duck_db", duck_default)))
|
|
495
684
|
out = os.path.join(work, "audio.wav")
|
|
496
685
|
peak = K.wav_write(out, samples)
|
|
497
686
|
print(" audio.wav %.2fs 峰值 %.3f 声道 %d" % (K.wav_dur(out), peak, 2))
|
|
@@ -516,14 +705,23 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
|
|
|
516
705
|
frame = mod.draw_frame(s, lt, s["dur"], t, script, fi)
|
|
517
706
|
if frame is None:
|
|
518
707
|
raise RuntimeError("场景 %s 在 t=%.2fs 返回 None" % (s.get("id"), t))
|
|
519
|
-
|
|
708
|
+
th = _theme_for(script) if _THEME_CACHE.get("k") != id(script) else _THEME_CACHE.get("th")
|
|
709
|
+
if id(script) != _THEME_CACHE.get("k"):
|
|
710
|
+
_THEME_CACHE.update(k=id(script), th=th)
|
|
711
|
+
frame = K.draw_subtitle(frame, K.sub_at(scenes, t, script.get("sub_max_hold")),
|
|
712
|
+
theme=th)
|
|
520
713
|
fade = 1.0
|
|
521
714
|
if t < 0.5:
|
|
522
715
|
fade *= K.ease_out(t / 0.5)
|
|
523
716
|
if t > total - 0.8:
|
|
524
717
|
fade *= max(0.0, (total - t) / 0.8)
|
|
525
718
|
if fade < 0.999:
|
|
526
|
-
|
|
719
|
+
# 浅色主题淡入淡出到纯黑会出现"闪一下黑",非常廉价。
|
|
720
|
+
# transition=wash 时淡向主题底色,观感是"渐显"而不是"黑场"。
|
|
721
|
+
wipe = (0, 0, 0)
|
|
722
|
+
if th is not None and str(th.get("transition") or "black") == "wash":
|
|
723
|
+
wipe = tuple(th["bg2"])
|
|
724
|
+
frame = K.Image.blend(K.Image.new("RGB", (K.W, K.H), wipe), frame, fade)
|
|
527
725
|
sink.write(frame)
|
|
528
726
|
if progress and fi - last_rep >= 300:
|
|
529
727
|
last_rep = fi
|
|
@@ -540,6 +738,16 @@ def _render_chunk_worker(args):
|
|
|
540
738
|
tl = json.load(open(tl_path, encoding="utf-8"))
|
|
541
739
|
script = json.load(open(script_path, encoding="utf-8"))
|
|
542
740
|
script["_total"] = tl["total"]
|
|
741
|
+
# v0.6.0 修:JSON 快照会把元组变成 list(主题色是 (r,g,b) 元组),子进程里
|
|
742
|
+
# scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
743
|
+
# 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
|
|
744
|
+
# 既拿回元组,也保证子进程的主题与父进程逐字段一致。
|
|
745
|
+
script.pop("_style", None)
|
|
746
|
+
try:
|
|
747
|
+
script["_style"] = resolve_style(script, verbose=False)
|
|
748
|
+
except Exception as e: # noqa: BLE001
|
|
749
|
+
print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
|
|
750
|
+
script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
|
|
543
751
|
mod = load_module(scenes_path, "film_scenes_chunk_%d" % f0)
|
|
544
752
|
return _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=False)
|
|
545
753
|
|
|
@@ -550,6 +758,9 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
550
758
|
return _st_frames_web(tl, work, script, jobs=jobs, resume=resume)
|
|
551
759
|
script = dict(script)
|
|
552
760
|
script["_total"] = tl["total"] # 场景算全局进度条要用
|
|
761
|
+
# 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
|
|
762
|
+
# _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
|
|
763
|
+
script.setdefault("_style", resolve_style(script))
|
|
553
764
|
crf = int(script.get("crf", 18))
|
|
554
765
|
preset = script.get("preset", "medium")
|
|
555
766
|
total_frames = tl["frames"]
|
|
@@ -699,8 +910,99 @@ def st_mux(silent, audio, out, script):
|
|
|
699
910
|
return out
|
|
700
911
|
|
|
701
912
|
|
|
913
|
+
def _qa_gates(lg, rep, deep, tl, work):
|
|
914
|
+
"""把 qa_report / 深检的结论拆成逐条门禁记进账本(pass / fail / skipped)。
|
|
915
|
+
|
|
916
|
+
为什么拆:只有一个"质检通过/失败"的总结论,读报告的人看不出"哪几项其实没执行"。
|
|
917
|
+
这里每条都带实测值,skipped 必须写明"为什么没执行"。
|
|
918
|
+
"""
|
|
919
|
+
pr = rep.get("probe") or {}
|
|
920
|
+
streams = pr.get("streams") or []
|
|
921
|
+
v = next((s for s in streams if s.get("codec_type") == "video"), {})
|
|
922
|
+
a = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
923
|
+
fmt = pr.get("format") or {}
|
|
924
|
+
W, H, FPS = K.W, K.H, K.FPS
|
|
925
|
+
|
|
926
|
+
# ---- G4 容器:分辨率 / 帧率 / 音轨 / 时长 ----
|
|
927
|
+
try:
|
|
928
|
+
rate = str(v.get("r_frame_rate") or "")
|
|
929
|
+
num, _, den = rate.partition("/")
|
|
930
|
+
got_fps = (float(num) / float(den or 1)) if num and den else 0.0
|
|
931
|
+
except Exception: # noqa: BLE001
|
|
932
|
+
got_fps = 0.0
|
|
933
|
+
dur = float(fmt.get("duration") or 0)
|
|
934
|
+
want_dur = float(tl["frames"]) / float(tl["fps"])
|
|
935
|
+
detail = "%sx%s@%.3g / %s%s / %.2fs" % (
|
|
936
|
+
v.get("width"), v.get("height"), got_fps,
|
|
937
|
+
("h264" if v.get("codec_name") == "h264" else str(v.get("codec_name") or "?")),
|
|
938
|
+
("+aac" if a else "(无音轨)"),
|
|
939
|
+
dur)
|
|
940
|
+
ok4 = (v.get("width") == W and v.get("height") == H
|
|
941
|
+
and abs(got_fps - float(FPS)) <= 0.05 and a is not None
|
|
942
|
+
and abs(dur - want_dur) <= 0.5)
|
|
943
|
+
(lg.ok if ok4 else lg.fail)("G4", "容器层(分辨率/帧率/音轨/时长)", detail)
|
|
944
|
+
|
|
945
|
+
# ---- G5 响度 / 真峰 ----
|
|
946
|
+
lo = rep.get("loudness") or {}
|
|
947
|
+
if "I" in lo and "TP" in lo:
|
|
948
|
+
i_l, tp = float(lo["I"]), float(lo["TP"])
|
|
949
|
+
ok5 = (-19 <= i_l <= -12) and tp <= -1.0
|
|
950
|
+
(lg.ok if ok5 else lg.fail)("G5", "响度 / 真峰(-16 LUFS / ≤ -1 dBTP)",
|
|
951
|
+
"%.1f LUFS · %.1f dBTP" % (i_l, tp))
|
|
952
|
+
else:
|
|
953
|
+
lg.skip("G5", "响度 / 真峰", "ebur128 没测到 %s" % ("I" if "I" not in lo else "TP"))
|
|
954
|
+
|
|
955
|
+
# ---- G6 编码锐度比 ----
|
|
956
|
+
q = rep.get("quality")
|
|
957
|
+
if q and "ratio" in q:
|
|
958
|
+
(lg.ok if q["ratio"] >= 0.85 else lg.fail)(
|
|
959
|
+
"G6", "编码锐度比(文字发虚)", "%.3f(阈值 0.85)" % q["ratio"])
|
|
960
|
+
else:
|
|
961
|
+
why = "没装 cv2" if K._cv2() is None else "没有参考帧(scenes.py 缺失 / web 引擎)"
|
|
962
|
+
lg.skip("G6", "编码锐度比(文字发虚)", why + ",本次没查")
|
|
963
|
+
|
|
964
|
+
# ---- G7 字幕条数(静默丢字幕的唯一检查点)----
|
|
965
|
+
srt = os.path.join(str(work), "sub.srt")
|
|
966
|
+
got = 0
|
|
967
|
+
if os.path.exists(srt):
|
|
968
|
+
got = len([b for b in open(srt, encoding="utf-8").read().strip().split("\n\n") if b.strip()])
|
|
969
|
+
want = len(tl["scenes"])
|
|
970
|
+
if got:
|
|
971
|
+
(lg.ok if got == want else lg.fail)("G7", "字幕条数 = 剧本句数",
|
|
972
|
+
"%d / %d" % (got, want))
|
|
973
|
+
else:
|
|
974
|
+
lg.skip("G7", "字幕条数 = 剧本句数", "没找到 sub.srt")
|
|
975
|
+
|
|
976
|
+
# ---- G8 黑帧 ----
|
|
977
|
+
black_msgs = [w for w in (rep.get("warnings") or []) if "黑帧" in w]
|
|
978
|
+
if black_msgs:
|
|
979
|
+
# qa_report 只在"首尾之外有长黑帧"时写 warning,本身是 warning 级
|
|
980
|
+
lg.skip("G8", "首尾之外无长黑帧", "检测到:" + black_msgs[0])
|
|
981
|
+
else:
|
|
982
|
+
lg.ok("G8", "首尾之外无长黑帧", "0 段")
|
|
983
|
+
|
|
984
|
+
# ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
|
|
985
|
+
if deep is None:
|
|
986
|
+
lg.skip("G9", "帧级深检(闪烁/空窗/同步)", "没有 qa_frames.py 或深检失败")
|
|
987
|
+
else:
|
|
988
|
+
nf = len(deep.get("flicker") or [])
|
|
989
|
+
nb = len(deep.get("black_windows") or [])
|
|
990
|
+
ns = len(deep.get("sync") or [])
|
|
991
|
+
judged = (deep.get("sync_info") or {}).get("judged")
|
|
992
|
+
sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
|
|
993
|
+
ok9 = nf == 0 and nb == 0 and ns == 0
|
|
994
|
+
detail9 = "闪烁 %d · 空窗 %d · 同步超差 %d(%s)" % (nf, nb, ns, sync_note)
|
|
995
|
+
if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
|
|
996
|
+
# 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
|
|
997
|
+
lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
|
|
998
|
+
detail9 + ";QA_REQUIRE_SYNC=1 要求同步必须被判过")
|
|
999
|
+
else:
|
|
1000
|
+
(lg.ok if ok9 else lg.fail)("G9", "帧级深检(闪烁/空窗/同步超差)", detail9)
|
|
1001
|
+
|
|
1002
|
+
|
|
702
1003
|
def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
703
1004
|
print("== QA")
|
|
1005
|
+
th = _theme_for(script) if script is not None else None
|
|
704
1006
|
shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
|
|
705
1007
|
fracs = (0.08, 0.3, 0.55, 0.78)
|
|
706
1008
|
ref_script = dict(script) if script is not None else None
|
|
@@ -723,7 +1025,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
723
1025
|
try:
|
|
724
1026
|
fr = scenes_mod.draw_frame(s, sec - s["start"], s["dur"], sec, ref_script,
|
|
725
1027
|
int(round(sec * tl["fps"])))
|
|
726
|
-
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold"))
|
|
1028
|
+
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, ref_script.get("sub_max_hold")),
|
|
1029
|
+
theme=th)
|
|
727
1030
|
refs.append((k, fr))
|
|
728
1031
|
except Exception as e: # noqa: BLE001
|
|
729
1032
|
print(" [WARN] 参考帧渲染失败:", e)
|
|
@@ -739,7 +1042,7 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
739
1042
|
print(" [WARN] 没有场景函数参考帧(web 引擎或 scenes.py 缺失),锐度比门禁本次跳过")
|
|
740
1043
|
rep = K.qa_report(film, expect_frames=tl["frames"], ref_img=ref_img,
|
|
741
1044
|
ref_sec=ref_sec, expected_subtitles=len(tl["scenes"]),
|
|
742
|
-
notes=os.path.join(work, "sub.srt"))
|
|
1045
|
+
notes=os.path.join(work, "sub.srt"), theme=th)
|
|
743
1046
|
pr = rep["probe"]
|
|
744
1047
|
f = pr.get("format", {})
|
|
745
1048
|
print(" 时长 %.2fs 大小 %.1f MB 码率 %.2f Mbps" % (
|
|
@@ -784,6 +1087,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None):
|
|
|
784
1087
|
print(" -", e)
|
|
785
1088
|
else:
|
|
786
1089
|
print(" [PASS] 质检通过")
|
|
1090
|
+
lg = _gate()
|
|
1091
|
+
if lg is not None:
|
|
1092
|
+
_qa_gates(lg, rep, deep, tl, work)
|
|
787
1093
|
return errors
|
|
788
1094
|
|
|
789
1095
|
|
|
@@ -819,6 +1125,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
819
1125
|
print("== Stills")
|
|
820
1126
|
script = dict(script)
|
|
821
1127
|
script["_total"] = tl["total"]
|
|
1128
|
+
script.setdefault("_style", resolve_style(script))
|
|
822
1129
|
outdir = os.path.join(work, "stills")
|
|
823
1130
|
os.makedirs(outdir, exist_ok=True)
|
|
824
1131
|
by_id = {s["id"]: s for s in tl["scenes"]}
|
|
@@ -844,7 +1151,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
844
1151
|
if tuple(fr.size) != (K.W, K.H):
|
|
845
1152
|
raise RuntimeError("场景 %s 在 t=%.2fs 返回 %dx%d,期望 %dx%d(stills 与 FrameSink 一样严格)"
|
|
846
1153
|
% (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"))
|
|
1154
|
+
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, script.get("sub_max_hold")),
|
|
1155
|
+
theme=_theme_for(script))
|
|
848
1156
|
p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
|
|
849
1157
|
fr.save(p)
|
|
850
1158
|
made.append(p)
|
|
@@ -856,6 +1164,42 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
856
1164
|
return made
|
|
857
1165
|
|
|
858
1166
|
|
|
1167
|
+
def _finish_gates(a):
|
|
1168
|
+
"""一次运行收尾:落 gates.json + 写 out/QC-REPORT.md + 统一退出码。"""
|
|
1169
|
+
lg = _LEDGER
|
|
1170
|
+
if lg is None:
|
|
1171
|
+
return 0
|
|
1172
|
+
errs = lg.exit_errors(strict=bool(getattr(a, "strict_gates", False)))
|
|
1173
|
+
try:
|
|
1174
|
+
gj = lg.write_json()
|
|
1175
|
+
except Exception as e: # noqa: BLE001
|
|
1176
|
+
print("[WARN] gates.json 写入失败:", e)
|
|
1177
|
+
gj = ""
|
|
1178
|
+
try:
|
|
1179
|
+
rep = GT.write_report(a.work, os.path.join(
|
|
1180
|
+
os.path.dirname(os.path.abspath(a.out)), "QC-REPORT.md"))
|
|
1181
|
+
except Exception as e: # noqa: BLE001
|
|
1182
|
+
print("[WARN] QC 报告写入失败:", e)
|
|
1183
|
+
rep = ""
|
|
1184
|
+
c = lg.counts()
|
|
1185
|
+
print("")
|
|
1186
|
+
print("== 门禁汇总:pass %d · fail %d · skipped %d%s"
|
|
1187
|
+
% (c[GT.OK], c[GT.FAIL], c[GT.SKIP],
|
|
1188
|
+
"(--strict-gates:skipped 也算失败)" if getattr(a, "strict_gates", False) else ""))
|
|
1189
|
+
if gj:
|
|
1190
|
+
print(" 账本:", gj)
|
|
1191
|
+
if rep:
|
|
1192
|
+
print(" 报告:", rep, " <- 交付时把这个文件一起给出")
|
|
1193
|
+
if c[GT.SKIP]:
|
|
1194
|
+
print(" 注意:有 %d 项本次**没有执行**(skipped != 通过)" % c[GT.SKIP])
|
|
1195
|
+
if errs:
|
|
1196
|
+
print(" [FAIL]")
|
|
1197
|
+
for e in errs:
|
|
1198
|
+
print(" -", e)
|
|
1199
|
+
return 1
|
|
1200
|
+
return 0
|
|
1201
|
+
|
|
1202
|
+
|
|
859
1203
|
# ---------------------------------------------------------------- main
|
|
860
1204
|
def main():
|
|
861
1205
|
_setup_stdio()
|
|
@@ -872,6 +1216,22 @@ def main():
|
|
|
872
1216
|
ap.add_argument("--force-tts", action="store_true", help="丢弃 TTS 缓存重新合成")
|
|
873
1217
|
ap.add_argument("--force-music", action="store_true", help="丢弃配乐重出(score 阶段)")
|
|
874
1218
|
ap.add_argument("--stills", default="", help="stills 阶段用:逗号分隔的 id:秒")
|
|
1219
|
+
ap.add_argument("--gate", default="", choices=("",) + GT.GATE_ORDER,
|
|
1220
|
+
help="accept 阶段用:要签核哪一道闸(%s)" % " / ".join(GT.GATE_ORDER))
|
|
1221
|
+
ap.add_argument("--verdict", default="approved", choices=("approved", "rejected"),
|
|
1222
|
+
help="accept 阶段用:签核结论(rejected 视为没签)")
|
|
1223
|
+
ap.add_argument("--note", default="", help="accept 阶段用:签核备注(写进报告)")
|
|
1224
|
+
ap.add_argument("--by", default="", help="accept 阶段用:签核人(缺省 user)")
|
|
1225
|
+
ap.add_argument("--strict-gates", action="store_true",
|
|
1226
|
+
help="把 skipped 门禁也算失败(对齐 QA_REQUIRE_SYNC 语义)")
|
|
1227
|
+
ap.add_argument("--strict-stale", action="store_true",
|
|
1228
|
+
help="上游产物过时算失败(默认只告警)")
|
|
1229
|
+
ap.add_argument("--allow-stale", action="store_true",
|
|
1230
|
+
help="过时也照跑(会在 QC 报告里留痕)")
|
|
1231
|
+
ap.add_argument("--no-gate", action="store_true",
|
|
1232
|
+
help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
|
|
1233
|
+
ap.add_argument("--cost", action="store_true",
|
|
1234
|
+
help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
|
|
875
1235
|
a = ap.parse_args()
|
|
876
1236
|
|
|
877
1237
|
try:
|
|
@@ -884,6 +1244,62 @@ def main():
|
|
|
884
1244
|
raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
|
|
885
1245
|
script["_engine"] = engine
|
|
886
1246
|
script["_scenes"] = str(Path(a.scenes).resolve())
|
|
1247
|
+
|
|
1248
|
+
# ---- 门禁账本(v0.6.0)----
|
|
1249
|
+
global _LEDGER
|
|
1250
|
+
_LEDGER = GT.GateLedger(a.work, script, scenes_path=a.scenes,
|
|
1251
|
+
script_path=a.script, engine=engine)
|
|
1252
|
+
if a.no_gate:
|
|
1253
|
+
_LEDGER.bypass("人工签核检查(--no-gate)", "本条不算通过,只记录")
|
|
1254
|
+
print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
|
|
1255
|
+
|
|
1256
|
+
def _sources():
|
|
1257
|
+
"""源头输入:剧本与 scenes.py(改它们等于改了所有下游的依据)。"""
|
|
1258
|
+
return {"script": a.script, "scenes": a.scenes}
|
|
1259
|
+
|
|
1260
|
+
def _stage_begin(name):
|
|
1261
|
+
"""stage 开始时查新鲜度并计时。返回 t0。"""
|
|
1262
|
+
if name and not a.allow_stale:
|
|
1263
|
+
SW.report(a.work, [name], strict=a.strict_stale, allow_stale=False,
|
|
1264
|
+
ledger=_LEDGER, sources=_sources())
|
|
1265
|
+
return time.time()
|
|
1266
|
+
|
|
1267
|
+
def _stage_end(name, t0, **extra):
|
|
1268
|
+
"""stage 结束时落盘:manifest 快照 + 耗时台账。"""
|
|
1269
|
+
if not name:
|
|
1270
|
+
return
|
|
1271
|
+
try:
|
|
1272
|
+
SW.snapshot(a.work, name, _sources())
|
|
1273
|
+
except Exception as e: # noqa: BLE001
|
|
1274
|
+
print(" [WARN] manifest 快照失败:", e)
|
|
1275
|
+
wall = time.time() - t0
|
|
1276
|
+
if a.cost:
|
|
1277
|
+
try:
|
|
1278
|
+
GT.append_cost(a.work, name, wall, **extra)
|
|
1279
|
+
except Exception as e: # noqa: BLE001
|
|
1280
|
+
print(" [WARN] cost.jsonl 写入失败:", e)
|
|
1281
|
+
print(" [time] %s 用时 %.1fs%s" % (name, wall, "(已记 work/cost.jsonl)" if a.cost else ""))
|
|
1282
|
+
|
|
1283
|
+
def _check_signoff(where):
|
|
1284
|
+
"""交付前的签核检查:缺签只告警,--strict-gates 下算失败。"""
|
|
1285
|
+
if a.no_gate:
|
|
1286
|
+
return
|
|
1287
|
+
state, stale = GT.signoff_state(a.work, _LEDGER.content_hash)
|
|
1288
|
+
bad = [g for g, v in state.items() if v["status"] == GT.SKIP]
|
|
1289
|
+
rejected = [g for g, v in state.items() if v["status"] == GT.FAIL]
|
|
1290
|
+
for g in rejected:
|
|
1291
|
+
_LEDGER.fail("signoff:" + g, "人工签核:" + GT.GATES[g], state[g]["detail"])
|
|
1292
|
+
if bad:
|
|
1293
|
+
_LEDGER.skip("signoff", "人工签核(--stage accept 签核)",
|
|
1294
|
+
"未签/已失效:%s%s"
|
|
1295
|
+
% ("、".join(bad), ("(内容已变:%s)" % "、".join(stale)) if stale else ""))
|
|
1296
|
+
print(" [WARN] 人工签核未完成:%s" % "、".join(bad))
|
|
1297
|
+
print(" 交付前请 --stage accept --gate <名字>;"
|
|
1298
|
+
"--strict-gates 会把它算作失败")
|
|
1299
|
+
for g in bad:
|
|
1300
|
+
print(" %-8s %s" % (g, GT.GATES[g]))
|
|
1301
|
+
elif not rejected:
|
|
1302
|
+
_LEDGER.ok("signoff", "人工签核", "%d/4 道闸已签" % len(GT.GATE_ORDER))
|
|
887
1303
|
if a.force_music:
|
|
888
1304
|
plan, _why = K.music_plan(script, has_music_file=os.path.exists(os.path.join(a.work, "music.wav")))
|
|
889
1305
|
if plan in ("off", "bed", "file"):
|
|
@@ -924,29 +1340,114 @@ def main():
|
|
|
924
1340
|
return mod
|
|
925
1341
|
|
|
926
1342
|
stage = a.stage
|
|
1343
|
+
|
|
1344
|
+
# ---- explain:只报"哪些阶段过时了",不跑任何 stage ----
|
|
1345
|
+
if stage == "explain":
|
|
1346
|
+
rows = SW.explain(a.work, sources=_sources())
|
|
1347
|
+
bad = 0
|
|
1348
|
+
for r in rows:
|
|
1349
|
+
if r["issues"]:
|
|
1350
|
+
bad += 1
|
|
1351
|
+
print("%s%-8s %s" % ("!! " if r["issues"] else "OK ", r["stage"],
|
|
1352
|
+
r["at"] or "(无记录)"))
|
|
1353
|
+
for it in r["issues"]:
|
|
1354
|
+
print(" -", it["reason"])
|
|
1355
|
+
if it.get("rerun"):
|
|
1356
|
+
print(" 最小重跑:", it["rerun"])
|
|
1357
|
+
print()
|
|
1358
|
+
if bad:
|
|
1359
|
+
print("%d 个阶段过时。默认只告警;--strict-stale 让它失败。" % bad)
|
|
1360
|
+
return 1 if a.strict_stale else 0
|
|
1361
|
+
print("没有发现过时的阶段(按 work/manifest.json 判断)。")
|
|
1362
|
+
return 0
|
|
1363
|
+
|
|
1364
|
+
# ---- accept:人工签核一道闸(写 work/signoff.json,绑内容指纹)----
|
|
1365
|
+
if stage == "accept":
|
|
1366
|
+
if not a.gate:
|
|
1367
|
+
raise SystemExit("[accept] 必须指定要签哪一道闸:--gate %s"
|
|
1368
|
+
% " / ".join(GT.GATE_ORDER))
|
|
1369
|
+
if a.verdict == "approved":
|
|
1370
|
+
# 签核前必须能证明"真的看过了":证据产物不在就拒绝签(不然签核是橡皮图章)。
|
|
1371
|
+
# 证据可能在 work/(渲染产物),也可能在项目根(FACTS.md 是人写的输入),两处都找。
|
|
1372
|
+
proj = os.path.dirname(os.path.abspath(a.script))
|
|
1373
|
+
want = {"style": [os.path.join(a.work, "style.json"),
|
|
1374
|
+
os.path.join(a.work, "voice_plan.json")],
|
|
1375
|
+
"stills": [os.path.join(a.work, "qa", "sheet.png")],
|
|
1376
|
+
"facts": [os.path.join(proj, "FACTS.md"), os.path.join(a.work, "FACTS.md")],
|
|
1377
|
+
"release": [a.out]}.get(a.gate, [])
|
|
1378
|
+
# 每一组是"多选一":任一条路径存在就算有证据。
|
|
1379
|
+
missing = None
|
|
1380
|
+
if want and not any(os.path.exists(str(w)) for w in want):
|
|
1381
|
+
missing = "、".join(os.path.relpath(w, proj) if os.path.isabs(w) else w
|
|
1382
|
+
for w in want)
|
|
1383
|
+
if missing:
|
|
1384
|
+
raise SystemExit("[accept] 证据不存在,拒绝签核:先跑出要看的东西(缺 %s)"
|
|
1385
|
+
% missing)
|
|
1386
|
+
rec = GT.write_signoff(a.work, a.gate, _LEDGER.content_hash,
|
|
1387
|
+
verdict=a.verdict, by=a.by or "user",
|
|
1388
|
+
note=a.note,
|
|
1389
|
+
artifacts=[os.path.join(a.work, "gates.json")])
|
|
1390
|
+
print("[accept] %s = %s(内容指纹 %s,%s)"
|
|
1391
|
+
% (a.gate, rec["verdict"], _LEDGER.content_hash, rec["atText"]))
|
|
1392
|
+
if a.verdict == "rejected":
|
|
1393
|
+
print("[accept] 已记「否决」(不算签核通过);改了内容后需重新签。")
|
|
1394
|
+
p_rep = GT.write_report(a.work, os.path.join(
|
|
1395
|
+
os.path.dirname(os.path.abspath(a.work)), "out", "QC-REPORT.md"))
|
|
1396
|
+
print("[accept] QC 报告已更新:", p_rep)
|
|
1397
|
+
return 0
|
|
1398
|
+
|
|
1399
|
+
if stage in ("all", "style"):
|
|
1400
|
+
_nm = "style" if stage == "style" else None
|
|
1401
|
+
_t0 = _stage_begin(_nm)
|
|
1402
|
+
resolve_style(script, a.work)
|
|
1403
|
+
_stage_end(_nm, _t0, n_tts=None)
|
|
1404
|
+
if stage == "style":
|
|
1405
|
+
print("== Style")
|
|
1406
|
+
_print_style(script["_style"])
|
|
1407
|
+
_need(os.path.exists(os.path.join(a.work, "style.json")), "style.json 没写出来")
|
|
1408
|
+
_LEDGER.ok("style", "风格包解析", str(script["_style"].get("style")))
|
|
1409
|
+
_LEDGER.write_json()
|
|
1410
|
+
_finish_gates(a)
|
|
1411
|
+
return 0
|
|
1412
|
+
|
|
927
1413
|
if stage in ("all", "tts"):
|
|
1414
|
+
_t0 = _stage_begin("tts")
|
|
928
1415
|
lines = st_tts(script, a.work, force=a.force_tts)
|
|
1416
|
+
_stage_end("tts", _t0, n_tts=len(lines) if isinstance(lines, list) else None)
|
|
1417
|
+
# 既有行为:单跑 tts 也会排一次轴(tts.json 的时长信息要用),这里一并计时。
|
|
1418
|
+
_t0 = _stage_begin("layout")
|
|
929
1419
|
tl = st_layout(script, a.work, lines)
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1420
|
+
_stage_end("layout", _t0)
|
|
1421
|
+
if stage == "layout":
|
|
1422
|
+
_t0 = _stage_begin("layout")
|
|
1423
|
+
_need(os.path.exists(tts_p), "没有 tts.json,先跑 --stage tts")
|
|
1424
|
+
tl = st_layout(script, a.work)
|
|
1425
|
+
_stage_end("layout", _t0)
|
|
934
1426
|
if stage in ("all", "cues"):
|
|
935
1427
|
tl = tl if stage == "all" else ensure_tl()
|
|
936
|
-
|
|
1428
|
+
_t0 = _stage_begin("cues")
|
|
1429
|
+
cues = st_cues(script, a.work, tl, engine=engine)
|
|
1430
|
+
_stage_end("cues", _t0,
|
|
1431
|
+
n_cues=(len((cues or {}).get("cues") or []) if isinstance(cues, dict) else None))
|
|
937
1432
|
if stage in ("all", "score"):
|
|
938
1433
|
tl = tl if stage == "all" else ensure_tl()
|
|
1434
|
+
_t0 = _stage_begin("score")
|
|
939
1435
|
st_score(script, a.work, tl)
|
|
1436
|
+
_stage_end("score", _t0)
|
|
940
1437
|
if stage in ("all", "sfx"):
|
|
941
1438
|
tl = tl if stage == "all" else ensure_tl()
|
|
1439
|
+
_t0 = _stage_begin("sfx")
|
|
942
1440
|
st_sfx(script, a.work, tl)
|
|
1441
|
+
_stage_end("sfx", _t0)
|
|
943
1442
|
if stage in ("all", "audio"):
|
|
944
1443
|
tl = tl if stage == "all" else ensure_tl()
|
|
945
1444
|
_need(all(s.get("path") for s in tl["scenes"]), "timeline 里没有旁白路径,先跑 --stage tts")
|
|
946
1445
|
missing = [s.get("id") for s in tl["scenes"] if not os.path.exists(str(s.get("path") or ""))]
|
|
947
1446
|
_need(not missing, "旁白文件不存在:%s(tts 目录被删/挪过?重新跑 --stage tts)"
|
|
948
1447
|
% (missing[:6],))
|
|
1448
|
+
_t0 = _stage_begin("audio")
|
|
949
1449
|
st_audio(tl, a.work, script)
|
|
1450
|
+
_stage_end("audio", _t0)
|
|
950
1451
|
|
|
951
1452
|
if stage == "stills":
|
|
952
1453
|
# 母版预览是"上手第一件事",可它需要 timeline。以前干净目录直接 SystemExit,
|
|
@@ -965,7 +1466,12 @@ def main():
|
|
|
965
1466
|
tl = ensure_tl()
|
|
966
1467
|
mod = load_scenes()
|
|
967
1468
|
specs = [x.strip() for x in a.stills.split(",") if x.strip()] if a.stills else None
|
|
968
|
-
|
|
1469
|
+
_t0 = _stage_begin("stills")
|
|
1470
|
+
made = st_stills(tl, a.work, mod, script, specs)
|
|
1471
|
+
_stage_end("stills", _t0)
|
|
1472
|
+
_LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
|
|
1473
|
+
"%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
|
|
1474
|
+
_finish_gates(a)
|
|
969
1475
|
return 0
|
|
970
1476
|
|
|
971
1477
|
if stage == "qa":
|
|
@@ -973,8 +1479,12 @@ def main():
|
|
|
973
1479
|
tl = ensure_tl()
|
|
974
1480
|
_need(os.path.exists(a.out), "没有成片 %s,先跑 --stage mux" % a.out)
|
|
975
1481
|
mod = load_scenes(strict=False) if engine == "pil" else None
|
|
1482
|
+
_t0 = _stage_begin("qa")
|
|
976
1483
|
errs = st_qa(a.out, tl, a.work, scenes_mod=mod, script=script)
|
|
977
|
-
|
|
1484
|
+
_stage_end("qa", _t0)
|
|
1485
|
+
_check_signoff("qa")
|
|
1486
|
+
rc = _finish_gates(a)
|
|
1487
|
+
return 1 if (errs or rc) else 0
|
|
978
1488
|
|
|
979
1489
|
if stage in ("all", "frames", "mux"):
|
|
980
1490
|
tl = tl if stage == "all" else ensure_tl()
|
|
@@ -988,19 +1498,37 @@ def main():
|
|
|
988
1498
|
_need(os.path.exists(audio), "混音阶段异常:audio.wav 未生成")
|
|
989
1499
|
if engine == "pil":
|
|
990
1500
|
mod = load_scenes()
|
|
1501
|
+
_t0 = _stage_begin("frames")
|
|
991
1502
|
silent = st_frames(tl, a.work, mod, script, jobs=a.jobs, resume=a.resume,
|
|
992
1503
|
engine=engine)
|
|
1504
|
+
_stage_end("frames", _t0, frames=int(tl["frames"]))
|
|
1505
|
+
if stage == "frames":
|
|
1506
|
+
_LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
|
|
1507
|
+
_finish_gates(a)
|
|
1508
|
+
return 0 if not _LEDGER.fails() else 1
|
|
993
1509
|
if stage in ("all", "mux"):
|
|
994
1510
|
# mux 只需要 silent.mp4 + audio.wav:不加载 scenes.py(缺场景模块也能合成)
|
|
995
1511
|
_need(os.path.exists(silent), "没有 silent.mp4,先跑 --stage frames")
|
|
996
1512
|
_need(os.path.exists(audio), "没有 audio.wav,先跑 --stage audio")
|
|
1513
|
+
_t0 = _stage_begin("mux")
|
|
997
1514
|
film = st_mux(silent, audio, a.out, script)
|
|
1515
|
+
_stage_end("mux", _t0)
|
|
1516
|
+
_check_signoff("mux")
|
|
1517
|
+
if stage == "mux":
|
|
1518
|
+
_LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
|
|
1519
|
+
_finish_gates(a)
|
|
1520
|
+
return 0 if not _LEDGER.fails() else 1
|
|
998
1521
|
if stage == "all":
|
|
1522
|
+
_t0 = _stage_begin("qa")
|
|
999
1523
|
errs = st_qa(film, tl, a.work, scenes_mod=mod, script=script)
|
|
1000
|
-
|
|
1524
|
+
_stage_end("qa", _t0)
|
|
1525
|
+
rc = _finish_gates(a)
|
|
1526
|
+
if errs or rc:
|
|
1001
1527
|
print("\n[质检未过] 成片已生成但没有通过门禁,按上面 [FAIL] 逐条修。")
|
|
1002
1528
|
return 1
|
|
1003
|
-
|
|
1529
|
+
return 0
|
|
1530
|
+
rc = _finish_gates(a) if stage == "all" else 0
|
|
1531
|
+
return rc
|
|
1004
1532
|
|
|
1005
1533
|
|
|
1006
1534
|
if __name__ == "__main__":
|