@dsh-gis/dsh-video-studio-skill 0.8.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 +582 -0
- package/README.md +287 -304
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +11 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +171 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1066
- 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 +12 -9
- package/skills/video-studio/assets/scenes.py +2207 -1190
- 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 +2 -2
- package/skills/video-studio/references/scene-grammar.md +311 -0
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +107 -168
- 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 +104 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +619 -0
- 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 +505 -142
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +851 -115
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +293 -570
- 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 +9 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +1263 -0
- 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-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,41 +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
|
-
|
|
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
|
+
"""
|
|
330
423
|
plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
|
|
331
424
|
if plan is not None and plan.get("legacy"):
|
|
332
425
|
return None
|
|
333
|
-
plan = resolve_style(script)
|
|
426
|
+
plan = resolve_style(script, work)
|
|
334
427
|
if plan.get("legacy"):
|
|
335
428
|
return None
|
|
336
429
|
# plan["theme"] 是 stylekit 合并过 film.json 的 visual.theme_overrides 之后的最终主题,
|
|
@@ -341,7 +434,22 @@ def _theme_for(script):
|
|
|
341
434
|
th = plan.get("theme")
|
|
342
435
|
if isinstance(th, dict):
|
|
343
436
|
return th
|
|
344
|
-
|
|
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"),))
|
|
345
453
|
|
|
346
454
|
|
|
347
455
|
def _print_style(plan):
|
|
@@ -353,8 +461,36 @@ def _print_style(plan):
|
|
|
353
461
|
print(" 主题: %s 版式: %s" % (plan["theme_id"],
|
|
354
462
|
",".join(sorted(set(plan["chapter_layout"].values())))
|
|
355
463
|
or plan["layout_default"]))
|
|
464
|
+
# v0.9:视觉语言(结构签名)也要能在这里看到 —— 用户判断"这套风格是不是只是换色"
|
|
465
|
+
# 的第一现场就是 --stage style 的打印,藏起来等于让他去读 style.json。
|
|
466
|
+
vl = plan.get("language") if isinstance(plan.get("language"), dict) else None
|
|
467
|
+
if vl:
|
|
468
|
+
mf = vl.get("motif") or {}
|
|
469
|
+
tr = vl.get("transition") or {}
|
|
470
|
+
ty = vl.get("typography") or {}
|
|
471
|
+
bi = vl.get("bias") or {}
|
|
472
|
+
print(" 视觉语言: 母题=%s 转场=%s(%.2fs) 字距=%.1f 编号=%s 重心=(%+.0f,%+.0f)"
|
|
473
|
+
% (mf.get("kind"), tr.get("kind"), float(tr.get("dur") or 0.0),
|
|
474
|
+
float(ty.get("tracking") or 0.0), ty.get("number"),
|
|
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)))
|
|
356
480
|
print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
|
|
357
|
-
% (
|
|
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)))
|
|
358
494
|
if v["map"]:
|
|
359
495
|
print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
|
|
360
496
|
p = plan["pacing"]
|
|
@@ -511,10 +647,33 @@ def st_layout(script, work, lines=None):
|
|
|
511
647
|
"legacy": plan["legacy"],
|
|
512
648
|
"chapter_layout": plan["chapter_layout"],
|
|
513
649
|
"item_stagger": pace["item_stagger"]}
|
|
650
|
+
# ---- v0.9 场景语法:内容 -> 构图选型(落 timeline.scenes[] 与 work/storyboard.json) ----
|
|
651
|
+
# legacy 不注入:v0.4 的三个老绘制器只读 kind,注入新字段等于给基线加噪声。
|
|
652
|
+
if not plan["legacy"]:
|
|
653
|
+
try:
|
|
654
|
+
import grammar as G
|
|
655
|
+
except Exception as e: # noqa: BLE001
|
|
656
|
+
G = None
|
|
657
|
+
print(" [WARN] 没有 grammar.py,本次全部按 kind 走兼容构图:%s" % e)
|
|
658
|
+
if G is not None:
|
|
659
|
+
sb = G.plan_scenes(tl["scenes"], script, plan)
|
|
660
|
+
try:
|
|
661
|
+
json.dump(sb, open(os.path.join(work, "storyboard.json"), "w", encoding="utf-8"),
|
|
662
|
+
ensure_ascii=False, indent=1)
|
|
663
|
+
except Exception as e: # noqa: BLE001
|
|
664
|
+
print(" [WARN] storyboard.json 落盘失败:%s" % e)
|
|
665
|
+
print(" 分镜: %s" % G.describe(sb))
|
|
666
|
+
for r in sb["scenes"]:
|
|
667
|
+
print(" %-6s %-8s -> %-9s v%d d%d %s"
|
|
668
|
+
% (r["id"], r["kind"], r["type"], r["variant"], r["decor"],
|
|
669
|
+
";".join(r["reason"][:2]) or "-"))
|
|
670
|
+
tl["storyboard"] = {"type_counts": sb["type_counts"]}
|
|
514
671
|
json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
|
|
515
672
|
ensure_ascii=False, indent=1)
|
|
516
673
|
n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
|
|
517
|
-
theme
|
|
674
|
+
# 同上:设计计划自带 theme,不能假定 theme_id 一定在内置表里(见 _theme_for 的注释)
|
|
675
|
+
# v1.0.1:没有内置主题表了,theme 必须由计划自带(design 路径一定有)
|
|
676
|
+
theme = (plan.get("theme") if not plan["legacy"] else None)
|
|
518
677
|
n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
|
|
519
678
|
print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
|
|
520
679
|
% (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
|
|
@@ -530,6 +689,26 @@ def st_layout(script, work, lines=None):
|
|
|
530
689
|
print(" %-6s %-7s %r" % (lid, fld, chars))
|
|
531
690
|
for s in tl["scenes"]:
|
|
532
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 只会加留白,不会缩短旁白)。")
|
|
533
712
|
return tl
|
|
534
713
|
|
|
535
714
|
|
|
@@ -569,6 +748,15 @@ def st_cues(script, work, tl, engine="pil"):
|
|
|
569
748
|
# scenes.py 里的 cues() 钩子:允许直接返回 [{at,type,...}] 或调用 bus.add(...)
|
|
570
749
|
n_hook = 0
|
|
571
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 阶段不因它失败,钩子里真需要会自己报错
|
|
572
760
|
if script.get("_scenes") and os.path.exists(script["_scenes"]):
|
|
573
761
|
try:
|
|
574
762
|
mod = load_module(script["_scenes"])
|
|
@@ -638,7 +826,7 @@ def _st_cues_web(script, work):
|
|
|
638
826
|
tl = json.load(open(tp, encoding="utf-8"))
|
|
639
827
|
except Exception: # noqa: BLE001
|
|
640
828
|
tl = {}
|
|
641
|
-
style = _web_style_payload(we, script, tl)
|
|
829
|
+
style = _web_style_payload(we, script, tl, work)
|
|
642
830
|
vp = (tl.get("video") or {})
|
|
643
831
|
data = we.export_cues(entry, style=style,
|
|
644
832
|
width=int(vp.get("out_w") or K.W),
|
|
@@ -648,33 +836,19 @@ def _st_cues_web(script, work):
|
|
|
648
836
|
|
|
649
837
|
|
|
650
838
|
# ---------------------------------------------------------------- 配乐 / 音效
|
|
651
|
-
def
|
|
652
|
-
"""
|
|
839
|
+
def _score_with_musicgen(script, work, tl, plan_s, mus):
|
|
840
|
+
"""用参数化编排器出成片配乐(与 previewkit 的样音同一个引擎、同一组参数)。
|
|
653
841
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
- mode=file:外部 work/music.wav,原样交给 audio 阶段;
|
|
659
|
-
- 缺省:有 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,无一是死旋钮。
|
|
660
846
|
"""
|
|
661
|
-
print("== Score")
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
|
|
665
|
-
print(" " + why)
|
|
666
|
-
if plan in ("off", "bed", "file", "none"):
|
|
667
|
-
return None
|
|
668
|
-
if plan in ("missing-file", "missing-bpm", "unknown-mode"):
|
|
669
|
-
raise SystemExit("[score] " + why)
|
|
670
|
-
sk = _try_module("scorekit")
|
|
671
|
-
if sk is None:
|
|
672
|
-
print(" [WARN] 没有 scorekit.py,跳过配乐(audio 阶段没有 music.wav,本片将无配乐)")
|
|
673
|
-
return None
|
|
674
|
-
anchors = K.scenes_anchors(tl["scenes"])
|
|
847
|
+
print("== Score(musicgen 参数化编排)")
|
|
848
|
+
mg = _try_module("musicgen")
|
|
849
|
+
mp = plan_s["music"]
|
|
675
850
|
sections = mus.get("sections")
|
|
676
851
|
if sections:
|
|
677
|
-
# film.json 允许用 id 表述分段起点,换成绝对秒
|
|
678
852
|
fixed = []
|
|
679
853
|
for s in sections:
|
|
680
854
|
s2 = dict(s)
|
|
@@ -682,76 +856,104 @@ def st_score(script, work, tl):
|
|
|
682
856
|
s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
|
|
683
857
|
fixed.append(s2)
|
|
684
858
|
sections = fixed
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
fp_hex = ""
|
|
696
|
-
if not plan_s["legacy"]:
|
|
697
|
-
fp_hex = str((plan_s.get("fingerprint") or {}).get("full") or "")
|
|
698
|
-
kw_fp = dict(kwargs, fingerprint=fp_hex) if fp_hex else kwargs
|
|
699
|
-
# scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
|
|
700
|
-
# 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
|
|
701
|
-
# 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
|
|
702
|
-
# 四级回退:带指纹 -> 不带指纹 -> 只有 style/mood -> 最老的签名。
|
|
703
|
-
pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
|
|
704
|
-
"hold": float(plan_s["pacing"]["hold"])}
|
|
705
|
-
mood_arg = str(mp.get("mood") or "steady")
|
|
706
|
-
samples = report = None
|
|
707
|
-
# v0.7.2:多级签名兼容链以前对**任何** TypeError 都继续换签名试,最后统一报
|
|
708
|
-
# "不接受任何一种已知签名"。scorekit 内部真出 TypeError(例如某个参数是 None 被拿去做
|
|
709
|
-
# 算术)时,用户会照着"签名不兼容"的方向白找半天。这里区分两类:
|
|
710
|
-
# - 报错文本含 "unexpected keyword argument" -> 确实是不认识这个参数,换签名重试;
|
|
711
|
-
# - 其它 TypeError -> 是**内部**错误,带上签名与错误信息立刻报,别吞。
|
|
712
|
-
attempts = (
|
|
713
|
-
dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kw_fp),
|
|
714
|
-
dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kwargs),
|
|
715
|
-
dict(style=style_arg, mood=mood_arg, **kwargs),
|
|
716
|
-
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,
|
|
717
869
|
)
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if report and not plan_s["legacy"]:
|
|
734
|
-
print(" 配乐风格: %s / mood %s / %s BPM / %s"
|
|
735
|
-
% (report.get("style") or style_arg, mp.get("mood"),
|
|
736
|
-
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
|
|
737
885
|
out = os.path.join(work, "music.wav")
|
|
738
|
-
|
|
886
|
+
mg.save(out, samples)
|
|
739
887
|
if report:
|
|
740
888
|
json.dump(report, open(os.path.join(work, "score.json"), "w", encoding="utf-8"),
|
|
741
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")))
|
|
742
893
|
samples_read, _sr = K.wav_read(out)
|
|
743
894
|
print(" music.wav %.2fs 峰值 %.3f %.1fs" % (
|
|
744
895
|
K.wav_dur(out), float(np.max(np.abs(samples_read))), time.time() - t0))
|
|
745
896
|
return out
|
|
746
897
|
|
|
747
898
|
|
|
748
|
-
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):
|
|
749
951
|
"""v0.7:算音效的内容派生上下文 (style, content_fp, key, enabled)。
|
|
750
952
|
|
|
751
953
|
legacy(没写 style 字段)时返回全 None -> CueBus 走旧的 seed_base + i 路径,
|
|
752
954
|
v0.4 及以前的片子逐样本不变。参考 references/audio-variation.md §3.2 / §4。
|
|
753
955
|
"""
|
|
754
|
-
plan = resolve_style(script)
|
|
956
|
+
plan = resolve_style(script, work)
|
|
755
957
|
sfx_cfg = script.get("sfx") or {}
|
|
756
958
|
want_content = bool(sfx_cfg.get("content_seed", True))
|
|
757
959
|
if plan.get("legacy") or not want_content:
|
|
@@ -764,7 +966,7 @@ def _sfx_seed_context(script):
|
|
|
764
966
|
def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
|
|
765
967
|
"""段落边界前的转场音效自动让位(v0.7,契约 §4)。
|
|
766
968
|
|
|
767
|
-
|
|
969
|
+
配乐在段落交界自己已经有一段过门(编排器写的 swell / riser / sub_boom),
|
|
768
970
|
sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
|
|
769
971
|
做法:对位于 [边界-window, 边界] 内的转场类 cue,在它的时长内乘一个 dB 增益包络。
|
|
770
972
|
只压转场类,不碰 impact / 提示音(那是"该响"的语义重音)。
|
|
@@ -832,7 +1034,7 @@ def st_sfx(script, work, tl):
|
|
|
832
1034
|
print(" cues.json 里没有提示点,跳过")
|
|
833
1035
|
return None
|
|
834
1036
|
sfx_cfg = script.get("sfx") or {}
|
|
835
|
-
style, content_fp, key = _sfx_seed_context(script)
|
|
1037
|
+
style, content_fp, key = _sfx_seed_context(script, work)
|
|
836
1038
|
try:
|
|
837
1039
|
samples = bus.build_audio(total=tl["total"],
|
|
838
1040
|
base_db=sfx_cfg.get("base_db"),
|
|
@@ -874,7 +1076,7 @@ def _voice_track(tl, script):
|
|
|
874
1076
|
|
|
875
1077
|
|
|
876
1078
|
def _legacy_music(tl, script):
|
|
877
|
-
"""v0.2 的和弦床(没有
|
|
1079
|
+
"""v0.2 的和弦床(没有 music.wav 时的兜底)。"""
|
|
878
1080
|
mus = script.get("music") or {}
|
|
879
1081
|
if not (mus.get("enabled", True) and mus.get("chords")):
|
|
880
1082
|
return None
|
|
@@ -924,7 +1126,7 @@ def st_audio(tl, work, script):
|
|
|
924
1126
|
print(" 配乐: " + why)
|
|
925
1127
|
if plan == "score" and music is None:
|
|
926
1128
|
# 单独跑 --stage audio 时 score 还没跑过:不混配乐,但必须说清楚为什么
|
|
927
|
-
print(" [WARN] music
|
|
1129
|
+
print(" [WARN] music.mode=score 但 %s 不存在(先跑 --stage score);本次无配乐"
|
|
928
1130
|
% music_path)
|
|
929
1131
|
|
|
930
1132
|
sfx = None
|
|
@@ -943,7 +1145,7 @@ def st_audio(tl, work, script):
|
|
|
943
1145
|
sfx = bus.bus[: int(round(total * K.SR))].copy()
|
|
944
1146
|
print(" 音效: 旧版转场 whoosh(没有 cues.json)")
|
|
945
1147
|
|
|
946
|
-
plan_s = resolve_style(script)
|
|
1148
|
+
plan_s = resolve_style(script, work)
|
|
947
1149
|
# 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
|
|
948
1150
|
# 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
|
|
949
1151
|
# v0.7:整体 +1 dB —— 旧值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
|
|
@@ -1026,6 +1228,29 @@ def _run_preflight(script, work, engine, tl=None, skip=False):
|
|
|
1026
1228
|
return True
|
|
1027
1229
|
|
|
1028
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
|
+
|
|
1029
1254
|
# ---------------------------------------------------------------- 渲染
|
|
1030
1255
|
def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True):
|
|
1031
1256
|
"""渲染 [f0, f1) 帧区间到 out_path。纯函数:同一帧号永远画出同一张图。"""
|
|
@@ -1051,7 +1276,8 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
|
|
|
1051
1276
|
_THEME_CACHE.update(k=id(script), th=th)
|
|
1052
1277
|
_hold = script.get("sub_max_hold")
|
|
1053
1278
|
frame = K.draw_subtitle(frame, K.sub_at(scenes, t, _hold), theme=th,
|
|
1054
|
-
words=K.sub_words_at(scenes, t, _hold)
|
|
1279
|
+
words=K.sub_words_at(scenes, t, _hold),
|
|
1280
|
+
**_sub_kw(script))
|
|
1055
1281
|
fade = 1.0
|
|
1056
1282
|
if t < 0.5:
|
|
1057
1283
|
fade *= K.ease_out(t / 0.5)
|
|
@@ -1103,7 +1329,8 @@ def _seg_usable(path, frames):
|
|
|
1103
1329
|
|
|
1104
1330
|
def _render_chunk_worker(args):
|
|
1105
1331
|
"""分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
|
|
1106
|
-
(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 ""
|
|
1107
1334
|
tl = json.load(open(tl_path, encoding="utf-8"))
|
|
1108
1335
|
script = json.load(open(script_path, encoding="utf-8"))
|
|
1109
1336
|
script["_total"] = tl["total"]
|
|
@@ -1111,9 +1338,15 @@ def _render_chunk_worker(args):
|
|
|
1111
1338
|
# scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
|
|
1112
1339
|
# 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
|
|
1113
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 最隐蔽——单线程跑一遍永远看不见。
|
|
1114
1347
|
script.pop("_style", None)
|
|
1115
1348
|
try:
|
|
1116
|
-
script["_style"] = resolve_style(script, verbose=False)
|
|
1349
|
+
script["_style"] = resolve_style(script, work or None, verbose=False)
|
|
1117
1350
|
except Exception as e: # noqa: BLE001
|
|
1118
1351
|
print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
|
|
1119
1352
|
script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
|
|
@@ -1134,7 +1367,7 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1134
1367
|
script["_total"] = tl["total"] # 场景算全局进度条要用
|
|
1135
1368
|
# 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
|
|
1136
1369
|
# _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
|
|
1137
|
-
script.setdefault("_style", resolve_style(script))
|
|
1370
|
+
script.setdefault("_style", resolve_style(script, work))
|
|
1138
1371
|
crf = int(script.get("crf", 18))
|
|
1139
1372
|
preset = script.get("preset", "medium")
|
|
1140
1373
|
total_frames = tl["frames"]
|
|
@@ -1211,7 +1444,8 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1211
1444
|
except OSError:
|
|
1212
1445
|
pass
|
|
1213
1446
|
part = os.path.join(seg_dir, "_part_" + os.path.basename(seg))
|
|
1214
|
-
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)))
|
|
1215
1449
|
|
|
1216
1450
|
# 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
|
|
1217
1451
|
stale = [n for n in os.listdir(seg_dir)
|
|
@@ -1253,15 +1487,18 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
|
|
|
1253
1487
|
return silent
|
|
1254
1488
|
|
|
1255
1489
|
|
|
1256
|
-
def _web_style_payload(we, script, tl=None):
|
|
1490
|
+
def _web_style_payload(we, script, tl=None, work=None):
|
|
1257
1491
|
"""把风格包解析成 webengine 能吃的 __STYLE__ 载荷(v0.8)。
|
|
1258
1492
|
|
|
1259
1493
|
style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
|
|
1260
1494
|
add_init_script 都不调,页面行为与 v0.7 逐字节一致(这是兼容性硬要求)。
|
|
1261
1495
|
"""
|
|
1262
1496
|
try:
|
|
1263
|
-
style_plan = resolve_style(script, verbose=False)
|
|
1264
|
-
|
|
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
|
|
1265
1502
|
print(" [WARN] web 风格包解析失败,页面用内置配色:%s" % e)
|
|
1266
1503
|
return None
|
|
1267
1504
|
if not style_plan or style_plan.get("legacy"):
|
|
@@ -1290,7 +1527,7 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
|
|
|
1290
1527
|
entry = _web_entry(script)
|
|
1291
1528
|
silent = os.path.join(work, "silent.mp4")
|
|
1292
1529
|
web = script.get("web") or {}
|
|
1293
|
-
style = _web_style_payload(we, script, tl)
|
|
1530
|
+
style = _web_style_payload(we, script, tl, work)
|
|
1294
1531
|
vp = tl.get("video") or {}
|
|
1295
1532
|
canvas_w = int(vp.get("out_w") or K.W)
|
|
1296
1533
|
canvas_h = int(vp.get("out_h") or K.H)
|
|
@@ -1431,8 +1668,15 @@ def _qa_gates(lg, rep, deep, tl, work):
|
|
|
1431
1668
|
ns = len(deep.get("sync") or [])
|
|
1432
1669
|
judged = (deep.get("sync_info") or {}).get("judged")
|
|
1433
1670
|
sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
|
|
1434
|
-
|
|
1435
|
-
|
|
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)
|
|
1436
1680
|
if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
|
|
1437
1681
|
# 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
|
|
1438
1682
|
lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
|
|
@@ -1556,8 +1800,19 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
|
|
|
1556
1800
|
if cfg["ssim"].get(ckey) is not None:
|
|
1557
1801
|
kw[key] = float(cfg["ssim"][ckey])
|
|
1558
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)
|
|
1559
1814
|
try:
|
|
1560
|
-
res = qv.report(film, tl, scenes_mod,
|
|
1815
|
+
res = qv.report(film, tl, scenes_mod, script_for_vmaf, mode=m,
|
|
1561
1816
|
out_json=os.path.join(str(work), "qa", "vmaf.json"), **kw)
|
|
1562
1817
|
except Exception as e: # noqa: BLE001
|
|
1563
1818
|
lg.fail("G11", "全片 VMAF / SSIM 画质", "门禁自身失败:%s" % e)
|
|
@@ -1654,7 +1909,7 @@ def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
|
|
|
1654
1909
|
|
|
1655
1910
|
def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
1656
1911
|
print("== QA")
|
|
1657
|
-
th = _theme_for(script) if script is not None else None
|
|
1912
|
+
th = _theme_for(script, work) if script is not None else None
|
|
1658
1913
|
shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
|
|
1659
1914
|
fracs = (0.08, 0.3, 0.55, 0.78)
|
|
1660
1915
|
ref_script = dict(script) if script is not None else None
|
|
@@ -1679,7 +1934,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
|
|
|
1679
1934
|
int(round(sec * tl["fps"])))
|
|
1680
1935
|
_hold = ref_script.get("sub_max_hold")
|
|
1681
1936
|
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, _hold), theme=th,
|
|
1682
|
-
words=K.sub_words_at(tl["scenes"], sec, _hold)
|
|
1937
|
+
words=K.sub_words_at(tl["scenes"], sec, _hold),
|
|
1938
|
+
**_sub_kw(ref_script))
|
|
1683
1939
|
refs.append((k, fr))
|
|
1684
1940
|
except Exception as e: # noqa: BLE001
|
|
1685
1941
|
print(" [WARN] 参考帧渲染失败:", e)
|
|
@@ -1786,7 +2042,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
1786
2042
|
print("== Stills")
|
|
1787
2043
|
script = dict(script)
|
|
1788
2044
|
script["_total"] = tl["total"]
|
|
1789
|
-
script.setdefault("_style", resolve_style(script))
|
|
2045
|
+
script.setdefault("_style", resolve_style(script, work))
|
|
1790
2046
|
outdir = os.path.join(work, "stills")
|
|
1791
2047
|
os.makedirs(outdir, exist_ok=True)
|
|
1792
2048
|
by_id = {s["id"]: s for s in tl["scenes"]}
|
|
@@ -1815,7 +2071,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
|
|
|
1815
2071
|
_hold = script.get("sub_max_hold")
|
|
1816
2072
|
fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, _hold),
|
|
1817
2073
|
theme=_theme_for(script),
|
|
1818
|
-
words=K.sub_words_at(tl["scenes"], tg, _hold)
|
|
2074
|
+
words=K.sub_words_at(tl["scenes"], tg, _hold),
|
|
2075
|
+
**_sub_kw(script))
|
|
1819
2076
|
p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
|
|
1820
2077
|
fr.save(p)
|
|
1821
2078
|
made.append(p)
|
|
@@ -2077,12 +2334,94 @@ def run_batch(a):
|
|
|
2077
2334
|
return 0 if okn == len(results) else 1
|
|
2078
2335
|
|
|
2079
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
|
+
|
|
2080
2418
|
# ---------------------------------------------------------------- main
|
|
2081
2419
|
def main():
|
|
2082
2420
|
_setup_stdio()
|
|
2083
2421
|
ap = argparse.ArgumentParser(description="video-studio 编排器")
|
|
2084
2422
|
ap.add_argument("--script", default="film.json")
|
|
2085
|
-
ap.add_argument("--scenes", default="
|
|
2423
|
+
ap.add_argument("--scenes", default="",
|
|
2424
|
+
help="场景绘制模块(默认依次找:<剧本目录>/scenes.py、本技能内置的 scenes.py)")
|
|
2086
2425
|
ap.add_argument("--out", default="out/film.mp4")
|
|
2087
2426
|
ap.add_argument("--work", default="work")
|
|
2088
2427
|
ap.add_argument("--stage", default="all", choices=STAGES)
|
|
@@ -2107,6 +2446,8 @@ def main():
|
|
|
2107
2446
|
help="过时也照跑(会在 QC 报告里留痕)")
|
|
2108
2447
|
ap.add_argument("--no-gate", action="store_true",
|
|
2109
2448
|
help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
|
|
2449
|
+
ap.add_argument("--require-proposal", action="store_true",
|
|
2450
|
+
help="把'提案未确认'当成失败(默认只警告;先跑 propose.py 再出片)")
|
|
2110
2451
|
ap.add_argument("--cost", action="store_true",
|
|
2111
2452
|
help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
|
|
2112
2453
|
# ---- v0.8 ----
|
|
@@ -2138,7 +2479,7 @@ def main():
|
|
|
2138
2479
|
if engine not in ("pil", "web"):
|
|
2139
2480
|
raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
|
|
2140
2481
|
script["_engine"] = engine
|
|
2141
|
-
script["_scenes"] =
|
|
2482
|
+
script["_scenes"] = _resolve_scenes(a.scenes, a.script)
|
|
2142
2483
|
|
|
2143
2484
|
# ---- 门禁账本(v0.6.0)----
|
|
2144
2485
|
global _LEDGER
|
|
@@ -2159,8 +2500,15 @@ def main():
|
|
|
2159
2500
|
print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
|
|
2160
2501
|
|
|
2161
2502
|
def _sources():
|
|
2162
|
-
"""
|
|
2163
|
-
|
|
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 ""}
|
|
2164
2512
|
|
|
2165
2513
|
# 本次命令行**计划要跑**的 stage 集合:--stage all 展开成 10 个,单 stage 就是它自己。
|
|
2166
2514
|
# 用途见 stagewatch.check 的 pending:本阶段马上要用新输入重算,那种"输入变了"不是过时。
|
|
@@ -2278,21 +2626,27 @@ def main():
|
|
|
2278
2626
|
raise SystemExit("[前置] %s 不是合法 JSON(%s),重新跑 --stage layout" % (tl_p, e)) from e
|
|
2279
2627
|
|
|
2280
2628
|
def load_scenes(strict=True):
|
|
2281
|
-
"""加载场景模块: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)
|
|
2282
2636
|
try:
|
|
2283
|
-
mod = load_module(
|
|
2637
|
+
mod = load_module(scenes_path)
|
|
2284
2638
|
except Exception as e: # noqa: BLE001
|
|
2285
2639
|
if strict:
|
|
2286
2640
|
raise SystemExit("[场景模块加载失败] %s:%s\n"
|
|
2287
2641
|
" --scenes 要指向定义 draw_frame() 的 scenes.py"
|
|
2288
|
-
% (
|
|
2289
|
-
print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (
|
|
2642
|
+
% (scenes_path, e)) from e
|
|
2643
|
+
print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (scenes_path, e))
|
|
2290
2644
|
return None
|
|
2291
2645
|
if not hasattr(mod, "draw_frame"):
|
|
2292
2646
|
if strict:
|
|
2293
2647
|
raise SystemExit("[场景模块错误] %s 没有 draw_frame(scene, t_local, dur, t_global, script, frame)"
|
|
2294
|
-
%
|
|
2295
|
-
print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" %
|
|
2648
|
+
% scenes_path)
|
|
2649
|
+
print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" % scenes_path)
|
|
2296
2650
|
return None
|
|
2297
2651
|
return mod
|
|
2298
2652
|
|
|
@@ -2317,7 +2671,11 @@ def main():
|
|
|
2317
2671
|
print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
|
|
2318
2672
|
sc2 = dict(script)
|
|
2319
2673
|
sc2["_total"] = tl["total"]
|
|
2320
|
-
|
|
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))
|
|
2321
2679
|
_render_range(tl, mod, sc2, 0, n, out_mp4, int(script.get("crf", 18)),
|
|
2322
2680
|
str(script.get("preset", "medium")), progress=False)
|
|
2323
2681
|
# 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
|
|
@@ -2522,6 +2880,11 @@ def main():
|
|
|
2522
2880
|
return 1 if (errs or rc) else 0
|
|
2523
2881
|
|
|
2524
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
|
|
2525
2888
|
tl = tl if stage == "all" else ensure_tl()
|
|
2526
2889
|
audio = os.path.join(a.work, "audio.wav")
|
|
2527
2890
|
silent = os.path.join(a.work, "silent.mp4")
|