@dsh-gis/dsh-video-studio-skill 1.0.2 → 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 +49 -0
- package/README.md +84 -108
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/package.json +1 -1
- package/schemas/design.schema.json +5 -5
- package/schemas/film.schema.json +31 -31
- package/skills/video-studio/SKILL.md +168 -268
- package/skills/video-studio/assets/film.json +2 -3
- package/skills/video-studio/assets/golden_frames.json +11 -11
- package/skills/video-studio/assets/scenes.py +92 -49
- package/skills/video-studio/references/{style-packs.md → appearance.md} +17 -15
- package/skills/video-studio/references/audio-variation.md +73 -96
- package/skills/video-studio/references/audio.md +40 -44
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +9 -11
- package/skills/video-studio/references/frame-qa.md +47 -15
- 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 +19 -36
- 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 +17 -24
- package/skills/video-studio/references/sfx-design.md +7 -5
- package/skills/video-studio/references/web-engine.md +7 -7
- package/skills/video-studio/references/word-timeline.md +7 -7
- package/skills/video-studio/requirements.txt +9 -13
- package/skills/video-studio/scripts/designkit.py +84 -89
- package/skills/video-studio/scripts/filmcheck.py +19 -19
- package/skills/video-studio/scripts/filmkit.py +2949 -2948
- package/skills/video-studio/scripts/gates.py +4 -4
- package/skills/video-studio/scripts/grammar.py +10 -10
- package/skills/video-studio/scripts/musicgen.py +3 -4
- package/skills/video-studio/scripts/preflight.py +5 -5
- package/skills/video-studio/scripts/previewkit.py +11 -12
- package/skills/video-studio/scripts/propose.py +11 -11
- package/skills/video-studio/scripts/qa_frames.py +34 -35
- package/skills/video-studio/scripts/qaasr.py +26 -7
- package/skills/video-studio/scripts/qavmaf.py +19 -19
- package/skills/video-studio/scripts/render.py +137 -141
- package/skills/video-studio/scripts/scorekit.py +21 -23
- package/skills/video-studio/scripts/selfcheck.py +208 -135
- package/skills/video-studio/scripts/sfxkit.py +27 -26
- package/skills/video-studio/scripts/stagewatch.py +4 -4
- package/skills/video-studio/scripts/stylekit.py +77 -282
- package/skills/video-studio/scripts/voicekit.py +43 -44
- package/skills/video-studio/scripts/webengine.py +62 -64
- package/tools/audio_similarity.py +12 -12
- package/tools/ci_check.py +16 -16
- package/tools/estimate.py +24 -25
- package/tools/golden_frames.py +17 -18
- package/tools/silhouette_qa.py +13 -14
|
@@ -1,10 +1,10 @@
|
|
|
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 生成)
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -177,7 +177,7 @@ def _web_where(entry):
|
|
|
177
177
|
|
|
178
178
|
# ---------------------------------------------------------------- 装载
|
|
179
179
|
def load_module(path: str, name="film_scenes"):
|
|
180
|
-
"""每次都用独立模块名 + 全新 module
|
|
180
|
+
"""每次都用独立模块名 + 全新 module 对象,避免跨片污染(否则会渲染出上一部片)。"""
|
|
181
181
|
path = str(Path(path).resolve())
|
|
182
182
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
183
183
|
mod = importlib.util.module_from_spec(spec)
|
|
@@ -206,7 +206,7 @@ def load_script(p) -> dict:
|
|
|
206
206
|
for k in ("music", "sfx", "web"):
|
|
207
207
|
if s.get(k) is not None and not isinstance(s.get(k), dict):
|
|
208
208
|
raise SystemExit("film.json 的 %s 必须是对象(当前是 %s)" % (k, type(s[k]).__name__))
|
|
209
|
-
#
|
|
209
|
+
# bpm 允许 "auto"(由风格梯度 + 内容指纹决定)。
|
|
210
210
|
# 同时允许写在 music.bpm 里(优先级更高)。
|
|
211
211
|
for _k, _v in (("bpm", s.get("bpm")),
|
|
212
212
|
("music.bpm", (s.get("music") or {}).get("bpm"))):
|
|
@@ -260,7 +260,7 @@ def _to_gif(mp4, gif, fps=12, width=640):
|
|
|
260
260
|
|
|
261
261
|
|
|
262
262
|
def _atomic_json_retry(path, obj, tries=3, sleep_s=0.1):
|
|
263
|
-
"""原子写 JSON +
|
|
263
|
+
"""原子写 JSON + 有限退避。
|
|
264
264
|
|
|
265
265
|
Windows 上文件被杀毒/预览进程短暂占用时,一次写入失败会让**整片**跑挂。
|
|
266
266
|
只对"可重试的写状态"退避,不对内容生成盲目重试(那会掩盖真错误)。
|
|
@@ -288,14 +288,14 @@ def _need(cond, msg):
|
|
|
288
288
|
raise SystemExit("[前置产物缺失] " + msg)
|
|
289
289
|
|
|
290
290
|
|
|
291
|
-
# ----------------------------------------------------------------
|
|
291
|
+
# ---------------------------------------------------------------- 风格解析
|
|
292
292
|
def _stylekit():
|
|
293
293
|
"""惰性 import stylekit;缺文件时给一句能照着做的错,而不是 ModuleNotFoundError。"""
|
|
294
294
|
try:
|
|
295
295
|
import stylekit as ST
|
|
296
296
|
return ST
|
|
297
297
|
except Exception as e: # noqa: BLE001
|
|
298
|
-
raise SystemExit("[缺少 stylekit]
|
|
298
|
+
raise SystemExit("[缺少 stylekit] render.py 需要同目录的 stylekit.py"
|
|
299
299
|
"(skills/video-studio/scripts/stylekit.py):%s" % e)
|
|
300
300
|
|
|
301
301
|
|
|
@@ -304,10 +304,10 @@ def _design_path(work):
|
|
|
304
304
|
|
|
305
305
|
|
|
306
306
|
def _plan_from_design(script, work, verbose=True):
|
|
307
|
-
"""
|
|
307
|
+
"""如果 work/design.json 存在(用户已确认提案),**以它为准**解析计划。
|
|
308
308
|
|
|
309
|
-
|
|
310
|
-
返回 None 表示没有 design
|
|
309
|
+
这是唯一主路径:design.json 是"这一版长什么样"的冻结点,剧本只提供台词。
|
|
310
|
+
返回 None 表示没有 design(调用方按 fail-closed 报错并给出两步指引)。
|
|
311
311
|
"""
|
|
312
312
|
direct = script.get("design")
|
|
313
313
|
design = None
|
|
@@ -329,7 +329,7 @@ def _plan_from_design(script, work, verbose=True):
|
|
|
329
329
|
except ImportError as e:
|
|
330
330
|
raise SystemExit("[design] 缺少 designkit.py(%s)——design.json 需要它解析成计划" % e) from e
|
|
331
331
|
# 骨架版/半成品 designkit 会 import 成功但接口没实现:那种情况必须给一句人话,
|
|
332
|
-
# 不能让 AttributeError 的 traceback 冒充"渲染器坏了"
|
|
332
|
+
# 不能让 AttributeError 的 traceback 冒充"渲染器坏了"(这种误报踩过多次)。
|
|
333
333
|
missing = [n for n in ("validate_design", "design_to_plan")
|
|
334
334
|
if not callable(getattr(DK, n, None))]
|
|
335
335
|
if missing:
|
|
@@ -347,10 +347,10 @@ def _plan_from_design(script, work, verbose=True):
|
|
|
347
347
|
|
|
348
348
|
|
|
349
349
|
def _apply_audio_overrides(script, plan):
|
|
350
|
-
"""把 design.audio 里的音量/音效参数填进 script 的 music/sfx
|
|
350
|
+
"""把 design.audio 里的音量/音效参数填进 script 的 music/sfx **缺省位**。
|
|
351
351
|
|
|
352
352
|
为什么需要:design.json 里 audio.music.gain_db / duck_db 与 audio.sfx.enabled /
|
|
353
|
-
density / gain_db
|
|
353
|
+
density / gain_db 若只被 validate_design 接受、没有任何渲染代码读它,
|
|
354
354
|
用户改了它们,成片一模一样(audit 实测:enabled=false 仍出 8 条音效)。
|
|
355
355
|
|
|
356
356
|
优先级(明确且可预期):**film.json 显式写了的 > design 的值 > 模块内置默认**。
|
|
@@ -380,10 +380,10 @@ def _apply_audio_overrides(script, plan):
|
|
|
380
380
|
def resolve_style(script, work=None, verbose=True):
|
|
381
381
|
"""解析风格计划并挂到 script["_style"] 上(同一进程内各 stage 共享)。
|
|
382
382
|
|
|
383
|
-
|
|
383
|
+
优先级:
|
|
384
384
|
1. script["_style"] 已存在(同进程内各 stage 复用)
|
|
385
|
-
2. work/design.json(用户已确认的提案)——
|
|
386
|
-
3.
|
|
385
|
+
2. work/design.json(用户已确认的提案)—— 这是唯一正式主路径
|
|
386
|
+
3. 都没有 -> fail-closed 报错(没有内置风格可回落)
|
|
387
387
|
|
|
388
388
|
纯函数 + 落盘 work/style.json(审计用:用户能看到"为什么是这套风格")。
|
|
389
389
|
"""
|
|
@@ -400,11 +400,11 @@ def resolve_style(script, work=None, verbose=True):
|
|
|
400
400
|
except Exception as e: # noqa: BLE001
|
|
401
401
|
print(" [WARN] style.json 落盘失败:%s" % e)
|
|
402
402
|
return plan
|
|
403
|
-
#
|
|
404
|
-
#
|
|
403
|
+
# **没有第三优先级**:回落到"按关键词自动选风格"等于模板库,
|
|
404
|
+
# 那正是本技能不提供的,所以必须明确报错,不静默回落。
|
|
405
405
|
raise SystemExit(
|
|
406
406
|
"[style] 找不到设计(work/design.json):\n"
|
|
407
|
-
"
|
|
407
|
+
" 本技能不提供任何内置风格,每一版外观都必须现场生成并由人确认。\n"
|
|
408
408
|
" 正确做法:\n"
|
|
409
409
|
" 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
|
|
410
410
|
" 2) python propose.py --confirm --candidate A # 冻结设计\n"
|
|
@@ -413,12 +413,11 @@ def resolve_style(script, work=None, verbose=True):
|
|
|
413
413
|
|
|
414
414
|
|
|
415
415
|
def _theme_for(script, work=None):
|
|
416
|
-
"""返回当前片的主题字典;legacy
|
|
416
|
+
"""返回当前片的主题字典;legacy 计划返回 None(现存的兼容分支)。
|
|
417
417
|
|
|
418
|
-
work **必须传**:design 路径的身份在 work/design.json
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
(历史实测:--stage qa 单独跑 VMAF 1.07,全部内联跑 97.15)。
|
|
418
|
+
work **必须传**:design 路径的身份在 work/design.json 里,不传就直接 SystemExit ——
|
|
419
|
+
但"传错 work"仍然值得防:work 指到别的目录时会读到**另一份** design.json,
|
|
420
|
+
参考帧与成片不同源(实测:--stage qa 单独跑 VMAF 1.07,全部内联跑 97.15)。
|
|
422
421
|
"""
|
|
423
422
|
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
424
423
|
if plan is not None and plan.get("legacy"):
|
|
@@ -434,14 +433,13 @@ def _theme_for(script, work=None):
|
|
|
434
433
|
th = plan.get("theme")
|
|
435
434
|
if isinstance(th, dict):
|
|
436
435
|
return th
|
|
437
|
-
#
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
# 在表里查不到 -> KeyError,整条出片链路在 layout 阶段崩掉。
|
|
436
|
+
# **先看 plan["theme"]**(designkit 生成的计划自带完整 35 字段主题)。
|
|
437
|
+
# 不能按 theme_id 去查表:没有内置主题表,设计路径的 theme_id(如 "a-44957fb5")
|
|
438
|
+
# 查不到 -> KeyError,整条出片链路会在 layout 阶段崩掉。
|
|
441
439
|
th = plan.get("theme")
|
|
442
440
|
if isinstance(th, dict) and th:
|
|
443
441
|
return th
|
|
444
|
-
#
|
|
442
|
+
# 没有 theme 计划就是"这份 script 没走过 design/propose"。
|
|
445
443
|
# 明确报错并给迁移指引,不静默回落(静默回落 = 用一套不存在的默认外观出片)。
|
|
446
444
|
raise SystemExit(
|
|
447
445
|
"[style] 这份剧本没有可用的主题:\n"
|
|
@@ -461,7 +459,7 @@ def _print_style(plan):
|
|
|
461
459
|
print(" 主题: %s 版式: %s" % (plan["theme_id"],
|
|
462
460
|
",".join(sorted(set(plan["chapter_layout"].values())))
|
|
463
461
|
or plan["layout_default"]))
|
|
464
|
-
#
|
|
462
|
+
# 视觉语言(结构签名)也要能在这里看到 —— 用户判断"这套风格是不是只是换色"
|
|
465
463
|
# 的第一现场就是 --stage style 的打印,藏起来等于让他去读 style.json。
|
|
466
464
|
vl = plan.get("language") if isinstance(plan.get("language"), dict) else None
|
|
467
465
|
if vl:
|
|
@@ -473,9 +471,9 @@ def _print_style(plan):
|
|
|
473
471
|
% (mf.get("kind"), tr.get("kind"), float(tr.get("dur") or 0.0),
|
|
474
472
|
float(ty.get("tracking") or 0.0), ty.get("number"),
|
|
475
473
|
float(bi.get("x") or 0.0), float(bi.get("y") or 0.0)))
|
|
476
|
-
#
|
|
477
|
-
#
|
|
478
|
-
#
|
|
474
|
+
# 不能无条件读 m["style"]:designkit 明确不写这个键(配乐风格表已删),
|
|
475
|
+
# 那样公开命令 `--stage style` 会 100% KeyError: "style"。
|
|
476
|
+
# 按「参数化配乐」的真实身份打印:调式 + BPM + 调性 + mood。
|
|
479
477
|
_music_id = m.get("arranger") or m.get("style") or ("%s %gBPM" % (m.get("scale", "?"), m.get("bpm", 0)))
|
|
480
478
|
print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
|
|
481
479
|
% (_music_id, m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
|
|
@@ -507,11 +505,11 @@ def st_style(script, work):
|
|
|
507
505
|
|
|
508
506
|
# ---------------------------------------------------------------- TTS
|
|
509
507
|
def _voice_plan(script, plan):
|
|
510
|
-
"""声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch
|
|
508
|
+
"""声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走单声线路径。"""
|
|
511
509
|
try:
|
|
512
510
|
import voicekit as VK
|
|
513
511
|
except Exception as e: # noqa: BLE001
|
|
514
|
-
print(" [WARN] 没有 voicekit.py
|
|
512
|
+
print(" [WARN] 没有 voicekit.py,本次旁白单声线(单声线兼容):%s" % e)
|
|
515
513
|
return None
|
|
516
514
|
try:
|
|
517
515
|
vp = VK.voice_plan(script, plan)
|
|
@@ -526,7 +524,7 @@ def st_tts(script, work, force=False):
|
|
|
526
524
|
plan = resolve_style(script, work)
|
|
527
525
|
print("== TTS (%d 句)" % len(lines))
|
|
528
526
|
if plan["legacy"]:
|
|
529
|
-
print(" 声线:
|
|
527
|
+
print(" 声线: 兼容模式(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
|
|
530
528
|
vp = None
|
|
531
529
|
else:
|
|
532
530
|
vp = _voice_plan(script, plan)
|
|
@@ -561,7 +559,7 @@ def st_tts(script, work, force=False):
|
|
|
561
559
|
|
|
562
560
|
|
|
563
561
|
def _write_words(work, done, want_words):
|
|
564
|
-
"""把逐句词级时间戳汇总成 work/words.json
|
|
562
|
+
"""把逐句词级时间戳汇总成 work/words.json。
|
|
565
563
|
|
|
566
564
|
结构:{schema, enabled, lines:{<id>:{dur, n, covered, tail, mono, words:[...]}}, stats}
|
|
567
565
|
门禁与字幕都从这里读,不再各自去翻 *.mp3.json(单一事实来源)。
|
|
@@ -600,7 +598,7 @@ def st_layout(script, work, lines=None):
|
|
|
600
598
|
print("== Layout")
|
|
601
599
|
pace = plan["pacing"]
|
|
602
600
|
# 逐句 lead/tail:voicekit 的 voice_plan 已经算好每句的停顿,这里只负责喂给时间轴。
|
|
603
|
-
#
|
|
601
|
+
# legacy 计划没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数。
|
|
604
602
|
vp = None
|
|
605
603
|
vp_path = os.path.join(work, "voice_plan.json")
|
|
606
604
|
if not plan["legacy"] and os.path.exists(vp_path):
|
|
@@ -631,7 +629,7 @@ def st_layout(script, work, lines=None):
|
|
|
631
629
|
",编码时 lanczos 缩放" if vp["scale"] else ""))
|
|
632
630
|
tl["title"] = script["title"]
|
|
633
631
|
tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
|
|
634
|
-
#
|
|
632
|
+
# 词级时间戳(句子时长仍是权威;词只用于字幕断行/锚点/门禁)
|
|
635
633
|
tl["words"] = bool(script.get("words", True))
|
|
636
634
|
wp = os.path.join(work, "words.json")
|
|
637
635
|
if tl["words"] and os.path.exists(wp):
|
|
@@ -647,8 +645,8 @@ def st_layout(script, work, lines=None):
|
|
|
647
645
|
"legacy": plan["legacy"],
|
|
648
646
|
"chapter_layout": plan["chapter_layout"],
|
|
649
647
|
"item_stagger": pace["item_stagger"]}
|
|
650
|
-
# ----
|
|
651
|
-
# legacy
|
|
648
|
+
# ---- 场景语法:内容 -> 构图选型(落 timeline.scenes[] 与 work/storyboard.json) ----
|
|
649
|
+
# legacy 不注入:兼容绘制器只读 kind,注入新字段等于给基线加噪声。
|
|
652
650
|
if not plan["legacy"]:
|
|
653
651
|
try:
|
|
654
652
|
import grammar as G
|
|
@@ -672,7 +670,7 @@ def st_layout(script, work, lines=None):
|
|
|
672
670
|
ensure_ascii=False, indent=1)
|
|
673
671
|
n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
|
|
674
672
|
# 同上:设计计划自带 theme,不能假定 theme_id 一定在内置表里(见 _theme_for 的注释)
|
|
675
|
-
#
|
|
673
|
+
# 没有内置主题表:theme 必须由计划自带(design 路径一定有)
|
|
676
674
|
theme = (plan.get("theme") if not plan["legacy"] else None)
|
|
677
675
|
n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
|
|
678
676
|
print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
|
|
@@ -689,8 +687,8 @@ def st_layout(script, work, lines=None):
|
|
|
689
687
|
print(" %-6s %-7s %r" % (lid, fld, chars))
|
|
690
688
|
for s in tl["scenes"]:
|
|
691
689
|
print(" %-8s %7.2f-%7.2f %s" % (s["id"], s["start"], s["end"], s["text"][:40]))
|
|
692
|
-
# ----
|
|
693
|
-
# brief 里写的「2 分钟 / 60
|
|
690
|
+
# ---- 目标时长对账 ----
|
|
691
|
+
# brief 里写的「2 分钟 / 60 秒」只影响 design.structure.segments 与 target_sec,
|
|
694
692
|
# 而成片时长由 TTS 实测决定 —— 用户以为"我要求了时长",实际拿到 23s。
|
|
695
693
|
# 不改时长(截断旁白或拉长留白都是坏做法),但**必须当场说出来差多少**,
|
|
696
694
|
# 并给出可执行的做法(加台词 / 调 hold / 减台词)。
|
|
@@ -748,10 +746,10 @@ def st_cues(script, work, tl, engine="pil"):
|
|
|
748
746
|
# scenes.py 里的 cues() 钩子:允许直接返回 [{at,type,...}] 或调用 bus.add(...)
|
|
749
747
|
n_hook = 0
|
|
750
748
|
mod = None
|
|
751
|
-
#
|
|
749
|
+
# scenes.py 的 cues() 常要读主题/视觉语言(assets 版就调 resolve_script_style)。
|
|
752
750
|
# 单跑 `--stage cues` 时前面没有 stage 把 script["_style"] 挂好,钩子就会去解析"设计"
|
|
753
751
|
# 并直接 raise —— 公开命令 100% 崩,报错还指向"先去提案"(其实设计已确认)。
|
|
754
|
-
#
|
|
752
|
+
# 所以在调用钩子前解析一次(幂等,已挂 _style 时直接复用)。
|
|
755
753
|
if script.get("_scenes") and os.path.exists(script["_scenes"]) and not isinstance(script.get("_style"), dict):
|
|
756
754
|
try:
|
|
757
755
|
script["_style"] = resolve_style(script, work)
|
|
@@ -769,7 +767,7 @@ def st_cues(script, work, tl, engine="pil"):
|
|
|
769
767
|
except Exception as e: # noqa: BLE001
|
|
770
768
|
raise SystemExit("scenes.py 的 cues() 钩子报错:%s" % e) from e
|
|
771
769
|
n_hook = _extend_safe(got, "scenes.py")
|
|
772
|
-
#
|
|
770
|
+
# web 引擎下要提醒"两套时间基准混用"。
|
|
773
771
|
# scenes.py 的 cues() 是按 **PIL 时间轴**(timeline.json 的段落与 visual_at)算的,
|
|
774
772
|
# 页面动画则按 **页面自己的时间轴**(window.DURATION)。两者合并进同一份 cues.json 后:
|
|
775
773
|
# - 音效会按 PIL 时间响(页面画面此刻可能正在另一幕);
|
|
@@ -839,8 +837,7 @@ def _st_cues_web(script, work):
|
|
|
839
837
|
def _score_with_musicgen(script, work, tl, plan_s, mus):
|
|
840
838
|
"""用参数化编排器出成片配乐(与 previewkit 的样音同一个引擎、同一组参数)。
|
|
841
839
|
|
|
842
|
-
|
|
843
|
-
而本技能 v1.0.1 起不再提供任何模板 —— 配乐必须由设计里的参数现场生成。
|
|
840
|
+
为什么必须走参数化编排:本技能不提供风格表/模板,配乐必须由设计里的参数现场生成。
|
|
844
841
|
musicgen 的六个参数(progressions / motif / drums / voices / mood / sections)
|
|
845
842
|
全部参与事件展开,实测参数相关性 seed 0.16 / mood 0.32 / voices 0.22,无一是死旋钮。
|
|
846
843
|
"""
|
|
@@ -863,7 +860,7 @@ def _score_with_musicgen(script, work, tl, plan_s, mus):
|
|
|
863
860
|
mood=str(mp.get("mood") or "steady"),
|
|
864
861
|
# seed_effective 是"内容指纹掺进去之后"的 32 位大数;musicgen 内部按
|
|
865
862
|
# sha1(seed) 派生,直接给大整数没问题,但统一夹到 2^31-1 内可以保证
|
|
866
|
-
#
|
|
863
|
+
# 取模口径固定(既有 work/ 目录里的 score.json 能对上)。
|
|
867
864
|
seed=int(mp.get("seed_effective") or mp.get("seed") or 0) % (2 ** 31 - 1),
|
|
868
865
|
sections=sections,
|
|
869
866
|
)
|
|
@@ -902,12 +899,12 @@ def st_score(script, work, tl):
|
|
|
902
899
|
配乐来源由 K.music_plan 统一判定(与 st_audio 共用同一份语义):
|
|
903
900
|
- mode=off/none 或 enabled=false:不出配乐,也不删已存在的 music.wav
|
|
904
901
|
(删文件会误删"外部提供的配乐",而 off 的语义只是"本片不要配乐");
|
|
905
|
-
- mode=bed
|
|
902
|
+
- mode=bed:走和弦床,不生成 musicgen 产物;
|
|
906
903
|
- mode=file:外部 work/music.wav,原样交给 audio 阶段;
|
|
907
|
-
- 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav /
|
|
904
|
+
- 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床。
|
|
908
905
|
|
|
909
906
|
需要真的生成配乐时,**只有 musicgen 一条路**:legacy 计划或 musicgen.py 缺失都会
|
|
910
|
-
|
|
907
|
+
明确报错并给迁移指引,不用 scorekit 的配乐模板兜底(零模板)。
|
|
911
908
|
"""
|
|
912
909
|
print("== Score")
|
|
913
910
|
mus = script.get("music") or {}
|
|
@@ -918,19 +915,19 @@ def st_score(script, work, tl):
|
|
|
918
915
|
return None
|
|
919
916
|
if plan in ("missing-file", "missing-bpm", "unknown-mode"):
|
|
920
917
|
raise SystemExit("[score] " + why)
|
|
921
|
-
#
|
|
918
|
+
# **配乐只有 musicgen 一条路**(参数化编排器,没有风格表)。
|
|
922
919
|
#
|
|
923
|
-
#
|
|
924
|
-
#
|
|
920
|
+
# 为什么必须同源:若 previewkit 用 musicgen 出 12 秒样音、而 st_score 用 scorekit
|
|
921
|
+
# 出成片音乐,两者是**两个不同的作曲器**,实测同一设计下相关系数 -0.0057(就是两首
|
|
925
922
|
# 不同的曲子)。用户看样音选方案,成片音乐完全不同,等于"确认"这个动作在音频上失效。
|
|
926
923
|
#
|
|
927
|
-
#
|
|
928
|
-
#
|
|
929
|
-
#
|
|
924
|
+
# scorekit 没有风格表(本技能不提供模板),它的 render() 只剩 legacy 逐样本兼容路径。
|
|
925
|
+
# 所以这里**没有 scorekit 回退**:scorekit 一旦回来当兜底,成片音乐就又变成一套
|
|
926
|
+
# 固定模板,与"每条视频都不一样"直接冲突。
|
|
930
927
|
plan_s0 = resolve_style(script, work)
|
|
931
928
|
if plan_s0.get("legacy"):
|
|
932
929
|
raise SystemExit(
|
|
933
|
-
"[score] legacy
|
|
930
|
+
"[score] legacy 配乐路径已移除(零模板):\n"
|
|
934
931
|
" scorekit 的 9 套配乐风格表已删除,legacy 回退会把成片配乐锁死成一套固定模板。\n"
|
|
935
932
|
" 正确做法(两步):\n"
|
|
936
933
|
" 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
|
|
@@ -940,18 +937,17 @@ def st_score(script, work, tl):
|
|
|
940
937
|
if _try_module("musicgen") is None:
|
|
941
938
|
raise SystemExit(
|
|
942
939
|
"[score] 找不到 musicgen.py,无法生成配乐:\n"
|
|
943
|
-
"
|
|
944
|
-
" 检查 <SKILL_DIR>/scripts/musicgen.py
|
|
945
|
-
" quickstart 的 cp <SKILL_DIR>/scripts/*.py . 会把它一起拷过来;\n"
|
|
940
|
+
" 配乐只有参数化编排器一条路(scorekit 的风格模板已删除,不再兜底)。\n"
|
|
941
|
+
" 检查 <SKILL_DIR>/scripts/musicgen.py 是否存在(技能脚本就在技能目录里跑,不用拷);\n"
|
|
946
942
|
" 只需要旁白/音效时把 film.json 的 music.mode 设为 off 或 file。")
|
|
947
943
|
return _score_with_musicgen(script, work, tl, plan_s0, mus)
|
|
948
944
|
|
|
949
945
|
|
|
950
946
|
def _sfx_seed_context(script, work=None):
|
|
951
|
-
"""
|
|
947
|
+
"""算音效的内容派生上下文 (style, content_fp, key, enabled)。
|
|
952
948
|
|
|
953
|
-
legacy(没写 style 字段)时返回全 None -> CueBus
|
|
954
|
-
|
|
949
|
+
legacy(没写 style 字段)时返回全 None -> CueBus 走 seed_base + i 路径,
|
|
950
|
+
逐样本与基线一致。参考 references/audio-variation.md §3.2 / §4。
|
|
955
951
|
"""
|
|
956
952
|
plan = resolve_style(script, work)
|
|
957
953
|
sfx_cfg = script.get("sfx") or {}
|
|
@@ -964,7 +960,7 @@ def _sfx_seed_context(script, work=None):
|
|
|
964
960
|
|
|
965
961
|
|
|
966
962
|
def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
|
|
967
|
-
"""
|
|
963
|
+
"""段落边界前的转场音效自动让位(契约 §4)。
|
|
968
964
|
|
|
969
965
|
配乐在段落交界自己已经有一段过门(编排器写的 swell / riser / sub_boom),
|
|
970
966
|
sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
|
|
@@ -1019,8 +1015,8 @@ def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2
|
|
|
1019
1015
|
def st_sfx(script, work, tl):
|
|
1020
1016
|
"""按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。
|
|
1021
1017
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1018
|
+
非 legacy 时走内容派生 seed(跨片同一位置不逐样本相同),
|
|
1019
|
+
并把配乐调性透传给提示音配方(不硬编码 Dm);
|
|
1024
1020
|
段落边界前的转场音效自动让位(不与配乐过门撞车)。
|
|
1025
1021
|
"""
|
|
1026
1022
|
print("== SFX")
|
|
@@ -1062,9 +1058,10 @@ def st_sfx(script, work, tl):
|
|
|
1062
1058
|
def _voice_track(tl, script):
|
|
1063
1059
|
"""把逐句旁白铺成一条总线,返回 (n,) 单声道。
|
|
1064
1060
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1061
|
+
全局增益只能在一处乘:voice_gain 若在这里和 mix_film 里**各乘一次**,
|
|
1062
|
+
净效果是 voice_gain^2(默认 1.0 时看不出来,改了才炸)。
|
|
1063
|
+
所以全局增益只在 mix_film 乘一次,这里只负责**逐句**增益
|
|
1064
|
+
(voicekit 的 gain_db,默认 0 dB -> 1.0)。
|
|
1068
1065
|
"""
|
|
1069
1066
|
bus = K.AudioBus(tl["total"])
|
|
1070
1067
|
for s in tl["scenes"]:
|
|
@@ -1076,7 +1073,7 @@ def _voice_track(tl, script):
|
|
|
1076
1073
|
|
|
1077
1074
|
|
|
1078
1075
|
def _legacy_music(tl, script):
|
|
1079
|
-
"""
|
|
1076
|
+
"""和弦床(没有 music.wav 时的兜底)。"""
|
|
1080
1077
|
mus = script.get("music") or {}
|
|
1081
1078
|
if not (mus.get("enabled", True) and mus.get("chords")):
|
|
1082
1079
|
return None
|
|
@@ -1092,11 +1089,11 @@ def _legacy_music(tl, script):
|
|
|
1092
1089
|
def st_audio(tl, work, script):
|
|
1093
1090
|
"""三总线混音:旁白 + 配乐 + 音效 -> audio.wav(立体声)。
|
|
1094
1091
|
|
|
1095
|
-
|
|
1092
|
+
三条总线分开处理再合:
|
|
1096
1093
|
- 旁白:单声道,居中(voice_gain 可调)
|
|
1097
1094
|
- 配乐:来源由 K.music_plan 判定(score/file/bed/off),口径与 st_score 完全一致——
|
|
1098
1095
|
music.mode=off 时一条配乐都不混,即使 work/music.wav 还在(外部配乐请用 mode=file)
|
|
1099
|
-
- 音效:work/sfx.wav(按 cues.json 合成);没有 cues
|
|
1096
|
+
- 音效:work/sfx.wav(按 cues.json 合成);没有 cues 时退回转场 whoosh
|
|
1100
1097
|
音效响起时配乐自动让位(duck_db),避免重击被配乐吃掉。
|
|
1101
1098
|
"""
|
|
1102
1099
|
print("== Audio")
|
|
@@ -1108,8 +1105,8 @@ def st_audio(tl, work, script):
|
|
|
1108
1105
|
music_path = os.path.join(work, "music.wav")
|
|
1109
1106
|
plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
|
|
1110
1107
|
if plan == "off":
|
|
1111
|
-
# 关键语义:mode=off =
|
|
1112
|
-
#
|
|
1108
|
+
# 关键语义:mode=off = 不要配乐。无条件读 work/music.wav 的话,
|
|
1109
|
+
# "上一轮跑出来的配乐"仍会被混进新片(用户改了 mode 却听不出变化)。
|
|
1113
1110
|
print(" 配乐: " + why)
|
|
1114
1111
|
elif plan == "bed":
|
|
1115
1112
|
music = _legacy_music(tl, script)
|
|
@@ -1135,7 +1132,7 @@ def st_audio(tl, work, script):
|
|
|
1135
1132
|
sfx, _ = K.wav_read(sfx_path)
|
|
1136
1133
|
print(" 音效: sfx.wav(按 cues.json 合成)")
|
|
1137
1134
|
elif script.get("whoosh", True) and not os.path.exists(os.path.join(work, "cues.json")):
|
|
1138
|
-
#
|
|
1135
|
+
# 兼容路径:没有提示点时才用"每段开头一声 whoosh"的行为
|
|
1139
1136
|
bus = K.AudioBus(total, channels=2)
|
|
1140
1137
|
for s in tl["scenes"][1:]:
|
|
1141
1138
|
if float(s["start"]) < 0.2:
|
|
@@ -1148,7 +1145,7 @@ def st_audio(tl, work, script):
|
|
|
1148
1145
|
plan_s = resolve_style(script, work)
|
|
1149
1146
|
# 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
|
|
1150
1147
|
# 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
|
|
1151
|
-
#
|
|
1148
|
+
# 整体 +1 dB 的来由:更低的值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
|
|
1152
1149
|
# 段落过门时两者在同一频段几乎等高,是"配乐和音效听起来像同一套"的混音侧成因。
|
|
1153
1150
|
duck_default = 4.0
|
|
1154
1151
|
if not plan_s["legacy"]:
|
|
@@ -1156,7 +1153,7 @@ def st_audio(tl, work, script):
|
|
|
1156
1153
|
"cinema": 5.5, "epic": 5.5}.get(plan_s["style"], 4.0)
|
|
1157
1154
|
samples = K.mix_film(voice, music, sfx, total=total,
|
|
1158
1155
|
voice_gain=float(script.get("voice_gain", 1.0)),
|
|
1159
|
-
#
|
|
1156
|
+
# 旁白优先:配乐基线 -12 dB。
|
|
1160
1157
|
# 视频以旁白为主,-1.5 dB 时背景与台词响度几乎等高,观众要费力分辨字;
|
|
1161
1158
|
# 12 dB 是"人声明显压住背景"的经验线(audio.md §2 的那条设计标准)。
|
|
1162
1159
|
music_gain_db=float(mus.get("gain_db", -12.0)),
|
|
@@ -1180,9 +1177,9 @@ def st_audio(tl, work, script):
|
|
|
1180
1177
|
|
|
1181
1178
|
# ---------------------------------------------------------------- 预检闸门
|
|
1182
1179
|
def _run_preflight(script, work, engine, tl=None, skip=False):
|
|
1183
|
-
"""
|
|
1180
|
+
"""渲染前资源闸门:缺字形 / 坏音频 / 缺字体,在渲第一帧之前就 fail。
|
|
1184
1181
|
|
|
1185
|
-
|
|
1182
|
+
为什么值得单独立一道闸:这类问题若只在"片渲完、QA 才报"——870 帧白跑,
|
|
1186
1183
|
而且 reports 里只有一句"缺字形",说不清是哪个字段哪句台词。
|
|
1187
1184
|
三档语义:没装 preflight.py -> skipped(写明原因);有 error -> fail;
|
|
1188
1185
|
只有 warning -> pass(warning 仍进账本,不静默)。
|
|
@@ -1228,14 +1225,14 @@ def _run_preflight(script, work, engine, tl=None, skip=False):
|
|
|
1228
1225
|
return True
|
|
1229
1226
|
|
|
1230
1227
|
|
|
1231
|
-
# ----------------------------------------------------------------
|
|
1228
|
+
# ---------------------------------------------------------------- 字幕样式
|
|
1232
1229
|
_SUB_CACHE = {}
|
|
1233
1230
|
|
|
1234
1231
|
|
|
1235
1232
|
def _sub_kw(script):
|
|
1236
1233
|
"""从已解析的 plan 里取字幕参数(缓存按 script 对象身份,连续帧不重复解析)。
|
|
1237
1234
|
|
|
1238
|
-
|
|
1235
|
+
字幕样式由 design.json 的 visual.subtitle 决定;参考帧渲染路径
|
|
1239
1236
|
(qavmaf._draw_ref_frame)必须调用同一个 filmkit.subtitle_kwargs(plan),
|
|
1240
1237
|
否则两帧字幕参数不同会把同源好片的 VMAF 拉低。
|
|
1241
1238
|
"""
|
|
@@ -1305,7 +1302,7 @@ def _seg_usable(path, frames):
|
|
|
1305
1302
|
|
|
1306
1303
|
为什么不能只看 size>1024:Ctrl-C / 杀进程留下的半截 mp4 也 >1024,被当成
|
|
1307
1304
|
"已存在"跳过后,拼接只在最后报一句"拼接后时长 0.07s != 预期 29.00s"。
|
|
1308
|
-
|
|
1305
|
+
分块先写 .part 再 os.replace,正常中断不会留下半截正式文件;这里
|
|
1309
1306
|
再补一道头部 nb_frames 比对,挡住"文件在/大小对但帧数不对"的异常(例如被
|
|
1310
1307
|
外部工具截断过)。
|
|
1311
1308
|
|
|
@@ -1334,15 +1331,14 @@ def _render_chunk_worker(args):
|
|
|
1334
1331
|
tl = json.load(open(tl_path, encoding="utf-8"))
|
|
1335
1332
|
script = json.load(open(script_path, encoding="utf-8"))
|
|
1336
1333
|
script["_total"] = tl["total"]
|
|
1337
|
-
#
|
|
1338
|
-
# scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
1334
|
+
# 不能直接用 JSON 快照里的计划:JSON 会把元组变成 list(主题色是 (r,g,b) 元组),
|
|
1335
|
+
# 子进程里 scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
1339
1336
|
# 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
|
|
1340
1337
|
# 既拿回元组,也保证子进程的主题与父进程逐字段一致。
|
|
1341
1338
|
#
|
|
1342
|
-
#
|
|
1343
|
-
# 不带 work 就只能走
|
|
1344
|
-
# 实测后果:--jobs 4
|
|
1345
|
-
# 设计本身的近黑金;两者 MAE 0.059、VMAF 直接掉到 1.49。
|
|
1339
|
+
# 重解时**必须带上 work**。design 路径的身份在 work/design.json 里,
|
|
1340
|
+
# 不带 work 就只能走 fail-closed 报错 -> 画出一套完全不同的主题。
|
|
1341
|
+
# 实测后果:--jobs 4 的成片与 --jobs 1 / QA 参考帧 MAE 0.059、VMAF 直接掉到 1.49。
|
|
1346
1342
|
# 这种"并行度不同就出不同片子"的 bug 最隐蔽——单线程跑一遍永远看不见。
|
|
1347
1343
|
script.pop("_style", None)
|
|
1348
1344
|
try:
|
|
@@ -1488,10 +1484,10 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1488
1484
|
|
|
1489
1485
|
|
|
1490
1486
|
def _web_style_payload(we, script, tl=None, work=None):
|
|
1491
|
-
"""把风格包解析成 webengine 能吃的 __STYLE__
|
|
1487
|
+
"""把风格包解析成 webengine 能吃的 __STYLE__ 载荷。
|
|
1492
1488
|
|
|
1493
1489
|
style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
|
|
1494
|
-
add_init_script
|
|
1490
|
+
add_init_script 都不调,页面行为逐字节不变(这是兼容性硬要求)。
|
|
1495
1491
|
"""
|
|
1496
1492
|
try:
|
|
1497
1493
|
style_plan = resolve_style(script, work, verbose=False)
|
|
@@ -1520,7 +1516,7 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
|
|
|
1520
1516
|
|
|
1521
1517
|
页面的 window.DURATION / window.FPS 是切块与校验的唯一真值来源:
|
|
1522
1518
|
和 timeline.json 对不上就直接报错(十有八九是改了剧本没重新 layout)。
|
|
1523
|
-
|
|
1519
|
+
把风格包与画幅/帧率一起注入页面(window.__STYLE__),页面颜色不再写死。
|
|
1524
1520
|
"""
|
|
1525
1521
|
we = _opt_module("webengine",
|
|
1526
1522
|
"浏览器渲染需要 webengine.py(本技能 scripts/ 里自带;还需要 pip install playwright)")
|
|
@@ -1591,8 +1587,8 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1591
1587
|
v = next((s for s in streams if s.get("codec_type") == "video"), {})
|
|
1592
1588
|
a = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
1593
1589
|
fmt = pr.get("format") or {}
|
|
1594
|
-
#
|
|
1595
|
-
#
|
|
1590
|
+
# 容器门禁比对的是**交付规格**(timeline 里记的 video 块),不是引擎内部常量。
|
|
1591
|
+
# 没有 video 字段的 timeline -> 回落到 1920x1080。
|
|
1596
1592
|
_vp = tl.get("video") or {}
|
|
1597
1593
|
W = int(_vp.get("out_w") or K.W)
|
|
1598
1594
|
H = int(_vp.get("out_h") or K.H)
|
|
@@ -1656,7 +1652,7 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1656
1652
|
else:
|
|
1657
1653
|
lg.ok("G8", "首尾之外无长黑帧", "0 段")
|
|
1658
1654
|
|
|
1659
|
-
# ---- G10
|
|
1655
|
+
# ---- G10 词级时间戳:覆盖率 / 单调 / 末词偏差 ----
|
|
1660
1656
|
_word_gate(lg, tl, work)
|
|
1661
1657
|
|
|
1662
1658
|
# ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
|
|
@@ -1668,7 +1664,7 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1668
1664
|
ns = len(deep.get("sync") or [])
|
|
1669
1665
|
judged = (deep.get("sync_info") or {}).get("judged")
|
|
1670
1666
|
sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
|
|
1671
|
-
#
|
|
1667
|
+
# 门禁要按 **deep.errors** 判,而不是按 flicker 条数。
|
|
1672
1668
|
# qa_frames 会把"块内像素有增有减"(文字入场/母题动画/镜头缓推)的候选从
|
|
1673
1669
|
# flicker 降级进 warnings(见 FLICKER_AGREE_MIN),此时 flicker 仍非空但已不成问题;
|
|
1674
1670
|
# 按条数判会让降级形同虚设(零拷贝首片实测:降级后仍判 fail)。
|
|
@@ -1691,7 +1687,7 @@ WORD_COVER_MIN = 0.45 # 词时长之和 / 句实测时长:中文有词
|
|
|
1691
1687
|
|
|
1692
1688
|
|
|
1693
1689
|
def _word_gate(lg, tl, work):
|
|
1694
|
-
"""
|
|
1690
|
+
"""词级时间戳门禁。
|
|
1695
1691
|
|
|
1696
1692
|
为什么值得单独一道闸:词级数据是"字幕按词折行 / cue 词锚点"的依据,
|
|
1697
1693
|
但它来自一个**逆向接口**(edge-tts)。服务端一改,最可能的表现是"仍返回音频、
|
|
@@ -1755,12 +1751,12 @@ def _word_gate(lg, tl, work):
|
|
|
1755
1751
|
|
|
1756
1752
|
|
|
1757
1753
|
# ⚠ 阈值**只有一套**:权威定义在 qavmaf.py(VMAF_PASS=92 / VMAF_WARN=96,SSIM 仅诊断)。
|
|
1758
|
-
# 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"
|
|
1754
|
+
# 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"。
|
|
1759
1755
|
# 真实标定(qavmaf 作者实测,参考帧含字幕与片头片尾淡入淡出):
|
|
1760
1756
|
# 模板片 crf17 VMAF 97.24(pass) / crf30 95.74(pass+warning) / 二次编码坏片 29.45(fail)
|
|
1761
1757
|
# 想按片收紧或放宽就写 film.json 的 qa.vmaf.pass / qa.vmaf.warn,会透传给 qavmaf。
|
|
1762
1758
|
def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
1763
|
-
"""全片 VMAF/SSIM
|
|
1759
|
+
"""全片 VMAF/SSIM 门禁:参考帧由场景函数实时渲染,与成片逐帧比较。
|
|
1764
1760
|
|
|
1765
1761
|
为什么值得全片跑(用户明确要求"全片 + 阻断"):锐度比只在 4 个时刻采样,
|
|
1766
1762
|
能发现"整体发虚",但发现不了"某 30 秒里字幕糊了 / 某段梯度出现色带"。
|
|
@@ -1845,7 +1841,7 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
|
1845
1841
|
return res
|
|
1846
1842
|
|
|
1847
1843
|
|
|
1848
|
-
#
|
|
1844
|
+
# 实测标定(同一部模板片,29 s,faster-whisper small / CPU int8):
|
|
1849
1845
|
# 干净成片:字符召回 0.828(专有名词 "DeepSeek 4.1 Flash" 被听成 DIXX/DeepSeek 变体,
|
|
1850
1846
|
# 4 段全对上、缺句 0)—— 这不是流水线坏了,是 ASR 对中英混排专名的正常误差。
|
|
1851
1847
|
# 所以默认线取 0.85 / 0.72:**低于 0.72 才说明"整句丢了/音轨跟剧本不是一套"**,
|
|
@@ -1856,7 +1852,7 @@ QA_ASR_RECALL_WARN = 0.72
|
|
|
1856
1852
|
|
|
1857
1853
|
|
|
1858
1854
|
def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
|
|
1859
|
-
"""ASR
|
|
1855
|
+
"""ASR 回读门禁:把成片音轨转回文字,和剧本逐句对账。
|
|
1860
1856
|
|
|
1861
1857
|
为什么值得:容器/响度/帧级质检都默认"音轨里念的就是剧本那几句"。TTS 缓存串了、
|
|
1862
1858
|
换声线漏句、混音压没了、音轨是旧的——这些从波形和容器上都看不出来,只有转回
|
|
@@ -1969,9 +1965,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
|
1969
1965
|
print(" [WARN] 锐度比门禁未执行(没有参考帧)")
|
|
1970
1966
|
safe = K.qa_text_safe(tl["scenes"])
|
|
1971
1967
|
print(" 字幕安全区:", "OK" if not safe else safe[:2])
|
|
1972
|
-
# 字幕安全区 /
|
|
1973
|
-
#
|
|
1974
|
-
#
|
|
1968
|
+
# 字幕安全区 / 缺字形若只 print、不进 qa_report 的 warnings,
|
|
1969
|
+
# CI、前端、任何只读 JSON 的人都**看不到**这两条("门禁可以保守,但不能静默")。
|
|
1970
|
+
# 所以它们进 warnings(仍是 warning 级,不阻断,但能被程序读到)。
|
|
1975
1971
|
for _m in (safe or [])[:4]:
|
|
1976
1972
|
rep["warnings"].append("字幕安全区: %s" % _m)
|
|
1977
1973
|
_glyph = K.check_glyphs((script or {}).get("lines") or [])
|
|
@@ -1981,7 +1977,7 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
|
1981
1977
|
sheet = K.contact_sheet(shots, os.path.join(work, "qa", "sheet.png"))
|
|
1982
1978
|
if sheet:
|
|
1983
1979
|
print(" 联系表:", sheet, " <- 必须亲眼看")
|
|
1984
|
-
# ----
|
|
1980
|
+
# ---- 帧级深检:单帧闪烁 / 画面空窗 / 音画同步 / 响度曲线 ----
|
|
1985
1981
|
deep = _qa_deep(film, work, tl, script)
|
|
1986
1982
|
if deep:
|
|
1987
1983
|
for w in deep.get("warnings") or []:
|
|
@@ -2127,10 +2123,10 @@ def _reload_ledger_from_disk(work, ledger):
|
|
|
2127
2123
|
def _report_path(a):
|
|
2128
2124
|
"""QC 报告的唯一落点:**站在成片旁边**(--out 的目录),不猜 work 的兄弟目录。
|
|
2129
2125
|
|
|
2130
|
-
|
|
2131
|
-
|
|
2126
|
+
accept 与 all/mux/qa 必须用同一个函数算路径:若前者用 dirname(abspath(work))/out/、
|
|
2127
|
+
后者用 dirname(abspath(out)),--out 指到任何非 out/ 的目录(deliver/、中文工作区/成品/…)
|
|
2132
2128
|
时,签核更新的是另一份没人交付的文件,交付出去的那份仍是"未签核"——正好打穿
|
|
2133
|
-
"报告是交付凭据"
|
|
2129
|
+
"报告是交付凭据"这条承诺。
|
|
2134
2130
|
"""
|
|
2135
2131
|
return os.path.join(os.path.dirname(os.path.abspath(str(a.out))), "QC-REPORT.md")
|
|
2136
2132
|
|
|
@@ -2202,8 +2198,8 @@ def _load_batch(path):
|
|
|
2202
2198
|
{"films": [{"script": "a/film.json", "work": "a/work",
|
|
2203
2199
|
"scenes": "a/scenes.py", "out": "out/a.mp4"}]}
|
|
2204
2200
|
|
|
2205
|
-
|
|
2206
|
-
|
|
2201
|
+
批量最容易死在"第 7 条跑一半才发现清单写错",所以数量/体积先卡死,
|
|
2202
|
+
并且在**开跑之前**全部预检一遍。
|
|
2207
2203
|
"""
|
|
2208
2204
|
p = str(path)
|
|
2209
2205
|
if not os.path.exists(p):
|
|
@@ -2334,11 +2330,11 @@ def run_batch(a):
|
|
|
2334
2330
|
return 0 if okn == len(results) else 1
|
|
2335
2331
|
|
|
2336
2332
|
|
|
2337
|
-
# ----------------------------------------------------------------
|
|
2333
|
+
# ---------------------------------------------------------------- 提案闸
|
|
2338
2334
|
def _proposal_gate(a):
|
|
2339
2335
|
"""提案闸:frames 之前必须先有"已确认的设计"。
|
|
2340
2336
|
|
|
2341
|
-
|
|
2337
|
+
本项目的主路径是"先提案、再出片",所以这条闸是语义要求,不是可选装饰。
|
|
2342
2338
|
但它默认只警告(0 退出码),加 --require-proposal 才失败——原因只有一条:
|
|
2343
2339
|
--stage frames 是开发期高频命令(改一帧看一帧),硬拦会让调试变得没法用。
|
|
2344
2340
|
|
|
@@ -2388,9 +2384,9 @@ def _builtin_scenes() -> str:
|
|
|
2388
2384
|
def _resolve_scenes(scenes_arg, script_path) -> str:
|
|
2389
2385
|
"""定位场景模块,按优先级:显式 --scenes > 剧本同目录 scenes.py > 技能内置 scenes.py。
|
|
2390
2386
|
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
重拷一份 98KB
|
|
2387
|
+
为什么要这条回退链:要求用户把 assets/scenes.py 拷进工作目录,
|
|
2388
|
+
即「提供一份模板代码」——与「不提供任何模板」直接冲突,而且用户每部片都得
|
|
2389
|
+
重拷一份 98KB 的绘制代码。所以把内置渲染器当**默认能力**:工作目录里没有 scenes.py
|
|
2394
2390
|
时直接用它出片;需要改画面再写自己的 scenes.py(显式指定或用同目录约定)。
|
|
2395
2391
|
"""
|
|
2396
2392
|
if scenes_arg:
|
|
@@ -2450,7 +2446,7 @@ def main():
|
|
|
2450
2446
|
help="把'提案未确认'当成失败(默认只警告;先跑 propose.py 再出片)")
|
|
2451
2447
|
ap.add_argument("--cost", action="store_true",
|
|
2452
2448
|
help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
|
|
2453
|
-
# ----
|
|
2449
|
+
# ---- 批量 / 画质门禁 ----
|
|
2454
2450
|
ap.add_argument("--batch", default="",
|
|
2455
2451
|
help="批量清单(JSON 数组或 {\"films\":[...]},≤100 条):"
|
|
2456
2452
|
"先全量预检,再逐条渲染,单条失败不阻断其余")
|
|
@@ -2481,11 +2477,11 @@ def main():
|
|
|
2481
2477
|
script["_engine"] = engine
|
|
2482
2478
|
script["_scenes"] = _resolve_scenes(a.scenes, a.script)
|
|
2483
2479
|
|
|
2484
|
-
# ----
|
|
2480
|
+
# ---- 门禁账本 ----
|
|
2485
2481
|
global _LEDGER
|
|
2486
2482
|
# engine=web 时把入口页实际引用的同目录页面脚本一起纳入内容指纹:web 的画面逻辑在
|
|
2487
|
-
# web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效"
|
|
2488
|
-
#
|
|
2483
|
+
# web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效",
|
|
2484
|
+
# 正好违背「签核绑内容指纹,改了画面代码旧签核自动失效」这条承诺。
|
|
2489
2485
|
web_entry = ""
|
|
2490
2486
|
if engine == "web":
|
|
2491
2487
|
try:
|
|
@@ -2502,7 +2498,7 @@ def main():
|
|
|
2502
2498
|
def _sources():
|
|
2503
2499
|
"""源头输入:剧本、scenes.py、冻结设计(改它们等于改了所有下游的依据)。
|
|
2504
2500
|
|
|
2505
|
-
design.json
|
|
2501
|
+
design.json 是第三个源头:它由 propose.py --confirm 写出,
|
|
2506
2502
|
记录"这一版长什么样"。改设计 = 画面必须重渲,所以它必须进 stagewatch 的
|
|
2507
2503
|
源头指纹,否则"改了配色但没重跑 frames"这种事故查不出来。
|
|
2508
2504
|
"""
|
|
@@ -2524,7 +2520,7 @@ def main():
|
|
|
2524
2520
|
"""stage 开始时查新鲜度并计时。返回 t0。
|
|
2525
2521
|
|
|
2526
2522
|
report() 自己负责打印 "[stale] <stage>: <原因>" 与"最小重跑"两行
|
|
2527
|
-
|
|
2523
|
+
(必须接它的返回值,否则用户只看到"最小重跑"不知道是哪个阶段、
|
|
2528
2524
|
为什么过时,得上 --stage explain 才知道)。
|
|
2529
2525
|
--allow-stale 也要调用:它承诺的是"放行并留痕"(QC 报告里记一条 skipped),
|
|
2530
2526
|
直接跳过检查连痕都没有——那就成了静默。
|
|
@@ -2545,7 +2541,7 @@ def main():
|
|
|
2545
2541
|
if errs:
|
|
2546
2542
|
# --strict-stale 的承诺是"上游产物过时算失败"。stagewatch.report 只把
|
|
2547
2543
|
# 问题放进返回值(并往账本记一条 fail),它自己不抛异常——所以必须在这里
|
|
2548
|
-
#
|
|
2544
|
+
# 真的失败。丢弃返回值的话,--stage layout --strict-stale 报完过时
|
|
2549
2545
|
# 照跑、退出码 0,help 文本与行为对不上。
|
|
2550
2546
|
#
|
|
2551
2547
|
# 选择"立刻退出"而不是"跑完再在收尾处报":过时的输入会把旧产物掺进
|
|
@@ -2628,8 +2624,8 @@ def main():
|
|
|
2628
2624
|
def load_scenes(strict=True):
|
|
2629
2625
|
"""加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。
|
|
2630
2626
|
|
|
2631
|
-
|
|
2632
|
-
|
|
2627
|
+
路径用 main() 里解析好的 script["_scenes"](显式 --scenes / 剧本同目录 /
|
|
2628
|
+
技能内置三档回退),不能用原始 a.scenes —— 缺省时它是空串,会变成
|
|
2633
2629
|
`load_module("")` 这种 "NoneType has no attribute loader" 的怪错误。
|
|
2634
2630
|
"""
|
|
2635
2631
|
scenes_path = script.get("_scenes") or _resolve_scenes(a.scenes, a.script)
|
|
@@ -2652,7 +2648,7 @@ def main():
|
|
|
2652
2648
|
|
|
2653
2649
|
stage = a.stage
|
|
2654
2650
|
|
|
2655
|
-
# ---- preview:前 N
|
|
2651
|
+
# ---- preview:前 N 秒试片 ----
|
|
2656
2652
|
if a.preview:
|
|
2657
2653
|
try:
|
|
2658
2654
|
secs = float(a.preview)
|
|
@@ -2671,7 +2667,7 @@ def main():
|
|
|
2671
2667
|
print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
|
|
2672
2668
|
sc2 = dict(script)
|
|
2673
2669
|
sc2["_total"] = tl["total"]
|
|
2674
|
-
#
|
|
2670
|
+
# 不能写成 setdefault(_style, resolve_style(script)):
|
|
2675
2671
|
# 对 sc2 做 setdefault 却传 script 的解析结果 —— script 有 _style 时,
|
|
2676
2672
|
# setdefault 的第二参数**仍会被求值**(Python 先算参数),等于多做一次无用解析;
|
|
2677
2673
|
# 而 script 没有 _style 时又把 work 漏了,单跑 --preview 会报"找不到设计"。
|
|
@@ -2680,7 +2676,7 @@ def main():
|
|
|
2680
2676
|
str(script.get("preset", "medium")), progress=False)
|
|
2681
2677
|
# 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
|
|
2682
2678
|
# 注意:**必须先把音轨截到预览时长**——K.mux 会校验音视频时长差 < 0.05s,
|
|
2683
|
-
# 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"
|
|
2679
|
+
# 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"(实测)。
|
|
2684
2680
|
audio = os.path.join(a.work, "audio.wav")
|
|
2685
2681
|
if os.path.exists(audio):
|
|
2686
2682
|
tmp = out_mp4 + ".mux.mp4"
|
|
@@ -2775,7 +2771,7 @@ def main():
|
|
|
2775
2771
|
return 0
|
|
2776
2772
|
|
|
2777
2773
|
if stage in ("all", "style"):
|
|
2778
|
-
# --stage all 也要把 style 这一行记进 manifest
|
|
2774
|
+
# --stage all 也要把 style 这一行记进 manifest:若 _nm 在 all 下是 None,
|
|
2779
2775
|
# 而 _stage_begin/_stage_end 对 None 是空操作 -> work/style.json 写出来了、
|
|
2780
2776
|
# manifest.json 却从来没有 style 记录。于是之后任何一次 `--stage style` 都会
|
|
2781
2777
|
# 命中「产物在但没有记录」的判据,报成 no-manifest(--stage all 的用户 100% 踩到)。
|
|
@@ -2833,8 +2829,8 @@ def main():
|
|
|
2833
2829
|
_stage_end("audio", _t0)
|
|
2834
2830
|
|
|
2835
2831
|
if stage == "stills":
|
|
2836
|
-
# 母版预览是"上手第一件事",可它需要 timeline
|
|
2837
|
-
#
|
|
2832
|
+
# 母版预览是"上手第一件事",可它需要 timeline。干净目录直接 SystemExit 的话,
|
|
2833
|
+
# 文档三步上手就把它排在第 4 步(照抄必失败)。所以这里自动补前置:
|
|
2838
2834
|
# 缺 tts.json 就真合成一次旁白(要联网),缺 timeline 就排一次轴——
|
|
2839
2835
|
# 但每一步都**明说**自己在补什么,不偷偷跑。
|
|
2840
2836
|
# 自动补的这几步也要 _stage_begin/_stage_end:否则 manifest 里只有 stills 一条记录,
|
|
@@ -2862,7 +2858,7 @@ def main():
|
|
|
2862
2858
|
_stage_end("stills", _t0)
|
|
2863
2859
|
_LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
|
|
2864
2860
|
"%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
|
|
2865
|
-
# 0 张静帧 = fail
|
|
2861
|
+
# 0 张静帧 = fail 门禁:无条件 return 0 的话,`--stills 写错 id` 会
|
|
2866
2862
|
# "全部跳过 + 退出码 0",CI 看不出根本没出图。
|
|
2867
2863
|
rc = _finish_gates(a)
|
|
2868
2864
|
return 1 if (rc or _LEDGER.fails()) else 0
|
|
@@ -2880,7 +2876,7 @@ def main():
|
|
|
2880
2876
|
return 1 if (errs or rc) else 0
|
|
2881
2877
|
|
|
2882
2878
|
if stage in ("all", "frames", "mux"):
|
|
2883
|
-
# ----
|
|
2879
|
+
# ---- 提案闸:出片前必须先有已确认的设计 ----
|
|
2884
2880
|
# 放在最前面(before ensure_tl/preflight/st_frames):渲完再拦等于白渲。
|
|
2885
2881
|
if stage in ("all", "frames"):
|
|
2886
2882
|
if not _proposal_gate(a):
|
|
@@ -2906,7 +2902,7 @@ def main():
|
|
|
2906
2902
|
_stage_end("frames", _t0, frames=int(tl["frames"]))
|
|
2907
2903
|
if stage == "frames":
|
|
2908
2904
|
_LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
|
|
2909
|
-
# 同上:strict-gates 下 skipped
|
|
2905
|
+
# 同上:strict-gates 下 skipped 也要反映到退出码(只看 fails() 会漏掉它)。
|
|
2910
2906
|
rc = _finish_gates(a)
|
|
2911
2907
|
return 1 if (rc or _LEDGER.fails()) else 0
|
|
2912
2908
|
if stage in ("all", "mux"):
|
|
@@ -2919,7 +2915,7 @@ def main():
|
|
|
2919
2915
|
_check_signoff("mux")
|
|
2920
2916
|
if stage == "mux":
|
|
2921
2917
|
_LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
|
|
2922
|
-
# 同上:strict-gates 下 skipped
|
|
2918
|
+
# 同上:strict-gates 下 skipped 也要反映到退出码(只看 fails() 会漏掉它)。
|
|
2923
2919
|
rc = _finish_gates(a)
|
|
2924
2920
|
return 1 if (rc or _LEDGER.fails()) else 0
|
|
2925
2921
|
if stage == "all":
|
|
@@ -2933,8 +2929,8 @@ def main():
|
|
|
2933
2929
|
return 1
|
|
2934
2930
|
return 0
|
|
2935
2931
|
# 所有 stage 统一收尾:落 gates.json + QC-REPORT.md,并按门禁结果给退出码。
|
|
2936
|
-
#
|
|
2937
|
-
# (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 里
|
|
2938
2934
|
# 记的 fail 传不到退出码,"门禁失败 -> 非 0"这个承诺按 stage 不同行为不一样。
|
|
2939
2935
|
# 默认路径不受影响:没有 fail(且没开 --strict-gates)时 _finish_gates 返回 0。
|
|
2940
2936
|
rc = _finish_gates(a, persist=(stage in _PERSIST_STAGES))
|