@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.9.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +1109 -0
  2. package/README.md +76 -10
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/docs/styles/sheet-8-styles.png +0 -0
  5. package/docs/styles/sheet-before-after.png +0 -0
  6. package/index.js +4 -1
  7. package/package.json +7 -3
  8. package/schemas/film.schema.json +329 -2
  9. package/skills/video-studio/SKILL.md +270 -13
  10. package/skills/video-studio/assets/golden_frames.json +65 -0
  11. package/skills/video-studio/assets/scenes.py +2162 -1190
  12. package/skills/video-studio/assets/web/engine.js +63 -2
  13. package/skills/video-studio/assets/web/film.js +224 -101
  14. package/skills/video-studio/assets/web/index.html +4 -2
  15. package/skills/video-studio/assets/web/qa_text.js +36 -8
  16. package/skills/video-studio/references/audio-variation.md +4 -3
  17. package/skills/video-studio/references/audio.md +2 -2
  18. package/skills/video-studio/references/change-impact.md +1 -1
  19. package/skills/video-studio/references/quality-gates.md +25 -3
  20. package/skills/video-studio/references/scene-grammar.md +306 -0
  21. package/skills/video-studio/references/style-packs.md +108 -3
  22. package/skills/video-studio/references/v08-quality.md +256 -0
  23. package/skills/video-studio/references/web-engine.md +19 -2
  24. package/skills/video-studio/references/word-timeline.md +147 -0
  25. package/skills/video-studio/requirements.txt +14 -0
  26. package/skills/video-studio/scripts/filmcheck.py +218 -0
  27. package/skills/video-studio/scripts/filmkit.py +521 -38
  28. package/skills/video-studio/scripts/gates.py +88 -4
  29. package/skills/video-studio/scripts/grammar.py +559 -0
  30. package/skills/video-studio/scripts/preflight.py +774 -0
  31. package/skills/video-studio/scripts/qa_frames.py +49 -8
  32. package/skills/video-studio/scripts/qaasr.py +1563 -0
  33. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  34. package/skills/video-studio/scripts/render.py +764 -32
  35. package/skills/video-studio/scripts/scorekit.py +16 -30
  36. package/skills/video-studio/scripts/selfcheck.py +483 -0
  37. package/skills/video-studio/scripts/sfxkit.py +130 -14
  38. package/skills/video-studio/scripts/stagewatch.py +19 -6
  39. package/skills/video-studio/scripts/stylekit.py +242 -12
  40. package/skills/video-studio/scripts/voicekit.py +15 -7
  41. package/skills/video-studio/scripts/webengine.py +1119 -72
  42. package/tools/ci_check.py +173 -31
  43. package/tools/estimate.py +130 -19
  44. package/tools/golden_frames.py +664 -0
  45. package/tools/silhouette_qa.py +910 -0
@@ -782,6 +782,33 @@ def tv_filter(x, kind: str, freq, q: float = 0.7071, gain_db: float = 0.0,
782
782
  return y
783
783
 
784
784
 
785
+ def _resonator_ir(f: float, t60: float, amp: float, n_max: int,
786
+ sr: int = SR) -> np.ndarray:
787
+ """模态谐振器的解析冲激响应 h[n] = amp · r^n · sin(ωn),长度 ≤ n_max。
788
+
789
+ 为什么不复用通用的 _iir_ir():无 scipy 时 resonator 也要走 FFT 卷积,而金属/
790
+ 玻璃模态的 t60 可达 5~7 s(见 MATERIALS 的 eta 量级),-80 dB 衰减长度约
791
+ 1.33·t60·sr ≈ 40 万采样。旧的 _iir_ir(cap=20000) 在 0.42 s 处把 IR 砍成 0:
792
+ 截断点在 -25 dB 上直接跳变,听感是长尾里多一下“咔”,且与 scipy 路径的
793
+ 产物长度/尾音全不同(references/sfx-design.md §12 要求静态滤波与 scipy
794
+ 相对误差 ≤ 2e-4,实测旧兜底最差 -8 dB)。这个二阶系统的 IR 有闭式解,
795
+ 一次向量化算完,比逐样本 Python 递推快两三个数量级。
796
+ 长度取 min(n_max, -80 dB 衰减长度):输出只取前 n_max 个采样,h 超过 n_max
797
+ 的部分不可能影响这些采样,所以这个截断是精确的、不是近似。
798
+ """
799
+ n_max = int(max(0, n_max))
800
+ if n_max == 0:
801
+ return np.zeros(0)
802
+ a1 = math.exp(-LN1000 / (max(float(t60), 1e-5) * float(sr)))
803
+ w = 2.0 * np.pi * float(f) / float(sr)
804
+ # 借 _ir_len() 的极点半径估计(它已处理不衰减/数值退化),保证与旧路径同口径
805
+ a = np.array([1.0, -2.0 * a1 * math.cos(w), a1 * a1])
806
+ b = np.array([0.0, float(amp) * a1 * math.sin(w)])
807
+ L = int(_ir_len(b, a, cap=n_max))
808
+ k = np.arange(L, dtype=float)
809
+ return float(amp) * np.power(a1, k) * np.sin(w * k)
810
+
811
+
785
812
  def resonator(excitation, f: float, t60: float, amp: float = 1.0,
786
813
  sr: int = SR) -> np.ndarray:
787
814
  """单个模态:二阶谐振器,冲激响应为 amp · r^n · sin(ωn)(指数衰减的正弦)。"""
@@ -794,7 +821,7 @@ def resonator(excitation, f: float, t60: float, amp: float = 1.0,
794
821
  a = np.array([1.0, -2.0 * a1 * math.cos(w), a1 * a1])
795
822
  if _sig is not None:
796
823
  return _sig.lfilter(b, a, x, axis=0)
797
- return _fftconv(x, _iir_ir(b, a, _ir_len(b, a, cap=20000)))[: len(x)]
824
+ return _fftconv(x, _resonator_ir(f, t60, amp, len(x), sr))[: len(x)]
798
825
 
799
826
 
800
827
  def resonator_bank(excitation, freqs, t60s, amps, sr: int = SR) -> np.ndarray:
@@ -1000,13 +1027,31 @@ class Body:
1000
1027
  y = np.zeros(n)
1001
1028
  if not events:
1002
1029
  return y
1003
- # 多声道但共用事件:先把激励按模态分组(每个模态一份稀疏激励)
1004
- exc = np.zeros(n)
1005
- for i, (f, t60) in enumerate(zip(self.freqs, self.t60s)):
1006
- exc[:] = 0.0
1007
- for s, pulse, w in events:
1008
- exc[s:s + len(pulse)] += w[i] * pulse
1009
- y += resonator(exc, float(f), float(t60), 1.0, sr)
1030
+ if _sig is not None:
1031
+ # 有 scipy:逐模态 lfilter(保持 v0.7 及以前的产物逐样本不变——用户成片不出新声音)
1032
+ exc = np.zeros(n)
1033
+ for i, (f, t60) in enumerate(zip(self.freqs, self.t60s)):
1034
+ exc[:] = 0.0
1035
+ for s, pulse, w in events:
1036
+ exc[s:s + len(pulse)] += w[i] * pulse
1037
+ y += resonator(exc, float(f), float(t60), 1.0, sr)
1038
+ return y
1039
+ # 无 scipy:先按事件把「加权模态 IR」合成一条,再与力脉冲卷积一次(N 模态 → 1 次 FFT)。
1040
+ # 等价性:每个事件的模态权重 w_i 是标量,Σ_i w_i·conv(pulse, h_i) = conv(pulse, Σ_i w_i·h_i)
1041
+ # 可换序;所以合并只减少 FFT 次数,不改变结果。只在兜底路径合并:有 scipy 的产物保持原样。
1042
+ # 代价:IR 长度仍按 -80 dB 衰减截断,且只保留到输出末尾(h 超过 n-s 的部分影响不到输出)。
1043
+ for s, pulse, w in events:
1044
+ lcap = n - s
1045
+ if lcap <= 0 or len(pulse) == 0:
1046
+ continue
1047
+ h = np.zeros(lcap)
1048
+ for i, (f, t60) in enumerate(zip(self.freqs, self.t60s)):
1049
+ if w[i] == 0.0:
1050
+ continue
1051
+ hi = _resonator_ir(float(f), float(t60), float(w[i]), lcap, sr)
1052
+ h[: len(hi)] += hi
1053
+ seg = _fftconv(pulse, h)[:lcap]
1054
+ y[s:s + len(seg)] += seg
1010
1055
  return y
1011
1056
 
1012
1057
 
@@ -2436,6 +2481,11 @@ def data_chirp(seed=0, dur=0.8, rate=28.0, base=1800.0, **kw):
2436
2481
  v0.7:音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
2437
2482
  """
2438
2483
  r = float(rate)
2484
+ # rate 是"每秒脉冲数",必须为正:rate=0 会在下面 i / r 抛 ZeroDivisionError(内部
2485
+ # 堆栈,用户看不出是参数问题);rate<0 时 int(dur*r) 为负、事件时刻为负,_place 会
2486
+ # 静默丢掉大部分脉冲——只剩哑音。与 _enum 同口径:认识但非法的值必须响亮报错。
2487
+ if not math.isfinite(r) or r <= 0.0:
2488
+ raise ValueError("data_chirp: rate 必须是正数(每秒脉冲数),收到 %r" % (rate,))
2439
2489
  n = max(2, int(float(dur) * r))
2440
2490
  g = rng(seed)
2441
2491
  x = _bus(dur, stereo=False)
@@ -3239,19 +3289,85 @@ def selftest() -> int:
3239
3289
  return "环境声响度贴合 master(±1.5 LU)"
3240
3290
 
3241
3291
  def t_fallback():
3292
+ # v0.8:这条以前只查"输出是有限值"——等于只验了"没崩",兜底路径的幅度
3293
+ # 正确性完全没查(references/sfx-design.md §12 承诺静态滤波与 scipy 相对
3294
+ # 误差 ≤ 2e-4)。现在按当前环境选参照:
3295
+ # 有 scipy:同一函数跑两遍(scipy 路径 vs 兜底路径)逐样本比 —— 直接验"等价";
3296
+ # 无 scipy:没有 scipy 路径可比,改用**解析参照**(双二阶的阶跃稳态/频响、
3297
+ # 谐振器的闭式冲激响应 amp·r^n·sin(ωn)),这样 CI 的 noscipy 任务
3298
+ # 也在查"数值对不对",而不是自己和自己比出一个假 PASS。
3242
3299
  old = globals().get("_sig")
3300
+ has_ref = old is not None
3301
+ S = globals()
3243
3302
  try:
3244
- globals()["_sig"] = None # 模拟没装 scipy
3245
- x = np.random.default_rng(0).standard_normal(24000)
3246
- y = lowpass(x, 1000.0)
3247
- if not np.all(np.isfinite(y)):
3248
- raise ValueError("无 scipy 兜底 lowpass 输出非有限值")
3303
+ x = np.random.default_rng(0).standard_normal(24000) * 0.1
3304
+ worst = 0.0
3305
+
3306
+ def cmp_paths(fn):
3307
+ """有 scipy 时:两条路径逐样本相对误差;无 scipy 时返回 None。"""
3308
+ if not has_ref:
3309
+ return None
3310
+ S["_sig"] = old
3311
+ a = fn()
3312
+ S["_sig"] = None
3313
+ b = fn()
3314
+ d = float(np.max(np.abs(a - b))) / max(float(np.max(np.abs(a))), 1e-12)
3315
+ return d
3316
+
3317
+ # 1) 静态滤波:有 scipy 比路径;无 scipy 用"阶跃稳态 = 解析 DC 增益"
3318
+ for kind, f0, dc in (("lowpass", 1000.0, 1.0), ("highpass", 1000.0, 0.0)):
3319
+ S["_sig"] = None
3320
+ step = np.concatenate([np.zeros(4800), np.ones(43200)])
3321
+ y = sosfilt(biquad(kind, f0), step)
3322
+ err = abs(float(np.mean(y[-1000:])) - dc)
3323
+ if err > 2e-3:
3324
+ raise ValueError("无 scipy %s(%g) 阶跃稳态 %.6f 偏离 DC 增益 %.3f"
3325
+ % (kind, f0, float(np.mean(y[-1000:])), dc))
3326
+ worst = max(worst, err)
3327
+ d = cmp_paths(lambda: lowpass(x, 1000.0))
3328
+ if d is not None:
3329
+ if d > 2e-4:
3330
+ raise ValueError("无 scipy 静态滤波与 scipy 相对误差 %.2e > 2e-4" % d)
3331
+ worst = max(worst, d)
3332
+
3333
+ # 2) 谐振器:闭式解。h[n] = amp·r^n·sin(ωn)(本函数构造的 b/a 决定的)。
3334
+ # 注意激励必须**够长**:旧兜底把 IR 截断在 20000 采样,若只喂 4096 点冲激,
3335
+ # 输出前 4096 点与解析解一致,截断根本测不出来(第一版断言就踩过这个坑)。
3336
+ # 这里用 30000 点白噪声:t60=3 s 的 -80 dB 长度约 19.2 万采样,输出第 2 万点之后
3337
+ # 的错误才会暴露,相对误差会到 3e-2 量级(远大于下面 2e-4 的阈值)。
3338
+ n = 30000
3339
+ exc = np.random.default_rng(7).standard_normal(n)
3340
+ for f, t60, amp in ((300.0, 1.0, 0.7), (220.0, 3.0, 1.0)):
3341
+ r0 = math.exp(-LN1000 / (t60 * float(SR)))
3342
+ w0 = 2.0 * math.pi * f / float(SR)
3343
+ k = np.arange(n, dtype=float)
3344
+ h_ref = amp * np.power(r0, k) * np.sin(w0 * k) # 解析 IR(独立于被测代码)
3345
+ # 参照卷积用 FFT(30000×30000 直接 np.convolve 是 9e8 次乘加,实测要 20 s,
3346
+ # 会把这个自检从 60 s 预算里挤爆);零填充到 2 的幂即可,长度不足部分不影响
3347
+ # 前 n 个输出采样。
3348
+ L = n + len(h_ref) - 1
3349
+ N = 1 << int(L - 1).bit_length()
3350
+ ref = np.fft.irfft(np.fft.rfft(exc, N) * np.fft.rfft(h_ref, N), N)[:n]
3351
+ S["_sig"] = None
3352
+ h = resonator(exc, f, t60, amp)
3353
+ err = float(np.max(np.abs(h - ref))) / max(float(np.max(np.abs(ref))), 1e-12)
3354
+ if err > 2e-4:
3355
+ raise ValueError("无 scipy 谐振器 f=%g t60=%g 与闭式解相对误差 %.2e > 2e-4"
3356
+ "(IR 被截断?)" % (f, t60, err))
3357
+ worst = max(worst, err)
3358
+
3359
+ # 3) 时变滤波:文档承认"块中心系数 + 重叠相加"是近似(高频控制信号会平滑),
3360
+ # 只查能跑 + 不出 NaN/Inf,不设等价阈值。
3361
+ S["_sig"] = None
3249
3362
  z = tv_filter(x, "bandpass", np.linspace(300.0, 4000.0, len(x)), 0.9)
3250
3363
  if not np.all(np.isfinite(z)):
3251
3364
  raise ValueError("无 scipy 兜底 tv_filter 输出非有限值")
3252
3365
  finally:
3253
3366
  globals()["_sig"] = old
3254
- return "无 scipy 兜底路径可用(短冲激响应 + FFT 卷积)"
3367
+ if has_ref:
3368
+ return "兜底 vs scipy:静态滤波/谐振器 ≤2e-4(实测 %.1e)/ tv_filter 可用" % worst
3369
+ return ("无 scipy 环境:静态滤波阶跃稳态 DC 增益 / 谐振器闭式解 均 ≤2e-3(实测 %.1e)+ "
3370
+ "tv_filter 可用(本环境没有 scipy 路径可比,用解析参照)" % worst)
3255
3371
 
3256
3372
  def t_analysis():
3257
3373
  m = analyze(render("impact", seed=6))
@@ -143,7 +143,7 @@ def _producer_of(art):
143
143
 
144
144
  # 硬类型:这些永远参与 --strict-stale 判定(产物丢了 / 源头改了 / 上游自己就过时)
145
145
  HARD_KINDS = ("source-changed", "output-missing", "no-manifest", "input-stale")
146
- SOFT_KINDS = ("input-pending",)
146
+ SOFT_KINDS = ("input-pending", "source-pending")
147
147
 
148
148
 
149
149
  def _src_fp(kind, sources):
@@ -234,14 +234,27 @@ def check(work, stage, sources=None, self_built=False, pending=(), _seen=None):
234
234
  for kind in (io.get("sources") or []):
235
235
  now = _src_fp(kind, sources)
236
236
  was = old_src.get(kind, "missing")
237
- if now != was:
238
- fname = {"script": "剧本 film.json", "scenes": "画面代码 scenes.py"}.get(kind, kind)
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 是同一套语义,只是源头走的是另一个循环。
239
245
  issues.append(dict(
240
- stage=str(stage), kind="source-changed", file=kind,
241
- reason="%s 在本阶段上次跑完之后改过(%s -> %s),但本阶段没有重跑;"
242
- "它现在装的还是旧内容" % (fname, was, now),
246
+ stage=str(stage), kind="source-pending", file=kind, soft=True,
247
+ reason="%s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
248
+ "按新内容重算" % (fname, was, now),
243
249
  rerun=RERUN_HINT.get(str(stage), ""),
244
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
+ ))
245
258
  old_inputs = rec.get("inputs") or {}
246
259
  for name in io["inputs"]:
247
260
  now = _fp(name, work)
@@ -334,7 +334,7 @@ THEMES["lo-fi"] = _theme(
334
334
  ("深夜", "学习", "效率", "专注", "写作", "整理", "笔记", "书", "日常", "房间"),
335
335
  # 冷调灰绿:和 paper(暖牛皮纸)在色相上分开,不靠"亮度差不多"硬撑
336
336
  dict(bg=(232, 238, 236), bg2=(210, 220, 216), surface=(244, 248, 246),
337
- ink=(44, 52, 50), muted=(86, 100, 96), dim=(112, 126, 122),
337
+ ink=(44, 52, 50), muted=(84, 98, 94), dim=(112, 126, 122),
338
338
  accent=(20, 86, 84), accent2=(104, 72, 96),
339
339
  good=(40, 100, 72), bad=(146, 52, 50), rule=(44, 52, 50)),
340
340
  dict(vignette=0.12, vignette_tint=(206, 200, 190), grain=7.0, grain_freq=0.24,
@@ -531,6 +531,205 @@ STYLES[LEGACY_STYLE] = {
531
531
  "pacing": {"lead": 0.55, "tail": 0.85, "min_dur": 3.0, "hold": 0.0},
532
532
  }
533
533
 
534
+ # ============================================================================
535
+ # 四、视觉语言包(VISUAL_LANGUAGES):v0.9
536
+ # ============================================================================
537
+ #
538
+ # 为什么还要一层
539
+ # -------------
540
+ # v0.5 的 THEMES 只管"皮肤":配色、字体族、纹理、颗粒、字距、缓动、HUD、3 种版式几何。
541
+ # 8 套风格共用同一批版式与同一套装饰(开场放射线 + 收尾光点环),所以换风格只是换色,
542
+ # 构图骨架(剪影)没变 —— 这正是"每个视频都差不多"的根因。
543
+ #
544
+ # 这一层补的是**结构签名**,四组:
545
+ # motion 入场怎么进、镜头怎么推、呼吸元素多快
546
+ # motif 这套风格独有的装饰图形语言(直角括角 / 手绘内框 / letterbox / 信号条 …)
547
+ # transition 段与段之间怎么过(dip / wash / 纸卷 / 斜擦 / 硬切 / 放射环)
548
+ # typography 字距、行高、对齐、编号写法、display:body 字号比
549
+ #
550
+ # 设计原则(与 THEMES 一致)
551
+ # 1. 纯数据:不 import PIL / filmkit,scenes.py 反过来读我,成环就炸。
552
+ # 2. 只追加不改顺序:新风格往 STYLE_IDS 末尾追加,老风格的指纹映射不漂。
553
+ # 3. 每条签名都必须"能被剪影看出差别":只改颜色/字重的项不算签名。
554
+ # 4. 全部受 PIL 预算约束:装饰要么预烘焙成静态层,要么是 O(几) 次 ImageDraw 调用;
555
+ # 禁每帧全屏 GaussianBlur(实测 30–60ms)与全屏双线性 resize(29ms),预算 25ms。
556
+ #
557
+ # 剪影判据(tools/silhouette_qa.py 用的口径,也是 P1 的验收标准)
558
+ # 同一段内容在两种风格下渲 3 帧 → 灰度 → 以四角亮度中位数为基准三值化 →
559
+ # 比较 前景占比 F / 前景质心 (Cx,Cy) / 四边覆盖率 B / 左右对称度 S。
560
+ # 五项里至少一项达标才算"这套风格真的不一样":
561
+ # ΔF ≥ 6pp、|ΔCx| ≥ 8pp、|ΔCy| ≥ 8pp、ΔB ≥ 14pp、ΔS ≥ 0.20
562
+
563
+ VISUAL_MOTIFS = ("brackets", "hairlines", "handdrawn", "slabs",
564
+ "minimal", "letterbox", "signal", "rays")
565
+ VISUAL_TRANSITIONS = ("dip", "wash", "paper", "wipe", "cut", "iris")
566
+ NUMBER_STYLES = ("dim", "serif", "cn", "block", "roman", "timecode", "dot", "gilt")
567
+
568
+ VISUAL_LANGUAGES = {}
569
+
570
+ VISUAL_LANGUAGES["tech-deep"] = {
571
+ "motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
572
+ "motif": {"kind": "brackets", "density": 1.0,
573
+ "detail": {"corner": 24, "rule_step": 120, "scan": True}},
574
+ "transition": {"kind": "dip", "dur": 0.28},
575
+ "typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
576
+ "number": "dim", "ratio": 1.50},
577
+ "bias": {"x": 0, "y": 0, "inset": 0},
578
+ "layout_cycle": ("left-stack", "split-right", "center-stack"),
579
+ }
580
+
581
+ VISUAL_LANGUAGES["magazine"] = {
582
+ "motion": {"ease": "out5", "enter": (0.60, 1.10), "push": 0.001, "breathe_hz": 0.50},
583
+ "motif": {"kind": "hairlines", "density": 0.85,
584
+ "detail": {"header_rule": True, "column_rule": True, "pageno": True}},
585
+ "transition": {"kind": "wash", "dur": 0.35},
586
+ "typography": {"tracking": 2.0, "line_gap": 1.34, "align": "right",
587
+ "number": "serif", "ratio": 1.53},
588
+ "bias": {"x": 60, "y": -20, "inset": 0},
589
+ "layout_cycle": ("split-right", "left-stack", "center-stack"),
590
+ }
591
+
592
+ VISUAL_LANGUAGES["paper"] = {
593
+ "motion": {"ease": "inOut3", "enter": (0.55, 1.00), "push": 0.03, "breathe_hz": 0.42},
594
+ "motif": {"kind": "handdrawn", "density": 1.0,
595
+ "detail": {"wobble": 3.0, "seal": True, "tape": True}},
596
+ "transition": {"kind": "paper", "dur": 0.35},
597
+ "typography": {"tracking": 1.0, "line_gap": 1.36, "align": "left",
598
+ "number": "cn", "ratio": 1.53},
599
+ # 重心偏移(px):paper 是"手账"气质——内容整体压在左下、右侧留出大片空白给框与胶带;
600
+ # 这不是配色能表达的差异,是构图差异(剪影 Cx/Cy 直接反映它)。
601
+ "bias": {"x": -150, "y": 80, "inset": 40},
602
+ "layout_cycle": ("left-stack", "center-stack", "split-right"),
603
+ }
604
+
605
+ VISUAL_LANGUAGES["vivid"] = {
606
+ "motion": {"ease": "outBack", "enter": (0.35, 0.70), "push": 0.10, "breathe_hz": 1.10},
607
+ "motif": {"kind": "slabs", "density": 1.0,
608
+ "detail": {"bar_h": 24, "slant": 18, "sticker": True}},
609
+ "transition": {"kind": "wipe", "dur": 0.20},
610
+ "typography": {"tracking": 1.0, "line_gap": 1.26, "align": "center",
611
+ "number": "block", "ratio": 2.97},
612
+ "bias": {"x": 0, "y": 40, "inset": 0},
613
+ "layout_cycle": ("center-stack", "left-stack", "split-right"),
614
+ }
615
+
616
+ VISUAL_LANGUAGES["mono"] = {
617
+ "motion": {"ease": "inOut3", "enter": (0.50, 0.90), "push": 0.02, "breathe_hz": 0.35},
618
+ "motif": {"kind": "minimal", "density": 0.55,
619
+ "detail": {"rule_h": 2, "box_radius": 0}},
620
+ "transition": {"kind": "cut", "dur": 0.0},
621
+ "typography": {"tracking": 6.0, "line_gap": 1.40, "align": "center",
622
+ "number": "roman", "ratio": 3.15},
623
+ "bias": {"x": 0, "y": 0, "inset": 0},
624
+ "layout_cycle": ("center-stack", "center-stack", "center-stack"),
625
+ }
626
+
627
+ VISUAL_LANGUAGES["cinema"] = {
628
+ "motion": {"ease": "inOut3", "enter": (0.70, 1.20), "push": 0.12, "breathe_hz": 0.30},
629
+ "motif": {"kind": "letterbox", "density": 1.0,
630
+ "detail": {"top": 40, "bottom": 44, "flares": True}},
631
+ "transition": {"kind": "dip", "dur": 0.45},
632
+ "typography": {"tracking": 3.0, "line_gap": 1.38, "align": "center",
633
+ "number": "timecode", "ratio": 1.62},
634
+ "bias": {"x": 0, "y": 30, "inset": 0},
635
+ "layout_cycle": ("center-stack", "left-stack", "split-right"),
636
+ }
637
+
638
+ VISUAL_LANGUAGES["lo-fi"] = {
639
+ "motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.05, "breathe_hz": 0.60},
640
+ "motif": {"kind": "signal", "density": 0.90,
641
+ "detail": {"bars": 12, "radius": 18, "note": True}},
642
+ "transition": {"kind": "wash", "dur": 0.30},
643
+ "typography": {"tracking": 1.0, "line_gap": 1.36, "align": "left",
644
+ "number": "dot", "ratio": 1.48},
645
+ # lo-fi 是"桌面便签"气质:整体右移、内容偏上,底部留给信号条与时间戳。
646
+ "bias": {"x": 60, "y": -70, "inset": 0},
647
+ "layout_cycle": ("left-stack", "split-right", "center-stack"),
648
+ }
649
+
650
+ VISUAL_LANGUAGES["epic"] = {
651
+ "motion": {"ease": "out5", "enter": (0.70, 1.20), "push": 0.14, "breathe_hz": 0.28},
652
+ "motif": {"kind": "rays", "density": 1.0,
653
+ "detail": {"rays": 18, "rule_gap": 26, "frame": True}},
654
+ "transition": {"kind": "iris", "dur": 0.40},
655
+ "typography": {"tracking": 4.0, "line_gap": 1.32, "align": "center",
656
+ "number": "gilt", "ratio": 3.18},
657
+ "bias": {"x": 0, "y": -30, "inset": 0},
658
+ "layout_cycle": ("center-stack", "center-stack", "center-stack"),
659
+ }
660
+
661
+ # legacy:v0.4 行为,没有视觉语言(scenes.py 走 _LEGACY_DRAWERS,不会读到这张表)
662
+ VISUAL_LANGUAGES[LEGACY_STYLE] = {
663
+ "motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
664
+ "motif": {"kind": "brackets", "density": 1.0, "detail": {}},
665
+ "transition": {"kind": "cut", "dur": 0.0},
666
+ "typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
667
+ "number": "dim", "ratio": 1.50},
668
+ "layout_cycle": ("left-stack",),
669
+ }
670
+
671
+
672
+ def visual_language(style_id):
673
+ """取风格的结构签名;未知 id 退到默认风格(不让拼写错误炸掉渲染)。"""
674
+ sid = str(style_id or "").strip()
675
+ got = VISUAL_LANGUAGES.get(sid)
676
+ if got is not None:
677
+ return got
678
+ return VISUAL_LANGUAGES[DEFAULT_STYLE]
679
+
680
+
681
+ def visual_lint(style_id):
682
+ """结构签名体检(--stage style 就拦,不等到渲完 300 帧)。返回问题列表,空 = 过。"""
683
+ bad = []
684
+ sid = str(style_id or "")
685
+ if sid not in VISUAL_LANGUAGES:
686
+ return ["风格 %r 没有视觉语言包" % sid]
687
+ vl = VISUAL_LANGUAGES[sid]
688
+ mo = vl.get("motion") or {}
689
+ mf = vl.get("motif") or {} # 注意:mo = motion,mf = motif,别混用
690
+ tr = vl.get("transition") or {}
691
+ ty = vl.get("typography") or {}
692
+ if str(mf.get("kind")) not in VISUAL_MOTIFS:
693
+ bad.append("motif.kind=%r 不在 %s 里" % (mf.get("kind"), " / ".join(VISUAL_MOTIFS)))
694
+ if str(tr.get("kind")) not in VISUAL_TRANSITIONS:
695
+ bad.append("transition.kind=%r 不在 %s 里"
696
+ % (tr.get("kind"), " / ".join(VISUAL_TRANSITIONS)))
697
+ if not 0.0 <= float(tr.get("dur") or 0.0) <= 0.60:
698
+ # 段界 veil 超过 0.6s 会吃掉 3–6s 节奏下限,也会把两段内容糊在一起
699
+ bad.append("transition.dur=%r 必须在 0~0.60s(段界 veil 上限)" % tr.get("dur"))
700
+ if str(ty.get("number")) not in NUMBER_STYLES:
701
+ bad.append("typography.number=%r 不在 %s 里"
702
+ % (ty.get("number"), " / ".join(NUMBER_STYLES)))
703
+ if not 1.0 <= float(ty.get("ratio") or 0.0) <= 3.6:
704
+ bad.append("typography.ratio=%r 必须在 1.0~3.6(display:body 字号比)" % ty.get("ratio"))
705
+ if not 0.0 <= float(ty.get("tracking") or 0.0) <= 8.0:
706
+ bad.append("typography.tracking=%r 必须在 0~8px" % ty.get("tracking"))
707
+ if not 1.15 <= float(ty.get("line_gap") or 0.0) <= 1.55:
708
+ bad.append("typography.line_gap=%r 必须在 1.15~1.55" % ty.get("line_gap"))
709
+ if not 0.0 <= float(mf.get("density") or 0.0) <= 1.0:
710
+ bad.append("motif.density=%r 必须在 0~1" % mf.get("density"))
711
+ if float(mf.get("density") or 0.0) <= 0.0 and str(mf.get("kind")) != "minimal":
712
+ bad.append("motif.density=0 只允许用于 minimal(否则等于没有装饰语言)")
713
+ enter = mo.get("enter") or (0.45, 0.85)
714
+ if (not isinstance(enter, (list, tuple)) or len(enter) != 2
715
+ or not 0.0 < float(enter[0]) <= float(enter[1]) <= 1.6):
716
+ bad.append("motion.enter=%r 必须是 (起, 止) 且 0 < 起 <= 止 <= 1.6 秒" % (enter,))
717
+ if not 0.0 <= float(mo.get("push") or 0.0) <= 0.16:
718
+ # push > 0.16 时背景层放大后内容会被推出 SAFE(_bg_plate 只裁不缩)
719
+ bad.append("motion.push=%r 必须在 0~0.16(超过会把内容推出安全区)" % mo.get("push"))
720
+ return bad
721
+
722
+
723
+ def visual_lint_all():
724
+ """全部内置风格的视觉语言体检,返回 {style_id: [问题]}(只有有问题的才在里面)。"""
725
+ out = {}
726
+ for sid in STYLE_IDS:
727
+ bad = visual_lint(sid)
728
+ if bad:
729
+ out[sid] = bad
730
+ return out
731
+
732
+
534
733
  ALL_STYLE_IDS = (LEGACY_STYLE,) + STYLE_IDS
535
734
 
536
735
 
@@ -633,7 +832,11 @@ def score_styles(script):
633
832
  score += 1.0 if sid in ("cinema", "paper", "mono") else 0.0
634
833
  if prof["hook"] > 0 and prof["outro"] > 0:
635
834
  score += 0.5 if sid in ("tech-deep", "vivid", "epic") else 0.0
636
- score += (int(fp["style"][:4], 16) % 7) * 0.0001 # 指纹破平
835
+ # 指纹破平:必须把**风格 id** 混进哈希,否则这一项对所有行是同一个常数,
836
+ # 等于没破平(v0.8 深审实测:旧写法在"AI 报告"上 tech-deep / magazine 同分 12.0,
837
+ # 永远按 STYLE_IDS 顺序选 tech-deep)。幅度上限 0.0006 << 最小真实分差 0.5,
838
+ # 只在精确同分时起作用,且不改变"改错别字不换风格"(指纹只吃标题)。
839
+ score += (_digest("tiebreak", fp["style"], sid) % 7) * 0.0001
637
840
  rows.append((sid, round(score, 4), hits))
638
841
  rows.sort(key=lambda r: (-r[1], STYLE_IDS.index(r[0])))
639
842
  return rows
@@ -645,7 +848,12 @@ def pick_style(script):
645
848
  best_id, best_score, best_hits = rows[0]
646
849
  fp = fingerprint(script)
647
850
  if best_score >= 1.0:
648
- reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
851
+ if best_hits:
852
+ reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
853
+ else:
854
+ # 分数可以只由结构加分(items / 长句 / hook+outro)凑到 1.0 以上,
855
+ # 这时旧文案会写"关键词命中:"后面空一截 —— 等于向用户报告没发生的事。
856
+ reason = ["无关键词命中,按题材结构加分选中(items / 长句 / hook+outro)"]
649
857
  else:
650
858
  pool = list(STYLE_IDS)
651
859
  best_id = pool[int(fp["tempo"], 16) % len(pool)]
@@ -816,6 +1024,15 @@ def resolve_script_style(script):
816
1024
  # theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
817
1025
  # 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
818
1026
  theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
1027
+ # visual.layout 是"显式换整片版式":旧的实现只把它写进 chapter_layout,而
1028
+ # PIL 路线渲染读的是 theme["layout"]、web 路线读 layout_default —— 两条路线都不看
1029
+ # chapter_layout,于是这条文档化的覆盖等于没写(v0.8 深审实测:visual.layout=
1030
+ # split-right 的帧与默认帧 sha256 完全相同)。这里补写 theme["layout"] 与 layout_default,
1031
+ # 让两条路线都跟着走;theme_overrides 更具体,它显式写了 layout 时以它为准。
1032
+ if explicit_layout and "layout" not in (vis.get("theme_overrides") or {}):
1033
+ theme["layout"] = explicit_layout
1034
+ if explicit_layout:
1035
+ chap_layout = {ch: str(theme["layout"]) for ch in chapters}
819
1036
 
820
1037
  return {
821
1038
  "version": 1,
@@ -831,7 +1048,7 @@ def resolve_script_style(script):
831
1048
  # 主题字典随计划一起返回:scenes.py 的 _scene_theme 优先用它(含 theme_overrides),
832
1049
  # 也方便 render.py 直接传给 draw_subtitle/write_ass,不必再按 id 查一次。
833
1050
  "theme": theme,
834
- "layout_default": layouts[0],
1051
+ "layout_default": str(theme["layout"]) if explicit_layout else layouts[0],
835
1052
  "chapter_layout": chap_layout,
836
1053
  "music": {
837
1054
  "style": music_style,
@@ -854,7 +1071,8 @@ def resolve_script_style(script):
854
1071
  # 七、门禁:主题体检(对比度 / 明暗 / 结构)
855
1072
  # ============================================================================
856
1073
  #
857
- # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(ink 16.3 / muted 7.8 / accent 7.0)。
1074
+ # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(tech-deep:ink 16.3 / muted 7.8 / accent 7.0,
1075
+ # 那是旧版按 bg/bg2 中点量的;v0.8 改成按两端最差值量后同一主题是 14.8 / 7.1 / 6.3,仍远高于线)。
858
1076
 
859
1077
  LINT_LIMITS = {
860
1078
  "ink": 7.0, # 正文级(WCAG AAA 正文线)
@@ -868,18 +1086,30 @@ LINT_LIMITS = {
868
1086
  }
869
1087
 
870
1088
 
1089
+ def bg_contrast(th, role):
1090
+ """文字色对背景的**最差**对比度:bg/bg2 是上下两端的主渐变(scenes._bg 按行插值),
1091
+ 任何一行文字都可能落在两端附近,所以取二者较小值,而不是看中点。
1092
+
1093
+ 为什么不能只看中点:lo-fi 的 muted(86,100,96) 对 bg 5.28、对 bg2 只有 4.42,
1094
+ 中点平均 4.84 把 AA 4.5 的门槛"平均"过去了 —— 实测过(v0.8 深审),
1095
+ 这一版把门槛改成端点最差值,并同时下调 lo-fi 的 muted 到 (84,98,94)
1096
+ (bg 5.44 / bg2 4.56,两端都过)。
1097
+ """
1098
+ return min(contrast(th[role], th["bg"]), contrast(th[role], th["bg2"]))
1099
+
1100
+
871
1101
  def theme_lint(th):
872
1102
  """主题体检,返回问题列表(空 = 过)。坏主题在 load 阶段就拦,别等渲完 300 帧。"""
873
1103
  if not isinstance(th, dict):
874
1104
  return ["主题不是 dict:%r" % (th,)]
875
1105
  bad = []
876
- bgs = midpoint(th["bg"], th["bg2"])
877
1106
  for role, need in (("ink", LINT_LIMITS["ink"]), ("muted", LINT_LIMITS["muted"]),
878
1107
  ("dim", LINT_LIMITS["dim"]), ("accent", LINT_LIMITS["accent"]),
879
1108
  ("accent2", LINT_LIMITS["accent2"])):
880
- got = contrast(th[role], bgs)
1109
+ got = bg_contrast(th, role)
881
1110
  if got < need:
882
- bad.append("%s 对背景对比度 %.2f < %.1f" % (role, got, need))
1111
+ bad.append("%s 对背景对比度 %.2f < %.1f(bg/bg2 两端取最差)"
1112
+ % (role, got, need))
883
1113
  for role in ("good", "bad"):
884
1114
  got = contrast(th[role], th["surface"])
885
1115
  if got < LINT_LIMITS[role]:
@@ -947,7 +1177,6 @@ def blend_theme(a, b, p):
947
1177
  def _describe(sid):
948
1178
  pack = STYLES[sid]
949
1179
  th = THEMES[pack["theme"]]
950
- bgs = midpoint(th["bg"], th["bg2"])
951
1180
  lines = [
952
1181
  "[%s] %s (theme=%s / layouts=%s)" % (sid, pack["name"], pack["theme"],
953
1182
  ",".join(pack["layouts"])),
@@ -961,9 +1190,10 @@ def _describe(sid):
961
1190
  % (pack["pacing"]["lead"], pack["pacing"]["tail"], pack["pacing"]["min_dur"]),
962
1191
  " palette : bg=%s bg2=%s ink=%s accent=%s"
963
1192
  % (th["bg"], th["bg2"], th["ink"], th["accent"]),
964
- " contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f"
965
- % (contrast(th["ink"], bgs), contrast(th["muted"], bgs),
966
- contrast(th["dim"], bgs), contrast(th["accent"], bgs)),
1193
+ # 与 theme_lint 同口径:bg/bg2 两端取最差(旧版打中点值,会掩盖端点不达标)
1194
+ " contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f(bg/bg2 最差端)"
1195
+ % (bg_contrast(th, "ink"), bg_contrast(th, "muted"),
1196
+ bg_contrast(th, "dim"), bg_contrast(th, "accent")),
967
1197
  " font : family=%s display=%spx hud=%s"
968
1198
  % (th["family"], th["scale"]["display"][0], th["hud"]),
969
1199
  ]
@@ -207,8 +207,12 @@ except Exception: # noqa: BLE001 — stylekit 缺失时
207
207
  # ============================================================================
208
208
  # 三、小工具:参数解析/格式化、汉字计数、抖动
209
209
  # ============================================================================
210
- _PCT_RE = re.compile(r"^\s*([+-]?)(\d+)\s*%\s*$")
211
- _HZ_RE = re.compile(r"^\s*([+-]?)(\d+)\s*Hz\s*$")
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;数字原样取整;解析失败回落 default。"""
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;解析失败回落 default。"""
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
- _QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e", "\u300a")
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 = ("但是", "然而", "不过", "其实", "问题是")