@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +424 -0
- package/README.md +287 -327
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +4 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +1 -2
- package/skills/video-studio/assets/golden_frames.json +11 -8
- package/skills/video-studio/assets/scenes.py +67 -22
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/audio-variation.md +15 -11
- package/skills/video-studio/references/audio.md +36 -34
- package/skills/video-studio/references/change-impact.md +3 -3
- package/skills/video-studio/references/frame-qa.md +2 -2
- package/skills/video-studio/references/proposal.md +177 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/scene-grammar.md +28 -23
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +95 -257
- package/skills/video-studio/references/v08-quality.md +8 -8
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/references/word-timeline.md +2 -2
- package/skills/video-studio/scripts/designkit.py +2120 -0
- package/skills/video-studio/scripts/filmcheck.py +7 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +67 -7
- package/skills/video-studio/scripts/musicgen.py +2043 -0
- package/skills/video-studio/scripts/preflight.py +10 -3
- package/skills/video-studio/scripts/previewkit.py +849 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +45 -10
- package/skills/video-studio/scripts/qavmaf.py +77 -11
- package/skills/video-studio/scripts/render.py +472 -154
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +658 -131
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +265 -741
- package/skills/video-studio/scripts/voicekit.py +502 -380
- package/skills/video-studio/scripts/webengine.py +15 -14
- package/tools/audio_similarity.py +57 -30
- package/tools/ci_check.py +7 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +521 -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
|
@@ -8,7 +8,7 @@
|
|
|
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;分块并行、断点续跑)
|
|
@@ -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:
|
|
@@ -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
|
|
|
@@ -296,53 +299,131 @@ def _stylekit():
|
|
|
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
|
+
"""v1.0:如果 work/design.json 存在(用户已确认提案),**以它为准**解析计划。
|
|
308
|
+
|
|
309
|
+
D3 之后这是唯一主路径:design.json 是"这一版长什么样"的冻结点,剧本只提供台词。
|
|
310
|
+
返回 None 表示没有 design(调用方回落到 stylekit 的旧解析,P3 会删掉那条路)。
|
|
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 冒充"渲染器坏了"(v0.9 之前这种误报踩过多次)。
|
|
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 **缺省位**(v1.0.2)。
|
|
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
|
+
优先级(v1.0):
|
|
384
|
+
1. script["_style"] 已存在(同进程内各 stage 复用)
|
|
385
|
+
2. work/design.json(用户已确认的提案)—— 这是正式主路径
|
|
386
|
+
3. stylekit 旧解析(P3 之后会删除;现在保留是为了让未迁移的项目还能跑)
|
|
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
|
-
|
|
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
|
+
# v1.0.1:**没有第三优先级了**。以前这里会回落到 stylekit 的"按关键词自动选风格",
|
|
404
|
+
# 那是模板库的核心;删掉模板后这条路径没有意义,必须明确报错。
|
|
405
|
+
raise SystemExit(
|
|
406
|
+
"[style] 找不到设计(work/design.json):\n"
|
|
407
|
+
" v1.0.1 起本技能不提供任何内置风格,每一版外观都必须现场生成并由人确认。\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(= v0.4 的硬编码外观,逐字节兼容)。
|
|
417
|
+
|
|
418
|
+
work **必须传**:design 路径的身份在 work/design.json 里。v1.0.1 之前不传会回落到
|
|
419
|
+
stylekit 的 auto 分支(已删除),现在不传就直接 SystemExit —— 但"传错 work"仍然值得防:
|
|
420
|
+
work 指到别的目录时会读到**另一份** design.json,参考帧与成片不同源
|
|
421
|
+
(历史实测:--stage qa 单独跑 VMAF 1.07,全部内联跑 97.15)。
|
|
422
|
+
"""
|
|
342
423
|
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
343
424
|
if plan is not None and plan.get("legacy"):
|
|
344
425
|
return None
|
|
345
|
-
plan = resolve_style(script)
|
|
426
|
+
plan = resolve_style(script, work)
|
|
346
427
|
if plan.get("legacy"):
|
|
347
428
|
return None
|
|
348
429
|
# plan["theme"] 是 stylekit 合并过 film.json 的 visual.theme_overrides 之后的最终主题,
|
|
@@ -353,7 +434,22 @@ def _theme_for(script):
|
|
|
353
434
|
th = plan.get("theme")
|
|
354
435
|
if isinstance(th, dict):
|
|
355
436
|
return th
|
|
356
|
-
|
|
437
|
+
# v1.0:**先看 plan["theme"]**(designkit 生成的计划自带完整 35 字段主题),
|
|
438
|
+
# 只有老计划(stylekit 解析、没有 theme 字段)才回查内置表。
|
|
439
|
+
# 早先无条件 THEMES[plan["theme_id"]],于是设计路径的 theme_id(如 "a-44957fb5")
|
|
440
|
+
# 在表里查不到 -> KeyError,整条出片链路在 layout 阶段崩掉。
|
|
441
|
+
th = plan.get("theme")
|
|
442
|
+
if isinstance(th, dict) and th:
|
|
443
|
+
return th
|
|
444
|
+
# v1.0.1:内置主题表已删除,没有 theme 计划就是"这份 script 没走过 design/propose"。
|
|
445
|
+
# 明确报错并给迁移指引,不静默回落(静默回落 = 用一套不存在的默认外观出片)。
|
|
446
|
+
raise SystemExit(
|
|
447
|
+
"[style] 这份剧本没有可用的主题:\n"
|
|
448
|
+
" 计划里缺 theme 字段,而内置主题表已在本版删除。\n"
|
|
449
|
+
" 正确做法:python propose.py --brief \"题材\" --script film.json # 出方案\n"
|
|
450
|
+
" python propose.py --confirm --candidate A # 冻结 design.json\n"
|
|
451
|
+
" python render.py --script film.json --stage all\n"
|
|
452
|
+
" (theme_id=%r)" % (plan.get("theme_id"),))
|
|
357
453
|
|
|
358
454
|
|
|
359
455
|
def _print_style(plan):
|
|
@@ -377,8 +473,24 @@ def _print_style(plan):
|
|
|
377
473
|
% (mf.get("kind"), tr.get("kind"), float(tr.get("dur") or 0.0),
|
|
378
474
|
float(ty.get("tracking") or 0.0), ty.get("number"),
|
|
379
475
|
float(bi.get("x") or 0.0), float(bi.get("y") or 0.0)))
|
|
476
|
+
# v1.0.2 修:这里以前无条件读 m["style"] —— 而 designkit 自 v1.0.1 起明确不再写
|
|
477
|
+
# 这个键(配乐风格表已删),于是公开命令 `--stage style` 100% KeyError: "style"。
|
|
478
|
+
# 现在按「参数化配乐」的真实身份打印:调式 + BPM + 调性 + mood。
|
|
479
|
+
_music_id = m.get("arranger") or m.get("style") or ("%s %gBPM" % (m.get("scale", "?"), m.get("bpm", 0)))
|
|
380
480
|
print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
|
|
381
|
-
% (
|
|
481
|
+
% (_music_id, m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
|
|
482
|
+
_sub = plan.get("subtitle") if isinstance(plan.get("subtitle"), dict) else None
|
|
483
|
+
if _sub:
|
|
484
|
+
_y = _sub.get("y")
|
|
485
|
+
print(" 字幕: %s %dpx 对齐=%s 背板α=%d 圆角=%d 最多%d行"
|
|
486
|
+
% ("y=%s" % _y if _y is not None else "底部安全区",
|
|
487
|
+
int(_sub.get("size") or 46), _sub.get("align") or "center",
|
|
488
|
+
int(_sub.get("plate_alpha") if _sub.get("plate_alpha") is not None else 170),
|
|
489
|
+
int(_sub.get("radius") if _sub.get("radius") is not None else 10),
|
|
490
|
+
int(_sub.get("max_lines") or 2)))
|
|
491
|
+
_cyc = plan.get("layout_cycle") or []
|
|
492
|
+
if len(_cyc) >= 2:
|
|
493
|
+
print(" 版式轮换: %s(按章节轮换,防全片一张版式)" % " -> ".join(map(str, _cyc)))
|
|
382
494
|
if v["map"]:
|
|
383
495
|
print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
|
|
384
496
|
p = plan["pacing"]
|
|
@@ -559,7 +671,9 @@ def st_layout(script, work, lines=None):
|
|
|
559
671
|
json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
|
|
560
672
|
ensure_ascii=False, indent=1)
|
|
561
673
|
n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
|
|
562
|
-
theme
|
|
674
|
+
# 同上:设计计划自带 theme,不能假定 theme_id 一定在内置表里(见 _theme_for 的注释)
|
|
675
|
+
# v1.0.1:没有内置主题表了,theme 必须由计划自带(design 路径一定有)
|
|
676
|
+
theme = (plan.get("theme") if not plan["legacy"] else None)
|
|
563
677
|
n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
|
|
564
678
|
print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
|
|
565
679
|
% (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
|
|
@@ -575,6 +689,26 @@ def st_layout(script, work, lines=None):
|
|
|
575
689
|
print(" %-6s %-7s %r" % (lid, fld, chars))
|
|
576
690
|
for s in tl["scenes"]:
|
|
577
691
|
print(" %-8s %7.2f-%7.2f %s" % (s["id"], s["start"], s["end"], s["text"][:40]))
|
|
692
|
+
# ---- 目标时长对账(v1.0.2)----
|
|
693
|
+
# brief 里写的「2 分钟 / 60 秒」过去只影响 design.structure.segments 与 target_sec,
|
|
694
|
+
# 而成片时长由 TTS 实测决定 —— 用户以为"我要求了时长",实际拿到 23s。
|
|
695
|
+
# 不改时长(截断旁白或拉长留白都是坏做法),但**必须当场说出来差多少**,
|
|
696
|
+
# 并给出可执行的做法(加台词 / 调 hold / 减台词)。
|
|
697
|
+
target = (plan or {}).get("target_sec")
|
|
698
|
+
if target:
|
|
699
|
+
try:
|
|
700
|
+
target = float(target)
|
|
701
|
+
except (TypeError, ValueError):
|
|
702
|
+
target = None
|
|
703
|
+
if target and target > 0:
|
|
704
|
+
diff = float(tl["total"]) - target
|
|
705
|
+
pct = diff / target * 100.0
|
|
706
|
+
print(" 目标时长 %.1fs / 实际 %.2fs(%+.1fs,%+.0f%%)"
|
|
707
|
+
% (target, tl["total"], diff, pct))
|
|
708
|
+
if abs(pct) > 15.0:
|
|
709
|
+
print(" [WARN] 与目标时长差超过 15%:成片时长由**实测旁白**决定,不收口就要改文案。"
|
|
710
|
+
"\n 想变长:加台词或调大 film.json 的 hold / lead / tail;"
|
|
711
|
+
"\n 想变短:删减台词(hold 只会加留白,不会缩短旁白)。")
|
|
578
712
|
return tl
|
|
579
713
|
|
|
580
714
|
|
|
@@ -614,6 +748,15 @@ def st_cues(script, work, tl, engine="pil"):
|
|
|
614
748
|
# scenes.py 里的 cues() 钩子:允许直接返回 [{at,type,...}] 或调用 bus.add(...)
|
|
615
749
|
n_hook = 0
|
|
616
750
|
mod = None
|
|
751
|
+
# v1.0.2 修:scenes.py 的 cues() 常要读主题/视觉语言(assets 版就调 resolve_script_style)。
|
|
752
|
+
# 单跑 `--stage cues` 时前面没有 stage 把 script["_style"] 挂好,钩子就会去解析"设计"
|
|
753
|
+
# 并直接 raise —— 公开命令 100% 崩,报错还指向"先去提案"(其实设计已确认)。
|
|
754
|
+
# 这里在调用钩子前解析一次(幂等,已挂 _style 时直接复用)。
|
|
755
|
+
if script.get("_scenes") and os.path.exists(script["_scenes"]) and not isinstance(script.get("_style"), dict):
|
|
756
|
+
try:
|
|
757
|
+
script["_style"] = resolve_style(script, work)
|
|
758
|
+
except SystemExit:
|
|
759
|
+
pass # 没设计:cues 阶段不因它失败,钩子里真需要会自己报错
|
|
617
760
|
if script.get("_scenes") and os.path.exists(script["_scenes"]):
|
|
618
761
|
try:
|
|
619
762
|
mod = load_module(script["_scenes"])
|
|
@@ -683,7 +826,7 @@ def _st_cues_web(script, work):
|
|
|
683
826
|
tl = json.load(open(tp, encoding="utf-8"))
|
|
684
827
|
except Exception: # noqa: BLE001
|
|
685
828
|
tl = {}
|
|
686
|
-
style = _web_style_payload(we, script, tl)
|
|
829
|
+
style = _web_style_payload(we, script, tl, work)
|
|
687
830
|
vp = (tl.get("video") or {})
|
|
688
831
|
data = we.export_cues(entry, style=style,
|
|
689
832
|
width=int(vp.get("out_w") or K.W),
|
|
@@ -693,33 +836,19 @@ def _st_cues_web(script, work):
|
|
|
693
836
|
|
|
694
837
|
|
|
695
838
|
# ---------------------------------------------------------------- 配乐 / 音效
|
|
696
|
-
def
|
|
697
|
-
"""
|
|
839
|
+
def _score_with_musicgen(script, work, tl, plan_s, mus):
|
|
840
|
+
"""用参数化编排器出成片配乐(与 previewkit 的样音同一个引擎、同一组参数)。
|
|
698
841
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
- mode=file:外部 work/music.wav,原样交给 audio 阶段;
|
|
704
|
-
- 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床(v0.2 兼容)。
|
|
842
|
+
为什么不复用 scorekit 那条路:scorekit 的 9 套 MUSIC_STYLES 是**风格表**,
|
|
843
|
+
而本技能 v1.0.1 起不再提供任何模板 —— 配乐必须由设计里的参数现场生成。
|
|
844
|
+
musicgen 的六个参数(progressions / motif / drums / voices / mood / sections)
|
|
845
|
+
全部参与事件展开,实测参数相关性 seed 0.16 / mood 0.32 / voices 0.22,无一是死旋钮。
|
|
705
846
|
"""
|
|
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"])
|
|
847
|
+
print("== Score(musicgen 参数化编排)")
|
|
848
|
+
mg = _try_module("musicgen")
|
|
849
|
+
mp = plan_s["music"]
|
|
720
850
|
sections = mus.get("sections")
|
|
721
851
|
if sections:
|
|
722
|
-
# film.json 允许用 id 表述分段起点,换成绝对秒
|
|
723
852
|
fixed = []
|
|
724
853
|
for s in sections:
|
|
725
854
|
s2 = dict(s)
|
|
@@ -727,76 +856,104 @@ def st_score(script, work, tl):
|
|
|
727
856
|
s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
|
|
728
857
|
fixed.append(s2)
|
|
729
858
|
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),
|
|
859
|
+
kwargs = dict(
|
|
860
|
+
bpm=float(mp["bpm"]),
|
|
861
|
+
key=str(mp["key"] or "Dm"),
|
|
862
|
+
scale=str(mp.get("scale") or "minor"),
|
|
863
|
+
mood=str(mp.get("mood") or "steady"),
|
|
864
|
+
# seed_effective 是"内容指纹掺进去之后"的 32 位大数;musicgen 内部按
|
|
865
|
+
# sha1(seed) 派生,直接给大整数没问题,但统一夹到 2^31-1 内可以保证
|
|
866
|
+
# 与 scorekit 时代的取模口径一致(老 work/ 目录里的 score.json 能对上)。
|
|
867
|
+
seed=int(mp.get("seed_effective") or mp.get("seed") or 0) % (2 ** 31 - 1),
|
|
868
|
+
sections=sections,
|
|
762
869
|
)
|
|
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")))
|
|
870
|
+
# 设计里显式给出的编排参数原样透传(progressions/motif/drums/voices/energy_curve)。
|
|
871
|
+
# 只在键存在时传:musicgen 对 None 与缺省的处理不同(缺省=自己按 seed 派生)。
|
|
872
|
+
for key in ("progressions", "motif", "drums", "voices", "energy_curve"):
|
|
873
|
+
if mp.get(key) is not None:
|
|
874
|
+
kwargs[key] = mp[key]
|
|
875
|
+
t0 = time.time()
|
|
876
|
+
try:
|
|
877
|
+
samples, report = mg.render(float(tl["total"]), **kwargs)
|
|
878
|
+
except Exception as e: # noqa: BLE001
|
|
879
|
+
raise SystemExit(
|
|
880
|
+
"[score] musicgen.render() 失败:%s\n"
|
|
881
|
+
" 参数:bpm=%s key=%s scale=%s mood=%s seed=%s\n"
|
|
882
|
+
" 这是配乐生成的真错误(不是签名不兼容)——先把参数改对,别改成静默跳过。"
|
|
883
|
+
% (e, kwargs.get("bpm"), kwargs.get("key"), kwargs.get("scale"),
|
|
884
|
+
kwargs.get("mood"), kwargs.get("seed"))) from e
|
|
782
885
|
out = os.path.join(work, "music.wav")
|
|
783
|
-
|
|
886
|
+
mg.save(out, samples)
|
|
784
887
|
if report:
|
|
785
888
|
json.dump(report, open(os.path.join(work, "score.json"), "w", encoding="utf-8"),
|
|
786
889
|
ensure_ascii=False, indent=1)
|
|
890
|
+
print(" musicgen: %s %s / %s BPM / %s / mood %s / seed %s"
|
|
891
|
+
% (report.get("key_name") or report.get("key"), report.get("scale"),
|
|
892
|
+
report.get("bpm"), "", report.get("mood"), report.get("seed")))
|
|
787
893
|
samples_read, _sr = K.wav_read(out)
|
|
788
894
|
print(" music.wav %.2fs 峰值 %.3f %.1fs" % (
|
|
789
895
|
K.wav_dur(out), float(np.max(np.abs(samples_read))), time.time() - t0))
|
|
790
896
|
return out
|
|
791
897
|
|
|
792
898
|
|
|
793
|
-
def
|
|
899
|
+
def st_score(script, work, tl):
|
|
900
|
+
"""参数化配乐 -> work/music.wav(musicgen 现场编排,没有模板可回退)。
|
|
901
|
+
|
|
902
|
+
配乐来源由 K.music_plan 统一判定(与 st_audio 共用同一份语义):
|
|
903
|
+
- mode=off/none 或 enabled=false:不出配乐,也不删已存在的 music.wav
|
|
904
|
+
(删文件会误删"外部提供的配乐",而 off 的语义只是"本片不要配乐");
|
|
905
|
+
- mode=bed:走 v0.2 和弦床,不生成 musicgen 产物;
|
|
906
|
+
- mode=file:外部 work/music.wav,原样交给 audio 阶段;
|
|
907
|
+
- 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床(v0.2 兼容)。
|
|
908
|
+
|
|
909
|
+
需要真的生成配乐时,**只有 musicgen 一条路**:legacy 计划或 musicgen.py 缺失都会
|
|
910
|
+
明确报错并给迁移指引,不再用 scorekit 的配乐模板兜底(v1.0.1 零模板)。
|
|
911
|
+
"""
|
|
912
|
+
print("== Score")
|
|
913
|
+
mus = script.get("music") or {}
|
|
914
|
+
music_path = os.path.join(work, "music.wav")
|
|
915
|
+
plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
|
|
916
|
+
print(" " + why)
|
|
917
|
+
if plan in ("off", "bed", "file", "none"):
|
|
918
|
+
return None
|
|
919
|
+
if plan in ("missing-file", "missing-bpm", "unknown-mode"):
|
|
920
|
+
raise SystemExit("[score] " + why)
|
|
921
|
+
# v1.0.1:**配乐只有 musicgen 一条路**(参数化编排器,没有风格表)。
|
|
922
|
+
#
|
|
923
|
+
# 为什么必须换:v1.0 里 previewkit 用 musicgen 出 12 秒样音,而 st_score 用 scorekit
|
|
924
|
+
# 出成片音乐 —— 两者是**两个不同的作曲器**,实测同一设计下相关系数 -0.0057(就是两首
|
|
925
|
+
# 不同的曲子)。用户看样音选方案,成片音乐完全不同,等于"确认"这个动作在音频上失效。
|
|
926
|
+
#
|
|
927
|
+
# v1.0.1 把 scorekit 的 9 套 MUSIC_STYLES 风格表整个删了(零模板),它的 render()
|
|
928
|
+
# 只剩 legacy 逐样本兼容路径。所以这里**不再有 scorekit 回退**:scorekit 一旦回来
|
|
929
|
+
# 当兜底,成片音乐就又变成一套固定模板,与"每条视频都不一样"直接冲突。
|
|
930
|
+
plan_s0 = resolve_style(script, work)
|
|
931
|
+
if plan_s0.get("legacy"):
|
|
932
|
+
raise SystemExit(
|
|
933
|
+
"[score] legacy 配乐路径已移除(v1.0.1 零模板):\n"
|
|
934
|
+
" scorekit 的 9 套配乐风格表已删除,legacy 回退会把成片配乐锁死成一套固定模板。\n"
|
|
935
|
+
" 正确做法(两步):\n"
|
|
936
|
+
" 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
|
|
937
|
+
" 2) python propose.py --confirm --candidate A # 冻结 work/design.json\n"
|
|
938
|
+
" 3) python render.py --script film.json --stage score # 用 musicgen 现场编排\n"
|
|
939
|
+
" (想手写配乐参数:film.json 的 music.bpm / key / mood / sections 仍然有效)")
|
|
940
|
+
if _try_module("musicgen") is None:
|
|
941
|
+
raise SystemExit(
|
|
942
|
+
"[score] 找不到 musicgen.py,无法生成配乐:\n"
|
|
943
|
+
" v1.0.1 起配乐只有参数化编排器一条路(scorekit 的风格模板已删除,不再兜底)。\n"
|
|
944
|
+
" 检查 <SKILL_DIR>/scripts/musicgen.py 是否被拷到工作目录——\n"
|
|
945
|
+
" quickstart 的 cp <SKILL_DIR>/scripts/*.py . 会把它一起拷过来;\n"
|
|
946
|
+
" 只需要旁白/音效时把 film.json 的 music.mode 设为 off 或 file。")
|
|
947
|
+
return _score_with_musicgen(script, work, tl, plan_s0, mus)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _sfx_seed_context(script, work=None):
|
|
794
951
|
"""v0.7:算音效的内容派生上下文 (style, content_fp, key, enabled)。
|
|
795
952
|
|
|
796
953
|
legacy(没写 style 字段)时返回全 None -> CueBus 走旧的 seed_base + i 路径,
|
|
797
954
|
v0.4 及以前的片子逐样本不变。参考 references/audio-variation.md §3.2 / §4。
|
|
798
955
|
"""
|
|
799
|
-
plan = resolve_style(script)
|
|
956
|
+
plan = resolve_style(script, work)
|
|
800
957
|
sfx_cfg = script.get("sfx") or {}
|
|
801
958
|
want_content = bool(sfx_cfg.get("content_seed", True))
|
|
802
959
|
if plan.get("legacy") or not want_content:
|
|
@@ -809,7 +966,7 @@ def _sfx_seed_context(script):
|
|
|
809
966
|
def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
|
|
810
967
|
"""段落边界前的转场音效自动让位(v0.7,契约 §4)。
|
|
811
968
|
|
|
812
|
-
|
|
969
|
+
配乐在段落交界自己已经有一段过门(编排器写的 swell / riser / sub_boom),
|
|
813
970
|
sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
|
|
814
971
|
做法:对位于 [边界-window, 边界] 内的转场类 cue,在它的时长内乘一个 dB 增益包络。
|
|
815
972
|
只压转场类,不碰 impact / 提示音(那是"该响"的语义重音)。
|
|
@@ -877,7 +1034,7 @@ def st_sfx(script, work, tl):
|
|
|
877
1034
|
print(" cues.json 里没有提示点,跳过")
|
|
878
1035
|
return None
|
|
879
1036
|
sfx_cfg = script.get("sfx") or {}
|
|
880
|
-
style, content_fp, key = _sfx_seed_context(script)
|
|
1037
|
+
style, content_fp, key = _sfx_seed_context(script, work)
|
|
881
1038
|
try:
|
|
882
1039
|
samples = bus.build_audio(total=tl["total"],
|
|
883
1040
|
base_db=sfx_cfg.get("base_db"),
|
|
@@ -919,7 +1076,7 @@ def _voice_track(tl, script):
|
|
|
919
1076
|
|
|
920
1077
|
|
|
921
1078
|
def _legacy_music(tl, script):
|
|
922
|
-
"""v0.2 的和弦床(没有
|
|
1079
|
+
"""v0.2 的和弦床(没有 music.wav 时的兜底)。"""
|
|
923
1080
|
mus = script.get("music") or {}
|
|
924
1081
|
if not (mus.get("enabled", True) and mus.get("chords")):
|
|
925
1082
|
return None
|
|
@@ -969,7 +1126,7 @@ def st_audio(tl, work, script):
|
|
|
969
1126
|
print(" 配乐: " + why)
|
|
970
1127
|
if plan == "score" and music is None:
|
|
971
1128
|
# 单独跑 --stage audio 时 score 还没跑过:不混配乐,但必须说清楚为什么
|
|
972
|
-
print(" [WARN] music
|
|
1129
|
+
print(" [WARN] music.mode=score 但 %s 不存在(先跑 --stage score);本次无配乐"
|
|
973
1130
|
% music_path)
|
|
974
1131
|
|
|
975
1132
|
sfx = None
|
|
@@ -988,7 +1145,7 @@ def st_audio(tl, work, script):
|
|
|
988
1145
|
sfx = bus.bus[: int(round(total * K.SR))].copy()
|
|
989
1146
|
print(" 音效: 旧版转场 whoosh(没有 cues.json)")
|
|
990
1147
|
|
|
991
|
-
plan_s = resolve_style(script)
|
|
1148
|
+
plan_s = resolve_style(script, work)
|
|
992
1149
|
# 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
|
|
993
1150
|
# 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
|
|
994
1151
|
# v0.7:整体 +1 dB —— 旧值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
|
|
@@ -1071,6 +1228,29 @@ def _run_preflight(script, work, engine, tl=None, skip=False):
|
|
|
1071
1228
|
return True
|
|
1072
1229
|
|
|
1073
1230
|
|
|
1231
|
+
# ---------------------------------------------------------------- 字幕样式(v1.0.2)
|
|
1232
|
+
_SUB_CACHE = {}
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _sub_kw(script):
|
|
1236
|
+
"""从已解析的 plan 里取字幕参数(缓存按 script 对象身份,连续帧不重复解析)。
|
|
1237
|
+
|
|
1238
|
+
v1.0.2:字幕样式由 design.json 的 visual.subtitle 决定;参考帧渲染路径
|
|
1239
|
+
(qavmaf._draw_ref_frame)必须调用同一个 filmkit.subtitle_kwargs(plan),
|
|
1240
|
+
否则两帧字幕参数不同会把同源好片的 VMAF 拉低。
|
|
1241
|
+
"""
|
|
1242
|
+
key = id(script)
|
|
1243
|
+
cached = _SUB_CACHE.get(key)
|
|
1244
|
+
if cached is not None:
|
|
1245
|
+
return cached
|
|
1246
|
+
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
1247
|
+
kw = K.subtitle_kwargs(plan)
|
|
1248
|
+
if len(_SUB_CACHE) > 8: # 批量渲染会跨很多 script:别无限长
|
|
1249
|
+
_SUB_CACHE.clear()
|
|
1250
|
+
_SUB_CACHE[key] = kw
|
|
1251
|
+
return kw
|
|
1252
|
+
|
|
1253
|
+
|
|
1074
1254
|
# ---------------------------------------------------------------- 渲染
|
|
1075
1255
|
def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True):
|
|
1076
1256
|
"""渲染 [f0, f1) 帧区间到 out_path。纯函数:同一帧号永远画出同一张图。"""
|
|
@@ -1096,7 +1276,8 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
|
|
|
1096
1276
|
_THEME_CACHE.update(k=id(script), th=th)
|
|
1097
1277
|
_hold = script.get("sub_max_hold")
|
|
1098
1278
|
frame = K.draw_subtitle(frame, K.sub_at(scenes, t, _hold), theme=th,
|
|
1099
|
-
words=K.sub_words_at(scenes, t, _hold)
|
|
1279
|
+
words=K.sub_words_at(scenes, t, _hold),
|
|
1280
|
+
**_sub_kw(script))
|
|
1100
1281
|
fade = 1.0
|
|
1101
1282
|
if t < 0.5:
|
|
1102
1283
|
fade *= K.ease_out(t / 0.5)
|
|
@@ -1148,7 +1329,8 @@ def _seg_usable(path, frames):
|
|
|
1148
1329
|
|
|
1149
1330
|
def _render_chunk_worker(args):
|
|
1150
1331
|
"""分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
|
|
1151
|
-
(tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args
|
|
1332
|
+
(tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args[:9]
|
|
1333
|
+
work = args[9] if len(args) > 9 else ""
|
|
1152
1334
|
tl = json.load(open(tl_path, encoding="utf-8"))
|
|
1153
1335
|
script = json.load(open(script_path, encoding="utf-8"))
|
|
1154
1336
|
script["_total"] = tl["total"]
|
|
@@ -1156,9 +1338,15 @@ def _render_chunk_worker(args):
|
|
|
1156
1338
|
# scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
1157
1339
|
# 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
|
|
1158
1340
|
# 既拿回元组,也保证子进程的主题与父进程逐字段一致。
|
|
1341
|
+
#
|
|
1342
|
+
# v1.0 修:重解时**必须带上 work**。design 路径的身份在 work/design.json 里,
|
|
1343
|
+
# 不带 work 就只能走 stylekit 的 auto 分支 -> 画出一套完全不同的主题。
|
|
1344
|
+
# 实测后果:--jobs 4 的成片是 tech-deep(偏蓝),而 --jobs 1 / QA 参考帧是
|
|
1345
|
+
# 设计本身的近黑金;两者 MAE 0.059、VMAF 直接掉到 1.49。
|
|
1346
|
+
# 这种"并行度不同就出不同片子"的 bug 最隐蔽——单线程跑一遍永远看不见。
|
|
1159
1347
|
script.pop("_style", None)
|
|
1160
1348
|
try:
|
|
1161
|
-
script["_style"] = resolve_style(script, verbose=False)
|
|
1349
|
+
script["_style"] = resolve_style(script, work or None, verbose=False)
|
|
1162
1350
|
except Exception as e: # noqa: BLE001
|
|
1163
1351
|
print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
|
|
1164
1352
|
script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
|
|
@@ -1179,7 +1367,7 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1179
1367
|
script["_total"] = tl["total"] # 场景算全局进度条要用
|
|
1180
1368
|
# 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
|
|
1181
1369
|
# _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
|
|
1182
|
-
script.setdefault("_style", resolve_style(script))
|
|
1370
|
+
script.setdefault("_style", resolve_style(script, work))
|
|
1183
1371
|
crf = int(script.get("crf", 18))
|
|
1184
1372
|
preset = script.get("preset", "medium")
|
|
1185
1373
|
total_frames = tl["frames"]
|
|
@@ -1256,7 +1444,8 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1256
1444
|
except OSError:
|
|
1257
1445
|
pass
|
|
1258
1446
|
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
|
|
1447
|
+
todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part,
|
|
1448
|
+
str(work)))
|
|
1260
1449
|
|
|
1261
1450
|
# 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
|
|
1262
1451
|
stale = [n for n in os.listdir(seg_dir)
|
|
@@ -1298,15 +1487,18 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1298
1487
|
return silent
|
|
1299
1488
|
|
|
1300
1489
|
|
|
1301
|
-
def _web_style_payload(we, script, tl=None):
|
|
1490
|
+
def _web_style_payload(we, script, tl=None, work=None):
|
|
1302
1491
|
"""把风格包解析成 webengine 能吃的 __STYLE__ 载荷(v0.8)。
|
|
1303
1492
|
|
|
1304
1493
|
style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
|
|
1305
1494
|
add_init_script 都不调,页面行为与 v0.7 逐字节一致(这是兼容性硬要求)。
|
|
1306
1495
|
"""
|
|
1307
1496
|
try:
|
|
1308
|
-
style_plan = resolve_style(script, verbose=False)
|
|
1309
|
-
|
|
1497
|
+
style_plan = resolve_style(script, work, verbose=False)
|
|
1498
|
+
# 必须连 SystemExit 一起接:resolve_style 在"没有设计/主题不过体检"时用 SystemExit
|
|
1499
|
+
# 报错(这是它向 CLI 报用法错误的方式),而 SystemExit 继承自 BaseException,
|
|
1500
|
+
# 只 catch Exception 接不住 —— web 页面会拿着半截载荷渲染,或者整个进程直接退出。
|
|
1501
|
+
except (Exception, SystemExit) as e: # noqa: BLE001
|
|
1310
1502
|
print(" [WARN] web 风格包解析失败,页面用内置配色:%s" % e)
|
|
1311
1503
|
return None
|
|
1312
1504
|
if not style_plan or style_plan.get("legacy"):
|
|
@@ -1335,7 +1527,7 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
|
|
|
1335
1527
|
entry = _web_entry(script)
|
|
1336
1528
|
silent = os.path.join(work, "silent.mp4")
|
|
1337
1529
|
web = script.get("web") or {}
|
|
1338
|
-
style = _web_style_payload(we, script, tl)
|
|
1530
|
+
style = _web_style_payload(we, script, tl, work)
|
|
1339
1531
|
vp = tl.get("video") or {}
|
|
1340
1532
|
canvas_w = int(vp.get("out_w") or K.W)
|
|
1341
1533
|
canvas_h = int(vp.get("out_h") or K.H)
|
|
@@ -1476,8 +1668,15 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1476
1668
|
ns = len(deep.get("sync") or [])
|
|
1477
1669
|
judged = (deep.get("sync_info") or {}).get("judged")
|
|
1478
1670
|
sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
|
|
1479
|
-
|
|
1480
|
-
|
|
1671
|
+
# v1.0.2:门禁要按 **deep.errors** 判,而不是按 flicker 条数。
|
|
1672
|
+
# qa_frames 会把"块内像素有增有减"(文字入场/母题动画/镜头缓推)的候选从
|
|
1673
|
+
# flicker 降级进 warnings(见 FLICKER_AGREE_MIN),此时 flicker 仍非空但已不成问题;
|
|
1674
|
+
# 按条数判会让降级形同虚设(零拷贝首片实测:降级后仍判 fail)。
|
|
1675
|
+
# errors 里同时也包含空窗与同步超差,所以只看 errors 不会漏判。
|
|
1676
|
+
n_err = len([e for e in (deep.get("errors") or []) if e])
|
|
1677
|
+
ok9 = n_err == 0
|
|
1678
|
+
detail9 = "问题 %d 条(闪烁候选 %d 已按符号一致度分档)· 空窗 %d · 同步超差 %d(%s)" % (
|
|
1679
|
+
n_err, nf, nb, ns, sync_note)
|
|
1481
1680
|
if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
|
|
1482
1681
|
# 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
|
|
1483
1682
|
lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
|
|
@@ -1601,8 +1800,19 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
|
1601
1800
|
if cfg["ssim"].get(ckey) is not None:
|
|
1602
1801
|
kw[key] = float(cfg["ssim"][ckey])
|
|
1603
1802
|
print(" [vmaf] 模式=%s(参考帧=场景函数实时渲染,不是成片抽帧)" % m)
|
|
1803
|
+
# qavmaf 是独立解析(它拿不到本进程的 script["_style"]),所以这里把
|
|
1804
|
+
# **冻结设计**一起传下去:参考帧必须与成片同源,否则 VMAF 会掉到个位数。
|
|
1805
|
+
# 实测未修前:design 片被解析成 tech-deep,VMAF 1.50、同源抽查 MAE=0.0384。
|
|
1806
|
+
script_for_vmaf = dict(script or {})
|
|
1807
|
+
dpath = os.path.join(str(work), "design.json")
|
|
1808
|
+
if "design" not in script_for_vmaf and os.path.exists(dpath):
|
|
1809
|
+
try:
|
|
1810
|
+
with open(dpath, encoding="utf-8") as fh:
|
|
1811
|
+
script_for_vmaf["design"] = json.load(fh)
|
|
1812
|
+
except (OSError, json.JSONDecodeError) as e: # noqa: BLE001
|
|
1813
|
+
print(" [WARN][vmaf] design.json 读不出来,参考帧可能不同源:%s" % e)
|
|
1604
1814
|
try:
|
|
1605
|
-
res = qv.report(film, tl, scenes_mod,
|
|
1815
|
+
res = qv.report(film, tl, scenes_mod, script_for_vmaf, mode=m,
|
|
1606
1816
|
out_json=os.path.join(str(work), "qa", "vmaf.json"), **kw)
|
|
1607
1817
|
except Exception as e: # noqa: BLE001
|
|
1608
1818
|
lg.fail("G11", "全片 VMAF / SSIM 画质", "门禁自身失败:%s" % e)
|
|
@@ -1699,7 +1909,7 @@ def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
|
|
|
1699
1909
|
|
|
1700
1910
|
def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
1701
1911
|
print("== QA")
|
|
1702
|
-
th = _theme_for(script) if script is not None else None
|
|
1912
|
+
th = _theme_for(script, work) if script is not None else None
|
|
1703
1913
|
shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
|
|
1704
1914
|
fracs = (0.08, 0.3, 0.55, 0.78)
|
|
1705
1915
|
ref_script = dict(script) if script is not None else None
|
|
@@ -1724,7 +1934,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
|
1724
1934
|
int(round(sec * tl["fps"])))
|
|
1725
1935
|
_hold = ref_script.get("sub_max_hold")
|
|
1726
1936
|
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, _hold), theme=th,
|
|
1727
|
-
words=K.sub_words_at(tl["scenes"], sec, _hold)
|
|
1937
|
+
words=K.sub_words_at(tl["scenes"], sec, _hold),
|
|
1938
|
+
**_sub_kw(ref_script))
|
|
1728
1939
|
refs.append((k, fr))
|
|
1729
1940
|
except Exception as e: # noqa: BLE001
|
|
1730
1941
|
print(" [WARN] 参考帧渲染失败:", e)
|
|
@@ -1831,7 +2042,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
1831
2042
|
print("== Stills")
|
|
1832
2043
|
script = dict(script)
|
|
1833
2044
|
script["_total"] = tl["total"]
|
|
1834
|
-
script.setdefault("_style", resolve_style(script))
|
|
2045
|
+
script.setdefault("_style", resolve_style(script, work))
|
|
1835
2046
|
outdir = os.path.join(work, "stills")
|
|
1836
2047
|
os.makedirs(outdir, exist_ok=True)
|
|
1837
2048
|
by_id = {s["id"]: s for s in tl["scenes"]}
|
|
@@ -1860,7 +2071,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
1860
2071
|
_hold = script.get("sub_max_hold")
|
|
1861
2072
|
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, _hold),
|
|
1862
2073
|
theme=_theme_for(script),
|
|
1863
|
-
words=K.sub_words_at(tl["scenes"], tg, _hold)
|
|
2074
|
+
words=K.sub_words_at(tl["scenes"], tg, _hold),
|
|
2075
|
+
**_sub_kw(script))
|
|
1864
2076
|
p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
|
|
1865
2077
|
fr.save(p)
|
|
1866
2078
|
made.append(p)
|
|
@@ -2122,12 +2334,94 @@ def run_batch(a):
|
|
|
2122
2334
|
return 0 if okn == len(results) else 1
|
|
2123
2335
|
|
|
2124
2336
|
|
|
2337
|
+
# ---------------------------------------------------------------- 提案闸(v1.0)
|
|
2338
|
+
def _proposal_gate(a):
|
|
2339
|
+
"""提案闸:frames 之前必须先有"已确认的设计"。
|
|
2340
|
+
|
|
2341
|
+
D3 之后本项目的主路径是"先提案、再出片",所以这条闸是语义要求,不是可选装饰。
|
|
2342
|
+
但它默认只警告(0 退出码),加 --require-proposal 才失败——原因只有一条:
|
|
2343
|
+
--stage frames 是开发期高频命令(改一帧看一帧),硬拦会让调试变得没法用。
|
|
2344
|
+
|
|
2345
|
+
判定(propose.proposal_state 的几档):
|
|
2346
|
+
absent -> 没有任何提案
|
|
2347
|
+
draft -> 有提案但没确认
|
|
2348
|
+
confirmed -> 已确认且 design.json 指纹与确认时一致(唯一放行态)
|
|
2349
|
+
stale -> 确认过但 design.json 被改过(等于没确认)
|
|
2350
|
+
"""
|
|
2351
|
+
try:
|
|
2352
|
+
import propose as PP
|
|
2353
|
+
except ImportError:
|
|
2354
|
+
print("[proposal] 没有 propose.py,跳过提案检查(只警告)")
|
|
2355
|
+
return True
|
|
2356
|
+
try:
|
|
2357
|
+
status, detail = PP.proposal_state(a.work)
|
|
2358
|
+
except SystemExit as e:
|
|
2359
|
+
status, detail = "broken", str(e)
|
|
2360
|
+
except Exception as e: # noqa: BLE001
|
|
2361
|
+
status, detail = "broken", "%s: %s" % (type(e).__name__, e)
|
|
2362
|
+
|
|
2363
|
+
if status == "confirmed":
|
|
2364
|
+
print("[proposal] OK %s" % detail)
|
|
2365
|
+
_LEDGER.ok("proposal", "提案已确认", detail)
|
|
2366
|
+
return True
|
|
2367
|
+
|
|
2368
|
+
msg = "提案闸未通过(%s):%s" % (status, detail)
|
|
2369
|
+
print("[proposal] WARN %s" % msg)
|
|
2370
|
+
if status in ("absent", "draft"):
|
|
2371
|
+
print(" 先跑:python propose.py --brief \"题材/要求\" --script %s --n 3" % a.script)
|
|
2372
|
+
print(" 确认:python propose.py --confirm --candidate A")
|
|
2373
|
+
if bool(getattr(a, "require_proposal", False)):
|
|
2374
|
+
_LEDGER.fail("proposal", "提案未确认", msg)
|
|
2375
|
+
print("\n[提案闸] --require-proposal 下这是失败项:先提案并确认,或去掉该参数")
|
|
2376
|
+
return False
|
|
2377
|
+
_LEDGER.skip("proposal", "提案闸", msg + "(默认只警告;--require-proposal 才失败)")
|
|
2378
|
+
return True
|
|
2379
|
+
|
|
2380
|
+
|
|
2381
|
+
# ---------------------------------------------------------------- 场景模块定位
|
|
2382
|
+
def _builtin_scenes() -> str:
|
|
2383
|
+
"""本技能内置的场景绘制模块路径(装成插件后在包内 skills/video-studio/assets/scenes.py)。"""
|
|
2384
|
+
cand = (Path(__file__).resolve().parent.parent / "assets" / "scenes.py")
|
|
2385
|
+
return str(cand) if cand.exists() else ""
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
def _resolve_scenes(scenes_arg, script_path) -> str:
|
|
2389
|
+
"""定位场景模块,按优先级:显式 --scenes > 剧本同目录 scenes.py > 技能内置 scenes.py。
|
|
2390
|
+
|
|
2391
|
+
为什么要这条回退链(v1.0.2):旧流程要求用户把 assets/scenes.py 拷进工作目录,
|
|
2392
|
+
即「提供一份模板代码」——与 v1.0.1「不提供任何模板」直接冲突,而且用户每部片都得
|
|
2393
|
+
重拷一份 98KB 的绘制代码。现在把内置渲染器当**默认能力**:工作目录里没有 scenes.py
|
|
2394
|
+
时直接用它出片;需要改画面再写自己的 scenes.py(显式指定或用同目录约定)。
|
|
2395
|
+
"""
|
|
2396
|
+
if scenes_arg:
|
|
2397
|
+
p = Path(scenes_arg).resolve()
|
|
2398
|
+
if p.exists():
|
|
2399
|
+
return str(p)
|
|
2400
|
+
raise SystemExit(
|
|
2401
|
+
"[缺少场景模块] --scenes 指向的 %s 不存在。\n"
|
|
2402
|
+
" 不写 --scenes 时会自动用剧本同目录的 scenes.py 或技能内置渲染器;"
|
|
2403
|
+
|
|
2404
|
+
"只想用内置的就去掉 --scenes。" % p)
|
|
2405
|
+
cand = Path(script_path).resolve().parent / "scenes.py"
|
|
2406
|
+
if cand.exists():
|
|
2407
|
+
return str(cand)
|
|
2408
|
+
builtin = _builtin_scenes()
|
|
2409
|
+
if builtin:
|
|
2410
|
+
return builtin
|
|
2411
|
+
raise SystemExit(
|
|
2412
|
+
"[缺少场景模块] 找不到 %s,也找不到技能内置渲染器(%s)。\n"
|
|
2413
|
+
" 装成插件时内置渲染器应在 <包>/skills/video-studio/assets/scenes.py;"
|
|
2414
|
+
|
|
2415
|
+
"请检查插件包是否完整,或显式传 --scenes 指向你自己的 scenes.py。" % (cand, _builtin_scenes() or "missing"))
|
|
2416
|
+
|
|
2417
|
+
|
|
2125
2418
|
# ---------------------------------------------------------------- main
|
|
2126
2419
|
def main():
|
|
2127
2420
|
_setup_stdio()
|
|
2128
2421
|
ap = argparse.ArgumentParser(description="video-studio 编排器")
|
|
2129
2422
|
ap.add_argument("--script", default="film.json")
|
|
2130
|
-
ap.add_argument("--scenes", default="
|
|
2423
|
+
ap.add_argument("--scenes", default="",
|
|
2424
|
+
help="场景绘制模块(默认依次找:<剧本目录>/scenes.py、本技能内置的 scenes.py)")
|
|
2131
2425
|
ap.add_argument("--out", default="out/film.mp4")
|
|
2132
2426
|
ap.add_argument("--work", default="work")
|
|
2133
2427
|
ap.add_argument("--stage", default="all", choices=STAGES)
|
|
@@ -2152,6 +2446,8 @@ def main():
|
|
|
2152
2446
|
help="过时也照跑(会在 QC 报告里留痕)")
|
|
2153
2447
|
ap.add_argument("--no-gate", action="store_true",
|
|
2154
2448
|
help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
|
|
2449
|
+
ap.add_argument("--require-proposal", action="store_true",
|
|
2450
|
+
help="把'提案未确认'当成失败(默认只警告;先跑 propose.py 再出片)")
|
|
2155
2451
|
ap.add_argument("--cost", action="store_true",
|
|
2156
2452
|
help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
|
|
2157
2453
|
# ---- v0.8 ----
|
|
@@ -2183,7 +2479,7 @@ def main():
|
|
|
2183
2479
|
if engine not in ("pil", "web"):
|
|
2184
2480
|
raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
|
|
2185
2481
|
script["_engine"] = engine
|
|
2186
|
-
script["_scenes"] =
|
|
2482
|
+
script["_scenes"] = _resolve_scenes(a.scenes, a.script)
|
|
2187
2483
|
|
|
2188
2484
|
# ---- 门禁账本(v0.6.0)----
|
|
2189
2485
|
global _LEDGER
|
|
@@ -2204,8 +2500,15 @@ def main():
|
|
|
2204
2500
|
print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
|
|
2205
2501
|
|
|
2206
2502
|
def _sources():
|
|
2207
|
-
"""
|
|
2208
|
-
|
|
2503
|
+
"""源头输入:剧本、scenes.py、冻结设计(改它们等于改了所有下游的依据)。
|
|
2504
|
+
|
|
2505
|
+
design.json 是 v1.0 新增的第三个源头:它由 propose.py --confirm 写出,
|
|
2506
|
+
记录"这一版长什么样"。改设计 = 画面必须重渲,所以它必须进 stagewatch 的
|
|
2507
|
+
源头指纹,否则"改了配色但没重跑 frames"这种事故查不出来。
|
|
2508
|
+
"""
|
|
2509
|
+
design = os.path.join(a.work, "design.json")
|
|
2510
|
+
return {"script": a.script, "scenes": a.scenes,
|
|
2511
|
+
"design": design if os.path.exists(design) else ""}
|
|
2209
2512
|
|
|
2210
2513
|
# 本次命令行**计划要跑**的 stage 集合:--stage all 展开成 10 个,单 stage 就是它自己。
|
|
2211
2514
|
# 用途见 stagewatch.check 的 pending:本阶段马上要用新输入重算,那种"输入变了"不是过时。
|
|
@@ -2323,21 +2626,27 @@ def main():
|
|
|
2323
2626
|
raise SystemExit("[前置] %s 不是合法 JSON(%s),重新跑 --stage layout" % (tl_p, e)) from e
|
|
2324
2627
|
|
|
2325
2628
|
def load_scenes(strict=True):
|
|
2326
|
-
"""加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。
|
|
2629
|
+
"""加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。
|
|
2630
|
+
|
|
2631
|
+
v1.0.2:路径用 main() 里解析好的 script["_scenes"](显式 --scenes / 剧本同目录 /
|
|
2632
|
+
技能内置三档回退),不能再用原始 a.scenes —— 缺省时它是空串,会变成
|
|
2633
|
+
`load_module("")` 这种 "NoneType has no attribute loader" 的怪错误。
|
|
2634
|
+
"""
|
|
2635
|
+
scenes_path = script.get("_scenes") or _resolve_scenes(a.scenes, a.script)
|
|
2327
2636
|
try:
|
|
2328
|
-
mod = load_module(
|
|
2637
|
+
mod = load_module(scenes_path)
|
|
2329
2638
|
except Exception as e: # noqa: BLE001
|
|
2330
2639
|
if strict:
|
|
2331
2640
|
raise SystemExit("[场景模块加载失败] %s:%s\n"
|
|
2332
2641
|
" --scenes 要指向定义 draw_frame() 的 scenes.py"
|
|
2333
|
-
% (
|
|
2334
|
-
print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (
|
|
2642
|
+
% (scenes_path, e)) from e
|
|
2643
|
+
print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (scenes_path, e))
|
|
2335
2644
|
return None
|
|
2336
2645
|
if not hasattr(mod, "draw_frame"):
|
|
2337
2646
|
if strict:
|
|
2338
2647
|
raise SystemExit("[场景模块错误] %s 没有 draw_frame(scene, t_local, dur, t_global, script, frame)"
|
|
2339
|
-
%
|
|
2340
|
-
print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" %
|
|
2648
|
+
% scenes_path)
|
|
2649
|
+
print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" % scenes_path)
|
|
2341
2650
|
return None
|
|
2342
2651
|
return mod
|
|
2343
2652
|
|
|
@@ -2362,7 +2671,11 @@ def main():
|
|
|
2362
2671
|
print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
|
|
2363
2672
|
sc2 = dict(script)
|
|
2364
2673
|
sc2["_total"] = tl["total"]
|
|
2365
|
-
|
|
2674
|
+
# 注意这里之前写成 setdefault(_style, resolve_style(script)):
|
|
2675
|
+
# 对 sc2 做 setdefault 却传 script 的解析结果 —— script 有 _style 时,
|
|
2676
|
+
# setdefault 的第二参数**仍会被求值**(Python 先算参数),等于多做一次无用解析;
|
|
2677
|
+
# 而 script 没有 _style 时又把 work 漏了,单跑 --preview 会报"找不到设计"。
|
|
2678
|
+
sc2.setdefault("_style", resolve_style(script, a.work))
|
|
2366
2679
|
_render_range(tl, mod, sc2, 0, n, out_mp4, int(script.get("crf", 18)),
|
|
2367
2680
|
str(script.get("preset", "medium")), progress=False)
|
|
2368
2681
|
# 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
|
|
@@ -2567,6 +2880,11 @@ def main():
|
|
|
2567
2880
|
return 1 if (errs or rc) else 0
|
|
2568
2881
|
|
|
2569
2882
|
if stage in ("all", "frames", "mux"):
|
|
2883
|
+
# ---- 提案闸(v1.0):出片前必须先有已确认的设计 ----
|
|
2884
|
+
# 放在最前面(before ensure_tl/preflight/st_frames):渲完再拦等于白渲。
|
|
2885
|
+
if stage in ("all", "frames"):
|
|
2886
|
+
if not _proposal_gate(a):
|
|
2887
|
+
return 1
|
|
2570
2888
|
tl = tl if stage == "all" else ensure_tl()
|
|
2571
2889
|
audio = os.path.join(a.work, "audio.wav")
|
|
2572
2890
|
silent = os.path.join(a.work, "silent.mp4")
|