@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.
- package/CHANGELOG.md +1109 -0
- package/README.md +76 -10
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/index.js +4 -1
- package/package.json +7 -3
- package/schemas/film.schema.json +329 -2
- package/skills/video-studio/SKILL.md +270 -13
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +2162 -1190
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/scene-grammar.md +306 -0
- package/skills/video-studio/references/style-packs.md +108 -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 +14 -0
- package/skills/video-studio/scripts/filmcheck.py +218 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/grammar.py +559 -0
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- 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 +764 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +483 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +242 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +173 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
- 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,
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
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-
|
|
241
|
-
reason="%s
|
|
242
|
-
"
|
|
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=(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1109
|
+
got = bg_contrast(th, role)
|
|
881
1110
|
if got < need:
|
|
882
|
-
bad.append("%s 对背景对比度 %.2f < %.1f"
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
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 = ("但是", "然而", "不过", "其实", "问题是")
|