@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +473 -0
- package/README.md +263 -327
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +31 -42
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1216 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +2 -4
- package/skills/video-studio/assets/golden_frames.json +13 -10
- package/skills/video-studio/assets/scenes.py +153 -65
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/appearance.md +123 -0
- package/skills/video-studio/references/audio-variation.md +74 -93
- package/skills/video-studio/references/audio.md +64 -66
- package/skills/video-studio/references/change-impact.md +5 -5
- package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
- package/skills/video-studio/references/frame-qa.md +48 -16
- package/skills/video-studio/references/pipeline.md +4 -4
- package/skills/video-studio/references/pitfalls.md +22 -3
- package/skills/video-studio/references/proposal.md +160 -0
- package/skills/video-studio/references/quality-gates.md +11 -11
- package/skills/video-studio/references/scene-authoring.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +40 -42
- package/skills/video-studio/references/sfx-design.md +8 -6
- package/skills/video-studio/references/web-engine.md +8 -8
- package/skills/video-studio/references/word-timeline.md +9 -9
- package/skills/video-studio/requirements.txt +9 -13
- package/skills/video-studio/scripts/designkit.py +2115 -0
- package/skills/video-studio/scripts/filmcheck.py +22 -48
- package/skills/video-studio/scripts/filmkit.py +2980 -2904
- package/skills/video-studio/scripts/gates.py +4 -4
- package/skills/video-studio/scripts/grammar.py +73 -13
- package/skills/video-studio/scripts/musicgen.py +2042 -0
- package/skills/video-studio/scripts/preflight.py +14 -7
- package/skills/video-studio/scripts/previewkit.py +848 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +73 -39
- package/skills/video-studio/scripts/qaasr.py +26 -7
- package/skills/video-studio/scripts/qavmaf.py +89 -23
- package/skills/video-studio/scripts/render.py +545 -231
- package/skills/video-studio/scripts/scorekit.py +55 -1755
- package/skills/video-studio/scripts/selfcheck.py +815 -215
- package/skills/video-studio/scripts/sfxkit.py +27 -26
- package/skills/video-studio/scripts/stagewatch.py +24 -8
- package/skills/video-studio/scripts/stylekit.py +207 -888
- package/skills/video-studio/scripts/voicekit.py +508 -387
- package/skills/video-studio/scripts/webengine.py +71 -72
- package/tools/audio_similarity.py +64 -37
- package/tools/ci_check.py +20 -14
- package/tools/estimate.py +34 -29
- package/tools/golden_frames.py +72 -28
- package/tools/silhouette_qa.py +520 -168
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/skills/video-studio/assets/score_demo.json +0 -307
- package/skills/video-studio/references/style-packs.md +0 -283
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""render.py —
|
|
2
|
+
"""render.py — 一条命令从剧本到成片。
|
|
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
|
-
style
|
|
7
|
+
style 解析设计 -> work/style.json(画风/配乐/旁白/节奏一次定死)
|
|
8
8
|
tts 逐句合成旁白 + 实测时长(带输入校验的缓存;按声线计划换人换语气)
|
|
9
9
|
layout 由实测时长排时间轴 -> timeline.json / sub.srt / sub.ass(含停留窗口 hold)
|
|
10
10
|
cues 画面提示点 -> work/cues.json(web 引擎从页面导出,pil 引擎从 film.json 生成)
|
|
11
|
-
score
|
|
11
|
+
score 参数化配乐 -> work/music.wav(musicgen;缺了直接报错,不再回退模板引擎)
|
|
12
12
|
sfx 按提示点合成音效轨 -> work/sfx.wav(sfxkit;无则跳过)
|
|
13
13
|
audio 三总线混音(旁白 + 配乐 + 音效,音效响起时配乐让位)-> audio.wav
|
|
14
14
|
frames 逐帧渲染 -> silent.mp4(--engine pil|web;分块并行、断点续跑)
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
--stage all 按顺序跑完;单跑某个 stage 时,若前置产物缺失会明确报错并提示先跑哪个。
|
|
23
23
|
--engine 覆盖 film.json 的 engine:pil = Pillow 逐帧(默认,依赖最少);web = 无头浏览器逐帧。
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
每个 stage 都往「门禁账本」记结论(pass / fail / skipped 三档),并在
|
|
26
26
|
--stage qa(或 --stage all 收尾)写出 out/QC-REPORT.md:这是**机器可读的交付结论**,
|
|
27
|
-
|
|
27
|
+
而不是"agent 在对话里复述质检结果"。人工签核走 --stage accept,绑内容指纹——
|
|
28
28
|
改了剧本 / scenes.py / 引擎,旧签核自动失效。
|
|
29
29
|
"""
|
|
30
30
|
from __future__ import annotations
|
|
@@ -68,7 +68,7 @@ def _setup_stdio():
|
|
|
68
68
|
|
|
69
69
|
只在"非交互"时切(isatty=False):Windows 下 python render.py ... | tee out.log
|
|
70
70
|
的 stdout 默认是 GBK 代码页,日志里中文全是乱码;真实控制台保持系统编码,
|
|
71
|
-
交互运行时中文照样正常。与
|
|
71
|
+
交互运行时中文照样正常。与 musicgen / webengine 的入口处理保持同一口径。
|
|
72
72
|
"""
|
|
73
73
|
for stream in (sys.stdout, sys.stderr):
|
|
74
74
|
try:
|
|
@@ -111,7 +111,7 @@ def _frames_plan_stamp(tl, script, scenes_path, chunk):
|
|
|
111
111
|
script=hashlib.sha1(body.encode("utf-8")).hexdigest()[:16])
|
|
112
112
|
return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
|
113
113
|
|
|
114
|
-
#
|
|
114
|
+
# 可选模块:缺哪个就明确报错/跳过,不影响主流程
|
|
115
115
|
HERE_SCRIPTS = str(HERE)
|
|
116
116
|
|
|
117
117
|
|
|
@@ -147,8 +147,11 @@ def _web_entry(script):
|
|
|
147
147
|
if not os.path.exists(entry):
|
|
148
148
|
raise SystemExit(
|
|
149
149
|
"[缺少 web 页面] %s\n"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
150
|
+
" web 引擎需要**你自己的页面**(本技能不提供模板)。\n"
|
|
151
|
+
" PIL 路线不需要页面,零拷贝就能出片(--engine pil)。\n"
|
|
152
|
+
" 想先跑通 web 链路可拷示例页:\n"
|
|
153
|
+
" cp -r <SKILL_DIR>/assets/web ./web # 示例页只有 3 段占位场景,不是出片模板\n"
|
|
154
|
+
" 或在 film.json 里写 web.entry 指向你自己的页面。" % entry)
|
|
152
155
|
return entry
|
|
153
156
|
|
|
154
157
|
|
|
@@ -174,7 +177,7 @@ def _web_where(entry):
|
|
|
174
177
|
|
|
175
178
|
# ---------------------------------------------------------------- 装载
|
|
176
179
|
def load_module(path: str, name="film_scenes"):
|
|
177
|
-
"""每次都用独立模块名 + 全新 module
|
|
180
|
+
"""每次都用独立模块名 + 全新 module 对象,避免跨片污染(否则会渲染出上一部片)。"""
|
|
178
181
|
path = str(Path(path).resolve())
|
|
179
182
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
180
183
|
mod = importlib.util.module_from_spec(spec)
|
|
@@ -203,7 +206,7 @@ def load_script(p) -> dict:
|
|
|
203
206
|
for k in ("music", "sfx", "web"):
|
|
204
207
|
if s.get(k) is not None and not isinstance(s.get(k), dict):
|
|
205
208
|
raise SystemExit("film.json 的 %s 必须是对象(当前是 %s)" % (k, type(s[k]).__name__))
|
|
206
|
-
#
|
|
209
|
+
# bpm 允许 "auto"(由风格梯度 + 内容指纹决定)。
|
|
207
210
|
# 同时允许写在 music.bpm 里(优先级更高)。
|
|
208
211
|
for _k, _v in (("bpm", s.get("bpm")),
|
|
209
212
|
("music.bpm", (s.get("music") or {}).get("bpm"))):
|
|
@@ -257,7 +260,7 @@ def _to_gif(mp4, gif, fps=12, width=640):
|
|
|
257
260
|
|
|
258
261
|
|
|
259
262
|
def _atomic_json_retry(path, obj, tries=3, sleep_s=0.1):
|
|
260
|
-
"""原子写 JSON +
|
|
263
|
+
"""原子写 JSON + 有限退避。
|
|
261
264
|
|
|
262
265
|
Windows 上文件被杀毒/预览进程短暂占用时,一次写入失败会让**整片**跑挂。
|
|
263
266
|
只对"可重试的写状态"退避,不对内容生成盲目重试(那会掩盖真错误)。
|
|
@@ -285,64 +288,141 @@ def _need(cond, msg):
|
|
|
285
288
|
raise SystemExit("[前置产物缺失] " + msg)
|
|
286
289
|
|
|
287
290
|
|
|
288
|
-
# ----------------------------------------------------------------
|
|
291
|
+
# ---------------------------------------------------------------- 风格解析
|
|
289
292
|
def _stylekit():
|
|
290
293
|
"""惰性 import stylekit;缺文件时给一句能照着做的错,而不是 ModuleNotFoundError。"""
|
|
291
294
|
try:
|
|
292
295
|
import stylekit as ST
|
|
293
296
|
return ST
|
|
294
297
|
except Exception as e: # noqa: BLE001
|
|
295
|
-
raise SystemExit("[缺少 stylekit]
|
|
298
|
+
raise SystemExit("[缺少 stylekit] render.py 需要同目录的 stylekit.py"
|
|
296
299
|
"(skills/video-studio/scripts/stylekit.py):%s" % e)
|
|
297
300
|
|
|
298
301
|
|
|
302
|
+
def _design_path(work):
|
|
303
|
+
return os.path.join(str(work), "design.json") if work else ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _plan_from_design(script, work, verbose=True):
|
|
307
|
+
"""如果 work/design.json 存在(用户已确认提案),**以它为准**解析计划。
|
|
308
|
+
|
|
309
|
+
这是唯一主路径:design.json 是"这一版长什么样"的冻结点,剧本只提供台词。
|
|
310
|
+
返回 None 表示没有 design(调用方按 fail-closed 报错并给出两步指引)。
|
|
311
|
+
"""
|
|
312
|
+
direct = script.get("design")
|
|
313
|
+
design = None
|
|
314
|
+
if isinstance(direct, dict):
|
|
315
|
+
design = direct
|
|
316
|
+
else:
|
|
317
|
+
p = _design_path(work)
|
|
318
|
+
if p and os.path.exists(p):
|
|
319
|
+
try:
|
|
320
|
+
with open(p, encoding="utf-8") as fh:
|
|
321
|
+
design = json.load(fh)
|
|
322
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
323
|
+
raise SystemExit("[design] %s 读不出来:%s(重新 propose.py --confirm 一次)"
|
|
324
|
+
% (p, e)) from e
|
|
325
|
+
if not isinstance(design, dict):
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
import designkit as DK
|
|
329
|
+
except ImportError as e:
|
|
330
|
+
raise SystemExit("[design] 缺少 designkit.py(%s)——design.json 需要它解析成计划" % e) from e
|
|
331
|
+
# 骨架版/半成品 designkit 会 import 成功但接口没实现:那种情况必须给一句人话,
|
|
332
|
+
# 不能让 AttributeError 的 traceback 冒充"渲染器坏了"(这种误报踩过多次)。
|
|
333
|
+
missing = [n for n in ("validate_design", "design_to_plan")
|
|
334
|
+
if not callable(getattr(DK, n, None))]
|
|
335
|
+
if missing:
|
|
336
|
+
raise SystemExit("[design] designkit.py 不完整:缺 %s(这个文件还没实现完)"
|
|
337
|
+
% "、".join(missing))
|
|
338
|
+
bad = DK.validate_design(design)
|
|
339
|
+
if bad:
|
|
340
|
+
raise SystemExit("[设计体检失败] %s\n - %s"
|
|
341
|
+
% (design.get("id") or "(无 id)", "\n - ".join(bad)))
|
|
342
|
+
plan = DK.design_to_plan(design)
|
|
343
|
+
_apply_audio_overrides(script, plan)
|
|
344
|
+
if verbose:
|
|
345
|
+
print(" 设计: %s(%s)" % (plan.get("style"), (design.get("pitch") or "")[:60]))
|
|
346
|
+
return plan
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _apply_audio_overrides(script, plan):
|
|
350
|
+
"""把 design.audio 里的音量/音效参数填进 script 的 music/sfx **缺省位**。
|
|
351
|
+
|
|
352
|
+
为什么需要:design.json 里 audio.music.gain_db / duck_db 与 audio.sfx.enabled /
|
|
353
|
+
density / gain_db 若只被 validate_design 接受、没有任何渲染代码读它,
|
|
354
|
+
用户改了它们,成片一模一样(audit 实测:enabled=false 仍出 8 条音效)。
|
|
355
|
+
|
|
356
|
+
优先级(明确且可预期):**film.json 显式写了的 > design 的值 > 模块内置默认**。
|
|
357
|
+
只填缺省位,绝不覆盖剧本里的显式写法——用户手里的 film.json 永远是最终裁决权。
|
|
358
|
+
"""
|
|
359
|
+
ov = plan.get("audio_overrides") if isinstance(plan, dict) else None
|
|
360
|
+
if not isinstance(ov, dict):
|
|
361
|
+
return
|
|
362
|
+
mus_ov = ov.get("music") if isinstance(ov.get("music"), dict) else {}
|
|
363
|
+
sfx_ov = ov.get("sfx") if isinstance(ov.get("sfx"), dict) else {}
|
|
364
|
+
mus = script.get("music")
|
|
365
|
+
if mus_ov:
|
|
366
|
+
if not isinstance(mus, dict):
|
|
367
|
+
mus = {}
|
|
368
|
+
script["music"] = mus
|
|
369
|
+
for k, v in mus_ov.items():
|
|
370
|
+
mus.setdefault(k, v)
|
|
371
|
+
sfx = script.get("sfx")
|
|
372
|
+
if sfx_ov:
|
|
373
|
+
if not isinstance(sfx, dict):
|
|
374
|
+
sfx = {}
|
|
375
|
+
script["sfx"] = sfx
|
|
376
|
+
for k, v in sfx_ov.items():
|
|
377
|
+
sfx.setdefault(k, v)
|
|
378
|
+
|
|
379
|
+
|
|
299
380
|
def resolve_style(script, work=None, verbose=True):
|
|
300
381
|
"""解析风格计划并挂到 script["_style"] 上(同一进程内各 stage 共享)。
|
|
301
382
|
|
|
383
|
+
优先级:
|
|
384
|
+
1. script["_style"] 已存在(同进程内各 stage 复用)
|
|
385
|
+
2. work/design.json(用户已确认的提案)—— 这是唯一正式主路径
|
|
386
|
+
3. 都没有 -> fail-closed 报错(没有内置风格可回落)
|
|
387
|
+
|
|
302
388
|
纯函数 + 落盘 work/style.json(审计用:用户能看到"为什么是这套风格")。
|
|
303
389
|
"""
|
|
304
390
|
if isinstance(script.get("_style"), dict):
|
|
305
391
|
return script["_style"]
|
|
306
|
-
|
|
307
|
-
plan
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
print(" [WARN] style.json 落盘失败:%s" % e)
|
|
337
|
-
return plan
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
def _theme_for(script):
|
|
341
|
-
"""返回当前片的主题字典;legacy 返回 None(= v0.4 的硬编码外观,逐字节兼容)。"""
|
|
392
|
+
plan = _plan_from_design(script, work, verbose=verbose)
|
|
393
|
+
if plan is not None:
|
|
394
|
+
script["_style"] = plan
|
|
395
|
+
if work and verbose:
|
|
396
|
+
try:
|
|
397
|
+
os.makedirs(work, exist_ok=True)
|
|
398
|
+
with open(os.path.join(work, "style.json"), "w", encoding="utf-8") as fh:
|
|
399
|
+
json.dump(plan, fh, ensure_ascii=False, indent=1)
|
|
400
|
+
except Exception as e: # noqa: BLE001
|
|
401
|
+
print(" [WARN] style.json 落盘失败:%s" % e)
|
|
402
|
+
return plan
|
|
403
|
+
# **没有第三优先级**:回落到"按关键词自动选风格"等于模板库,
|
|
404
|
+
# 那正是本技能不提供的,所以必须明确报错,不静默回落。
|
|
405
|
+
raise SystemExit(
|
|
406
|
+
"[style] 找不到设计(work/design.json):\n"
|
|
407
|
+
" 本技能不提供任何内置风格,每一版外观都必须现场生成并由人确认。\n"
|
|
408
|
+
" 正确做法:\n"
|
|
409
|
+
" 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
|
|
410
|
+
" 2) python propose.py --confirm --candidate A # 冻结设计\n"
|
|
411
|
+
" 3) python render.py --script film.json --stage all # 出片\n"
|
|
412
|
+
" (work=%r)" % (work,))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _theme_for(script, work=None):
|
|
416
|
+
"""返回当前片的主题字典;legacy 计划返回 None(现存的兼容分支)。
|
|
417
|
+
|
|
418
|
+
work **必须传**:design 路径的身份在 work/design.json 里,不传就直接 SystemExit ——
|
|
419
|
+
但"传错 work"仍然值得防:work 指到别的目录时会读到**另一份** design.json,
|
|
420
|
+
参考帧与成片不同源(实测:--stage qa 单独跑 VMAF 1.07,全部内联跑 97.15)。
|
|
421
|
+
"""
|
|
342
422
|
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
343
423
|
if plan is not None and plan.get("legacy"):
|
|
344
424
|
return None
|
|
345
|
-
plan = resolve_style(script)
|
|
425
|
+
plan = resolve_style(script, work)
|
|
346
426
|
if plan.get("legacy"):
|
|
347
427
|
return None
|
|
348
428
|
# plan["theme"] 是 stylekit 合并过 film.json 的 visual.theme_overrides 之后的最终主题,
|
|
@@ -353,7 +433,21 @@ def _theme_for(script):
|
|
|
353
433
|
th = plan.get("theme")
|
|
354
434
|
if isinstance(th, dict):
|
|
355
435
|
return th
|
|
356
|
-
|
|
436
|
+
# **先看 plan["theme"]**(designkit 生成的计划自带完整 35 字段主题)。
|
|
437
|
+
# 不能按 theme_id 去查表:没有内置主题表,设计路径的 theme_id(如 "a-44957fb5")
|
|
438
|
+
# 查不到 -> KeyError,整条出片链路会在 layout 阶段崩掉。
|
|
439
|
+
th = plan.get("theme")
|
|
440
|
+
if isinstance(th, dict) and th:
|
|
441
|
+
return th
|
|
442
|
+
# 没有 theme 计划就是"这份 script 没走过 design/propose"。
|
|
443
|
+
# 明确报错并给迁移指引,不静默回落(静默回落 = 用一套不存在的默认外观出片)。
|
|
444
|
+
raise SystemExit(
|
|
445
|
+
"[style] 这份剧本没有可用的主题:\n"
|
|
446
|
+
" 计划里缺 theme 字段,而内置主题表已在本版删除。\n"
|
|
447
|
+
" 正确做法:python propose.py --brief \"题材\" --script film.json # 出方案\n"
|
|
448
|
+
" python propose.py --confirm --candidate A # 冻结 design.json\n"
|
|
449
|
+
" python render.py --script film.json --stage all\n"
|
|
450
|
+
" (theme_id=%r)" % (plan.get("theme_id"),))
|
|
357
451
|
|
|
358
452
|
|
|
359
453
|
def _print_style(plan):
|
|
@@ -365,7 +459,7 @@ def _print_style(plan):
|
|
|
365
459
|
print(" 主题: %s 版式: %s" % (plan["theme_id"],
|
|
366
460
|
",".join(sorted(set(plan["chapter_layout"].values())))
|
|
367
461
|
or plan["layout_default"]))
|
|
368
|
-
#
|
|
462
|
+
# 视觉语言(结构签名)也要能在这里看到 —— 用户判断"这套风格是不是只是换色"
|
|
369
463
|
# 的第一现场就是 --stage style 的打印,藏起来等于让他去读 style.json。
|
|
370
464
|
vl = plan.get("language") if isinstance(plan.get("language"), dict) else None
|
|
371
465
|
if vl:
|
|
@@ -377,8 +471,24 @@ def _print_style(plan):
|
|
|
377
471
|
% (mf.get("kind"), tr.get("kind"), float(tr.get("dur") or 0.0),
|
|
378
472
|
float(ty.get("tracking") or 0.0), ty.get("number"),
|
|
379
473
|
float(bi.get("x") or 0.0), float(bi.get("y") or 0.0)))
|
|
474
|
+
# 不能无条件读 m["style"]:designkit 明确不写这个键(配乐风格表已删),
|
|
475
|
+
# 那样公开命令 `--stage style` 会 100% KeyError: "style"。
|
|
476
|
+
# 按「参数化配乐」的真实身份打印:调式 + BPM + 调性 + mood。
|
|
477
|
+
_music_id = m.get("arranger") or m.get("style") or ("%s %gBPM" % (m.get("scale", "?"), m.get("bpm", 0)))
|
|
380
478
|
print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
|
|
381
|
-
% (
|
|
479
|
+
% (_music_id, m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
|
|
480
|
+
_sub = plan.get("subtitle") if isinstance(plan.get("subtitle"), dict) else None
|
|
481
|
+
if _sub:
|
|
482
|
+
_y = _sub.get("y")
|
|
483
|
+
print(" 字幕: %s %dpx 对齐=%s 背板α=%d 圆角=%d 最多%d行"
|
|
484
|
+
% ("y=%s" % _y if _y is not None else "底部安全区",
|
|
485
|
+
int(_sub.get("size") or 46), _sub.get("align") or "center",
|
|
486
|
+
int(_sub.get("plate_alpha") if _sub.get("plate_alpha") is not None else 170),
|
|
487
|
+
int(_sub.get("radius") if _sub.get("radius") is not None else 10),
|
|
488
|
+
int(_sub.get("max_lines") or 2)))
|
|
489
|
+
_cyc = plan.get("layout_cycle") or []
|
|
490
|
+
if len(_cyc) >= 2:
|
|
491
|
+
print(" 版式轮换: %s(按章节轮换,防全片一张版式)" % " -> ".join(map(str, _cyc)))
|
|
382
492
|
if v["map"]:
|
|
383
493
|
print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
|
|
384
494
|
p = plan["pacing"]
|
|
@@ -395,11 +505,11 @@ def st_style(script, work):
|
|
|
395
505
|
|
|
396
506
|
# ---------------------------------------------------------------- TTS
|
|
397
507
|
def _voice_plan(script, plan):
|
|
398
|
-
"""声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch
|
|
508
|
+
"""声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走单声线路径。"""
|
|
399
509
|
try:
|
|
400
510
|
import voicekit as VK
|
|
401
511
|
except Exception as e: # noqa: BLE001
|
|
402
|
-
print(" [WARN] 没有 voicekit.py
|
|
512
|
+
print(" [WARN] 没有 voicekit.py,本次旁白单声线(单声线兼容):%s" % e)
|
|
403
513
|
return None
|
|
404
514
|
try:
|
|
405
515
|
vp = VK.voice_plan(script, plan)
|
|
@@ -414,7 +524,7 @@ def st_tts(script, work, force=False):
|
|
|
414
524
|
plan = resolve_style(script, work)
|
|
415
525
|
print("== TTS (%d 句)" % len(lines))
|
|
416
526
|
if plan["legacy"]:
|
|
417
|
-
print(" 声线:
|
|
527
|
+
print(" 声线: 兼容模式(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
|
|
418
528
|
vp = None
|
|
419
529
|
else:
|
|
420
530
|
vp = _voice_plan(script, plan)
|
|
@@ -449,7 +559,7 @@ def st_tts(script, work, force=False):
|
|
|
449
559
|
|
|
450
560
|
|
|
451
561
|
def _write_words(work, done, want_words):
|
|
452
|
-
"""把逐句词级时间戳汇总成 work/words.json
|
|
562
|
+
"""把逐句词级时间戳汇总成 work/words.json。
|
|
453
563
|
|
|
454
564
|
结构:{schema, enabled, lines:{<id>:{dur, n, covered, tail, mono, words:[...]}}, stats}
|
|
455
565
|
门禁与字幕都从这里读,不再各自去翻 *.mp3.json(单一事实来源)。
|
|
@@ -488,7 +598,7 @@ def st_layout(script, work, lines=None):
|
|
|
488
598
|
print("== Layout")
|
|
489
599
|
pace = plan["pacing"]
|
|
490
600
|
# 逐句 lead/tail:voicekit 的 voice_plan 已经算好每句的停顿,这里只负责喂给时间轴。
|
|
491
|
-
#
|
|
601
|
+
# legacy 计划没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数。
|
|
492
602
|
vp = None
|
|
493
603
|
vp_path = os.path.join(work, "voice_plan.json")
|
|
494
604
|
if not plan["legacy"] and os.path.exists(vp_path):
|
|
@@ -519,7 +629,7 @@ def st_layout(script, work, lines=None):
|
|
|
519
629
|
",编码时 lanczos 缩放" if vp["scale"] else ""))
|
|
520
630
|
tl["title"] = script["title"]
|
|
521
631
|
tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
|
|
522
|
-
#
|
|
632
|
+
# 词级时间戳(句子时长仍是权威;词只用于字幕断行/锚点/门禁)
|
|
523
633
|
tl["words"] = bool(script.get("words", True))
|
|
524
634
|
wp = os.path.join(work, "words.json")
|
|
525
635
|
if tl["words"] and os.path.exists(wp):
|
|
@@ -535,8 +645,8 @@ def st_layout(script, work, lines=None):
|
|
|
535
645
|
"legacy": plan["legacy"],
|
|
536
646
|
"chapter_layout": plan["chapter_layout"],
|
|
537
647
|
"item_stagger": pace["item_stagger"]}
|
|
538
|
-
# ----
|
|
539
|
-
# legacy
|
|
648
|
+
# ---- 场景语法:内容 -> 构图选型(落 timeline.scenes[] 与 work/storyboard.json) ----
|
|
649
|
+
# legacy 不注入:兼容绘制器只读 kind,注入新字段等于给基线加噪声。
|
|
540
650
|
if not plan["legacy"]:
|
|
541
651
|
try:
|
|
542
652
|
import grammar as G
|
|
@@ -559,7 +669,9 @@ def st_layout(script, work, lines=None):
|
|
|
559
669
|
json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
|
|
560
670
|
ensure_ascii=False, indent=1)
|
|
561
671
|
n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
|
|
562
|
-
theme
|
|
672
|
+
# 同上:设计计划自带 theme,不能假定 theme_id 一定在内置表里(见 _theme_for 的注释)
|
|
673
|
+
# 没有内置主题表:theme 必须由计划自带(design 路径一定有)
|
|
674
|
+
theme = (plan.get("theme") if not plan["legacy"] else None)
|
|
563
675
|
n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
|
|
564
676
|
print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
|
|
565
677
|
% (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
|
|
@@ -575,6 +687,26 @@ def st_layout(script, work, lines=None):
|
|
|
575
687
|
print(" %-6s %-7s %r" % (lid, fld, chars))
|
|
576
688
|
for s in tl["scenes"]:
|
|
577
689
|
print(" %-8s %7.2f-%7.2f %s" % (s["id"], s["start"], s["end"], s["text"][:40]))
|
|
690
|
+
# ---- 目标时长对账 ----
|
|
691
|
+
# brief 里写的「2 分钟 / 60 秒」只影响 design.structure.segments 与 target_sec,
|
|
692
|
+
# 而成片时长由 TTS 实测决定 —— 用户以为"我要求了时长",实际拿到 23s。
|
|
693
|
+
# 不改时长(截断旁白或拉长留白都是坏做法),但**必须当场说出来差多少**,
|
|
694
|
+
# 并给出可执行的做法(加台词 / 调 hold / 减台词)。
|
|
695
|
+
target = (plan or {}).get("target_sec")
|
|
696
|
+
if target:
|
|
697
|
+
try:
|
|
698
|
+
target = float(target)
|
|
699
|
+
except (TypeError, ValueError):
|
|
700
|
+
target = None
|
|
701
|
+
if target and target > 0:
|
|
702
|
+
diff = float(tl["total"]) - target
|
|
703
|
+
pct = diff / target * 100.0
|
|
704
|
+
print(" 目标时长 %.1fs / 实际 %.2fs(%+.1fs,%+.0f%%)"
|
|
705
|
+
% (target, tl["total"], diff, pct))
|
|
706
|
+
if abs(pct) > 15.0:
|
|
707
|
+
print(" [WARN] 与目标时长差超过 15%:成片时长由**实测旁白**决定,不收口就要改文案。"
|
|
708
|
+
"\n 想变长:加台词或调大 film.json 的 hold / lead / tail;"
|
|
709
|
+
"\n 想变短:删减台词(hold 只会加留白,不会缩短旁白)。")
|
|
578
710
|
return tl
|
|
579
711
|
|
|
580
712
|
|
|
@@ -614,6 +746,15 @@ def st_cues(script, work, tl, engine="pil"):
|
|
|
614
746
|
# scenes.py 里的 cues() 钩子:允许直接返回 [{at,type,...}] 或调用 bus.add(...)
|
|
615
747
|
n_hook = 0
|
|
616
748
|
mod = None
|
|
749
|
+
# scenes.py 的 cues() 常要读主题/视觉语言(assets 版就调 resolve_script_style)。
|
|
750
|
+
# 单跑 `--stage cues` 时前面没有 stage 把 script["_style"] 挂好,钩子就会去解析"设计"
|
|
751
|
+
# 并直接 raise —— 公开命令 100% 崩,报错还指向"先去提案"(其实设计已确认)。
|
|
752
|
+
# 所以在调用钩子前解析一次(幂等,已挂 _style 时直接复用)。
|
|
753
|
+
if script.get("_scenes") and os.path.exists(script["_scenes"]) and not isinstance(script.get("_style"), dict):
|
|
754
|
+
try:
|
|
755
|
+
script["_style"] = resolve_style(script, work)
|
|
756
|
+
except SystemExit:
|
|
757
|
+
pass # 没设计:cues 阶段不因它失败,钩子里真需要会自己报错
|
|
617
758
|
if script.get("_scenes") and os.path.exists(script["_scenes"]):
|
|
618
759
|
try:
|
|
619
760
|
mod = load_module(script["_scenes"])
|
|
@@ -626,7 +767,7 @@ def st_cues(script, work, tl, engine="pil"):
|
|
|
626
767
|
except Exception as e: # noqa: BLE001
|
|
627
768
|
raise SystemExit("scenes.py 的 cues() 钩子报错:%s" % e) from e
|
|
628
769
|
n_hook = _extend_safe(got, "scenes.py")
|
|
629
|
-
#
|
|
770
|
+
# web 引擎下要提醒"两套时间基准混用"。
|
|
630
771
|
# scenes.py 的 cues() 是按 **PIL 时间轴**(timeline.json 的段落与 visual_at)算的,
|
|
631
772
|
# 页面动画则按 **页面自己的时间轴**(window.DURATION)。两者合并进同一份 cues.json 后:
|
|
632
773
|
# - 音效会按 PIL 时间响(页面画面此刻可能正在另一幕);
|
|
@@ -683,7 +824,7 @@ def _st_cues_web(script, work):
|
|
|
683
824
|
tl = json.load(open(tp, encoding="utf-8"))
|
|
684
825
|
except Exception: # noqa: BLE001
|
|
685
826
|
tl = {}
|
|
686
|
-
style = _web_style_payload(we, script, tl)
|
|
827
|
+
style = _web_style_payload(we, script, tl, work)
|
|
687
828
|
vp = (tl.get("video") or {})
|
|
688
829
|
data = we.export_cues(entry, style=style,
|
|
689
830
|
width=int(vp.get("out_w") or K.W),
|
|
@@ -693,33 +834,18 @@ def _st_cues_web(script, work):
|
|
|
693
834
|
|
|
694
835
|
|
|
695
836
|
# ---------------------------------------------------------------- 配乐 / 音效
|
|
696
|
-
def
|
|
697
|
-
"""
|
|
837
|
+
def _score_with_musicgen(script, work, tl, plan_s, mus):
|
|
838
|
+
"""用参数化编排器出成片配乐(与 previewkit 的样音同一个引擎、同一组参数)。
|
|
698
839
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
- mode=bed:走 v0.2 和弦床,不生成 scorekit 产物;
|
|
703
|
-
- mode=file:外部 work/music.wav,原样交给 audio 阶段;
|
|
704
|
-
- 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床(v0.2 兼容)。
|
|
840
|
+
为什么必须走参数化编排:本技能不提供风格表/模板,配乐必须由设计里的参数现场生成。
|
|
841
|
+
musicgen 的六个参数(progressions / motif / drums / voices / mood / sections)
|
|
842
|
+
全部参与事件展开,实测参数相关性 seed 0.16 / mood 0.32 / voices 0.22,无一是死旋钮。
|
|
705
843
|
"""
|
|
706
|
-
print("== Score")
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
|
|
710
|
-
print(" " + why)
|
|
711
|
-
if plan in ("off", "bed", "file", "none"):
|
|
712
|
-
return None
|
|
713
|
-
if plan in ("missing-file", "missing-bpm", "unknown-mode"):
|
|
714
|
-
raise SystemExit("[score] " + why)
|
|
715
|
-
sk = _try_module("scorekit")
|
|
716
|
-
if sk is None:
|
|
717
|
-
print(" [WARN] 没有 scorekit.py,跳过配乐(audio 阶段没有 music.wav,本片将无配乐)")
|
|
718
|
-
return None
|
|
719
|
-
anchors = K.scenes_anchors(tl["scenes"])
|
|
844
|
+
print("== Score(musicgen 参数化编排)")
|
|
845
|
+
mg = _try_module("musicgen")
|
|
846
|
+
mp = plan_s["music"]
|
|
720
847
|
sections = mus.get("sections")
|
|
721
848
|
if sections:
|
|
722
|
-
# film.json 允许用 id 表述分段起点,换成绝对秒
|
|
723
849
|
fixed = []
|
|
724
850
|
for s in sections:
|
|
725
851
|
s2 = dict(s)
|
|
@@ -727,76 +853,103 @@ def st_score(script, work, tl):
|
|
|
727
853
|
s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
|
|
728
854
|
fixed.append(s2)
|
|
729
855
|
sections = fixed
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
fp_hex = ""
|
|
741
|
-
if not plan_s["legacy"]:
|
|
742
|
-
fp_hex = str((plan_s.get("fingerprint") or {}).get("full") or "")
|
|
743
|
-
kw_fp = dict(kwargs, fingerprint=fp_hex) if fp_hex else kwargs
|
|
744
|
-
# scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
|
|
745
|
-
# 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
|
|
746
|
-
# 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
|
|
747
|
-
# 四级回退:带指纹 -> 不带指纹 -> 只有 style/mood -> 最老的签名。
|
|
748
|
-
pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
|
|
749
|
-
"hold": float(plan_s["pacing"]["hold"])}
|
|
750
|
-
mood_arg = str(mp.get("mood") or "steady")
|
|
751
|
-
samples = report = None
|
|
752
|
-
# v0.7.2:多级签名兼容链以前对**任何** TypeError 都继续换签名试,最后统一报
|
|
753
|
-
# "不接受任何一种已知签名"。scorekit 内部真出 TypeError(例如某个参数是 None 被拿去做
|
|
754
|
-
# 算术)时,用户会照着"签名不兼容"的方向白找半天。这里区分两类:
|
|
755
|
-
# - 报错文本含 "unexpected keyword argument" -> 确实是不认识这个参数,换签名重试;
|
|
756
|
-
# - 其它 TypeError -> 是**内部**错误,带上签名与错误信息立刻报,别吞。
|
|
757
|
-
attempts = (
|
|
758
|
-
dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kw_fp),
|
|
759
|
-
dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kwargs),
|
|
760
|
-
dict(style=style_arg, mood=mood_arg, **kwargs),
|
|
761
|
-
dict(**kwargs),
|
|
856
|
+
kwargs = dict(
|
|
857
|
+
bpm=float(mp["bpm"]),
|
|
858
|
+
key=str(mp["key"] or "Dm"),
|
|
859
|
+
scale=str(mp.get("scale") or "minor"),
|
|
860
|
+
mood=str(mp.get("mood") or "steady"),
|
|
861
|
+
# seed_effective 是"内容指纹掺进去之后"的 32 位大数;musicgen 内部按
|
|
862
|
+
# sha1(seed) 派生,直接给大整数没问题,但统一夹到 2^31-1 内可以保证
|
|
863
|
+
# 取模口径固定(既有 work/ 目录里的 score.json 能对上)。
|
|
864
|
+
seed=int(mp.get("seed_effective") or mp.get("seed") or 0) % (2 ** 31 - 1),
|
|
865
|
+
sections=sections,
|
|
762
866
|
)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
if report and not plan_s["legacy"]:
|
|
779
|
-
print(" 配乐风格: %s / mood %s / %s BPM / %s"
|
|
780
|
-
% (report.get("style") or style_arg, mp.get("mood"),
|
|
781
|
-
report.get("bpm"), report.get("key")))
|
|
867
|
+
# 设计里显式给出的编排参数原样透传(progressions/motif/drums/voices/energy_curve)。
|
|
868
|
+
# 只在键存在时传:musicgen 对 None 与缺省的处理不同(缺省=自己按 seed 派生)。
|
|
869
|
+
for key in ("progressions", "motif", "drums", "voices", "energy_curve"):
|
|
870
|
+
if mp.get(key) is not None:
|
|
871
|
+
kwargs[key] = mp[key]
|
|
872
|
+
t0 = time.time()
|
|
873
|
+
try:
|
|
874
|
+
samples, report = mg.render(float(tl["total"]), **kwargs)
|
|
875
|
+
except Exception as e: # noqa: BLE001
|
|
876
|
+
raise SystemExit(
|
|
877
|
+
"[score] musicgen.render() 失败:%s\n"
|
|
878
|
+
" 参数:bpm=%s key=%s scale=%s mood=%s seed=%s\n"
|
|
879
|
+
" 这是配乐生成的真错误(不是签名不兼容)——先把参数改对,别改成静默跳过。"
|
|
880
|
+
% (e, kwargs.get("bpm"), kwargs.get("key"), kwargs.get("scale"),
|
|
881
|
+
kwargs.get("mood"), kwargs.get("seed"))) from e
|
|
782
882
|
out = os.path.join(work, "music.wav")
|
|
783
|
-
|
|
883
|
+
mg.save(out, samples)
|
|
784
884
|
if report:
|
|
785
885
|
json.dump(report, open(os.path.join(work, "score.json"), "w", encoding="utf-8"),
|
|
786
886
|
ensure_ascii=False, indent=1)
|
|
887
|
+
print(" musicgen: %s %s / %s BPM / %s / mood %s / seed %s"
|
|
888
|
+
% (report.get("key_name") or report.get("key"), report.get("scale"),
|
|
889
|
+
report.get("bpm"), "", report.get("mood"), report.get("seed")))
|
|
787
890
|
samples_read, _sr = K.wav_read(out)
|
|
788
891
|
print(" music.wav %.2fs 峰值 %.3f %.1fs" % (
|
|
789
892
|
K.wav_dur(out), float(np.max(np.abs(samples_read))), time.time() - t0))
|
|
790
893
|
return out
|
|
791
894
|
|
|
792
895
|
|
|
793
|
-
def
|
|
794
|
-
"""
|
|
896
|
+
def st_score(script, work, tl):
|
|
897
|
+
"""参数化配乐 -> work/music.wav(musicgen 现场编排,没有模板可回退)。
|
|
898
|
+
|
|
899
|
+
配乐来源由 K.music_plan 统一判定(与 st_audio 共用同一份语义):
|
|
900
|
+
- mode=off/none 或 enabled=false:不出配乐,也不删已存在的 music.wav
|
|
901
|
+
(删文件会误删"外部提供的配乐",而 off 的语义只是"本片不要配乐");
|
|
902
|
+
- mode=bed:走和弦床,不生成 musicgen 产物;
|
|
903
|
+
- mode=file:外部 work/music.wav,原样交给 audio 阶段;
|
|
904
|
+
- 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床。
|
|
905
|
+
|
|
906
|
+
需要真的生成配乐时,**只有 musicgen 一条路**:legacy 计划或 musicgen.py 缺失都会
|
|
907
|
+
明确报错并给迁移指引,不用 scorekit 的配乐模板兜底(零模板)。
|
|
908
|
+
"""
|
|
909
|
+
print("== Score")
|
|
910
|
+
mus = script.get("music") or {}
|
|
911
|
+
music_path = os.path.join(work, "music.wav")
|
|
912
|
+
plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
|
|
913
|
+
print(" " + why)
|
|
914
|
+
if plan in ("off", "bed", "file", "none"):
|
|
915
|
+
return None
|
|
916
|
+
if plan in ("missing-file", "missing-bpm", "unknown-mode"):
|
|
917
|
+
raise SystemExit("[score] " + why)
|
|
918
|
+
# **配乐只有 musicgen 一条路**(参数化编排器,没有风格表)。
|
|
919
|
+
#
|
|
920
|
+
# 为什么必须同源:若 previewkit 用 musicgen 出 12 秒样音、而 st_score 用 scorekit
|
|
921
|
+
# 出成片音乐,两者是**两个不同的作曲器**,实测同一设计下相关系数 -0.0057(就是两首
|
|
922
|
+
# 不同的曲子)。用户看样音选方案,成片音乐完全不同,等于"确认"这个动作在音频上失效。
|
|
923
|
+
#
|
|
924
|
+
# scorekit 没有风格表(本技能不提供模板),它的 render() 只剩 legacy 逐样本兼容路径。
|
|
925
|
+
# 所以这里**没有 scorekit 回退**:scorekit 一旦回来当兜底,成片音乐就又变成一套
|
|
926
|
+
# 固定模板,与"每条视频都不一样"直接冲突。
|
|
927
|
+
plan_s0 = resolve_style(script, work)
|
|
928
|
+
if plan_s0.get("legacy"):
|
|
929
|
+
raise SystemExit(
|
|
930
|
+
"[score] legacy 配乐路径已移除(零模板):\n"
|
|
931
|
+
" scorekit 的 9 套配乐风格表已删除,legacy 回退会把成片配乐锁死成一套固定模板。\n"
|
|
932
|
+
" 正确做法(两步):\n"
|
|
933
|
+
" 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
|
|
934
|
+
" 2) python propose.py --confirm --candidate A # 冻结 work/design.json\n"
|
|
935
|
+
" 3) python render.py --script film.json --stage score # 用 musicgen 现场编排\n"
|
|
936
|
+
" (想手写配乐参数:film.json 的 music.bpm / key / mood / sections 仍然有效)")
|
|
937
|
+
if _try_module("musicgen") is None:
|
|
938
|
+
raise SystemExit(
|
|
939
|
+
"[score] 找不到 musicgen.py,无法生成配乐:\n"
|
|
940
|
+
" 配乐只有参数化编排器一条路(scorekit 的风格模板已删除,不再兜底)。\n"
|
|
941
|
+
" 检查 <SKILL_DIR>/scripts/musicgen.py 是否存在(技能脚本就在技能目录里跑,不用拷);\n"
|
|
942
|
+
" 只需要旁白/音效时把 film.json 的 music.mode 设为 off 或 file。")
|
|
943
|
+
return _score_with_musicgen(script, work, tl, plan_s0, mus)
|
|
795
944
|
|
|
796
|
-
|
|
797
|
-
|
|
945
|
+
|
|
946
|
+
def _sfx_seed_context(script, work=None):
|
|
947
|
+
"""算音效的内容派生上下文 (style, content_fp, key, enabled)。
|
|
948
|
+
|
|
949
|
+
legacy(没写 style 字段)时返回全 None -> CueBus 走 seed_base + i 路径,
|
|
950
|
+
逐样本与基线一致。参考 references/audio-variation.md §3.2 / §4。
|
|
798
951
|
"""
|
|
799
|
-
plan = resolve_style(script)
|
|
952
|
+
plan = resolve_style(script, work)
|
|
800
953
|
sfx_cfg = script.get("sfx") or {}
|
|
801
954
|
want_content = bool(sfx_cfg.get("content_seed", True))
|
|
802
955
|
if plan.get("legacy") or not want_content:
|
|
@@ -807,9 +960,9 @@ def _sfx_seed_context(script):
|
|
|
807
960
|
|
|
808
961
|
|
|
809
962
|
def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
|
|
810
|
-
"""
|
|
963
|
+
"""段落边界前的转场音效自动让位(契约 §4)。
|
|
811
964
|
|
|
812
|
-
|
|
965
|
+
配乐在段落交界自己已经有一段过门(编排器写的 swell / riser / sub_boom),
|
|
813
966
|
sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
|
|
814
967
|
做法:对位于 [边界-window, 边界] 内的转场类 cue,在它的时长内乘一个 dB 增益包络。
|
|
815
968
|
只压转场类,不碰 impact / 提示音(那是"该响"的语义重音)。
|
|
@@ -862,8 +1015,8 @@ def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2
|
|
|
862
1015
|
def st_sfx(script, work, tl):
|
|
863
1016
|
"""按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。
|
|
864
1017
|
|
|
865
|
-
|
|
866
|
-
|
|
1018
|
+
非 legacy 时走内容派生 seed(跨片同一位置不逐样本相同),
|
|
1019
|
+
并把配乐调性透传给提示音配方(不硬编码 Dm);
|
|
867
1020
|
段落边界前的转场音效自动让位(不与配乐过门撞车)。
|
|
868
1021
|
"""
|
|
869
1022
|
print("== SFX")
|
|
@@ -877,7 +1030,7 @@ def st_sfx(script, work, tl):
|
|
|
877
1030
|
print(" cues.json 里没有提示点,跳过")
|
|
878
1031
|
return None
|
|
879
1032
|
sfx_cfg = script.get("sfx") or {}
|
|
880
|
-
style, content_fp, key = _sfx_seed_context(script)
|
|
1033
|
+
style, content_fp, key = _sfx_seed_context(script, work)
|
|
881
1034
|
try:
|
|
882
1035
|
samples = bus.build_audio(total=tl["total"],
|
|
883
1036
|
base_db=sfx_cfg.get("base_db"),
|
|
@@ -905,9 +1058,10 @@ def st_sfx(script, work, tl):
|
|
|
905
1058
|
def _voice_track(tl, script):
|
|
906
1059
|
"""把逐句旁白铺成一条总线,返回 (n,) 单声道。
|
|
907
1060
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1061
|
+
全局增益只能在一处乘:voice_gain 若在这里和 mix_film 里**各乘一次**,
|
|
1062
|
+
净效果是 voice_gain^2(默认 1.0 时看不出来,改了才炸)。
|
|
1063
|
+
所以全局增益只在 mix_film 乘一次,这里只负责**逐句**增益
|
|
1064
|
+
(voicekit 的 gain_db,默认 0 dB -> 1.0)。
|
|
911
1065
|
"""
|
|
912
1066
|
bus = K.AudioBus(tl["total"])
|
|
913
1067
|
for s in tl["scenes"]:
|
|
@@ -919,7 +1073,7 @@ def _voice_track(tl, script):
|
|
|
919
1073
|
|
|
920
1074
|
|
|
921
1075
|
def _legacy_music(tl, script):
|
|
922
|
-
"""
|
|
1076
|
+
"""和弦床(没有 music.wav 时的兜底)。"""
|
|
923
1077
|
mus = script.get("music") or {}
|
|
924
1078
|
if not (mus.get("enabled", True) and mus.get("chords")):
|
|
925
1079
|
return None
|
|
@@ -935,11 +1089,11 @@ def _legacy_music(tl, script):
|
|
|
935
1089
|
def st_audio(tl, work, script):
|
|
936
1090
|
"""三总线混音:旁白 + 配乐 + 音效 -> audio.wav(立体声)。
|
|
937
1091
|
|
|
938
|
-
|
|
1092
|
+
三条总线分开处理再合:
|
|
939
1093
|
- 旁白:单声道,居中(voice_gain 可调)
|
|
940
1094
|
- 配乐:来源由 K.music_plan 判定(score/file/bed/off),口径与 st_score 完全一致——
|
|
941
1095
|
music.mode=off 时一条配乐都不混,即使 work/music.wav 还在(外部配乐请用 mode=file)
|
|
942
|
-
- 音效:work/sfx.wav(按 cues.json 合成);没有 cues
|
|
1096
|
+
- 音效:work/sfx.wav(按 cues.json 合成);没有 cues 时退回转场 whoosh
|
|
943
1097
|
音效响起时配乐自动让位(duck_db),避免重击被配乐吃掉。
|
|
944
1098
|
"""
|
|
945
1099
|
print("== Audio")
|
|
@@ -951,8 +1105,8 @@ def st_audio(tl, work, script):
|
|
|
951
1105
|
music_path = os.path.join(work, "music.wav")
|
|
952
1106
|
plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
|
|
953
1107
|
if plan == "off":
|
|
954
|
-
# 关键语义:mode=off =
|
|
955
|
-
#
|
|
1108
|
+
# 关键语义:mode=off = 不要配乐。无条件读 work/music.wav 的话,
|
|
1109
|
+
# "上一轮跑出来的配乐"仍会被混进新片(用户改了 mode 却听不出变化)。
|
|
956
1110
|
print(" 配乐: " + why)
|
|
957
1111
|
elif plan == "bed":
|
|
958
1112
|
music = _legacy_music(tl, script)
|
|
@@ -969,7 +1123,7 @@ def st_audio(tl, work, script):
|
|
|
969
1123
|
print(" 配乐: " + why)
|
|
970
1124
|
if plan == "score" and music is None:
|
|
971
1125
|
# 单独跑 --stage audio 时 score 还没跑过:不混配乐,但必须说清楚为什么
|
|
972
|
-
print(" [WARN] music
|
|
1126
|
+
print(" [WARN] music.mode=score 但 %s 不存在(先跑 --stage score);本次无配乐"
|
|
973
1127
|
% music_path)
|
|
974
1128
|
|
|
975
1129
|
sfx = None
|
|
@@ -978,7 +1132,7 @@ def st_audio(tl, work, script):
|
|
|
978
1132
|
sfx, _ = K.wav_read(sfx_path)
|
|
979
1133
|
print(" 音效: sfx.wav(按 cues.json 合成)")
|
|
980
1134
|
elif script.get("whoosh", True) and not os.path.exists(os.path.join(work, "cues.json")):
|
|
981
|
-
#
|
|
1135
|
+
# 兼容路径:没有提示点时才用"每段开头一声 whoosh"的行为
|
|
982
1136
|
bus = K.AudioBus(total, channels=2)
|
|
983
1137
|
for s in tl["scenes"][1:]:
|
|
984
1138
|
if float(s["start"]) < 0.2:
|
|
@@ -988,10 +1142,10 @@ def st_audio(tl, work, script):
|
|
|
988
1142
|
sfx = bus.bus[: int(round(total * K.SR))].copy()
|
|
989
1143
|
print(" 音效: 旧版转场 whoosh(没有 cues.json)")
|
|
990
1144
|
|
|
991
|
-
plan_s = resolve_style(script)
|
|
1145
|
+
plan_s = resolve_style(script, work)
|
|
992
1146
|
# 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
|
|
993
1147
|
# 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
|
|
994
|
-
#
|
|
1148
|
+
# 整体 +1 dB 的来由:更低的值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
|
|
995
1149
|
# 段落过门时两者在同一频段几乎等高,是"配乐和音效听起来像同一套"的混音侧成因。
|
|
996
1150
|
duck_default = 4.0
|
|
997
1151
|
if not plan_s["legacy"]:
|
|
@@ -999,7 +1153,7 @@ def st_audio(tl, work, script):
|
|
|
999
1153
|
"cinema": 5.5, "epic": 5.5}.get(plan_s["style"], 4.0)
|
|
1000
1154
|
samples = K.mix_film(voice, music, sfx, total=total,
|
|
1001
1155
|
voice_gain=float(script.get("voice_gain", 1.0)),
|
|
1002
|
-
#
|
|
1156
|
+
# 旁白优先:配乐基线 -12 dB。
|
|
1003
1157
|
# 视频以旁白为主,-1.5 dB 时背景与台词响度几乎等高,观众要费力分辨字;
|
|
1004
1158
|
# 12 dB 是"人声明显压住背景"的经验线(audio.md §2 的那条设计标准)。
|
|
1005
1159
|
music_gain_db=float(mus.get("gain_db", -12.0)),
|
|
@@ -1023,9 +1177,9 @@ def st_audio(tl, work, script):
|
|
|
1023
1177
|
|
|
1024
1178
|
# ---------------------------------------------------------------- 预检闸门
|
|
1025
1179
|
def _run_preflight(script, work, engine, tl=None, skip=False):
|
|
1026
|
-
"""
|
|
1180
|
+
"""渲染前资源闸门:缺字形 / 坏音频 / 缺字体,在渲第一帧之前就 fail。
|
|
1027
1181
|
|
|
1028
|
-
|
|
1182
|
+
为什么值得单独立一道闸:这类问题若只在"片渲完、QA 才报"——870 帧白跑,
|
|
1029
1183
|
而且 reports 里只有一句"缺字形",说不清是哪个字段哪句台词。
|
|
1030
1184
|
三档语义:没装 preflight.py -> skipped(写明原因);有 error -> fail;
|
|
1031
1185
|
只有 warning -> pass(warning 仍进账本,不静默)。
|
|
@@ -1071,6 +1225,29 @@ def _run_preflight(script, work, engine, tl=None, skip=False):
|
|
|
1071
1225
|
return True
|
|
1072
1226
|
|
|
1073
1227
|
|
|
1228
|
+
# ---------------------------------------------------------------- 字幕样式
|
|
1229
|
+
_SUB_CACHE = {}
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _sub_kw(script):
|
|
1233
|
+
"""从已解析的 plan 里取字幕参数(缓存按 script 对象身份,连续帧不重复解析)。
|
|
1234
|
+
|
|
1235
|
+
字幕样式由 design.json 的 visual.subtitle 决定;参考帧渲染路径
|
|
1236
|
+
(qavmaf._draw_ref_frame)必须调用同一个 filmkit.subtitle_kwargs(plan),
|
|
1237
|
+
否则两帧字幕参数不同会把同源好片的 VMAF 拉低。
|
|
1238
|
+
"""
|
|
1239
|
+
key = id(script)
|
|
1240
|
+
cached = _SUB_CACHE.get(key)
|
|
1241
|
+
if cached is not None:
|
|
1242
|
+
return cached
|
|
1243
|
+
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
1244
|
+
kw = K.subtitle_kwargs(plan)
|
|
1245
|
+
if len(_SUB_CACHE) > 8: # 批量渲染会跨很多 script:别无限长
|
|
1246
|
+
_SUB_CACHE.clear()
|
|
1247
|
+
_SUB_CACHE[key] = kw
|
|
1248
|
+
return kw
|
|
1249
|
+
|
|
1250
|
+
|
|
1074
1251
|
# ---------------------------------------------------------------- 渲染
|
|
1075
1252
|
def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True):
|
|
1076
1253
|
"""渲染 [f0, f1) 帧区间到 out_path。纯函数:同一帧号永远画出同一张图。"""
|
|
@@ -1096,7 +1273,8 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
|
|
|
1096
1273
|
_THEME_CACHE.update(k=id(script), th=th)
|
|
1097
1274
|
_hold = script.get("sub_max_hold")
|
|
1098
1275
|
frame = K.draw_subtitle(frame, K.sub_at(scenes, t, _hold), theme=th,
|
|
1099
|
-
words=K.sub_words_at(scenes, t, _hold)
|
|
1276
|
+
words=K.sub_words_at(scenes, t, _hold),
|
|
1277
|
+
**_sub_kw(script))
|
|
1100
1278
|
fade = 1.0
|
|
1101
1279
|
if t < 0.5:
|
|
1102
1280
|
fade *= K.ease_out(t / 0.5)
|
|
@@ -1124,7 +1302,7 @@ def _seg_usable(path, frames):
|
|
|
1124
1302
|
|
|
1125
1303
|
为什么不能只看 size>1024:Ctrl-C / 杀进程留下的半截 mp4 也 >1024,被当成
|
|
1126
1304
|
"已存在"跳过后,拼接只在最后报一句"拼接后时长 0.07s != 预期 29.00s"。
|
|
1127
|
-
|
|
1305
|
+
分块先写 .part 再 os.replace,正常中断不会留下半截正式文件;这里
|
|
1128
1306
|
再补一道头部 nb_frames 比对,挡住"文件在/大小对但帧数不对"的异常(例如被
|
|
1129
1307
|
外部工具截断过)。
|
|
1130
1308
|
|
|
@@ -1148,17 +1326,23 @@ def _seg_usable(path, frames):
|
|
|
1148
1326
|
|
|
1149
1327
|
def _render_chunk_worker(args):
|
|
1150
1328
|
"""分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
|
|
1151
|
-
(tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args
|
|
1329
|
+
(tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args[:9]
|
|
1330
|
+
work = args[9] if len(args) > 9 else ""
|
|
1152
1331
|
tl = json.load(open(tl_path, encoding="utf-8"))
|
|
1153
1332
|
script = json.load(open(script_path, encoding="utf-8"))
|
|
1154
1333
|
script["_total"] = tl["total"]
|
|
1155
|
-
#
|
|
1156
|
-
# scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
1334
|
+
# 不能直接用 JSON 快照里的计划:JSON 会把元组变成 list(主题色是 (r,g,b) 元组),
|
|
1335
|
+
# 子进程里 scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
1157
1336
|
# 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
|
|
1158
1337
|
# 既拿回元组,也保证子进程的主题与父进程逐字段一致。
|
|
1338
|
+
#
|
|
1339
|
+
# 重解时**必须带上 work**。design 路径的身份在 work/design.json 里,
|
|
1340
|
+
# 不带 work 就只能走 fail-closed 报错 -> 画出一套完全不同的主题。
|
|
1341
|
+
# 实测后果:--jobs 4 的成片与 --jobs 1 / QA 参考帧 MAE 0.059、VMAF 直接掉到 1.49。
|
|
1342
|
+
# 这种"并行度不同就出不同片子"的 bug 最隐蔽——单线程跑一遍永远看不见。
|
|
1159
1343
|
script.pop("_style", None)
|
|
1160
1344
|
try:
|
|
1161
|
-
script["_style"] = resolve_style(script, verbose=False)
|
|
1345
|
+
script["_style"] = resolve_style(script, work or None, verbose=False)
|
|
1162
1346
|
except Exception as e: # noqa: BLE001
|
|
1163
1347
|
print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
|
|
1164
1348
|
script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
|
|
@@ -1179,7 +1363,7 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1179
1363
|
script["_total"] = tl["total"] # 场景算全局进度条要用
|
|
1180
1364
|
# 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
|
|
1181
1365
|
# _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
|
|
1182
|
-
script.setdefault("_style", resolve_style(script))
|
|
1366
|
+
script.setdefault("_style", resolve_style(script, work))
|
|
1183
1367
|
crf = int(script.get("crf", 18))
|
|
1184
1368
|
preset = script.get("preset", "medium")
|
|
1185
1369
|
total_frames = tl["frames"]
|
|
@@ -1256,7 +1440,8 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1256
1440
|
except OSError:
|
|
1257
1441
|
pass
|
|
1258
1442
|
part = os.path.join(seg_dir, "_part_" + os.path.basename(seg))
|
|
1259
|
-
todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part
|
|
1443
|
+
todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part,
|
|
1444
|
+
str(work)))
|
|
1260
1445
|
|
|
1261
1446
|
# 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
|
|
1262
1447
|
stale = [n for n in os.listdir(seg_dir)
|
|
@@ -1298,15 +1483,18 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1298
1483
|
return silent
|
|
1299
1484
|
|
|
1300
1485
|
|
|
1301
|
-
def _web_style_payload(we, script, tl=None):
|
|
1302
|
-
"""把风格包解析成 webengine 能吃的 __STYLE__
|
|
1486
|
+
def _web_style_payload(we, script, tl=None, work=None):
|
|
1487
|
+
"""把风格包解析成 webengine 能吃的 __STYLE__ 载荷。
|
|
1303
1488
|
|
|
1304
1489
|
style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
|
|
1305
|
-
add_init_script
|
|
1490
|
+
add_init_script 都不调,页面行为逐字节不变(这是兼容性硬要求)。
|
|
1306
1491
|
"""
|
|
1307
1492
|
try:
|
|
1308
|
-
style_plan = resolve_style(script, verbose=False)
|
|
1309
|
-
|
|
1493
|
+
style_plan = resolve_style(script, work, verbose=False)
|
|
1494
|
+
# 必须连 SystemExit 一起接:resolve_style 在"没有设计/主题不过体检"时用 SystemExit
|
|
1495
|
+
# 报错(这是它向 CLI 报用法错误的方式),而 SystemExit 继承自 BaseException,
|
|
1496
|
+
# 只 catch Exception 接不住 —— web 页面会拿着半截载荷渲染,或者整个进程直接退出。
|
|
1497
|
+
except (Exception, SystemExit) as e: # noqa: BLE001
|
|
1310
1498
|
print(" [WARN] web 风格包解析失败,页面用内置配色:%s" % e)
|
|
1311
1499
|
return None
|
|
1312
1500
|
if not style_plan or style_plan.get("legacy"):
|
|
@@ -1328,14 +1516,14 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
|
|
|
1328
1516
|
|
|
1329
1517
|
页面的 window.DURATION / window.FPS 是切块与校验的唯一真值来源:
|
|
1330
1518
|
和 timeline.json 对不上就直接报错(十有八九是改了剧本没重新 layout)。
|
|
1331
|
-
|
|
1519
|
+
把风格包与画幅/帧率一起注入页面(window.__STYLE__),页面颜色不再写死。
|
|
1332
1520
|
"""
|
|
1333
1521
|
we = _opt_module("webengine",
|
|
1334
1522
|
"浏览器渲染需要 webengine.py(本技能 scripts/ 里自带;还需要 pip install playwright)")
|
|
1335
1523
|
entry = _web_entry(script)
|
|
1336
1524
|
silent = os.path.join(work, "silent.mp4")
|
|
1337
1525
|
web = script.get("web") or {}
|
|
1338
|
-
style = _web_style_payload(we, script, tl)
|
|
1526
|
+
style = _web_style_payload(we, script, tl, work)
|
|
1339
1527
|
vp = tl.get("video") or {}
|
|
1340
1528
|
canvas_w = int(vp.get("out_w") or K.W)
|
|
1341
1529
|
canvas_h = int(vp.get("out_h") or K.H)
|
|
@@ -1399,8 +1587,8 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1399
1587
|
v = next((s for s in streams if s.get("codec_type") == "video"), {})
|
|
1400
1588
|
a = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
1401
1589
|
fmt = pr.get("format") or {}
|
|
1402
|
-
#
|
|
1403
|
-
#
|
|
1590
|
+
# 容器门禁比对的是**交付规格**(timeline 里记的 video 块),不是引擎内部常量。
|
|
1591
|
+
# 没有 video 字段的 timeline -> 回落到 1920x1080。
|
|
1404
1592
|
_vp = tl.get("video") or {}
|
|
1405
1593
|
W = int(_vp.get("out_w") or K.W)
|
|
1406
1594
|
H = int(_vp.get("out_h") or K.H)
|
|
@@ -1464,7 +1652,7 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1464
1652
|
else:
|
|
1465
1653
|
lg.ok("G8", "首尾之外无长黑帧", "0 段")
|
|
1466
1654
|
|
|
1467
|
-
# ---- G10
|
|
1655
|
+
# ---- G10 词级时间戳:覆盖率 / 单调 / 末词偏差 ----
|
|
1468
1656
|
_word_gate(lg, tl, work)
|
|
1469
1657
|
|
|
1470
1658
|
# ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
|
|
@@ -1476,8 +1664,15 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1476
1664
|
ns = len(deep.get("sync") or [])
|
|
1477
1665
|
judged = (deep.get("sync_info") or {}).get("judged")
|
|
1478
1666
|
sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
|
|
1479
|
-
|
|
1480
|
-
|
|
1667
|
+
# 门禁要按 **deep.errors** 判,而不是按 flicker 条数。
|
|
1668
|
+
# qa_frames 会把"块内像素有增有减"(文字入场/母题动画/镜头缓推)的候选从
|
|
1669
|
+
# flicker 降级进 warnings(见 FLICKER_AGREE_MIN),此时 flicker 仍非空但已不成问题;
|
|
1670
|
+
# 按条数判会让降级形同虚设(零拷贝首片实测:降级后仍判 fail)。
|
|
1671
|
+
# errors 里同时也包含空窗与同步超差,所以只看 errors 不会漏判。
|
|
1672
|
+
n_err = len([e for e in (deep.get("errors") or []) if e])
|
|
1673
|
+
ok9 = n_err == 0
|
|
1674
|
+
detail9 = "问题 %d 条(闪烁候选 %d 已按符号一致度分档)· 空窗 %d · 同步超差 %d(%s)" % (
|
|
1675
|
+
n_err, nf, nb, ns, sync_note)
|
|
1481
1676
|
if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
|
|
1482
1677
|
# 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
|
|
1483
1678
|
lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
|
|
@@ -1492,7 +1687,7 @@ WORD_COVER_MIN = 0.45 # 词时长之和 / 句实测时长:中文有词
|
|
|
1492
1687
|
|
|
1493
1688
|
|
|
1494
1689
|
def _word_gate(lg, tl, work):
|
|
1495
|
-
"""
|
|
1690
|
+
"""词级时间戳门禁。
|
|
1496
1691
|
|
|
1497
1692
|
为什么值得单独一道闸:词级数据是"字幕按词折行 / cue 词锚点"的依据,
|
|
1498
1693
|
但它来自一个**逆向接口**(edge-tts)。服务端一改,最可能的表现是"仍返回音频、
|
|
@@ -1556,12 +1751,12 @@ def _word_gate(lg, tl, work):
|
|
|
1556
1751
|
|
|
1557
1752
|
|
|
1558
1753
|
# ⚠ 阈值**只有一套**:权威定义在 qavmaf.py(VMAF_PASS=92 / VMAF_WARN=96,SSIM 仅诊断)。
|
|
1559
|
-
# 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"
|
|
1754
|
+
# 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"。
|
|
1560
1755
|
# 真实标定(qavmaf 作者实测,参考帧含字幕与片头片尾淡入淡出):
|
|
1561
1756
|
# 模板片 crf17 VMAF 97.24(pass) / crf30 95.74(pass+warning) / 二次编码坏片 29.45(fail)
|
|
1562
1757
|
# 想按片收紧或放宽就写 film.json 的 qa.vmaf.pass / qa.vmaf.warn,会透传给 qavmaf。
|
|
1563
1758
|
def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
1564
|
-
"""全片 VMAF/SSIM
|
|
1759
|
+
"""全片 VMAF/SSIM 门禁:参考帧由场景函数实时渲染,与成片逐帧比较。
|
|
1565
1760
|
|
|
1566
1761
|
为什么值得全片跑(用户明确要求"全片 + 阻断"):锐度比只在 4 个时刻采样,
|
|
1567
1762
|
能发现"整体发虚",但发现不了"某 30 秒里字幕糊了 / 某段梯度出现色带"。
|
|
@@ -1601,8 +1796,19 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
|
1601
1796
|
if cfg["ssim"].get(ckey) is not None:
|
|
1602
1797
|
kw[key] = float(cfg["ssim"][ckey])
|
|
1603
1798
|
print(" [vmaf] 模式=%s(参考帧=场景函数实时渲染,不是成片抽帧)" % m)
|
|
1799
|
+
# qavmaf 是独立解析(它拿不到本进程的 script["_style"]),所以这里把
|
|
1800
|
+
# **冻结设计**一起传下去:参考帧必须与成片同源,否则 VMAF 会掉到个位数。
|
|
1801
|
+
# 实测未修前:design 片被解析成 tech-deep,VMAF 1.50、同源抽查 MAE=0.0384。
|
|
1802
|
+
script_for_vmaf = dict(script or {})
|
|
1803
|
+
dpath = os.path.join(str(work), "design.json")
|
|
1804
|
+
if "design" not in script_for_vmaf and os.path.exists(dpath):
|
|
1805
|
+
try:
|
|
1806
|
+
with open(dpath, encoding="utf-8") as fh:
|
|
1807
|
+
script_for_vmaf["design"] = json.load(fh)
|
|
1808
|
+
except (OSError, json.JSONDecodeError) as e: # noqa: BLE001
|
|
1809
|
+
print(" [WARN][vmaf] design.json 读不出来,参考帧可能不同源:%s" % e)
|
|
1604
1810
|
try:
|
|
1605
|
-
res = qv.report(film, tl, scenes_mod,
|
|
1811
|
+
res = qv.report(film, tl, scenes_mod, script_for_vmaf, mode=m,
|
|
1606
1812
|
out_json=os.path.join(str(work), "qa", "vmaf.json"), **kw)
|
|
1607
1813
|
except Exception as e: # noqa: BLE001
|
|
1608
1814
|
lg.fail("G11", "全片 VMAF / SSIM 画质", "门禁自身失败:%s" % e)
|
|
@@ -1635,7 +1841,7 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
|
1635
1841
|
return res
|
|
1636
1842
|
|
|
1637
1843
|
|
|
1638
|
-
#
|
|
1844
|
+
# 实测标定(同一部模板片,29 s,faster-whisper small / CPU int8):
|
|
1639
1845
|
# 干净成片:字符召回 0.828(专有名词 "DeepSeek 4.1 Flash" 被听成 DIXX/DeepSeek 变体,
|
|
1640
1846
|
# 4 段全对上、缺句 0)—— 这不是流水线坏了,是 ASR 对中英混排专名的正常误差。
|
|
1641
1847
|
# 所以默认线取 0.85 / 0.72:**低于 0.72 才说明"整句丢了/音轨跟剧本不是一套"**,
|
|
@@ -1646,7 +1852,7 @@ QA_ASR_RECALL_WARN = 0.72
|
|
|
1646
1852
|
|
|
1647
1853
|
|
|
1648
1854
|
def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
|
|
1649
|
-
"""ASR
|
|
1855
|
+
"""ASR 回读门禁:把成片音轨转回文字,和剧本逐句对账。
|
|
1650
1856
|
|
|
1651
1857
|
为什么值得:容器/响度/帧级质检都默认"音轨里念的就是剧本那几句"。TTS 缓存串了、
|
|
1652
1858
|
换声线漏句、混音压没了、音轨是旧的——这些从波形和容器上都看不出来,只有转回
|
|
@@ -1699,7 +1905,7 @@ def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
|
|
|
1699
1905
|
|
|
1700
1906
|
def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
1701
1907
|
print("== QA")
|
|
1702
|
-
th = _theme_for(script) if script is not None else None
|
|
1908
|
+
th = _theme_for(script, work) if script is not None else None
|
|
1703
1909
|
shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
|
|
1704
1910
|
fracs = (0.08, 0.3, 0.55, 0.78)
|
|
1705
1911
|
ref_script = dict(script) if script is not None else None
|
|
@@ -1724,7 +1930,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
|
1724
1930
|
int(round(sec * tl["fps"])))
|
|
1725
1931
|
_hold = ref_script.get("sub_max_hold")
|
|
1726
1932
|
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, _hold), theme=th,
|
|
1727
|
-
words=K.sub_words_at(tl["scenes"], sec, _hold)
|
|
1933
|
+
words=K.sub_words_at(tl["scenes"], sec, _hold),
|
|
1934
|
+
**_sub_kw(ref_script))
|
|
1728
1935
|
refs.append((k, fr))
|
|
1729
1936
|
except Exception as e: # noqa: BLE001
|
|
1730
1937
|
print(" [WARN] 参考帧渲染失败:", e)
|
|
@@ -1758,9 +1965,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
|
1758
1965
|
print(" [WARN] 锐度比门禁未执行(没有参考帧)")
|
|
1759
1966
|
safe = K.qa_text_safe(tl["scenes"])
|
|
1760
1967
|
print(" 字幕安全区:", "OK" if not safe else safe[:2])
|
|
1761
|
-
# 字幕安全区 /
|
|
1762
|
-
#
|
|
1763
|
-
#
|
|
1968
|
+
# 字幕安全区 / 缺字形若只 print、不进 qa_report 的 warnings,
|
|
1969
|
+
# CI、前端、任何只读 JSON 的人都**看不到**这两条("门禁可以保守,但不能静默")。
|
|
1970
|
+
# 所以它们进 warnings(仍是 warning 级,不阻断,但能被程序读到)。
|
|
1764
1971
|
for _m in (safe or [])[:4]:
|
|
1765
1972
|
rep["warnings"].append("字幕安全区: %s" % _m)
|
|
1766
1973
|
_glyph = K.check_glyphs((script or {}).get("lines") or [])
|
|
@@ -1770,7 +1977,7 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
|
1770
1977
|
sheet = K.contact_sheet(shots, os.path.join(work, "qa", "sheet.png"))
|
|
1771
1978
|
if sheet:
|
|
1772
1979
|
print(" 联系表:", sheet, " <- 必须亲眼看")
|
|
1773
|
-
# ----
|
|
1980
|
+
# ---- 帧级深检:单帧闪烁 / 画面空窗 / 音画同步 / 响度曲线 ----
|
|
1774
1981
|
deep = _qa_deep(film, work, tl, script)
|
|
1775
1982
|
if deep:
|
|
1776
1983
|
for w in deep.get("warnings") or []:
|
|
@@ -1831,7 +2038,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
1831
2038
|
print("== Stills")
|
|
1832
2039
|
script = dict(script)
|
|
1833
2040
|
script["_total"] = tl["total"]
|
|
1834
|
-
script.setdefault("_style", resolve_style(script))
|
|
2041
|
+
script.setdefault("_style", resolve_style(script, work))
|
|
1835
2042
|
outdir = os.path.join(work, "stills")
|
|
1836
2043
|
os.makedirs(outdir, exist_ok=True)
|
|
1837
2044
|
by_id = {s["id"]: s for s in tl["scenes"]}
|
|
@@ -1860,7 +2067,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
1860
2067
|
_hold = script.get("sub_max_hold")
|
|
1861
2068
|
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, _hold),
|
|
1862
2069
|
theme=_theme_for(script),
|
|
1863
|
-
words=K.sub_words_at(tl["scenes"], tg, _hold)
|
|
2070
|
+
words=K.sub_words_at(tl["scenes"], tg, _hold),
|
|
2071
|
+
**_sub_kw(script))
|
|
1864
2072
|
p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
|
|
1865
2073
|
fr.save(p)
|
|
1866
2074
|
made.append(p)
|
|
@@ -1915,10 +2123,10 @@ def _reload_ledger_from_disk(work, ledger):
|
|
|
1915
2123
|
def _report_path(a):
|
|
1916
2124
|
"""QC 报告的唯一落点:**站在成片旁边**(--out 的目录),不猜 work 的兄弟目录。
|
|
1917
2125
|
|
|
1918
|
-
|
|
1919
|
-
|
|
2126
|
+
accept 与 all/mux/qa 必须用同一个函数算路径:若前者用 dirname(abspath(work))/out/、
|
|
2127
|
+
后者用 dirname(abspath(out)),--out 指到任何非 out/ 的目录(deliver/、中文工作区/成品/…)
|
|
1920
2128
|
时,签核更新的是另一份没人交付的文件,交付出去的那份仍是"未签核"——正好打穿
|
|
1921
|
-
"报告是交付凭据"
|
|
2129
|
+
"报告是交付凭据"这条承诺。
|
|
1922
2130
|
"""
|
|
1923
2131
|
return os.path.join(os.path.dirname(os.path.abspath(str(a.out))), "QC-REPORT.md")
|
|
1924
2132
|
|
|
@@ -1990,8 +2198,8 @@ def _load_batch(path):
|
|
|
1990
2198
|
{"films": [{"script": "a/film.json", "work": "a/work",
|
|
1991
2199
|
"scenes": "a/scenes.py", "out": "out/a.mp4"}]}
|
|
1992
2200
|
|
|
1993
|
-
|
|
1994
|
-
|
|
2201
|
+
批量最容易死在"第 7 条跑一半才发现清单写错",所以数量/体积先卡死,
|
|
2202
|
+
并且在**开跑之前**全部预检一遍。
|
|
1995
2203
|
"""
|
|
1996
2204
|
p = str(path)
|
|
1997
2205
|
if not os.path.exists(p):
|
|
@@ -2122,12 +2330,94 @@ def run_batch(a):
|
|
|
2122
2330
|
return 0 if okn == len(results) else 1
|
|
2123
2331
|
|
|
2124
2332
|
|
|
2333
|
+
# ---------------------------------------------------------------- 提案闸
|
|
2334
|
+
def _proposal_gate(a):
|
|
2335
|
+
"""提案闸:frames 之前必须先有"已确认的设计"。
|
|
2336
|
+
|
|
2337
|
+
本项目的主路径是"先提案、再出片",所以这条闸是语义要求,不是可选装饰。
|
|
2338
|
+
但它默认只警告(0 退出码),加 --require-proposal 才失败——原因只有一条:
|
|
2339
|
+
--stage frames 是开发期高频命令(改一帧看一帧),硬拦会让调试变得没法用。
|
|
2340
|
+
|
|
2341
|
+
判定(propose.proposal_state 的几档):
|
|
2342
|
+
absent -> 没有任何提案
|
|
2343
|
+
draft -> 有提案但没确认
|
|
2344
|
+
confirmed -> 已确认且 design.json 指纹与确认时一致(唯一放行态)
|
|
2345
|
+
stale -> 确认过但 design.json 被改过(等于没确认)
|
|
2346
|
+
"""
|
|
2347
|
+
try:
|
|
2348
|
+
import propose as PP
|
|
2349
|
+
except ImportError:
|
|
2350
|
+
print("[proposal] 没有 propose.py,跳过提案检查(只警告)")
|
|
2351
|
+
return True
|
|
2352
|
+
try:
|
|
2353
|
+
status, detail = PP.proposal_state(a.work)
|
|
2354
|
+
except SystemExit as e:
|
|
2355
|
+
status, detail = "broken", str(e)
|
|
2356
|
+
except Exception as e: # noqa: BLE001
|
|
2357
|
+
status, detail = "broken", "%s: %s" % (type(e).__name__, e)
|
|
2358
|
+
|
|
2359
|
+
if status == "confirmed":
|
|
2360
|
+
print("[proposal] OK %s" % detail)
|
|
2361
|
+
_LEDGER.ok("proposal", "提案已确认", detail)
|
|
2362
|
+
return True
|
|
2363
|
+
|
|
2364
|
+
msg = "提案闸未通过(%s):%s" % (status, detail)
|
|
2365
|
+
print("[proposal] WARN %s" % msg)
|
|
2366
|
+
if status in ("absent", "draft"):
|
|
2367
|
+
print(" 先跑:python propose.py --brief \"题材/要求\" --script %s --n 3" % a.script)
|
|
2368
|
+
print(" 确认:python propose.py --confirm --candidate A")
|
|
2369
|
+
if bool(getattr(a, "require_proposal", False)):
|
|
2370
|
+
_LEDGER.fail("proposal", "提案未确认", msg)
|
|
2371
|
+
print("\n[提案闸] --require-proposal 下这是失败项:先提案并确认,或去掉该参数")
|
|
2372
|
+
return False
|
|
2373
|
+
_LEDGER.skip("proposal", "提案闸", msg + "(默认只警告;--require-proposal 才失败)")
|
|
2374
|
+
return True
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
# ---------------------------------------------------------------- 场景模块定位
|
|
2378
|
+
def _builtin_scenes() -> str:
|
|
2379
|
+
"""本技能内置的场景绘制模块路径(装成插件后在包内 skills/video-studio/assets/scenes.py)。"""
|
|
2380
|
+
cand = (Path(__file__).resolve().parent.parent / "assets" / "scenes.py")
|
|
2381
|
+
return str(cand) if cand.exists() else ""
|
|
2382
|
+
|
|
2383
|
+
|
|
2384
|
+
def _resolve_scenes(scenes_arg, script_path) -> str:
|
|
2385
|
+
"""定位场景模块,按优先级:显式 --scenes > 剧本同目录 scenes.py > 技能内置 scenes.py。
|
|
2386
|
+
|
|
2387
|
+
为什么要这条回退链:要求用户把 assets/scenes.py 拷进工作目录,
|
|
2388
|
+
即「提供一份模板代码」——与「不提供任何模板」直接冲突,而且用户每部片都得
|
|
2389
|
+
重拷一份 98KB 的绘制代码。所以把内置渲染器当**默认能力**:工作目录里没有 scenes.py
|
|
2390
|
+
时直接用它出片;需要改画面再写自己的 scenes.py(显式指定或用同目录约定)。
|
|
2391
|
+
"""
|
|
2392
|
+
if scenes_arg:
|
|
2393
|
+
p = Path(scenes_arg).resolve()
|
|
2394
|
+
if p.exists():
|
|
2395
|
+
return str(p)
|
|
2396
|
+
raise SystemExit(
|
|
2397
|
+
"[缺少场景模块] --scenes 指向的 %s 不存在。\n"
|
|
2398
|
+
" 不写 --scenes 时会自动用剧本同目录的 scenes.py 或技能内置渲染器;"
|
|
2399
|
+
|
|
2400
|
+
"只想用内置的就去掉 --scenes。" % p)
|
|
2401
|
+
cand = Path(script_path).resolve().parent / "scenes.py"
|
|
2402
|
+
if cand.exists():
|
|
2403
|
+
return str(cand)
|
|
2404
|
+
builtin = _builtin_scenes()
|
|
2405
|
+
if builtin:
|
|
2406
|
+
return builtin
|
|
2407
|
+
raise SystemExit(
|
|
2408
|
+
"[缺少场景模块] 找不到 %s,也找不到技能内置渲染器(%s)。\n"
|
|
2409
|
+
" 装成插件时内置渲染器应在 <包>/skills/video-studio/assets/scenes.py;"
|
|
2410
|
+
|
|
2411
|
+
"请检查插件包是否完整,或显式传 --scenes 指向你自己的 scenes.py。" % (cand, _builtin_scenes() or "missing"))
|
|
2412
|
+
|
|
2413
|
+
|
|
2125
2414
|
# ---------------------------------------------------------------- main
|
|
2126
2415
|
def main():
|
|
2127
2416
|
_setup_stdio()
|
|
2128
2417
|
ap = argparse.ArgumentParser(description="video-studio 编排器")
|
|
2129
2418
|
ap.add_argument("--script", default="film.json")
|
|
2130
|
-
ap.add_argument("--scenes", default="
|
|
2419
|
+
ap.add_argument("--scenes", default="",
|
|
2420
|
+
help="场景绘制模块(默认依次找:<剧本目录>/scenes.py、本技能内置的 scenes.py)")
|
|
2131
2421
|
ap.add_argument("--out", default="out/film.mp4")
|
|
2132
2422
|
ap.add_argument("--work", default="work")
|
|
2133
2423
|
ap.add_argument("--stage", default="all", choices=STAGES)
|
|
@@ -2152,9 +2442,11 @@ def main():
|
|
|
2152
2442
|
help="过时也照跑(会在 QC 报告里留痕)")
|
|
2153
2443
|
ap.add_argument("--no-gate", action="store_true",
|
|
2154
2444
|
help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
|
|
2445
|
+
ap.add_argument("--require-proposal", action="store_true",
|
|
2446
|
+
help="把'提案未确认'当成失败(默认只警告;先跑 propose.py 再出片)")
|
|
2155
2447
|
ap.add_argument("--cost", action="store_true",
|
|
2156
2448
|
help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
|
|
2157
|
-
# ----
|
|
2449
|
+
# ---- 批量 / 画质门禁 ----
|
|
2158
2450
|
ap.add_argument("--batch", default="",
|
|
2159
2451
|
help="批量清单(JSON 数组或 {\"films\":[...]},≤100 条):"
|
|
2160
2452
|
"先全量预检,再逐条渲染,单条失败不阻断其余")
|
|
@@ -2183,13 +2475,13 @@ def main():
|
|
|
2183
2475
|
if engine not in ("pil", "web"):
|
|
2184
2476
|
raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
|
|
2185
2477
|
script["_engine"] = engine
|
|
2186
|
-
script["_scenes"] =
|
|
2478
|
+
script["_scenes"] = _resolve_scenes(a.scenes, a.script)
|
|
2187
2479
|
|
|
2188
|
-
# ----
|
|
2480
|
+
# ---- 门禁账本 ----
|
|
2189
2481
|
global _LEDGER
|
|
2190
2482
|
# engine=web 时把入口页实际引用的同目录页面脚本一起纳入内容指纹:web 的画面逻辑在
|
|
2191
|
-
# web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效"
|
|
2192
|
-
#
|
|
2483
|
+
# web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效",
|
|
2484
|
+
# 正好违背「签核绑内容指纹,改了画面代码旧签核自动失效」这条承诺。
|
|
2193
2485
|
web_entry = ""
|
|
2194
2486
|
if engine == "web":
|
|
2195
2487
|
try:
|
|
@@ -2204,8 +2496,15 @@ def main():
|
|
|
2204
2496
|
print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
|
|
2205
2497
|
|
|
2206
2498
|
def _sources():
|
|
2207
|
-
"""
|
|
2208
|
-
|
|
2499
|
+
"""源头输入:剧本、scenes.py、冻结设计(改它们等于改了所有下游的依据)。
|
|
2500
|
+
|
|
2501
|
+
design.json 是第三个源头:它由 propose.py --confirm 写出,
|
|
2502
|
+
记录"这一版长什么样"。改设计 = 画面必须重渲,所以它必须进 stagewatch 的
|
|
2503
|
+
源头指纹,否则"改了配色但没重跑 frames"这种事故查不出来。
|
|
2504
|
+
"""
|
|
2505
|
+
design = os.path.join(a.work, "design.json")
|
|
2506
|
+
return {"script": a.script, "scenes": a.scenes,
|
|
2507
|
+
"design": design if os.path.exists(design) else ""}
|
|
2209
2508
|
|
|
2210
2509
|
# 本次命令行**计划要跑**的 stage 集合:--stage all 展开成 10 个,单 stage 就是它自己。
|
|
2211
2510
|
# 用途见 stagewatch.check 的 pending:本阶段马上要用新输入重算,那种"输入变了"不是过时。
|
|
@@ -2221,7 +2520,7 @@ def main():
|
|
|
2221
2520
|
"""stage 开始时查新鲜度并计时。返回 t0。
|
|
2222
2521
|
|
|
2223
2522
|
report() 自己负责打印 "[stale] <stage>: <原因>" 与"最小重跑"两行
|
|
2224
|
-
|
|
2523
|
+
(必须接它的返回值,否则用户只看到"最小重跑"不知道是哪个阶段、
|
|
2225
2524
|
为什么过时,得上 --stage explain 才知道)。
|
|
2226
2525
|
--allow-stale 也要调用:它承诺的是"放行并留痕"(QC 报告里记一条 skipped),
|
|
2227
2526
|
直接跳过检查连痕都没有——那就成了静默。
|
|
@@ -2242,7 +2541,7 @@ def main():
|
|
|
2242
2541
|
if errs:
|
|
2243
2542
|
# --strict-stale 的承诺是"上游产物过时算失败"。stagewatch.report 只把
|
|
2244
2543
|
# 问题放进返回值(并往账本记一条 fail),它自己不抛异常——所以必须在这里
|
|
2245
|
-
#
|
|
2544
|
+
# 真的失败。丢弃返回值的话,--stage layout --strict-stale 报完过时
|
|
2246
2545
|
# 照跑、退出码 0,help 文本与行为对不上。
|
|
2247
2546
|
#
|
|
2248
2547
|
# 选择"立刻退出"而不是"跑完再在收尾处报":过时的输入会把旧产物掺进
|
|
@@ -2323,27 +2622,33 @@ def main():
|
|
|
2323
2622
|
raise SystemExit("[前置] %s 不是合法 JSON(%s),重新跑 --stage layout" % (tl_p, e)) from e
|
|
2324
2623
|
|
|
2325
2624
|
def load_scenes(strict=True):
|
|
2326
|
-
"""加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。
|
|
2625
|
+
"""加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。
|
|
2626
|
+
|
|
2627
|
+
路径用 main() 里解析好的 script["_scenes"](显式 --scenes / 剧本同目录 /
|
|
2628
|
+
技能内置三档回退),不能用原始 a.scenes —— 缺省时它是空串,会变成
|
|
2629
|
+
`load_module("")` 这种 "NoneType has no attribute loader" 的怪错误。
|
|
2630
|
+
"""
|
|
2631
|
+
scenes_path = script.get("_scenes") or _resolve_scenes(a.scenes, a.script)
|
|
2327
2632
|
try:
|
|
2328
|
-
mod = load_module(
|
|
2633
|
+
mod = load_module(scenes_path)
|
|
2329
2634
|
except Exception as e: # noqa: BLE001
|
|
2330
2635
|
if strict:
|
|
2331
2636
|
raise SystemExit("[场景模块加载失败] %s:%s\n"
|
|
2332
2637
|
" --scenes 要指向定义 draw_frame() 的 scenes.py"
|
|
2333
|
-
% (
|
|
2334
|
-
print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (
|
|
2638
|
+
% (scenes_path, e)) from e
|
|
2639
|
+
print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (scenes_path, e))
|
|
2335
2640
|
return None
|
|
2336
2641
|
if not hasattr(mod, "draw_frame"):
|
|
2337
2642
|
if strict:
|
|
2338
2643
|
raise SystemExit("[场景模块错误] %s 没有 draw_frame(scene, t_local, dur, t_global, script, frame)"
|
|
2339
|
-
%
|
|
2340
|
-
print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" %
|
|
2644
|
+
% scenes_path)
|
|
2645
|
+
print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" % scenes_path)
|
|
2341
2646
|
return None
|
|
2342
2647
|
return mod
|
|
2343
2648
|
|
|
2344
2649
|
stage = a.stage
|
|
2345
2650
|
|
|
2346
|
-
# ---- preview:前 N
|
|
2651
|
+
# ---- preview:前 N 秒试片 ----
|
|
2347
2652
|
if a.preview:
|
|
2348
2653
|
try:
|
|
2349
2654
|
secs = float(a.preview)
|
|
@@ -2362,12 +2667,16 @@ def main():
|
|
|
2362
2667
|
print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
|
|
2363
2668
|
sc2 = dict(script)
|
|
2364
2669
|
sc2["_total"] = tl["total"]
|
|
2365
|
-
|
|
2670
|
+
# 不能写成 setdefault(_style, resolve_style(script)):
|
|
2671
|
+
# 对 sc2 做 setdefault 却传 script 的解析结果 —— script 有 _style 时,
|
|
2672
|
+
# setdefault 的第二参数**仍会被求值**(Python 先算参数),等于多做一次无用解析;
|
|
2673
|
+
# 而 script 没有 _style 时又把 work 漏了,单跑 --preview 会报"找不到设计"。
|
|
2674
|
+
sc2.setdefault("_style", resolve_style(script, a.work))
|
|
2366
2675
|
_render_range(tl, mod, sc2, 0, n, out_mp4, int(script.get("crf", 18)),
|
|
2367
2676
|
str(script.get("preset", "medium")), progress=False)
|
|
2368
2677
|
# 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
|
|
2369
2678
|
# 注意:**必须先把音轨截到预览时长**——K.mux 会校验音视频时长差 < 0.05s,
|
|
2370
|
-
# 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"
|
|
2679
|
+
# 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"(实测)。
|
|
2371
2680
|
audio = os.path.join(a.work, "audio.wav")
|
|
2372
2681
|
if os.path.exists(audio):
|
|
2373
2682
|
tmp = out_mp4 + ".mux.mp4"
|
|
@@ -2462,7 +2771,7 @@ def main():
|
|
|
2462
2771
|
return 0
|
|
2463
2772
|
|
|
2464
2773
|
if stage in ("all", "style"):
|
|
2465
|
-
# --stage all 也要把 style 这一行记进 manifest
|
|
2774
|
+
# --stage all 也要把 style 这一行记进 manifest:若 _nm 在 all 下是 None,
|
|
2466
2775
|
# 而 _stage_begin/_stage_end 对 None 是空操作 -> work/style.json 写出来了、
|
|
2467
2776
|
# manifest.json 却从来没有 style 记录。于是之后任何一次 `--stage style` 都会
|
|
2468
2777
|
# 命中「产物在但没有记录」的判据,报成 no-manifest(--stage all 的用户 100% 踩到)。
|
|
@@ -2520,8 +2829,8 @@ def main():
|
|
|
2520
2829
|
_stage_end("audio", _t0)
|
|
2521
2830
|
|
|
2522
2831
|
if stage == "stills":
|
|
2523
|
-
# 母版预览是"上手第一件事",可它需要 timeline
|
|
2524
|
-
#
|
|
2832
|
+
# 母版预览是"上手第一件事",可它需要 timeline。干净目录直接 SystemExit 的话,
|
|
2833
|
+
# 文档三步上手就把它排在第 4 步(照抄必失败)。所以这里自动补前置:
|
|
2525
2834
|
# 缺 tts.json 就真合成一次旁白(要联网),缺 timeline 就排一次轴——
|
|
2526
2835
|
# 但每一步都**明说**自己在补什么,不偷偷跑。
|
|
2527
2836
|
# 自动补的这几步也要 _stage_begin/_stage_end:否则 manifest 里只有 stills 一条记录,
|
|
@@ -2549,7 +2858,7 @@ def main():
|
|
|
2549
2858
|
_stage_end("stills", _t0)
|
|
2550
2859
|
_LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
|
|
2551
2860
|
"%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
|
|
2552
|
-
# 0 张静帧 = fail
|
|
2861
|
+
# 0 张静帧 = fail 门禁:无条件 return 0 的话,`--stills 写错 id` 会
|
|
2553
2862
|
# "全部跳过 + 退出码 0",CI 看不出根本没出图。
|
|
2554
2863
|
rc = _finish_gates(a)
|
|
2555
2864
|
return 1 if (rc or _LEDGER.fails()) else 0
|
|
@@ -2567,6 +2876,11 @@ def main():
|
|
|
2567
2876
|
return 1 if (errs or rc) else 0
|
|
2568
2877
|
|
|
2569
2878
|
if stage in ("all", "frames", "mux"):
|
|
2879
|
+
# ---- 提案闸:出片前必须先有已确认的设计 ----
|
|
2880
|
+
# 放在最前面(before ensure_tl/preflight/st_frames):渲完再拦等于白渲。
|
|
2881
|
+
if stage in ("all", "frames"):
|
|
2882
|
+
if not _proposal_gate(a):
|
|
2883
|
+
return 1
|
|
2570
2884
|
tl = tl if stage == "all" else ensure_tl()
|
|
2571
2885
|
audio = os.path.join(a.work, "audio.wav")
|
|
2572
2886
|
silent = os.path.join(a.work, "silent.mp4")
|
|
@@ -2588,7 +2902,7 @@ def main():
|
|
|
2588
2902
|
_stage_end("frames", _t0, frames=int(tl["frames"]))
|
|
2589
2903
|
if stage == "frames":
|
|
2590
2904
|
_LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
|
|
2591
|
-
# 同上:strict-gates 下 skipped
|
|
2905
|
+
# 同上:strict-gates 下 skipped 也要反映到退出码(只看 fails() 会漏掉它)。
|
|
2592
2906
|
rc = _finish_gates(a)
|
|
2593
2907
|
return 1 if (rc or _LEDGER.fails()) else 0
|
|
2594
2908
|
if stage in ("all", "mux"):
|
|
@@ -2601,7 +2915,7 @@ def main():
|
|
|
2601
2915
|
_check_signoff("mux")
|
|
2602
2916
|
if stage == "mux":
|
|
2603
2917
|
_LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
|
|
2604
|
-
# 同上:strict-gates 下 skipped
|
|
2918
|
+
# 同上:strict-gates 下 skipped 也要反映到退出码(只看 fails() 会漏掉它)。
|
|
2605
2919
|
rc = _finish_gates(a)
|
|
2606
2920
|
return 1 if (rc or _LEDGER.fails()) else 0
|
|
2607
2921
|
if stage == "all":
|
|
@@ -2615,8 +2929,8 @@ def main():
|
|
|
2615
2929
|
return 1
|
|
2616
2930
|
return 0
|
|
2617
2931
|
# 所有 stage 统一收尾:落 gates.json + QC-REPORT.md,并按门禁结果给退出码。
|
|
2618
|
-
#
|
|
2619
|
-
# (tts/layout/cues/score/sfx/audio/stills
|
|
2932
|
+
# 只有 style/frames/mux/qa 这几条分支自己调 _finish_gates 时,其余
|
|
2933
|
+
# (tts/layout/cues/score/sfx/audio/stills)会落在这行 return 0——于是 ledger 里
|
|
2620
2934
|
# 记的 fail 传不到退出码,"门禁失败 -> 非 0"这个承诺按 stage 不同行为不一样。
|
|
2621
2935
|
# 默认路径不受影响:没有 fail(且没开 --strict-gates)时 _finish_gates 返回 0。
|
|
2622
2936
|
rc = _finish_gates(a, persist=(stage in _PERSIST_STAGES))
|