@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.1
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 +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- 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", "source-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 没重跑 -> 产物一定过时
|
|
@@ -179,28 +234,69 @@ def check(work, stage, sources=None):
|
|
|
179
234
|
for kind in (io.get("sources") or []):
|
|
180
235
|
now = _src_fp(kind, sources)
|
|
181
236
|
was = old_src.get(kind, "missing")
|
|
182
|
-
if now
|
|
183
|
-
|
|
237
|
+
if now == was:
|
|
238
|
+
continue
|
|
239
|
+
fname = {"script": "剧本 film.json", "scenes": "画面代码 scenes.py"}.get(kind, kind)
|
|
240
|
+
if str(stage) in planned:
|
|
241
|
+
# 源头变了 + 本轮马上就要重跑本阶段 = 正常待办,不是过时。
|
|
242
|
+
# 不软化的话,「改一句台词 -> --stage all --strict-stale」会在第一个 stage
|
|
243
|
+
# 直接失败("没重跑却装了旧内容"——其实马上就要重跑),把主工作流堵死;
|
|
244
|
+
# 与 inputs 的 input-pending 是同一套语义,只是源头走的是另一个循环。
|
|
184
245
|
issues.append(dict(
|
|
185
|
-
stage=str(stage), kind="source-
|
|
186
|
-
reason="%s
|
|
187
|
-
"
|
|
246
|
+
stage=str(stage), kind="source-pending", file=kind, soft=True,
|
|
247
|
+
reason="%s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
|
|
248
|
+
"按新内容重算" % (fname, was, now),
|
|
188
249
|
rerun=RERUN_HINT.get(str(stage), ""),
|
|
189
250
|
))
|
|
251
|
+
continue
|
|
252
|
+
issues.append(dict(
|
|
253
|
+
stage=str(stage), kind="source-changed", file=kind,
|
|
254
|
+
reason="%s 在本阶段上次跑完之后改过(%s -> %s),但本阶段没有重跑;"
|
|
255
|
+
"它现在装的还是旧内容" % (fname, was, now),
|
|
256
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
257
|
+
))
|
|
190
258
|
old_inputs = rec.get("inputs") or {}
|
|
191
259
|
for name in io["inputs"]:
|
|
192
260
|
now = _fp(name, work)
|
|
193
261
|
was = old_inputs.get(name, "missing")
|
|
194
|
-
if now
|
|
262
|
+
if now == was:
|
|
263
|
+
continue
|
|
264
|
+
if str(stage) in planned:
|
|
265
|
+
# 本阶段本轮就要重跑:拿新输入重算正是它该干的事。照实打一行(不静默),
|
|
266
|
+
# 但不参与 strict 判定——否则 `--stage frames mux` 这种 stagewatch 自己建议的
|
|
267
|
+
# 重跑组合,会在 mux 开跑前被自己拦下,strict 下永远出不了片。
|
|
268
|
+
issues.append(dict(
|
|
269
|
+
stage=str(stage), kind="input-pending", file=name, soft=True,
|
|
270
|
+
reason="输入 %s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
|
|
271
|
+
"按新输入重算" % (name, was, now),
|
|
272
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
273
|
+
))
|
|
274
|
+
continue
|
|
275
|
+
# 不由本阶段重算时:还要确认产出这个输入的 stage **自己**没坏。
|
|
276
|
+
# 上游自己就过时(源头改了 / 它的输入也变了 / 它的产物丢了),那这台旧货是真的不能用。
|
|
277
|
+
prod = _producer_of(name)
|
|
278
|
+
ups = _upstream_hard(work, prod, sources, _seen) if prod else []
|
|
279
|
+
if ups:
|
|
280
|
+
issues.append(dict(
|
|
281
|
+
stage=str(stage), kind="input-stale", file=name,
|
|
282
|
+
reason="输入 %s 变了(%s -> %s),并且产出它的 %s 阶段本身就已经过时:%s"
|
|
283
|
+
% (name, was, now, prod, ";".join(ups[:3])),
|
|
284
|
+
rerun=RERUN_HINT.get(str(stage), ""),
|
|
285
|
+
))
|
|
286
|
+
else:
|
|
195
287
|
issues.append(dict(
|
|
196
288
|
stage=str(stage), kind="input-changed", file=name,
|
|
197
289
|
reason="输入 %s 在上次跑本阶段之后变了(%s -> %s),本次会拿**新输入**算,"
|
|
198
290
|
"但它下游的产物仍是旧的" % (name, was, now),
|
|
199
291
|
rerun=RERUN_HINT.get(str(stage), ""),
|
|
200
292
|
))
|
|
201
|
-
# 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"
|
|
202
|
-
|
|
203
|
-
|
|
293
|
+
# 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"是两件事,都要报)。
|
|
294
|
+
# 只报「上次记录到真实指纹、现在没了」的文件:有些产物是**条件性**的(legacy 剧本
|
|
295
|
+
# 不写 voice_plan.json),它们上次就记为 missing,现在还是 missing,那是正常状态;
|
|
296
|
+
# 一律按"现在是 missing 就报"会让这类文件变成永久误报(--strict-stale 下直接失败)。
|
|
297
|
+
rec_out = rec.get("outputs") or {}
|
|
298
|
+
missing_out = [k for k, was in rec_out.items()
|
|
299
|
+
if was not in (None, "missing") and _fp(k, work) == "missing"]
|
|
204
300
|
if missing_out:
|
|
205
301
|
issues.append(dict(stage=str(stage), kind="output-missing",
|
|
206
302
|
reason="上次跑过本阶段,但产物 %s 不见了(被删 / 被挪 / 磁盘满)"
|
|
@@ -209,8 +305,11 @@ def check(work, stage, sources=None):
|
|
|
209
305
|
return issues
|
|
210
306
|
|
|
211
307
|
|
|
212
|
-
def explain(work, stages=None, sources=None):
|
|
213
|
-
"""--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。
|
|
308
|
+
def explain(work, stages=None, sources=None, self_built=False, pending=()):
|
|
309
|
+
"""--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。
|
|
310
|
+
|
|
311
|
+
pending: 本次命令行要跑的 stage 集合(见 check);--stage explain 不传 = 全按"不重跑"判。
|
|
312
|
+
"""
|
|
214
313
|
d = load(work)
|
|
215
314
|
rows = []
|
|
216
315
|
for st in (stages or STAGE_ORDER):
|
|
@@ -218,7 +317,7 @@ def explain(work, stages=None, sources=None):
|
|
|
218
317
|
if io is None:
|
|
219
318
|
continue
|
|
220
319
|
rec = (d.get("stages") or {}).get(st)
|
|
221
|
-
issues = check(work, st, sources)
|
|
320
|
+
issues = check(work, st, sources, self_built=self_built, pending=pending)
|
|
222
321
|
rows.append(dict(stage=st, recorded=bool(rec),
|
|
223
322
|
at=(rec or {}).get("atText", ""),
|
|
224
323
|
issues=issues))
|
|
@@ -226,40 +325,64 @@ def explain(work, stages=None, sources=None):
|
|
|
226
325
|
|
|
227
326
|
|
|
228
327
|
def report(work, stages=None, strict=False, allow_stale=False, ledger=None, write=None,
|
|
229
|
-
sources=None):
|
|
328
|
+
sources=None, self_built=False, pending=()):
|
|
230
329
|
"""查 + 打印 + 判定。返回 (errors, warnings);errors 在 strict 下非空。
|
|
231
330
|
|
|
232
331
|
ledger: 传 gates.GateLedger 时把结论记进账本(一条汇总 gate)。
|
|
233
332
|
write: 传 callable(str, str) 时按行输出(方便测试捕获)。
|
|
333
|
+
self_built: 见 check()——本次运行的 work/ 是刚建的,别报"没有记录=过时"。
|
|
334
|
+
pending: 本次命令行要跑的 stage 集合(见 check)。其中"本阶段会重算"的输入变化
|
|
335
|
+
标 soft,照实打印但不参与 strict 判定。
|
|
234
336
|
"""
|
|
235
337
|
out = write or (lambda level, msg: print(msg))
|
|
236
|
-
rows = explain(work, stages, sources)
|
|
338
|
+
rows = explain(work, stages, sources, self_built=self_built, pending=pending)
|
|
237
339
|
problems = [(r["stage"], i) for r in rows for i in r["issues"]]
|
|
238
340
|
warns, errs = [], []
|
|
239
341
|
for st, it in problems:
|
|
240
|
-
|
|
241
|
-
|
|
342
|
+
soft = bool(it.get("soft")) or it.get("kind") in SOFT_KINDS
|
|
343
|
+
# 两类不要用同一个前缀:soft 是"本轮会重算"的待办,不是过时。用 [stale] 会把
|
|
344
|
+
# 人吓一跳,也会让"grep [stale] 判红"的 CI 配方误报。
|
|
345
|
+
msg = (("[待重算] %s: %s" if soft else "[stale] %s: %s") % (st, it["reason"]))
|
|
346
|
+
if soft:
|
|
347
|
+
# 本轮会重算的输入变化:照实说,但不算失败。--strict-stale 也不拦。
|
|
348
|
+
warns.append(msg)
|
|
349
|
+
elif allow_stale:
|
|
242
350
|
warns.append(msg + "(--allow-stale 已放行,本次结果可能掺旧数据)")
|
|
243
351
|
elif strict:
|
|
244
352
|
errs.append(msg)
|
|
245
353
|
else:
|
|
246
354
|
warns.append(msg)
|
|
247
|
-
|
|
355
|
+
# 原因行必须打出来:调用方(render.py --stage all)只显示"最小重跑",
|
|
356
|
+
# 用户会看到"共 1 处过时"却不知道是哪个 stage、为什么——这正是本模块
|
|
357
|
+
# 开头承诺的"可以保守,但不能静默"。返回的 warns/errs 是给调用方判定的,
|
|
358
|
+
# 不代替人读的输出。
|
|
359
|
+
out(("warn" if soft else "err") if (strict and not allow_stale) else "warn", msg)
|
|
360
|
+
if it.get("rerun") and not soft:
|
|
248
361
|
out("warn", " 最小重跑: %s" % it["rerun"])
|
|
362
|
+
hard = [(st, it) for st, it in problems
|
|
363
|
+
if not (it.get("soft") or it.get("kind") in SOFT_KINDS)]
|
|
249
364
|
if problems:
|
|
250
365
|
out("warn", "")
|
|
251
|
-
|
|
252
|
-
|
|
366
|
+
if hard:
|
|
367
|
+
out("warn", " ↑ 共 %d 处过时。默认只告警;--strict-stale 让它失败,"
|
|
368
|
+
"--allow-stale 显式放行。放行会记进本次门禁账本,"
|
|
369
|
+
"交付阶段(qa / mux / all / stills / style)会体现在 QC 报告里。"
|
|
370
|
+
% len(hard))
|
|
371
|
+
else:
|
|
372
|
+
out("warn", " ↑ 上面 %d 条都是「本轮会重算」的输入变化(不是过时,不拦 strict)。"
|
|
373
|
+
% len(problems))
|
|
253
374
|
if ledger is not None:
|
|
254
375
|
try:
|
|
255
|
-
if not
|
|
256
|
-
ledger.ok("stale", "阶段新鲜度",
|
|
376
|
+
if not hard:
|
|
377
|
+
ledger.ok("stale", "阶段新鲜度",
|
|
378
|
+
"上游输入与上次记录一致" if not problems
|
|
379
|
+
else "%d 处输入会在本轮重算,无需拦" % len(problems))
|
|
257
380
|
elif errs:
|
|
258
|
-
ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(
|
|
381
|
+
ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(hard))
|
|
259
382
|
else:
|
|
260
383
|
ledger.skip("stale", "阶段新鲜度",
|
|
261
384
|
"%d 处过时,已告警未阻断%s"
|
|
262
|
-
% (len(
|
|
385
|
+
% (len(hard), "(--allow-stale)" if allow_stale else ""))
|
|
263
386
|
except Exception: # noqa: BLE001
|
|
264
387
|
pass
|
|
265
388
|
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()}
|
|
@@ -247,7 +334,7 @@ THEMES["lo-fi"] = _theme(
|
|
|
247
334
|
("深夜", "学习", "效率", "专注", "写作", "整理", "笔记", "书", "日常", "房间"),
|
|
248
335
|
# 冷调灰绿:和 paper(暖牛皮纸)在色相上分开,不靠"亮度差不多"硬撑
|
|
249
336
|
dict(bg=(232, 238, 236), bg2=(210, 220, 216), surface=(244, 248, 246),
|
|
250
|
-
ink=(44, 52, 50), muted=(
|
|
337
|
+
ink=(44, 52, 50), muted=(84, 98, 94), dim=(112, 126, 122),
|
|
251
338
|
accent=(20, 86, 84), accent2=(104, 72, 96),
|
|
252
339
|
good=(40, 100, 72), bad=(146, 52, 50), rule=(44, 52, 50)),
|
|
253
340
|
dict(vignette=0.12, vignette_tint=(206, 200, 190), grain=7.0, grain_freq=0.24,
|
|
@@ -546,7 +633,11 @@ def score_styles(script):
|
|
|
546
633
|
score += 1.0 if sid in ("cinema", "paper", "mono") else 0.0
|
|
547
634
|
if prof["hook"] > 0 and prof["outro"] > 0:
|
|
548
635
|
score += 0.5 if sid in ("tech-deep", "vivid", "epic") else 0.0
|
|
549
|
-
|
|
636
|
+
# 指纹破平:必须把**风格 id** 混进哈希,否则这一项对所有行是同一个常数,
|
|
637
|
+
# 等于没破平(v0.8 深审实测:旧写法在"AI 报告"上 tech-deep / magazine 同分 12.0,
|
|
638
|
+
# 永远按 STYLE_IDS 顺序选 tech-deep)。幅度上限 0.0006 << 最小真实分差 0.5,
|
|
639
|
+
# 只在精确同分时起作用,且不改变"改错别字不换风格"(指纹只吃标题)。
|
|
640
|
+
score += (_digest("tiebreak", fp["style"], sid) % 7) * 0.0001
|
|
550
641
|
rows.append((sid, round(score, 4), hits))
|
|
551
642
|
rows.sort(key=lambda r: (-r[1], STYLE_IDS.index(r[0])))
|
|
552
643
|
return rows
|
|
@@ -558,7 +649,12 @@ def pick_style(script):
|
|
|
558
649
|
best_id, best_score, best_hits = rows[0]
|
|
559
650
|
fp = fingerprint(script)
|
|
560
651
|
if best_score >= 1.0:
|
|
561
|
-
|
|
652
|
+
if best_hits:
|
|
653
|
+
reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
|
|
654
|
+
else:
|
|
655
|
+
# 分数可以只由结构加分(items / 长句 / hook+outro)凑到 1.0 以上,
|
|
656
|
+
# 这时旧文案会写"关键词命中:"后面空一截 —— 等于向用户报告没发生的事。
|
|
657
|
+
reason = ["无关键词命中,按题材结构加分选中(items / 长句 / hook+outro)"]
|
|
562
658
|
else:
|
|
563
659
|
pool = list(STYLE_IDS)
|
|
564
660
|
best_id = pool[int(fp["tempo"], 16) % len(pool)]
|
|
@@ -726,12 +822,18 @@ def resolve_script_style(script):
|
|
|
726
822
|
else:
|
|
727
823
|
chap_layout = {ch: layouts[i % len(layouts)] for i, ch in enumerate(chapters)}
|
|
728
824
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
825
|
+
# theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
|
|
826
|
+
# 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
|
|
827
|
+
theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
|
|
828
|
+
# visual.layout 是"显式换整片版式":旧的实现只把它写进 chapter_layout,而
|
|
829
|
+
# PIL 路线渲染读的是 theme["layout"]、web 路线读 layout_default —— 两条路线都不看
|
|
830
|
+
# chapter_layout,于是这条文档化的覆盖等于没写(v0.8 深审实测:visual.layout=
|
|
831
|
+
# split-right 的帧与默认帧 sha256 完全相同)。这里补写 theme["layout"] 与 layout_default,
|
|
832
|
+
# 让两条路线都跟着走;theme_overrides 更具体,它显式写了 layout 时以它为准。
|
|
833
|
+
if explicit_layout and "layout" not in (vis.get("theme_overrides") or {}):
|
|
834
|
+
theme["layout"] = explicit_layout
|
|
835
|
+
if explicit_layout:
|
|
836
|
+
chap_layout = {ch: str(theme["layout"]) for ch in chapters}
|
|
735
837
|
|
|
736
838
|
return {
|
|
737
839
|
"version": 1,
|
|
@@ -747,7 +849,7 @@ def resolve_script_style(script):
|
|
|
747
849
|
# 主题字典随计划一起返回:scenes.py 的 _scene_theme 优先用它(含 theme_overrides),
|
|
748
850
|
# 也方便 render.py 直接传给 draw_subtitle/write_ass,不必再按 id 查一次。
|
|
749
851
|
"theme": theme,
|
|
750
|
-
"layout_default": layouts[0],
|
|
852
|
+
"layout_default": str(theme["layout"]) if explicit_layout else layouts[0],
|
|
751
853
|
"chapter_layout": chap_layout,
|
|
752
854
|
"music": {
|
|
753
855
|
"style": music_style,
|
|
@@ -770,7 +872,8 @@ def resolve_script_style(script):
|
|
|
770
872
|
# 七、门禁:主题体检(对比度 / 明暗 / 结构)
|
|
771
873
|
# ============================================================================
|
|
772
874
|
#
|
|
773
|
-
# 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(ink 16.3 / muted 7.8 / accent 7.0
|
|
875
|
+
# 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(tech-deep:ink 16.3 / muted 7.8 / accent 7.0,
|
|
876
|
+
# 那是旧版按 bg/bg2 中点量的;v0.8 改成按两端最差值量后同一主题是 14.8 / 7.1 / 6.3,仍远高于线)。
|
|
774
877
|
|
|
775
878
|
LINT_LIMITS = {
|
|
776
879
|
"ink": 7.0, # 正文级(WCAG AAA 正文线)
|
|
@@ -784,18 +887,30 @@ LINT_LIMITS = {
|
|
|
784
887
|
}
|
|
785
888
|
|
|
786
889
|
|
|
890
|
+
def bg_contrast(th, role):
|
|
891
|
+
"""文字色对背景的**最差**对比度:bg/bg2 是上下两端的主渐变(scenes._bg 按行插值),
|
|
892
|
+
任何一行文字都可能落在两端附近,所以取二者较小值,而不是看中点。
|
|
893
|
+
|
|
894
|
+
为什么不能只看中点:lo-fi 的 muted(86,100,96) 对 bg 5.28、对 bg2 只有 4.42,
|
|
895
|
+
中点平均 4.84 把 AA 4.5 的门槛"平均"过去了 —— 实测过(v0.8 深审),
|
|
896
|
+
这一版把门槛改成端点最差值,并同时下调 lo-fi 的 muted 到 (84,98,94)
|
|
897
|
+
(bg 5.44 / bg2 4.56,两端都过)。
|
|
898
|
+
"""
|
|
899
|
+
return min(contrast(th[role], th["bg"]), contrast(th[role], th["bg2"]))
|
|
900
|
+
|
|
901
|
+
|
|
787
902
|
def theme_lint(th):
|
|
788
903
|
"""主题体检,返回问题列表(空 = 过)。坏主题在 load 阶段就拦,别等渲完 300 帧。"""
|
|
789
904
|
if not isinstance(th, dict):
|
|
790
905
|
return ["主题不是 dict:%r" % (th,)]
|
|
791
906
|
bad = []
|
|
792
|
-
bgs = midpoint(th["bg"], th["bg2"])
|
|
793
907
|
for role, need in (("ink", LINT_LIMITS["ink"]), ("muted", LINT_LIMITS["muted"]),
|
|
794
908
|
("dim", LINT_LIMITS["dim"]), ("accent", LINT_LIMITS["accent"]),
|
|
795
909
|
("accent2", LINT_LIMITS["accent2"])):
|
|
796
|
-
got =
|
|
910
|
+
got = bg_contrast(th, role)
|
|
797
911
|
if got < need:
|
|
798
|
-
bad.append("%s 对背景对比度 %.2f < %.1f"
|
|
912
|
+
bad.append("%s 对背景对比度 %.2f < %.1f(bg/bg2 两端取最差)"
|
|
913
|
+
% (role, got, need))
|
|
799
914
|
for role in ("good", "bad"):
|
|
800
915
|
got = contrast(th[role], th["surface"])
|
|
801
916
|
if got < LINT_LIMITS[role]:
|
|
@@ -863,7 +978,6 @@ def blend_theme(a, b, p):
|
|
|
863
978
|
def _describe(sid):
|
|
864
979
|
pack = STYLES[sid]
|
|
865
980
|
th = THEMES[pack["theme"]]
|
|
866
|
-
bgs = midpoint(th["bg"], th["bg2"])
|
|
867
981
|
lines = [
|
|
868
982
|
"[%s] %s (theme=%s / layouts=%s)" % (sid, pack["name"], pack["theme"],
|
|
869
983
|
",".join(pack["layouts"])),
|
|
@@ -877,9 +991,10 @@ def _describe(sid):
|
|
|
877
991
|
% (pack["pacing"]["lead"], pack["pacing"]["tail"], pack["pacing"]["min_dur"]),
|
|
878
992
|
" palette : bg=%s bg2=%s ink=%s accent=%s"
|
|
879
993
|
% (th["bg"], th["bg2"], th["ink"], th["accent"]),
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
994
|
+
# 与 theme_lint 同口径:bg/bg2 两端取最差(旧版打中点值,会掩盖端点不达标)
|
|
995
|
+
" contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f(bg/bg2 最差端)"
|
|
996
|
+
% (bg_contrast(th, "ink"), bg_contrast(th, "muted"),
|
|
997
|
+
bg_contrast(th, "dim"), bg_contrast(th, "accent")),
|
|
883
998
|
" font : family=%s display=%spx hud=%s"
|
|
884
999
|
% (th["family"], th["scale"]["display"][0], th["hud"]),
|
|
885
1000
|
]
|
|
@@ -900,6 +1015,10 @@ def main(argv=None):
|
|
|
900
1015
|
except Exception: # noqa: BLE001
|
|
901
1016
|
pass
|
|
902
1017
|
if a.show:
|
|
1018
|
+
# 未知 id 以前是 KeyError traceback,现在给可用清单 + exit 2(与 render.py 的口径一致)
|
|
1019
|
+
if a.show not in STYLES:
|
|
1020
|
+
print("[未知风格] %r;可用:%s" % (a.show, " / ".join(ALL_STYLE_IDS)), file=sys.stderr)
|
|
1021
|
+
return 2
|
|
903
1022
|
if a.json:
|
|
904
1023
|
import json
|
|
905
1024
|
print(json.dumps({"style": STYLES[a.show],
|
|
@@ -207,8 +207,12 @@ except Exception: # noqa: BLE001 — stylekit 缺失时
|
|
|
207
207
|
# ============================================================================
|
|
208
208
|
# 三、小工具:参数解析/格式化、汉字计数、抖动
|
|
209
209
|
# ============================================================================
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
# 允许小数("+4.5%"):edge-tts 只收整数百分比,所以解析后按 _round_half_away 取整。
|
|
211
|
+
# 以前正则只认整数,"+4.5%" 解析失败 → 静默回落 0 —— 而 0 与 "+0%" 在 spec_key 里
|
|
212
|
+
# 是**同一个 TTS 缓存键**,于是"把语速从 0 调到 4.5%"完全没生效,还复用了旧音频。
|
|
213
|
+
# 这是配置写对了却不生效里最难查的一类:没有报错、没有日志、听起来就是"没反应"。
|
|
214
|
+
_PCT_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*%\s*$")
|
|
215
|
+
_HZ_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*Hz\s*$")
|
|
212
216
|
|
|
213
217
|
|
|
214
218
|
def _round_half_away(v):
|
|
@@ -218,24 +222,24 @@ def _round_half_away(v):
|
|
|
218
222
|
|
|
219
223
|
|
|
220
224
|
def parse_pct(v, default=0):
|
|
221
|
-
"""'+4%' -> 4;'-3%' -> -3
|
|
225
|
+
"""'+4%' -> 4;'-3%' -> -3;'+4.5%' -> 5(取整);数字原样取整;解析失败回落 default。"""
|
|
222
226
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
223
227
|
return int(_round_half_away(v))
|
|
224
228
|
m = _PCT_RE.match(str(v if v is not None else ""))
|
|
225
229
|
if not m:
|
|
226
230
|
return int(default)
|
|
227
|
-
n = int(m.group(2))
|
|
231
|
+
n = int(_round_half_away(float(m.group(2))))
|
|
228
232
|
return -n if m.group(1) == "-" else n
|
|
229
233
|
|
|
230
234
|
|
|
231
235
|
def parse_hz(v, default=0):
|
|
232
|
-
"""'+2Hz' -> 2
|
|
236
|
+
"""'+2Hz' -> 2;'+2.5Hz' -> 3(取整);解析失败回落 default。"""
|
|
233
237
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
234
238
|
return int(_round_half_away(v))
|
|
235
239
|
m = _HZ_RE.match(str(v if v is not None else ""))
|
|
236
240
|
if not m:
|
|
237
241
|
return int(default)
|
|
238
|
-
n = int(m.group(2))
|
|
242
|
+
n = int(_round_half_away(float(m.group(2))))
|
|
239
243
|
return -n if m.group(1) == "-" else n
|
|
240
244
|
|
|
241
245
|
|
|
@@ -306,7 +310,11 @@ def _quantize(v, u):
|
|
|
306
310
|
# ============================================================================
|
|
307
311
|
# 四、文本规则(说什么):全部确定性,命中即返回理由
|
|
308
312
|
# ============================================================================
|
|
309
|
-
|
|
313
|
+
# 引语起始符。**不含书名号 《(《)**:正文里出现"《设计模式》"是书名,不是引用某人在说话。
|
|
314
|
+
# 实测踩过:把 《 算进引语后,"今天我们聊《设计模式》"这句的 tone 被判成 quote,
|
|
315
|
+
# 进而触发 rule:quote:persona,把 persona 从 narrator_male 换成 narrator_female ——
|
|
316
|
+
# 旁白中途换人,听感像换了主播(而这句根本没有人引语)。
|
|
317
|
+
_QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e")
|
|
310
318
|
_QUOTE_WORDS = ("他说", "她说", "有人问", "所谓", "据称")
|
|
311
319
|
_NUM_UNITS = ("%", "万", "亿", "年", "倍")
|
|
312
320
|
_TURN_WORDS = ("但是", "然而", "不过", "其实", "问题是")
|
|
@@ -861,6 +869,14 @@ def main(argv=None):
|
|
|
861
869
|
return _cli_list()
|
|
862
870
|
if a.calibrate:
|
|
863
871
|
only = [x.strip() for x in (a.only or "").split(",") if x.strip()] or None
|
|
872
|
+
if only:
|
|
873
|
+
unknown = [x for x in only if x not in VOICE_PERSONAS]
|
|
874
|
+
if unknown:
|
|
875
|
+
# 以前拼错 persona 会静默变成空集合 -> 打出"所有音色都合成失败(多半是没网)",
|
|
876
|
+
# 把配置错误伪装成网络错误。这里先点名校验,给人话可照抄的可用清单。
|
|
877
|
+
print("[未知 persona] %s;可用:%s"
|
|
878
|
+
% ("、".join(unknown), " / ".join(PERSONA_IDS)), file=sys.stderr)
|
|
879
|
+
return 2
|
|
864
880
|
return calibrate(only, log=print)
|
|
865
881
|
if a.plan:
|
|
866
882
|
return _cli_plan(a.plan)
|