@dsh-gis/dsh-video-studio-skill 0.4.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.
Files changed (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. package/tools/selftest_noscipy.py +70 -0
@@ -17,6 +17,7 @@ BS.1770 响度、块级动态处理)改编自 ai-history-film(MIT),实
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
+ import hashlib
20
21
  import inspect
21
22
  import math
22
23
  import os
@@ -1683,6 +1684,12 @@ def save(path, x, subtype: str = "PCM_24") -> Path:
1683
1684
  """写 WAV(stdlib wave,不依赖 soundfile)。subtype: PCM_16 / PCM_24 / PCM_32。"""
1684
1685
  p = Path(path)
1685
1686
  a = np.asarray(x, dtype=float)
1687
+ if a.size and not np.all(np.isfinite(a)):
1688
+ # NaN/Inf 经 astype(<i2/<i4) 会被静默写成 0 或未定义值(不同平台结果不同),
1689
+ # 文件听起来是"随机哑音"而调用方毫无察觉——与 filmkit.wav_write 同口径:报错。
1690
+ bad = int(a.size - np.count_nonzero(np.isfinite(a)))
1691
+ raise ValueError("sfxkit.save: 采样里有 %d 个 NaN/Inf(%s)——上游合成有数值错误,"
1692
+ "拒绝写出损坏的 WAV" % (bad, p))
1686
1693
  if a.ndim == 1:
1687
1694
  inter = a
1688
1695
  n_ch = 1
@@ -2011,35 +2018,79 @@ CUE_ALIASES = {
2011
2018
  "stream": "text_stream", # 兼容写法:文字流
2012
2019
  }
2013
2020
 
2014
- _PENTA_D = None
2021
+ # ============================================================================
2022
+ # 调性跟随 / 内容派生 seed(v0.7,references/audio-variation.md §3.1)
2023
+ # ============================================================================
2024
+ # 提示音以前硬编码 D 小调五声:配乐一旦选到 C / Am 等调,提示音就"跑调"。
2025
+ # 这里给 in_key() 加可选的 key 参数,key=None 时逐音保持 v0.6 的 D 小调五声
2026
+ # (自检/legacy 逐样本兼容),C 路(CueBus.build_audio)再把配乐调性透传下来。
2027
+ #
2028
+ # 调名表与 scorekit.KEYS_ALL 对齐(4 个小调 + 6 个大调),不另发明一套写法:
2029
+ # 小调 -> 自然小调五声(0/3/5/7/10 半音,Dm = D F G A C)
2030
+ # 大调 -> 大调五声(0/2/4/7/9 半音,C = C D E G A)
2031
+ _KEY_SPECS = {
2032
+ "Dm": ("D", 2, "minor"), "Am": ("A", 9, "minor"),
2033
+ "Cm": ("C", 0, "minor"), "Em": ("E", 4, "minor"),
2034
+ "C": ("C", 0, "major"), "G": ("G", 7, "major"), "D": ("D", 2, "major"),
2035
+ "F": ("F", 5, "major"), "A": ("A", 9, "major"), "Bb": ("Bb", 10, "major"),
2036
+ }
2037
+ _PENTA_CACHE = {}
2015
2038
 
2016
2039
 
2017
2040
  def _penta(key="Dm"):
2018
- """把提示音的音高吸附到一个五声音阶上(避免"叮"和配乐跑调)。
2041
+ """把某一调的五声音阶算成 [Hz, ...](从小到大,MIDI 24~107)。
2019
2042
 
2020
- 默认 D 小调五声(D F G A C)。返回 [Hz, ...] 从小到大。
2043
+ 小调用自然小调五声、大调用大调五声;结果按调名缓存(纯 numpy,无随机)。
2044
+ key 缺省 / None 仍是 D 小调五声:与 v0.6 的数组逐音一致,legacy 逐样本不变。
2021
2045
  """
2022
- global _PENTA_D
2023
- if _PENTA_D is not None:
2024
- return _PENTA_D
2025
- out = []
2026
- for midi in range(24, 108):
2027
- pc = midi % 12
2028
- if pc in (2, 5, 7, 9, 0): # D F G A C
2029
- out.append(midi)
2030
- _PENTA_D = np.array([440.0 * 2.0 ** ((m - 69) / 12.0) for m in out])
2031
- return _PENTA_D
2032
-
2033
-
2034
- def in_key(freq: float) -> float:
2035
- """把任意频率吸附到最近的 D 小调五声音阶音(配乐同调)。"""
2036
- p = _penta()
2046
+ name = "Dm" if key is None else str(key)
2047
+ got = _PENTA_CACHE.get(name)
2048
+ if got is not None:
2049
+ return got
2050
+ spec = _KEY_SPECS.get(name)
2051
+ if spec is None:
2052
+ raise ValueError("in_key: 未知调名 %r(可用 %s)"
2053
+ % (key, "/".join(_KEY_SPECS)))
2054
+ pc0, mode = int(spec[1]), spec[2] # spec[0] 是音名(只给读代码的人看),不参与计算
2055
+ steps = (0, 3, 5, 7, 10) if mode == "minor" else (0, 2, 4, 7, 9)
2056
+ pcs = {(int(pc0) + s) % 12 for s in steps}
2057
+ out = [m for m in range(24, 108) if (m % 12) in pcs]
2058
+ _PENTA_CACHE[name] = np.array([440.0 * 2.0 ** ((m - 69) / 12.0) for m in out])
2059
+ return _PENTA_CACHE[name]
2060
+
2061
+
2062
+ def in_key(freq: float, key: str | None = None) -> float:
2063
+ """把任意频率吸附到最近的五声音阶音(与配乐同调)。
2064
+
2065
+ key=None -> v0.6 的旧行为:硬编码 D 小调五声(D F G A C),逐样本兼容。
2066
+ key 给 "Dm"/"Am"/"Cm"/"Em"(自然小调五声)或 "C"/"G"/"D"/"F"/"A"/"Bb"
2067
+ (大调五声)-> 吸附到该调;未知调名 raise ValueError,不静默降级(与 _enum 同口径)。
2068
+ """
2069
+ p = _penta(key)
2037
2070
  if freq <= 0:
2038
2071
  return float(p[0])
2039
2072
  i = int(np.argmin(np.abs(np.log(p) - np.log(max(freq, 20.0)))))
2040
2073
  return float(p[i])
2041
2074
 
2042
2075
 
2076
+ def content_seed(base: int, style: str, content_fp: str, recipe: str, t: float) -> int:
2077
+ """内容派生 seed(v0.7 契约 §3.1):同片同 cue 稳定,换片同一位置也换种子。
2078
+
2079
+ 公式(与 filmkit.content_seed 逐字一致,两边同参必须同值):
2080
+ sha1("v0.7|<base>|<style>|<content_fp>|<recipe>|<t 三位小数>") -> 前 8 位 hex
2081
+ -> int(h[:8], 16) % 9000 + 1000(32 位非负 int,落在 1000~9999)。
2082
+
2083
+ 纯 stdlib(hashlib):同进程/跨进程/跨机器同参数同结果,不受 numpy 随机源影响。
2084
+ 为什么不用 seed_base + i:那样只跟 cue 的**出现顺序**有关,两部内容完全不同的片子
2085
+ 只要 cue 序列一样,音效轨就逐样本相同(跨片"音效都一样"的直接来源)。
2086
+ """
2087
+ h = hashlib.sha1(
2088
+ ("v0.7|%d|%s|%s|%s|%.3f"
2089
+ % (int(base), str(style), str(content_fp), str(recipe), float(t)))
2090
+ .encode("utf-8")).hexdigest()
2091
+ return int(h[:8], 16) % 9000 + 1000
2092
+
2093
+
2043
2094
  def _enum(value, allowed, where: str, name: str = "值") -> str:
2044
2095
  """严格校验语义枚举参数:未知值立刻报错并列出可用值,不静默落到默认分支。
2045
2096
 
@@ -2228,10 +2279,14 @@ def recipe(name=None, *, tags=(), master=None):
2228
2279
  # ---------------------------------------------------------------- 界面音
2229
2280
  @_recipe(tags=("界面", "提示音"))
2230
2281
  def ui_blip(seed=0, pitch=2200.0, kind="tick", dur=0.22, **kw):
2231
- """极短的"嘀":玻璃/金属的敲击,音高吸附五声音阶。"""
2282
+ """极短的"嘀":玻璃/金属的敲击,音高吸附五声音阶。
2283
+
2284
+ v0.7:key 从 **kw 透传(C 路把配乐调性放进来)——None/缺省 = 旧行为(D 小调五声),
2285
+ 给 "Dm"/"C"/"Am" 等则吸附到该调,未知调名 raise ValueError(见 in_key)。
2286
+ """
2232
2287
  kind = _enum(kind, ("tick", "ping"), "ui_blip", "kind")
2233
2288
  g = rng(seed)
2234
- f = in_key(float(pitch)) * float(g.uniform(0.985, 1.015))
2289
+ f = in_key(float(pitch), kw.get("key")) * float(g.uniform(0.985, 1.015))
2235
2290
  if kind == "tick":
2236
2291
  body = _tone(f, dur * 0.55, 0.035, attack=0.0004)
2237
2292
  click = contact_noise(1.4, center_hz=6200.0, q=0.9, seed=seed) * 0.35
@@ -2276,9 +2331,16 @@ def relay_click(seed=0, count=1, gap=0.18, **kw):
2276
2331
 
2277
2332
  @_recipe(tags=("界面", "抽签"))
2278
2333
  def roulette_tick(seed=0, last=False, **kw):
2279
- """转盘抽签的木质"咔嗒":硬接触的木块,last=True 时更重、更低。"""
2334
+ """转盘抽签的木质"咔嗒":硬接触的木块,last=True 时更重、更低。
2335
+
2336
+ v0.7:给了 key 时把木块基频吸附到该调(提示音跟配乐同调);key=None 走旧公式,
2337
+ 随机序列与取样点都不变 -> legacy 逐样本兼容。
2338
+ """
2280
2339
  g = rng(seed)
2281
2340
  f1 = (410.0 if not last else 300.0) * float(g.uniform(0.97, 1.03))
2341
+ key = kw.get("key")
2342
+ if key is not None:
2343
+ f1 = in_key(f1, key)
2282
2344
  b = bar(f1, material="wood", n_modes=7, tilt=0.25, seed=seed)
2283
2345
  x = _bus(0.26, stereo=False)
2284
2346
  _place(x, b.render(Hit(0.0, velocity=1.0 if last else 0.75,
@@ -2289,10 +2351,14 @@ def roulette_tick(seed=0, last=False, **kw):
2289
2351
 
2290
2352
  @_recipe(tags=("界面", "提示音", "音乐"))
2291
2353
  def answer_chime(seed=0, note=1174.66, grace=880.0, grace_lead=0.07, **kw):
2292
- """"猜中了"的钟琴:一个装饰音 + 一个主音,都在五声音阶上。"""
2354
+ """"猜中了"的钟琴:一个装饰音 + 一个主音,都在五声音阶上。
2355
+
2356
+ v0.7:key 从 **kw 透传(None = 旧行为 D 小调五声;给调名则整条钟琴跟随配乐调性)。
2357
+ """
2358
+ key = kw.get("key")
2293
2359
  x = _bus(1.6, stereo=False)
2294
- _place(x, _tone(in_key(grace), 0.9, 0.5, attack=0.001, index=1.6, ratio=3.5), 0.0, -10.0)
2295
- _place(x, _tone(in_key(note), 1.4, 0.9, attack=0.0015, index=1.2, ratio=2.0),
2360
+ _place(x, _tone(in_key(grace, key), 0.9, 0.5, attack=0.001, index=1.6, ratio=3.5), 0.0, -10.0)
2361
+ _place(x, _tone(in_key(note, key), 1.4, 0.9, attack=0.0015, index=1.2, ratio=2.0),
2296
2362
  float(grace_lead), -1.5)
2297
2363
  return x
2298
2364
 
@@ -2341,7 +2407,10 @@ def typing_at(seed=0, times=(0.0, 0.12, 0.25, 0.4), spaces=(), **kw):
2341
2407
 
2342
2408
  @_recipe(tags=("界面", "数据", "文字"))
2343
2409
  def text_stream(seed=0, times=(0.0, 0.04, 0.08, 0.12), base=1174.7, voice="tone", **kw):
2344
- """AI 逐字输出:比打字更连贯。voice="tone" 是带音高的五声颗粒;"soft" 是不带音高的沙沙。"""
2410
+ """AI 逐字输出:比打字更连贯。voice="tone" 是带音高的五声颗粒;"soft" 是不带音高的沙沙。
2411
+
2412
+ v0.7:voice="tone" 的音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
2413
+ """
2345
2414
  voice = _enum(voice, ("tone", "soft"), "text_stream", "voice")
2346
2415
  ts = _times(times, "text_stream")
2347
2416
  g = rng(seed)
@@ -2354,21 +2423,26 @@ def text_stream(seed=0, times=(0.0, 0.04, 0.08, 0.12), base=1174.7, voice="tone"
2354
2423
  k = bandpass(k, 3200.0 + 900.0 * float(g.random()), q=0.9)
2355
2424
  _place(x, k, t, -6.0 + float(g.uniform(-1.5, 1.5)))
2356
2425
  else:
2357
- f = in_key(float(base) * (2.0 ** (float(g.choice([-2, 0, 2, 4])) / 12.0)))
2426
+ f = in_key(float(base) * (2.0 ** (float(g.choice([-2, 0, 2, 4])) / 12.0)),
2427
+ kw.get("key"))
2358
2428
  _place(x, _tone(f, 0.06, 0.02, attack=0.0006), t, -9.0 + float(g.uniform(-1.0, 1.0)))
2359
2429
  return x
2360
2430
 
2361
2431
 
2362
2432
  @_recipe(tags=("界面", "数据"))
2363
2433
  def data_chirp(seed=0, dur=0.8, rate=28.0, base=1800.0, **kw):
2364
- """数据流:一串很快的、带音高的短脉冲,频率在五声上随机跳动。"""
2434
+ """数据流:一串很快的、带音高的短脉冲,频率在五声上随机跳动。
2435
+
2436
+ v0.7:音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
2437
+ """
2365
2438
  r = float(rate)
2366
2439
  n = max(2, int(float(dur) * r))
2367
2440
  g = rng(seed)
2368
2441
  x = _bus(dur, stereo=False)
2369
2442
  for i in range(n):
2370
2443
  at = i / r
2371
- f = in_key(float(base) * (2.0 ** (float(g.choice([-5, -2, 0, 2, 3, 5, 7])) / 12.0)))
2444
+ f = in_key(float(base) * (2.0 ** (float(g.choice([-5, -2, 0, 2, 3, 5, 7])) / 12.0)),
2445
+ kw.get("key"))
2372
2446
  env_amp = 1.0 - 0.55 * (i / max(1, n - 1))
2373
2447
  _place(x, _tone(f, 0.05, 0.012, attack=0.0004), at, -14.0 + 20 * np.log10(env_amp + 1e-6))
2374
2448
  return x
@@ -2981,7 +3055,33 @@ def selftest() -> int:
2981
3055
  b = render(k, seed=11, dur=3.0) if k == "rain" else render(k, seed=11)
2982
3056
  if not np.array_equal(np.asarray(a), np.asarray(b)):
2983
3057
  raise ValueError("%s 同 seed 两次渲染不一致" % k)
2984
- return "impact/whoosh/ui_blip/typing/rain 逐采样一致"
3058
+ # v0.7 契约 §3.1:key=None 与 key="Dm" 必须逐样本一致(旧路径兼容);
3059
+ # key 给别的调必须真的换音(用 F4:Dm 五声里有 F、C 大调五声里没有,一定吸附到 E4)。
3060
+ old = np.asarray(render("ui_blip", seed=11))
3061
+ dm = np.asarray(render("ui_blip", seed=11, key="Dm"))
3062
+ if not np.array_equal(old, dm):
3063
+ raise ValueError('ui_blip key="Dm" 与 key=None(旧行为)不一致')
3064
+ f4 = np.asarray(render("ui_blip", seed=11, pitch=349.23))
3065
+ c_major = np.asarray(render("ui_blip", seed=11, pitch=349.23, key="C"))
3066
+ if np.array_equal(f4, c_major):
3067
+ raise ValueError('ui_blip key="C" 没有换调(F4 应吸附到 E4)')
3068
+ for rn in ("ui_blip", "answer_chime", "text_stream", "data_chirp", "roulette_tick"):
3069
+ y = render(rn, seed=2, key="Am")
3070
+ if y is None or len(y) == 0:
3071
+ raise ValueError("%s 透传 key='Am' 后无输出" % rn)
3072
+ # content_seed:同参数稳定、对 content_fp/recipe 敏感、落在 1000~9999(契约 §3.1)。
3073
+ s1 = content_seed(1000, "tech-deep", "aaaa1111", "ui_blip", 12.5)
3074
+ s2 = content_seed(1000, "tech-deep", "aaaa1111", "ui_blip", 12.5)
3075
+ s3 = content_seed(1000, "tech-deep", "bbbb2222", "ui_blip", 12.5)
3076
+ s4 = content_seed(1000, "tech-deep", "aaaa1111", "whoosh", 12.5)
3077
+ if s1 != s2:
3078
+ raise ValueError("content_seed 同参数两次不一致")
3079
+ if s3 == s1 or s4 == s1:
3080
+ raise ValueError("content_seed 对 content_fp/recipe 不敏感(跨片会撞种子)")
3081
+ if not (1000 <= s1 <= 9999):
3082
+ raise ValueError("content_seed 超出 1000~9999:%r" % (s1,))
3083
+ return ("impact/whoosh/ui_blip/typing/rain 逐采样一致;key=Dm 同旧行为、key=C 换调;"
3084
+ "content_seed 稳定(s=%d)" % s1)
2985
3085
 
2986
3086
  def t_all_render():
2987
3087
  # 预算分两条路径(见文件头 PERF_BUDGET_* 注释):有 scipy 1.5 s;无 scipy 走
@@ -3054,7 +3154,9 @@ def selftest() -> int:
3054
3154
  ("knock_wood", dict(room="bogus")),
3055
3155
  ("metal_hit", dict(obj="bogus")),
3056
3156
  ("relay_click", dict(gap=-1.0, count=3)),
3057
- ("typing_at", dict(times=[-1.0])))
3157
+ ("typing_at", dict(times=[-1.0])),
3158
+ ("ui_blip", dict(key="bogus")), # v0.7:未知调名必须响亮报错
3159
+ ("answer_chime", dict(key="bogus")))
3058
3160
  for rname, kw in bad:
3059
3161
  try:
3060
3162
  render(rname, seed=3, **kw)
@@ -0,0 +1,405 @@
1
+ # -*- coding: utf-8 -*-
2
+ """stagewatch.py — 阶段新鲜度(stale)守卫(v0.6.0)。
3
+
4
+ 解决的是"改了一句台词,旧产物还在用,但不报错"这一类事故。
5
+
6
+ 已经在 `_frames_plan_stamp` 里为一个点做过(改台词 -> 旧分块复用 -> 画面旁白对不上),
7
+ 但其余 stage 全是裸奔:
8
+ * 改 style 后 `--stage audio` 仍读上一轮的 work/music.wav(配乐是旧的);
9
+ * 改 hold 后 `--stage frames` 仍用旧 cues.json(音效落位错);
10
+ * 单独跑 `--stage audio` 会无条件混入已存在的 work/sfx.wav。
11
+
12
+ 机制:每个 stage 结束时往 `work/manifest.json` 记一行
13
+ {stage, at, inputs: {name: 指纹}, outputs: {name: 指纹}}
14
+ 下次该 stage 开始前,重算**上游 inputs** 与**自己 outputs** 的指纹:
15
+ * 上游变了 -> 本 stage 的输入过时(会拿旧输入算出新产物);
16
+ * 自己的 outputs 变了 -> 下游全部过时。
17
+ 两者都只**警告 + 给出最小重跑命令**,默认不阻断(可用 --strict-stale 升级为失败,
18
+ --allow-stale 显式放行并留痕)。这是"门禁可以保守,但不能静默"的同一条原则。
19
+
20
+ 指纹策略:小文件用 sha1(准),大文件用 size+mtime(快)。
21
+ 100 MB 的 audio.wav 每 stage 都算 sha1 会让"跑一次 qa"变慢一倍,而它的目的只是
22
+ "发现它被人动过",size+mtime 足够。
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import time
30
+
31
+ SCHEMA = 1
32
+ BIG_BYTES = 8 << 20 # > 8 MB 走 size+mtime 快路径
33
+
34
+ # 每个 stage 的输入与输出(相对 work/ 或绝对路径)。
35
+ # 顺序即依赖顺序:后面的 stage 用前面 stage 的产物。
36
+ # `sources` 是**源头输入**:剧本(film.json)与画面代码(scenes.py)。
37
+ # 早期版本只跟踪"stage 之间的产物",于是"改了一句台词但没重跑 tts"查不出任何过时——
38
+ # 而那正是最需要它报警的场景(旧 tts 配新剧本 = 配音和字幕全错位)。
39
+ STAGE_IO = {
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"]),
49
+ "layout": dict(sources=["script"],
50
+ inputs=["tts.json", "style.json", "voice_plan.json"],
51
+ outputs=["timeline.json", "sub.srt", "sub.ass"]),
52
+ "cues": dict(sources=["script", "scenes"], inputs=["timeline.json"],
53
+ outputs=["cues.json"]),
54
+ "score": dict(sources=["script"], inputs=["timeline.json", "style.json"],
55
+ outputs=["music.wav", "score.json"]),
56
+ "sfx": dict(sources=["script"], inputs=["cues.json"], outputs=["sfx.wav"]),
57
+ "audio": dict(sources=["script"],
58
+ inputs=["tts.json", "timeline.json", "music.wav", "sfx.wav"],
59
+ outputs=["audio.wav"]),
60
+ "frames": dict(sources=["script", "scenes"],
61
+ inputs=["timeline.json", "audio.wav", "cues.json"],
62
+ outputs=["silent.mp4"]),
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 里生成,才是真凭证。
67
+ "qa": dict(sources=["script", "scenes"], inputs=[],
68
+ outputs=["qa/sheet.png", "qa/deep.json"]),
69
+ "stills": dict(sources=["script", "scenes"], inputs=["timeline.json", "style.json"],
70
+ outputs=[]),
71
+ }
72
+ STAGE_ORDER = tuple(STAGE_IO)
73
+
74
+ # 每个 stage 过时后的"最小重跑命令"提示(只提示本 stage + 必要的下游)
75
+ RERUN_HINT = {
76
+ "style": "python render.py --stage style tts layout cues score sfx audio frames mux qa",
77
+ "tts": "python render.py --stage tts layout cues sfx audio frames mux qa",
78
+ "layout": "python render.py --stage layout cues score sfx audio frames mux qa",
79
+ "cues": "python render.py --stage cues sfx audio frames mux qa",
80
+ "score": "python render.py --stage score audio mux qa",
81
+ "sfx": "python render.py --stage sfx audio mux qa",
82
+ "audio": "python render.py --stage audio mux qa",
83
+ "frames": "python render.py --stage frames mux qa (分块缓存会被指纹拒绝,需 rm -rf work/segments)",
84
+ "mux": "python render.py --stage mux qa",
85
+ "qa": "python render.py --stage qa",
86
+ "stills": "python render.py --stage stills",
87
+ }
88
+
89
+
90
+ def _fp(path, work):
91
+ """文件指纹:缺失 -> 'missing';>8MB 用 size+mtime(快),否则 sha1(准)。"""
92
+ p = path if os.path.isabs(str(path)) else os.path.join(str(work), str(path))
93
+ try:
94
+ st = os.stat(p)
95
+ except OSError:
96
+ return "missing"
97
+ if st.st_size > BIG_BYTES:
98
+ return "sm:%d:%d" % (st.st_size, int(st.st_mtime))
99
+ try:
100
+ h = hashlib.sha1()
101
+ with open(p, "rb") as fh:
102
+ for blk in iter(lambda: fh.read(1 << 20), b""):
103
+ h.update(blk)
104
+ return "sha1:" + h.hexdigest()[:16]
105
+ except OSError:
106
+ return "missing"
107
+
108
+
109
+ def manifest_path(work):
110
+ return os.path.join(str(work), "manifest.json")
111
+
112
+
113
+ def load(work):
114
+ p = manifest_path(work)
115
+ if not os.path.exists(p):
116
+ return {"schema": SCHEMA, "stages": {}}
117
+ try:
118
+ d = json.load(open(p, encoding="utf-8"))
119
+ except (json.JSONDecodeError, OSError):
120
+ return {"schema": SCHEMA, "stages": {}, "broken": p}
121
+ if not isinstance(d, dict) or not isinstance(d.get("stages"), dict):
122
+ return {"schema": SCHEMA, "stages": {}, "broken": p}
123
+ return d
124
+
125
+
126
+ def save(work, d):
127
+ os.makedirs(str(work), exist_ok=True)
128
+ p = manifest_path(work)
129
+ tmp = "%s.%d.tmp" % (p, os.getpid())
130
+ with open(tmp, "w", encoding="utf-8") as fh:
131
+ json.dump(d, fh, ensure_ascii=False, indent=1)
132
+ os.replace(tmp, p)
133
+ return p
134
+
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
+
149
+ def _src_fp(kind, sources):
150
+ """源头输入指纹:script=剧本(film.json)、scenes=场景代码(scenes.py)。"""
151
+ p = (sources or {}).get(kind)
152
+ return _fp(p, ".") if p else "missing"
153
+
154
+
155
+ def snapshot(work, stage, sources=None):
156
+ """记下某个 stage 的输入/输出指纹(stage 结束时调用)。"""
157
+ io = STAGE_IO.get(str(stage))
158
+ if io is None:
159
+ return None
160
+ d = load(work)
161
+ d.setdefault("stages", {})[str(stage)] = dict(
162
+ at=time.time(),
163
+ atText=time.strftime("%Y-%m-%d %H:%M:%S"),
164
+ src={k: _src_fp(k, sources) for k in (io.get("sources") or [])},
165
+ inputs={k: _fp(k, work) for k in io["inputs"]},
166
+ outputs={k: _fp(k, work) for k in io["outputs"]},
167
+ )
168
+ save(work, d)
169
+ return d["stages"][str(stage)]
170
+
171
+
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):
185
+ """stage 开始前查新鲜度:返回问题列表(每项含 stage / reason / rerun)。
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
+
194
+ 只报"这一跑真的会读到旧东西"的情况:
195
+ 1) 本 stage 的上游输入与上次记录不同(上游被重跑过,或人手改过文件);
196
+ 2) 本 stage 自己的产物比它的输入还旧(上次跑到一半被中断)。
197
+ 另一个 stage 的产物变了但**不**是本周 stage 的输入时,不报(跟我们无关)。
198
+
199
+ self_built: 这些输入是**本次运行自己刚写出来的**(render.py --stage all 跑在
200
+ 一个刚建的 work/ 上)。此时"manifest 没有本阶段记录"只是因为我们还没跑到它,
201
+ 不是旧产物;报 no-manifest 等于让全新目录的首跑刷一屏假警报(v0.7.2 修)。
202
+ """
203
+ io = STAGE_IO.get(str(stage))
204
+ if io is None:
205
+ return []
206
+ _seen = set(_seen or ())
207
+ if str(stage) in _seen:
208
+ return []
209
+ _seen.add(str(stage))
210
+ d = load(work)
211
+ stages = d.get("stages") or {}
212
+ rec = stages.get(str(stage))
213
+ planned = set(str(x) for x in (pending or ()))
214
+ issues = []
215
+ if not isinstance(rec, dict):
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:
227
+ issues.append(dict(stage=str(stage), kind="no-manifest",
228
+ reason="work 里已经有本阶段的产物(%s),但 manifest.json 没有本阶段的记录"
229
+ "(旧版本跑过 / 手改过)" % "、".join(present_out),
230
+ rerun=RERUN_HINT.get(str(stage), "")))
231
+ return issues
232
+ # 源头先查:剧本 / scenes.py 改了但本 stage 没重跑 -> 产物一定过时
233
+ old_src = rec.get("src") or {}
234
+ for kind in (io.get("sources") or []):
235
+ now = _src_fp(kind, sources)
236
+ was = old_src.get(kind, "missing")
237
+ if now != was:
238
+ fname = {"script": "剧本 film.json", "scenes": "画面代码 scenes.py"}.get(kind, kind)
239
+ issues.append(dict(
240
+ stage=str(stage), kind="source-changed", file=kind,
241
+ reason="%s 在本阶段上次跑完之后改过(%s -> %s),但本阶段没有重跑;"
242
+ "它现在装的还是旧内容" % (fname, was, now),
243
+ rerun=RERUN_HINT.get(str(stage), ""),
244
+ ))
245
+ old_inputs = rec.get("inputs") or {}
246
+ for name in io["inputs"]:
247
+ now = _fp(name, work)
248
+ was = old_inputs.get(name, "missing")
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:
274
+ issues.append(dict(
275
+ stage=str(stage), kind="input-changed", file=name,
276
+ reason="输入 %s 在上次跑本阶段之后变了(%s -> %s),本次会拿**新输入**算,"
277
+ "但它下游的产物仍是旧的" % (name, was, now),
278
+ rerun=RERUN_HINT.get(str(stage), ""),
279
+ ))
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"]
287
+ if missing_out:
288
+ issues.append(dict(stage=str(stage), kind="output-missing",
289
+ reason="上次跑过本阶段,但产物 %s 不见了(被删 / 被挪 / 磁盘满)"
290
+ % "、".join(missing_out),
291
+ rerun=RERUN_HINT.get(str(stage), "")))
292
+ return issues
293
+
294
+
295
+ def explain(work, stages=None, sources=None, self_built=False, pending=()):
296
+ """--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。
297
+
298
+ pending: 本次命令行要跑的 stage 集合(见 check);--stage explain 不传 = 全按"不重跑"判。
299
+ """
300
+ d = load(work)
301
+ rows = []
302
+ for st in (stages or STAGE_ORDER):
303
+ io = STAGE_IO.get(st)
304
+ if io is None:
305
+ continue
306
+ rec = (d.get("stages") or {}).get(st)
307
+ issues = check(work, st, sources, self_built=self_built, pending=pending)
308
+ rows.append(dict(stage=st, recorded=bool(rec),
309
+ at=(rec or {}).get("atText", ""),
310
+ issues=issues))
311
+ return rows
312
+
313
+
314
+ def report(work, stages=None, strict=False, allow_stale=False, ledger=None, write=None,
315
+ sources=None, self_built=False, pending=()):
316
+ """查 + 打印 + 判定。返回 (errors, warnings);errors 在 strict 下非空。
317
+
318
+ ledger: 传 gates.GateLedger 时把结论记进账本(一条汇总 gate)。
319
+ write: 传 callable(str, str) 时按行输出(方便测试捕获)。
320
+ self_built: 见 check()——本次运行的 work/ 是刚建的,别报"没有记录=过时"。
321
+ pending: 本次命令行要跑的 stage 集合(见 check)。其中"本阶段会重算"的输入变化
322
+ 标 soft,照实打印但不参与 strict 判定。
323
+ """
324
+ out = write or (lambda level, msg: print(msg))
325
+ rows = explain(work, stages, sources, self_built=self_built, pending=pending)
326
+ problems = [(r["stage"], i) for r in rows for i in r["issues"]]
327
+ warns, errs = [], []
328
+ for st, it in problems:
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:
337
+ warns.append(msg + "(--allow-stale 已放行,本次结果可能掺旧数据)")
338
+ elif strict:
339
+ errs.append(msg)
340
+ else:
341
+ warns.append(msg)
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:
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)]
351
+ if problems:
352
+ out("warn", "")
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))
361
+ if ledger is not None:
362
+ try:
363
+ if not hard:
364
+ ledger.ok("stale", "阶段新鲜度",
365
+ "上游输入与上次记录一致" if not problems
366
+ else "%d 处输入会在本轮重算,无需拦" % len(problems))
367
+ elif errs:
368
+ ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(hard))
369
+ else:
370
+ ledger.skip("stale", "阶段新鲜度",
371
+ "%d 处过时,已告警未阻断%s"
372
+ % (len(hard), "(--allow-stale)" if allow_stale else ""))
373
+ except Exception: # noqa: BLE001
374
+ pass
375
+ return errs, warns
376
+
377
+
378
+ if __name__ == "__main__":
379
+ import argparse
380
+ import sys
381
+ ap = argparse.ArgumentParser(description="阶段新鲜度检查:现在哪些产物是旧的")
382
+ ap.add_argument("--work", default="work")
383
+ ap.add_argument("--stage", default="", help="只看某个 stage(缺省 = 全部)")
384
+ ap.add_argument("--json", action="store_true")
385
+ a = ap.parse_args()
386
+ stages = [a.stage] if a.stage else None
387
+ rows = explain(a.work, stages)
388
+ if a.json:
389
+ print(json.dumps(rows, ensure_ascii=False, indent=1))
390
+ sys.exit(0)
391
+ bad = 0
392
+ for r in rows:
393
+ mark = "OK " if not r["issues"] else "!! "
394
+ if r["issues"]:
395
+ bad += 1
396
+ print("%s%-8s %s" % (mark, r["stage"], r["at"] or "(无记录)"))
397
+ for it in r["issues"]:
398
+ print(" -", it["reason"])
399
+ if it.get("rerun"):
400
+ print(" 最小重跑:", it["rerun"])
401
+ print()
402
+ if bad:
403
+ print("%d 个阶段过时。只想看某个:--stage <名字>;打印机读:--json" % bad)
404
+ sys.exit(1)
405
+ print("没有发现过时的阶段(按 manifest.json 记录判断)。")