@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.7.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/README.md +41 -16
- package/index.js +20 -7
- package/package.json +4 -2
- package/schemas/film.schema.json +75 -4
- package/skills/video-studio/SKILL.md +95 -23
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +18 -1
- package/skills/video-studio/assets/web/film.js +29 -4
- package/skills/video-studio/assets/web/index.html +5 -0
- package/skills/video-studio/references/audio-variation.md +158 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +2 -1
- package/skills/video-studio/scripts/filmcheck.py +81 -12
- package/skills/video-studio/scripts/filmkit.py +271 -18
- package/skills/video-studio/scripts/gates.py +75 -12
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +420 -51
- package/skills/video-studio/scripts/scorekit.py +289 -46
- package/skills/video-studio/scripts/selfcheck.py +302 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +141 -31
- package/skills/video-studio/scripts/stylekit.py +94 -6
- package/skills/video-studio/scripts/voicekit.py +8 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +145 -34
- package/tools/estimate.py +133 -54
- package/tools/selftest_noscipy.py +70 -0
|
@@ -37,8 +37,15 @@ BIG_BYTES = 8 << 20 # > 8 MB 走 size+mtime 快路径
|
|
|
37
37
|
# 早期版本只跟踪"stage 之间的产物",于是"改了一句台词但没重跑 tts"查不出任何过时——
|
|
38
38
|
# 而那正是最需要它报警的场景(旧 tts 配新剧本 = 配音和字幕全错位)。
|
|
39
39
|
STAGE_IO = {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
# voice_plan.json 是 **tts** 阶段写的(render.py 的 st_tts:非 legacy 才写),
|
|
41
|
+
# 不是 style 写的——v0.7.2 前它被挂在 style.outputs 下,于是单跑 `--stage style`
|
|
42
|
+
# 之后每次重跑都报「产物 voice_plan.json 不见了」(其实从来没写过),
|
|
43
|
+
# 开了 --strict-stale 就直接失败,而 --stage style 是全新项目的第一条生产命令。
|
|
44
|
+
# legacy 剧本本来就不写这个文件:check() 只在「上次记录的是具体指纹、现在没了」
|
|
45
|
+
# 时才报 output-missing,所以 legacy 下记为 missing 不会被当成永久过时。
|
|
46
|
+
"style": dict(sources=["script"], inputs=[], outputs=["style.json"]),
|
|
47
|
+
"tts": dict(sources=["script"], inputs=["style.json"],
|
|
48
|
+
outputs=["tts.json", "voice_plan.json"]),
|
|
42
49
|
"layout": dict(sources=["script"],
|
|
43
50
|
inputs=["tts.json", "style.json", "voice_plan.json"],
|
|
44
51
|
outputs=["timeline.json", "sub.srt", "sub.ass"]),
|
|
@@ -54,8 +61,11 @@ STAGE_IO = {
|
|
|
54
61
|
inputs=["timeline.json", "audio.wav", "cues.json"],
|
|
55
62
|
outputs=["silent.mp4"]),
|
|
56
63
|
"mux": dict(sources=["script"], inputs=["silent.mp4", "audio.wav"], outputs=[]),
|
|
64
|
+
# gates.json 不列进 qa 的产物:它由 _finish_gates 在 style/frames/mux/qa 任何一次收尾时
|
|
65
|
+
# 都会写,不能当"qa 跑过"的证据(否则删掉 qa 记录后它还在,会误判 no-manifest)。
|
|
66
|
+
# qa/sheet.png(联系表)只在 --stage qa 里生成,才是真凭证。
|
|
57
67
|
"qa": dict(sources=["script", "scenes"], inputs=[],
|
|
58
|
-
outputs=["
|
|
68
|
+
outputs=["qa/sheet.png", "qa/deep.json"]),
|
|
59
69
|
"stills": dict(sources=["script", "scenes"], inputs=["timeline.json", "style.json"],
|
|
60
70
|
outputs=[]),
|
|
61
71
|
}
|
|
@@ -123,6 +133,19 @@ def save(work, d):
|
|
|
123
133
|
return p
|
|
124
134
|
|
|
125
135
|
|
|
136
|
+
def _producer_of(art):
|
|
137
|
+
"""work/ 里的产物文件是哪个 stage 写的(由 STAGE_IO 反查,找不到返回 None)。"""
|
|
138
|
+
for st, io in STAGE_IO.items():
|
|
139
|
+
if art in (io.get("outputs") or []):
|
|
140
|
+
return st
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# 硬类型:这些永远参与 --strict-stale 判定(产物丢了 / 源头改了 / 上游自己就过时)
|
|
145
|
+
HARD_KINDS = ("source-changed", "output-missing", "no-manifest", "input-stale")
|
|
146
|
+
SOFT_KINDS = ("input-pending",)
|
|
147
|
+
|
|
148
|
+
|
|
126
149
|
def _src_fp(kind, sources):
|
|
127
150
|
"""源头输入指纹:script=剧本(film.json)、scenes=场景代码(scenes.py)。"""
|
|
128
151
|
p = (sources or {}).get(kind)
|
|
@@ -146,32 +169,64 @@ def snapshot(work, stage, sources=None):
|
|
|
146
169
|
return d["stages"][str(stage)]
|
|
147
170
|
|
|
148
171
|
|
|
149
|
-
def
|
|
172
|
+
def _upstream_hard(work, stage, sources=None, _seen=None):
|
|
173
|
+
"""产出某输入的 stage 是否有**硬**问题(源头变了 / 它的输入也变了 / 产物丢了)。
|
|
174
|
+
|
|
175
|
+
只取硬类型:软化过的 input-pending 不算(那只说明"它也会重跑")。
|
|
176
|
+
递归带上 _seen 防环——STAGE_IO 是 DAG,但用户手改的 manifest 不该把检查器搞成死循环。
|
|
177
|
+
"""
|
|
178
|
+
if not stage:
|
|
179
|
+
return []
|
|
180
|
+
issues = check(work, stage, sources, pending=(), _seen=_seen)
|
|
181
|
+
return [it["reason"] for it in issues if it.get("kind") in HARD_KINDS]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check(work, stage, sources=None, self_built=False, pending=(), _seen=None):
|
|
150
185
|
"""stage 开始前查新鲜度:返回问题列表(每项含 stage / reason / rerun)。
|
|
151
186
|
|
|
187
|
+
pending: 本次命令行**已经计划要跑**的 stage 集合(render.py 传 args.stage 展开后的列表)。
|
|
188
|
+
对本 stage 来说,"自己上次跑完之后上游变了" + "本轮马上就要重跑自己" = 正常待办,
|
|
189
|
+
不是过时(`--stage frames mux` 里 frames 刚重出 silent.mp4,mux 报"输入变了"是自相矛盾)。
|
|
190
|
+
这类问题标 kind="input-pending"、soft=True:照实打印,但不参与 --strict-stale 判定。
|
|
191
|
+
真问题不受影响:上游 stage **自己**过时(它的输入也变了)仍按 input-stale/output-missing 硬报,
|
|
192
|
+
所以"单独跑 mux、上游 frames 早过时"照样失败。
|
|
193
|
+
|
|
152
194
|
只报"这一跑真的会读到旧东西"的情况:
|
|
153
195
|
1) 本 stage 的上游输入与上次记录不同(上游被重跑过,或人手改过文件);
|
|
154
196
|
2) 本 stage 自己的产物比它的输入还旧(上次跑到一半被中断)。
|
|
155
197
|
另一个 stage 的产物变了但**不**是本周 stage 的输入时,不报(跟我们无关)。
|
|
198
|
+
|
|
199
|
+
self_built: 这些输入是**本次运行自己刚写出来的**(render.py --stage all 跑在
|
|
200
|
+
一个刚建的 work/ 上)。此时"manifest 没有本阶段记录"只是因为我们还没跑到它,
|
|
201
|
+
不是旧产物;报 no-manifest 等于让全新目录的首跑刷一屏假警报(v0.7.2 修)。
|
|
156
202
|
"""
|
|
157
203
|
io = STAGE_IO.get(str(stage))
|
|
158
204
|
if io is None:
|
|
159
205
|
return []
|
|
206
|
+
_seen = set(_seen or ())
|
|
207
|
+
if str(stage) in _seen:
|
|
208
|
+
return []
|
|
209
|
+
_seen.add(str(stage))
|
|
160
210
|
d = load(work)
|
|
161
211
|
stages = d.get("stages") or {}
|
|
162
212
|
rec = stages.get(str(stage))
|
|
213
|
+
planned = set(str(x) for x in (pending or ()))
|
|
163
214
|
issues = []
|
|
164
215
|
if not isinstance(rec, dict):
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
|
|
216
|
+
# 从没记录过这个 stage。判据必须能区分两件事:
|
|
217
|
+
# * 「还没跑到它」——分步跑(SKILL.md 主推:--stage style / tts / layout / …)时,
|
|
218
|
+
# 刚跑完 style,tts 的输入 style.json 当然存在、manifest 里当然没有 tts 记录。
|
|
219
|
+
# 这**不是**过时,按"输入齐了"报会把这套标准用法整个打成红(--strict-stale 下必然失败)。
|
|
220
|
+
# * 「跑过它但查不到记录」——旧版本产物 / 手改 / 复制来的 work 目录。
|
|
221
|
+
# 区分证据:本 stage **自己声明的产物**已经在 work/ 里。产物在、记录没有,才证明它跑过。
|
|
222
|
+
# 输入齐全不能当证据(分步跑时本来就齐),输出存在才可以。
|
|
223
|
+
present_out = [k for k in (io.get("outputs") or []) if _fp(k, work) != "missing"]
|
|
224
|
+
# stills / mux 的产物不在 work/(mux 写 --out,stills 写 work/stills/),无从证明跑过,
|
|
225
|
+
# 不冤枉它们;self_built(本次新建的 work/)连 manifest 都刚建,也不报。
|
|
226
|
+
if not self_built and stages and present_out:
|
|
172
227
|
issues.append(dict(stage=str(stage), kind="no-manifest",
|
|
173
|
-
reason="work
|
|
174
|
-
"(旧版本跑过 / 手改过)" % "、".join(
|
|
228
|
+
reason="work 里已经有本阶段的产物(%s),但 manifest.json 没有本阶段的记录"
|
|
229
|
+
"(旧版本跑过 / 手改过)" % "、".join(present_out),
|
|
175
230
|
rerun=RERUN_HINT.get(str(stage), "")))
|
|
176
231
|
return issues
|
|
177
232
|
# 源头先查:剧本 / scenes.py 改了但本 stage 没重跑 -> 产物一定过时
|
|
@@ -191,16 +246,44 @@ def check(work, stage, sources=None):
|
|
|
191
246
|
for name in io["inputs"]:
|
|
192
247
|
now = _fp(name, work)
|
|
193
248
|
was = old_inputs.get(name, "missing")
|
|
194
|
-
if now
|
|
249
|
+
if now == was:
|
|
250
|
+
continue
|
|
251
|
+
if str(stage) in planned:
|
|
252
|
+
# 本阶段本轮就要重跑:拿新输入重算正是它该干的事。照实打一行(不静默),
|
|
253
|
+
# 但不参与 strict 判定——否则 `--stage frames mux` 这种 stagewatch 自己建议的
|
|
254
|
+
# 重跑组合,会在 mux 开跑前被自己拦下,strict 下永远出不了片。
|
|
255
|
+
issues.append(dict(
|
|
256
|
+
stage=str(stage), kind="input-pending", file=name, soft=True,
|
|
257
|
+
reason="输入 %s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
|
|
258
|
+
"按新输入重算" % (name, was, now),
|
|
259
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
260
|
+
))
|
|
261
|
+
continue
|
|
262
|
+
# 不由本阶段重算时:还要确认产出这个输入的 stage **自己**没坏。
|
|
263
|
+
# 上游自己就过时(源头改了 / 它的输入也变了 / 它的产物丢了),那这台旧货是真的不能用。
|
|
264
|
+
prod = _producer_of(name)
|
|
265
|
+
ups = _upstream_hard(work, prod, sources, _seen) if prod else []
|
|
266
|
+
if ups:
|
|
267
|
+
issues.append(dict(
|
|
268
|
+
stage=str(stage), kind="input-stale", file=name,
|
|
269
|
+
reason="输入 %s 变了(%s -> %s),并且产出它的 %s 阶段本身就已经过时:%s"
|
|
270
|
+
% (name, was, now, prod, ";".join(ups[:3])),
|
|
271
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
272
|
+
))
|
|
273
|
+
else:
|
|
195
274
|
issues.append(dict(
|
|
196
275
|
stage=str(stage), kind="input-changed", file=name,
|
|
197
276
|
reason="输入 %s 在上次跑本阶段之后变了(%s -> %s),本次会拿**新输入**算,"
|
|
198
277
|
"但它下游的产物仍是旧的" % (name, was, now),
|
|
199
278
|
rerun=RERUN_HINT.get(str(stage), ""),
|
|
200
279
|
))
|
|
201
|
-
# 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"
|
|
202
|
-
|
|
203
|
-
|
|
280
|
+
# 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"是两件事,都要报)。
|
|
281
|
+
# 只报「上次记录到真实指纹、现在没了」的文件:有些产物是**条件性**的(legacy 剧本
|
|
282
|
+
# 不写 voice_plan.json),它们上次就记为 missing,现在还是 missing,那是正常状态;
|
|
283
|
+
# 一律按"现在是 missing 就报"会让这类文件变成永久误报(--strict-stale 下直接失败)。
|
|
284
|
+
rec_out = rec.get("outputs") or {}
|
|
285
|
+
missing_out = [k for k, was in rec_out.items()
|
|
286
|
+
if was not in (None, "missing") and _fp(k, work) == "missing"]
|
|
204
287
|
if missing_out:
|
|
205
288
|
issues.append(dict(stage=str(stage), kind="output-missing",
|
|
206
289
|
reason="上次跑过本阶段,但产物 %s 不见了(被删 / 被挪 / 磁盘满)"
|
|
@@ -209,8 +292,11 @@ def check(work, stage, sources=None):
|
|
|
209
292
|
return issues
|
|
210
293
|
|
|
211
294
|
|
|
212
|
-
def explain(work, stages=None, sources=None):
|
|
213
|
-
"""--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。
|
|
295
|
+
def explain(work, stages=None, sources=None, self_built=False, pending=()):
|
|
296
|
+
"""--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。
|
|
297
|
+
|
|
298
|
+
pending: 本次命令行要跑的 stage 集合(见 check);--stage explain 不传 = 全按"不重跑"判。
|
|
299
|
+
"""
|
|
214
300
|
d = load(work)
|
|
215
301
|
rows = []
|
|
216
302
|
for st in (stages or STAGE_ORDER):
|
|
@@ -218,7 +304,7 @@ def explain(work, stages=None, sources=None):
|
|
|
218
304
|
if io is None:
|
|
219
305
|
continue
|
|
220
306
|
rec = (d.get("stages") or {}).get(st)
|
|
221
|
-
issues = check(work, st, sources)
|
|
307
|
+
issues = check(work, st, sources, self_built=self_built, pending=pending)
|
|
222
308
|
rows.append(dict(stage=st, recorded=bool(rec),
|
|
223
309
|
at=(rec or {}).get("atText", ""),
|
|
224
310
|
issues=issues))
|
|
@@ -226,40 +312,64 @@ def explain(work, stages=None, sources=None):
|
|
|
226
312
|
|
|
227
313
|
|
|
228
314
|
def report(work, stages=None, strict=False, allow_stale=False, ledger=None, write=None,
|
|
229
|
-
sources=None):
|
|
315
|
+
sources=None, self_built=False, pending=()):
|
|
230
316
|
"""查 + 打印 + 判定。返回 (errors, warnings);errors 在 strict 下非空。
|
|
231
317
|
|
|
232
318
|
ledger: 传 gates.GateLedger 时把结论记进账本(一条汇总 gate)。
|
|
233
319
|
write: 传 callable(str, str) 时按行输出(方便测试捕获)。
|
|
320
|
+
self_built: 见 check()——本次运行的 work/ 是刚建的,别报"没有记录=过时"。
|
|
321
|
+
pending: 本次命令行要跑的 stage 集合(见 check)。其中"本阶段会重算"的输入变化
|
|
322
|
+
标 soft,照实打印但不参与 strict 判定。
|
|
234
323
|
"""
|
|
235
324
|
out = write or (lambda level, msg: print(msg))
|
|
236
|
-
rows = explain(work, stages, sources)
|
|
325
|
+
rows = explain(work, stages, sources, self_built=self_built, pending=pending)
|
|
237
326
|
problems = [(r["stage"], i) for r in rows for i in r["issues"]]
|
|
238
327
|
warns, errs = [], []
|
|
239
328
|
for st, it in problems:
|
|
240
|
-
|
|
241
|
-
|
|
329
|
+
soft = bool(it.get("soft")) or it.get("kind") in SOFT_KINDS
|
|
330
|
+
# 两类不要用同一个前缀:soft 是"本轮会重算"的待办,不是过时。用 [stale] 会把
|
|
331
|
+
# 人吓一跳,也会让"grep [stale] 判红"的 CI 配方误报。
|
|
332
|
+
msg = (("[待重算] %s: %s" if soft else "[stale] %s: %s") % (st, it["reason"]))
|
|
333
|
+
if soft:
|
|
334
|
+
# 本轮会重算的输入变化:照实说,但不算失败。--strict-stale 也不拦。
|
|
335
|
+
warns.append(msg)
|
|
336
|
+
elif allow_stale:
|
|
242
337
|
warns.append(msg + "(--allow-stale 已放行,本次结果可能掺旧数据)")
|
|
243
338
|
elif strict:
|
|
244
339
|
errs.append(msg)
|
|
245
340
|
else:
|
|
246
341
|
warns.append(msg)
|
|
247
|
-
|
|
342
|
+
# 原因行必须打出来:调用方(render.py --stage all)只显示"最小重跑",
|
|
343
|
+
# 用户会看到"共 1 处过时"却不知道是哪个 stage、为什么——这正是本模块
|
|
344
|
+
# 开头承诺的"可以保守,但不能静默"。返回的 warns/errs 是给调用方判定的,
|
|
345
|
+
# 不代替人读的输出。
|
|
346
|
+
out(("warn" if soft else "err") if (strict and not allow_stale) else "warn", msg)
|
|
347
|
+
if it.get("rerun") and not soft:
|
|
248
348
|
out("warn", " 最小重跑: %s" % it["rerun"])
|
|
349
|
+
hard = [(st, it) for st, it in problems
|
|
350
|
+
if not (it.get("soft") or it.get("kind") in SOFT_KINDS)]
|
|
249
351
|
if problems:
|
|
250
352
|
out("warn", "")
|
|
251
|
-
|
|
252
|
-
|
|
353
|
+
if hard:
|
|
354
|
+
out("warn", " ↑ 共 %d 处过时。默认只告警;--strict-stale 让它失败,"
|
|
355
|
+
"--allow-stale 显式放行。放行会记进本次门禁账本,"
|
|
356
|
+
"交付阶段(qa / mux / all / stills / style)会体现在 QC 报告里。"
|
|
357
|
+
% len(hard))
|
|
358
|
+
else:
|
|
359
|
+
out("warn", " ↑ 上面 %d 条都是「本轮会重算」的输入变化(不是过时,不拦 strict)。"
|
|
360
|
+
% len(problems))
|
|
253
361
|
if ledger is not None:
|
|
254
362
|
try:
|
|
255
|
-
if not
|
|
256
|
-
ledger.ok("stale", "阶段新鲜度",
|
|
363
|
+
if not hard:
|
|
364
|
+
ledger.ok("stale", "阶段新鲜度",
|
|
365
|
+
"上游输入与上次记录一致" if not problems
|
|
366
|
+
else "%d 处输入会在本轮重算,无需拦" % len(problems))
|
|
257
367
|
elif errs:
|
|
258
|
-
ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(
|
|
368
|
+
ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(hard))
|
|
259
369
|
else:
|
|
260
370
|
ledger.skip("stale", "阶段新鲜度",
|
|
261
371
|
"%d 处过时,已告警未阻断%s"
|
|
262
|
-
% (len(
|
|
372
|
+
% (len(hard), "(--allow-stale)" if allow_stale else ""))
|
|
263
373
|
except Exception: # noqa: BLE001
|
|
264
374
|
pass
|
|
265
375
|
return errs, warns
|
|
@@ -37,6 +37,7 @@ from __future__ import annotations
|
|
|
37
37
|
|
|
38
38
|
import argparse
|
|
39
39
|
import hashlib
|
|
40
|
+
import re
|
|
40
41
|
import sys
|
|
41
42
|
|
|
42
43
|
# ============================================================================
|
|
@@ -77,6 +78,92 @@ def _rgb(seq):
|
|
|
77
78
|
return t
|
|
78
79
|
|
|
79
80
|
|
|
81
|
+
_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _color_of(v, key):
|
|
85
|
+
"""把 theme_overrides 里的颜色值统一成 (r,g,b):支持 [r,g,b] 和 "#rrggbb" / "#rgb"。
|
|
86
|
+
|
|
87
|
+
为什么必须支持 hex:schemas/film.schema.json 与 references/style-packs.md 的示例
|
|
88
|
+
就是 {"accent": "#ff8a3d"},而 JSON 没有元组,照着文档写 hex 是必然的。
|
|
89
|
+
v0.5 旧实现把字符串原样塞进主题,scenes.py 第一次 int("#") 就崩在渲染期
|
|
90
|
+
(实测:ink="#ffffff" -> ValueError: invalid literal for int() with base 10: '#')。
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(v, str):
|
|
93
|
+
m = _HEX_RE.match(v.strip())
|
|
94
|
+
if not m:
|
|
95
|
+
raise ValueError("theme_overrides.%s 的颜色写法不认识:%r(用 [r,g,b] 或 \"#rrggbb\")"
|
|
96
|
+
% (key, v))
|
|
97
|
+
h = m.group(1)
|
|
98
|
+
if len(h) == 3:
|
|
99
|
+
h = "".join(ch * 2 for ch in h)
|
|
100
|
+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
|
101
|
+
if isinstance(v, (list, tuple)) and len(v) == 3:
|
|
102
|
+
return _rgb(v)
|
|
103
|
+
raise ValueError("theme_overrides.%s 必须是颜色([r,g,b] 或 \"#rrggbb\"),收到 %r"
|
|
104
|
+
% (key, v))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# 主题里"颜色字段"的白名单(值是 0..255 的 RGB 元组)。不靠"长度 3 的元组"猜:
|
|
108
|
+
# keywords 也是元组(长度 10),将来若加一个正好 3 项的关键词元组就会被误当颜色。
|
|
109
|
+
_THEME_COLOR_KEYS = frozenset(("bg", "bg2", "surface", "ink", "muted", "dim",
|
|
110
|
+
"accent", "accent2", "good", "bad", "rule", "vignette_tint"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def apply_theme_overrides(theme, overrides):
|
|
114
|
+
"""把 film.json 的 visual.theme_overrides 合并进主题**并做类型校验**。
|
|
115
|
+
|
|
116
|
+
契约(schemas/film.schema.json:只接受主题里已存在的键;references/style-packs.md §3):
|
|
117
|
+
- 未知键忽略:以后主题加字段时老剧本不因为它不认识就炸;
|
|
118
|
+
- 颜色键(值本来是三色元组的那些)接受 [r,g,b] / "#rrggbb" / "#rgb",统一成元组;
|
|
119
|
+
- 数值键接受数字或数字字符串;字符串键必须是字符串;
|
|
120
|
+
- 嵌套结构(card / scale / qa …)不支持覆盖,静默忽略(与 v0.5 一致,避免破坏老配置);
|
|
121
|
+
真要改嵌套字段请改风格包。
|
|
122
|
+
校验只在"值类型不对、旧版会在渲染期崩"的场合抛 ValueError(提前到 --stage style)。
|
|
123
|
+
返回新的 theme dict(不改入参)。
|
|
124
|
+
"""
|
|
125
|
+
out = dict(theme or {})
|
|
126
|
+
for k, v in (overrides or {}).items():
|
|
127
|
+
if k not in out:
|
|
128
|
+
continue
|
|
129
|
+
base = out[k]
|
|
130
|
+
if k in _THEME_COLOR_KEYS:
|
|
131
|
+
out[k] = _color_of(v, k)
|
|
132
|
+
continue
|
|
133
|
+
if isinstance(base, bool):
|
|
134
|
+
if not isinstance(v, bool):
|
|
135
|
+
raise ValueError("theme_overrides.%s 必须是布尔,收到 %r" % (k, v))
|
|
136
|
+
out[k] = bool(v)
|
|
137
|
+
continue
|
|
138
|
+
if isinstance(base, (int, float)):
|
|
139
|
+
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
|
140
|
+
raise ValueError("theme_overrides.%s 必须是数字,收到 %r" % (k, v))
|
|
141
|
+
try:
|
|
142
|
+
num = float(v)
|
|
143
|
+
except (TypeError, ValueError):
|
|
144
|
+
raise ValueError("theme_overrides.%s 必须是数字,收到 %r" % (k, v))
|
|
145
|
+
out[k] = int(round(num)) if isinstance(base, int) else float(num)
|
|
146
|
+
continue
|
|
147
|
+
if isinstance(base, dict):
|
|
148
|
+
# 嵌套结构(card / scale / qa)不支持覆盖。这里保持 v0.5 的**静默忽略**:
|
|
149
|
+
# 老配置里如果写了这种覆盖,旧版只是没生效、并不会崩;升级后突然报错是破坏性变更。
|
|
150
|
+
# 想改嵌套字段请改风格包(STYLES/THEMES),见 references/style-packs.md §7。
|
|
151
|
+
continue
|
|
152
|
+
if isinstance(base, str):
|
|
153
|
+
if not isinstance(v, str):
|
|
154
|
+
raise ValueError("theme_overrides.%s 必须是字符串,收到 %r" % (k, v))
|
|
155
|
+
if k == "layout" and v not in LAYOUTS:
|
|
156
|
+
raise ValueError("theme_overrides.layout 未知版式 %r;可用:%s"
|
|
157
|
+
% (v, " / ".join(LAYOUT_IDS)))
|
|
158
|
+
if k == "hud" and v not in ("chip-left", "corner-line", "none"):
|
|
159
|
+
raise ValueError("theme_overrides.hud 未知取值 %r;可用:chip-left / corner-line / none"
|
|
160
|
+
% (v,))
|
|
161
|
+
out[k] = v
|
|
162
|
+
continue
|
|
163
|
+
# 其它类型(None 等):保持原值,不折腾
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
80
167
|
def _theme(style_id, label, keywords, palette, material, typ, layout, motion, hud, qa):
|
|
81
168
|
"""把主题字段组装成一个扁平 dict(场景代码直接下标取,不嵌套,读起来快)。"""
|
|
82
169
|
p = {k: _rgb(v) for k, v in palette.items()}
|
|
@@ -726,12 +813,9 @@ def resolve_script_style(script):
|
|
|
726
813
|
else:
|
|
727
814
|
chap_layout = {ch: layouts[i % len(layouts)] for i, ch in enumerate(chapters)}
|
|
728
815
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
theme[k] = v
|
|
733
|
-
elif k in theme and isinstance(v, (list, tuple)) and len(v) == 3:
|
|
734
|
-
theme[k] = _rgb(v)
|
|
816
|
+
# theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
|
|
817
|
+
# 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
|
|
818
|
+
theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
|
|
735
819
|
|
|
736
820
|
return {
|
|
737
821
|
"version": 1,
|
|
@@ -900,6 +984,10 @@ def main(argv=None):
|
|
|
900
984
|
except Exception: # noqa: BLE001
|
|
901
985
|
pass
|
|
902
986
|
if a.show:
|
|
987
|
+
# 未知 id 以前是 KeyError traceback,现在给可用清单 + exit 2(与 render.py 的口径一致)
|
|
988
|
+
if a.show not in STYLES:
|
|
989
|
+
print("[未知风格] %r;可用:%s" % (a.show, " / ".join(ALL_STYLE_IDS)), file=sys.stderr)
|
|
990
|
+
return 2
|
|
903
991
|
if a.json:
|
|
904
992
|
import json
|
|
905
993
|
print(json.dumps({"style": STYLES[a.show],
|
|
@@ -861,6 +861,14 @@ def main(argv=None):
|
|
|
861
861
|
return _cli_list()
|
|
862
862
|
if a.calibrate:
|
|
863
863
|
only = [x.strip() for x in (a.only or "").split(",") if x.strip()] or None
|
|
864
|
+
if only:
|
|
865
|
+
unknown = [x for x in only if x not in VOICE_PERSONAS]
|
|
866
|
+
if unknown:
|
|
867
|
+
# 以前拼错 persona 会静默变成空集合 -> 打出"所有音色都合成失败(多半是没网)",
|
|
868
|
+
# 把配置错误伪装成网络错误。这里先点名校验,给人话可照抄的可用清单。
|
|
869
|
+
print("[未知 persona] %s;可用:%s"
|
|
870
|
+
% ("、".join(unknown), " / ".join(PERSONA_IDS)), file=sys.stderr)
|
|
871
|
+
return 2
|
|
864
872
|
return calibrate(only, log=print)
|
|
865
873
|
if a.plan:
|
|
866
874
|
return _cli_plan(a.plan)
|
|
@@ -32,6 +32,7 @@ import asyncio
|
|
|
32
32
|
import base64
|
|
33
33
|
import hashlib
|
|
34
34
|
import json
|
|
35
|
+
import math
|
|
35
36
|
import os
|
|
36
37
|
import re
|
|
37
38
|
import shutil
|
|
@@ -186,6 +187,31 @@ def _format_contract(contract: dict) -> str:
|
|
|
186
187
|
"有" if contract.get("hasFK") else "无"))
|
|
187
188
|
|
|
188
189
|
|
|
190
|
+
async def page_number(page, key: str, *, default=None) -> float:
|
|
191
|
+
"""读页面上的数值契约(window.DURATION / window.FPS),缺了或不是正数就给可操作报错。
|
|
192
|
+
|
|
193
|
+
v0.5 以前直接 float(await page.evaluate("window.DURATION")):页面忘了导出 DURATION
|
|
194
|
+
时得到的是 None,报错只剩一句 "float() argument must be ... 'NoneType'",看不出
|
|
195
|
+
是页面契约缺字段(实测 page_meta / export_cues 都会这样)。
|
|
196
|
+
default 传 None 表示必填(DURATION);传数字表示缺省值(FPS 历史口径是 30)。
|
|
197
|
+
"""
|
|
198
|
+
raw = await page.evaluate("k => window[k]", key)
|
|
199
|
+
if raw is None:
|
|
200
|
+
if default is not None:
|
|
201
|
+
return float(default)
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
"页面没有导出 window.%s(页面契约见 references/web-engine.md §3)。"
|
|
204
|
+
"index.html 里在 film.js 之前写 window.WINDOW_DURATION = 秒数,"
|
|
205
|
+
"或让模板按场景表自动推导(assets/web/film.js 的 resolveDuration)。" % key)
|
|
206
|
+
try:
|
|
207
|
+
val = float(raw)
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
raise RuntimeError("页面 window.%s 不是数字:%r(必须是数字)" % (key, raw))
|
|
210
|
+
if not math.isfinite(val) or val <= 0:
|
|
211
|
+
raise RuntimeError("页面 window.%s 非法:%r(必须是正数)" % (key, raw))
|
|
212
|
+
return val
|
|
213
|
+
|
|
214
|
+
|
|
189
215
|
async def open_page(pw, url, width=W, height=1080, log=False, deterministic=None,
|
|
190
216
|
ready_timeout=300_000):
|
|
191
217
|
"""打开页面并等到 window.ready === true,返回 (browser, page, cdp)。
|
|
@@ -279,8 +305,8 @@ async def _page_meta_async(html_path, deterministic=None):
|
|
|
279
305
|
async with _playwright()() as pw:
|
|
280
306
|
browser, page, _cdp = await open_page(pw, url, deterministic=deterministic)
|
|
281
307
|
try:
|
|
282
|
-
duration =
|
|
283
|
-
fps =
|
|
308
|
+
duration = await page_number(page, "DURATION")
|
|
309
|
+
fps = await page_number(page, "FPS", default=FPS)
|
|
284
310
|
return duration, fps
|
|
285
311
|
finally:
|
|
286
312
|
try:
|
|
@@ -315,7 +341,7 @@ async def _snap_async(html_path, times, out: Path, tag: str, deterministic=None)
|
|
|
315
341
|
async with _playwright()() as pw:
|
|
316
342
|
browser, page, cdp = await open_page(pw, url, W, H, deterministic=deterministic)
|
|
317
343
|
try:
|
|
318
|
-
fps =
|
|
344
|
+
fps = await page_number(page, "FPS", default=FPS)
|
|
319
345
|
for t in times:
|
|
320
346
|
png = await _grab(page, cdp, int(round(t * fps)))
|
|
321
347
|
path = out / ("%st%07.2f.png" % (tag, t))
|
|
@@ -353,6 +379,13 @@ def _norm_cue_list(raw, src: str) -> list:
|
|
|
353
379
|
|
|
354
380
|
|
|
355
381
|
def _norm_events(raw) -> list:
|
|
382
|
+
"""规整页面导出的视觉事件;统一打 src="web" 标记。
|
|
383
|
+
|
|
384
|
+
为什么打标记:render.py 在 engine=web 时会把 film.json / scenes.py 的 cue 与页面 cue
|
|
385
|
+
**三路合并**(三路都是合法的登记方式),但 scenes.py 的 cue 是按 PIL 时间轴算的,
|
|
386
|
+
与页面动画时间基准不同。qa_frames 的 sync_check 靠 src 判断"能不能配",
|
|
387
|
+
否则会拿 29s PIL 时间轴上的 ui_blip 去配 12s 页面上的 ui_blip,报出假 FAIL。
|
|
388
|
+
"""
|
|
356
389
|
out = []
|
|
357
390
|
for item in raw or []:
|
|
358
391
|
if not isinstance(item, dict):
|
|
@@ -362,6 +395,7 @@ def _norm_events(raw) -> list:
|
|
|
362
395
|
continue
|
|
363
396
|
ev["t"] = round(float(ev["t"]), 3)
|
|
364
397
|
ev.setdefault("kind", "misc")
|
|
398
|
+
ev.setdefault("src", "web")
|
|
365
399
|
out.append(ev)
|
|
366
400
|
out.sort(key=lambda e: e["t"])
|
|
367
401
|
return out
|
|
@@ -378,8 +412,8 @@ async def _export_cues_async(html_path, deterministic=None) -> dict:
|
|
|
378
412
|
async with _playwright()() as pw:
|
|
379
413
|
browser, page, _cdp = await open_page(pw, url, W, H, deterministic=deterministic)
|
|
380
414
|
try:
|
|
381
|
-
duration =
|
|
382
|
-
fps =
|
|
415
|
+
duration = await page_number(page, "DURATION")
|
|
416
|
+
fps = await page_number(page, "FPS", default=FPS)
|
|
383
417
|
cues = await page.evaluate("window.getCues ? window.getCues() : []")
|
|
384
418
|
events = await page.evaluate("window.getEvents ? window.getEvents() : []")
|
|
385
419
|
finally:
|
|
@@ -532,17 +566,26 @@ async def _write_chunk(br: _Browser, f0: int, f1: int, dst: Path, fps: float,
|
|
|
532
566
|
part = dst.with_name(dst.name[: -len(".mp4")] + PART_SUFFIX)
|
|
533
567
|
if part.exists():
|
|
534
568
|
part.unlink()
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
569
|
+
# stderr 落临时文件,不用 PIPE:编码器一旦刷出超过管道缓冲(Windows 64 KB)的 stderr,
|
|
570
|
+
# 没人读就会写阻塞;而我们这边 proc.stdin.write 是**同步**阻塞调用(不交出事件循环),
|
|
571
|
+
# 两边互等就是死锁 —— asyncio.wait_for 也救不了它。临时文件没有缓冲上限,也不会堵。
|
|
572
|
+
errf = tempfile.TemporaryFile()
|
|
573
|
+
try:
|
|
574
|
+
proc = subprocess.Popen(
|
|
575
|
+
_encoder_cmd(part, fps, crf, preset),
|
|
576
|
+
stdin=subprocess.PIPE,
|
|
577
|
+
stderr=errf,
|
|
578
|
+
)
|
|
579
|
+
except BaseException:
|
|
580
|
+
errf.close()
|
|
581
|
+
raise
|
|
540
582
|
try:
|
|
541
583
|
for f in range(f0, f1):
|
|
542
584
|
proc.stdin.write(await br.frame(f))
|
|
543
585
|
proc.stdin.close()
|
|
544
586
|
rc = proc.wait(timeout=300)
|
|
545
|
-
|
|
587
|
+
errf.seek(0)
|
|
588
|
+
err = errf.read().decode("utf-8", "replace")
|
|
546
589
|
except BaseException:
|
|
547
590
|
try:
|
|
548
591
|
proc.kill()
|
|
@@ -560,7 +603,7 @@ async def _write_chunk(br: _Browser, f0: int, f1: int, dst: Path, fps: float,
|
|
|
560
603
|
raise
|
|
561
604
|
finally:
|
|
562
605
|
try:
|
|
563
|
-
|
|
606
|
+
errf.close()
|
|
564
607
|
except Exception:
|
|
565
608
|
pass
|
|
566
609
|
if rc != 0:
|
|
@@ -702,7 +745,7 @@ async def _qa_text_async(html_path, t0, t1, step, deterministic=None) -> dict:
|
|
|
702
745
|
browser, page, _cdp = await open_page(pw, url, W, H, deterministic=deterministic)
|
|
703
746
|
try:
|
|
704
747
|
if t1 is None:
|
|
705
|
-
t1 =
|
|
748
|
+
t1 = await page_number(page, "DURATION")
|
|
706
749
|
result = await page.evaluate(
|
|
707
750
|
"([a, b, c]) => window.qaText(a, b, c)", [float(t0), float(t1), float(step)]
|
|
708
751
|
)
|
|
@@ -1262,7 +1305,18 @@ def main(argv=None) -> int:
|
|
|
1262
1305
|
return 2
|
|
1263
1306
|
|
|
1264
1307
|
if args.cmd == "snap":
|
|
1265
|
-
|
|
1308
|
+
try:
|
|
1309
|
+
times = _parse_times(args.times)
|
|
1310
|
+
except ValueError:
|
|
1311
|
+
print("[错误] --times 里有不是数字的项:%r(要逗号分隔的秒数,如 3,9.5,13)"
|
|
1312
|
+
% (args.times,), flush=True)
|
|
1313
|
+
return 2
|
|
1314
|
+
if args.every is not None and float(args.every) <= 0:
|
|
1315
|
+
# 以前 --every 0 被当成"没写"、--every -1 则是死循环:先 page_meta 开一次浏览器,
|
|
1316
|
+
# 之后 while k <= end 永远成立,times 一路追加到内存爆掉(实测 40 s 都出不来)。
|
|
1317
|
+
print("[错误] --every 必须是正数秒(当前 %r);不想等间隔采样就别传 --every" % (args.every,),
|
|
1318
|
+
flush=True)
|
|
1319
|
+
return 2
|
|
1266
1320
|
if args.every:
|
|
1267
1321
|
end = args.t_to if args.t_to is not None else page_meta(args.html)[0]
|
|
1268
1322
|
k = float(args.t_from)
|