@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
@@ -48,32 +48,216 @@ CUE_ALIASES = {
48
48
 
49
49
 
50
50
  # ---------------------------------------------------------------- ffmpeg
51
+ # ffmpeg 探测必须 fail-closed:候选"文件存在"不等于"能用"。
52
+ # 本机曾静默选中 C:\Program Files\EVCapture\ffmpeg.exe(2019 旧版,没有 xfade),
53
+ # 一路跑到滤镜阶段才挂,报错离原因太远。现在每个候选都必须真的跑通
54
+ # "ffmpeg -version" 且 returncode == 0 才算可用(超时/异常一律视为不可用)。
55
+ _FFMPEG_ENV = "FILMKIT_FFMPEG"
56
+ _FFMPEG_STRICT_ENV = "FILMKIT_FFMPEG_STRICT"
57
+ _FFMPEG_UP_LEVELS = 5 # 从 cwd 向上最多找 5 层父目录的便携布局
58
+ _FFMPEG_NAMES = ("ffmpeg.exe", "ffmpeg") if os.name == "nt" else ("ffmpeg", "ffmpeg.exe")
59
+
60
+ # 来源键 -> 给人看的说明(ffmpeg_source() 用;backend 回显 / 日志都靠它)
61
+ FFMPEG_SOURCE_LABELS = {
62
+ "env": "显式指定(FILMKIT_FFMPEG)",
63
+ "portable": "向上搜索(tools/ffmpeg)",
64
+ "hardcoded": "硬编码候选(WinGet/系统路径)",
65
+ "path": "PATH(shutil.which)",
66
+ "fallback": "未找到(回退裸命令,默认宽松模式)",
67
+ }
68
+ _FFMPEG_CACHE = {} # {候选集合 tuple: (可执行路径, 来源键)}:同一候选集合只探测一次
69
+ _FFPROBE_CACHE = {} # {ffmpeg 路径: ffprobe 路径}:ffprobe 定位也不重复做
70
+
71
+ _FFMPEG_STRICT_HINT = (
72
+ "找不到可用的 ffmpeg:FILMKIT_FFMPEG、从当前目录向上 %d 层的 tools/ffmpeg、"
73
+ "WinGet/系统固定路径、PATH 里都没有能跑通 \"ffmpeg -version\"(returncode=0)的可执行文件。"
74
+ "装了没有?winget install Gyan.FFmpeg 可以装;装在哪?便携版放到 <项目>/tools/ffmpeg/ffmpeg.exe "
75
+ "即可被自动发现;怎么指?设置环境变量 FILMKIT_FFMPEG=D:/tools/ffmpeg.exe。"
76
+ "(当前 FILMKIT_FFMPEG_STRICT=1,所以直接退出;去掉该变量会回退成裸命令 ffmpeg。)"
77
+ % _FFMPEG_UP_LEVELS
78
+ )
79
+
80
+
81
+ def _probe_ffmpeg(path):
82
+ """候选是否真的可用:返回可执行路径,不可用返回 None(fail-closed)。
83
+
84
+ 必须跑通 "-version";文件不存在 / 不是可执行文件 / 权限拒绝 / 超时 / 非 0 退出,
85
+ 一律视为不可用。异常吞掉继续试下一个候选——探测阶段不应该把 import 炸掉。
86
+ """
87
+ try:
88
+ if not path:
89
+ return None
90
+ exe = str(path)
91
+ if not os.path.isfile(exe):
92
+ exe = shutil.which(exe) or "" # 允许 FILMKIT_FFMPEG=ffmpeg 这种裸名字
93
+ if not exe:
94
+ return None
95
+ elif not os.path.isabs(exe):
96
+ exe = os.path.abspath(exe) # 相对路径候选按 cwd 解析,别让 execvp 去 PATH 里找
97
+ p = subprocess.run([exe, "-version"], capture_output=True, timeout=10)
98
+ if p.returncode != 0:
99
+ return None
100
+ return exe
101
+ except Exception: # noqa: BLE001 —— 探测失败=候选不可用,换下一个
102
+ return None
103
+
104
+
105
+ def _iter_up_dirs(levels=None):
106
+ """cwd + 向上 levels 层父目录(含 cwd 共 levels+1 个)。cwd 被删时安全退化。"""
107
+ try:
108
+ d = os.path.abspath(os.getcwd())
109
+ except Exception: # noqa: BLE001
110
+ return
111
+ yield d
112
+ for _ in range(int(_FFMPEG_UP_LEVELS if levels is None else levels)):
113
+ parent = os.path.dirname(d)
114
+ if parent == d: # 到盘符根 / 文件系统根,停
115
+ break
116
+ d = parent
117
+ yield d
118
+
119
+
120
+ def _portable_ffmpeg_candidates():
121
+ """便携布局:<目录>/tools/ffmpeg/ffmpeg(.exe) 与 <目录>/tools/ffmpeg(.exe)。"""
122
+ out = []
123
+ for d in _iter_up_dirs():
124
+ for name in _FFMPEG_NAMES:
125
+ out.append(os.path.join(d, "tools", "ffmpeg", name))
126
+ for name in _FFMPEG_NAMES:
127
+ out.append(os.path.join(d, "tools", name))
128
+ return out
129
+
130
+
131
+ def _hardcoded_ffmpeg_candidates():
132
+ """原有硬编码候选(WinGet Links / Unix 常见路径)。顺序保持原样,不新增 EVCapture 之类的意外构建。"""
133
+ out = []
134
+ local = os.environ.get("LOCALAPPDATA", "")
135
+ user = os.environ.get("USERPROFILE", "")
136
+ if local:
137
+ out.append(os.path.join(local, "Microsoft", "WinGet", "Links", "ffmpeg.exe"))
138
+ if user:
139
+ out.append(os.path.join(user, "AppData", "Local", "Microsoft", "WinGet",
140
+ "Links", "ffmpeg.exe"))
141
+ out += ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
142
+ return out
143
+
144
+
145
+ def _ffmpeg_candidates():
146
+ """按优先级返回 [(候选路径, 来源键)]:显式指定 -> 向上搜索 -> 硬编码 -> PATH。"""
147
+ cands, seen = [], set()
148
+
149
+ def add(path, src):
150
+ path = (path or "").strip()
151
+ if len(path) >= 2 and path[0] == path[-1] and path[0] in "\"'":
152
+ path = path[1:-1].strip() # 容忍用户把路径连引号一起粘进环境变量
153
+ if not path:
154
+ return
155
+ key = os.path.normcase(os.path.abspath(path))
156
+ if key in seen:
157
+ return
158
+ seen.add(key)
159
+ cands.append((path, src))
160
+
161
+ add(os.environ.get(_FFMPEG_ENV), "env")
162
+ for p in _portable_ffmpeg_candidates():
163
+ add(p, "portable")
164
+ for p in _hardcoded_ffmpeg_candidates():
165
+ add(p, "hardcoded")
166
+ add(shutil.which("ffmpeg"), "path")
167
+ return cands
168
+
169
+
170
+ def _resolve_ffmpeg():
171
+ """探测并缓存 ffmpeg,返回 (可执行路径, 来源键)。
172
+
173
+ 默认宽松:一个候选都不可用时回退裸命令 "ffmpeg"(让真正调用 subprocess 的地方自己报错);
174
+ FILMKIT_FFMPEG_STRICT=1 时改为 raise SystemExit,并在消息里说清怎么装/放哪/怎么指。
175
+ """
176
+ cands = _ffmpeg_candidates()
177
+ key = tuple((os.path.normcase(os.path.abspath(p)), s) for p, s in cands)
178
+ hit = _FFMPEG_CACHE.get(key)
179
+ if hit is not None:
180
+ # 回退结果命中缓存时也要按"当前"STRICT 再判一次:改完 env 不 reset 也不能漏掉 fail-closed
181
+ if hit[1] != "fallback" or (os.environ.get(_FFMPEG_STRICT_ENV) or "").strip() != "1":
182
+ return hit
183
+ raise SystemExit(_FFMPEG_STRICT_HINT)
184
+ for cand, src in cands:
185
+ exe = _probe_ffmpeg(cand)
186
+ if exe:
187
+ res = (exe if isinstance(exe, str) else cand, src)
188
+ _FFMPEG_CACHE[key] = res
189
+ return res
190
+ if (os.environ.get(_FFMPEG_STRICT_ENV) or "").strip() == "1":
191
+ raise SystemExit(_FFMPEG_STRICT_HINT)
192
+ res = ("ffmpeg", "fallback")
193
+ _FFMPEG_CACHE[key] = res
194
+ return res
195
+
196
+
51
197
  def _find_ffmpeg() -> str:
52
- env = os.environ.get("FILMKIT_FFMPEG")
53
- if env and os.path.exists(env):
54
- return env
55
- cands = [
56
- os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "WinGet", "Links", "ffmpeg.exe"),
57
- os.path.join(os.environ.get("USERPROFILE", ""), "AppData", "Local", "Microsoft",
58
- "WinGet", "Links", "ffmpeg.exe"),
59
- "/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg",
60
- ]
61
- for c in cands:
62
- if c and os.path.exists(c):
63
- return c
64
- return shutil.which("ffmpeg") or "ffmpeg"
198
+ """旧内部接口保留:返回探测到的 ffmpeg 可执行路径。"""
199
+ return _resolve_ffmpeg()[0]
200
+
201
+
202
+ def _reset_ffmpeg_cache():
203
+ """清空探测缓存并立即重探(测试改过 os.environ / cwd 后必须调它)。
204
+
205
+ 同时刷新模块级 FFMPEG,保证 filmkit.FFMPEG、ffprobe_path()、向上搜索布局
206
+ 在同一个进程里改环境后能拿到新结果。
207
+ """
208
+ global FFMPEG
209
+ _FFMPEG_CACHE.clear()
210
+ _FFPROBE_CACHE.clear()
211
+ FFMPEG = _find_ffmpeg()
212
+ return FFMPEG
65
213
 
66
214
 
67
215
  FFMPEG = _find_ffmpeg()
68
216
  PY = sys.executable
69
217
 
70
218
 
219
+ def _find_ffprobe(ffmpeg=None) -> str:
220
+ """ffprobe 定位:先找 ffmpeg 同目录的 ffprobe(.exe),再用 shutil.which 兜底。
221
+
222
+ 不再做 FFMPEG.replace("ffmpeg", "ffprobe"):ffmpeg 被改名(如 ffmpeg-full.exe)时
223
+ 字符串替换出来的路径根本不存在,调用要到远处才炸。
224
+ """
225
+ ff = str(ffmpeg or FFMPEG)
226
+ hit = _FFPROBE_CACHE.get(ff)
227
+ if hit:
228
+ return hit
229
+ cand = ""
230
+ looks_pathy = os.path.isabs(ff) or os.sep in ff or bool(os.altsep and os.altsep in ff)
231
+ if looks_pathy:
232
+ d = os.path.dirname(os.path.abspath(ff))
233
+ names = ("ffprobe.exe", "ffprobe") if os.name == "nt" else ("ffprobe", "ffprobe.exe")
234
+ for name in names:
235
+ p = os.path.join(d, name)
236
+ if os.path.exists(p):
237
+ cand = p
238
+ break
239
+ if not cand:
240
+ cand = shutil.which("ffprobe") or "ffprobe"
241
+ _FFPROBE_CACHE[ff] = cand
242
+ return cand
243
+
244
+
71
245
  def ffprobe_path() -> str:
72
- base = os.path.basename(FFMPEG)
73
- cand = FFMPEG[: -len(base)] + ("ffprobe.exe" if base.endswith(".exe") else "ffprobe")
74
- if os.path.exists(cand):
75
- return cand
76
- return shutil.which("ffprobe") or FFMPEG.replace("ffmpeg", "ffprobe")
246
+ """ffprobe 可执行文件路径(按 ffmpeg 路径缓存;_reset_ffmpeg_cache() 可清)。"""
247
+ return _find_ffprobe()
248
+
249
+
250
+ def ffmpeg_source() -> str:
251
+ """可读的 ffmpeg 来源回显,如 "显式指定(FILMKIT_FFMPEG): D:/tools/ffmpeg.exe"。
252
+
253
+ 供 backend / 日志说明"到底用了哪个 ffmpeg、它从哪来的"。
254
+ """
255
+ path, src = _resolve_ffmpeg()
256
+ txt = "%s: %s" % (FFMPEG_SOURCE_LABELS.get(src, src), path)
257
+ env = (os.environ.get(_FFMPEG_ENV) or "").strip()
258
+ if env and src != "env":
259
+ txt += ";注意 %s=%r 不可用,已跳过" % (_FFMPEG_ENV, env)
260
+ return txt
77
261
 
78
262
 
79
263
  def _run(cmd, **kw):
@@ -96,11 +280,55 @@ FONT_CANDIDATES = {
96
280
  }
97
281
 
98
282
 
99
- def font(size: int, weight: str = "bold") -> ImageFont.FreeTypeFont:
100
- key = (int(size), weight)
283
+ # ---------------------------------------------------------------- 字体族(v0.5.0)
284
+ # 换片要换气质,字体是第一眼。但"换字体族"必须同时换**所有**缓存键,
285
+ # 否则会出现"用 A 字体量宽度、用 B 字体绘制"——文字直接溢出安全区。
286
+ # 每一族都给中文回退:找不到 serif/kai 时退到同族 reg 再退 sans,**绝不退到 mono**(无中文字形)。
287
+ FONT_FAMILIES = {
288
+ "sans": { # 现状:微软雅黑一族
289
+ "bold": [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf",
290
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"],
291
+ "reg": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simhei.ttf",
292
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"],
293
+ "light": [r"C:\Windows\Fonts\msyhl.ttc", r"C:\Windows\Fonts\msyh.ttc"],
294
+ },
295
+ "serif": { # 杂志 / 史诗:宋体系
296
+ "bold": [r"C:\Windows\Fonts\NotoSerifSC-VF.ttf", r"C:\Windows\Fonts\simsun.ttc",
297
+ r"C:\Windows\Fonts\msyhbd.ttc"],
298
+ "reg": [r"C:\Windows\Fonts\NotoSerifSC-VF.ttf", r"C:\Windows\Fonts\simsun.ttc",
299
+ r"C:\Windows\Fonts\msyh.ttc"],
300
+ "light": [r"C:\Windows\Fonts\NotoSerifSC-VF.ttf", r"C:\Windows\Fonts\simsun.ttc",
301
+ r"C:\Windows\Fonts\msyh.ttc"],
302
+ },
303
+ "kai": { # 手绘纸感:楷体
304
+ "bold": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\simfang.ttf",
305
+ r"C:\Windows\Fonts\msyhbd.ttc"],
306
+ "reg": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\simfang.ttf",
307
+ r"C:\Windows\Fonts\msyh.ttc"],
308
+ "light": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc"],
309
+ },
310
+ "round": { # 圆润:等线 / 雅黑回退
311
+ "bold": [r"C:\Windows\Fonts\Dengb.ttf", r"C:\Windows\Fonts\msyhbd.ttc"],
312
+ "reg": [r"C:\Windows\Fonts\Deng.ttf", r"C:\Windows\Fonts\msyh.ttc"],
313
+ "light": [r"C:\Windows\Fonts\Dengl.ttf", r"C:\Windows\Fonts\msyhl.ttc"],
314
+ },
315
+ }
316
+ FAMILY_IDS = tuple(FONT_FAMILIES.keys())
317
+
318
+
319
+ def font(size: int, weight: str = "bold", family: str = "sans") -> ImageFont.FreeTypeFont:
320
+ """取字体。family 缺省 sans = v0.4 行为(老调用 font(46, "reg") 输出不变)。"""
321
+ fam = str(family or "sans")
322
+ if fam not in FONT_FAMILIES:
323
+ fam = "sans" # 未知族名退到 sans,不让拼写错误炸掉渲染
324
+ key = (int(size), weight, fam)
101
325
  if key in _font_cache:
102
326
  return _font_cache[key]
103
- for path in FONT_CANDIDATES.get(weight, []) + FONT_CANDIDATES["reg"]:
327
+ chain = list(FONT_FAMILIES[fam].get(weight) or [])
328
+ chain += list(FONT_FAMILIES[fam]["reg"])
329
+ chain += list(FONT_CANDIDATES.get(weight) or [])
330
+ chain += list(FONT_CANDIDATES["reg"])
331
+ for path in chain:
104
332
  if path and os.path.exists(path):
105
333
  try:
106
334
  f = ImageFont.truetype(path, int(size), index=0)
@@ -257,24 +485,25 @@ _wrap_cache = {}
257
485
  _tw_cache = {}
258
486
 
259
487
 
260
- def wrap_cn_cached(s: str, size: int, max_w: float, weight: str = "reg"):
488
+ def wrap_cn_cached(s: str, size: int, max_w: float, weight: str = "reg",
489
+ family: str = "sans"):
261
490
  """带缓存的换行。场景函数每帧都要调用,务必用这个版本。
262
491
 
263
492
  实测:每帧重新排版(12 张卡片 x 2 个 wrap_cn)会让单帧从 4.6ms 涨到 12ms。
264
493
  """
265
- key = (s, int(size), int(max_w), weight)
494
+ key = (s, int(size), int(max_w), weight, str(family or "sans"))
266
495
  r = _wrap_cache.get(key)
267
496
  if r is None:
268
- r = wrap_cn(s, font(size, weight), max_w)
497
+ r = wrap_cn(s, font(size, weight, family), max_w)
269
498
  _wrap_cache[key] = r
270
499
  return r
271
500
 
272
501
 
273
- def text_w_cached(s: str, size: int, weight: str = "reg") -> float:
274
- key = (s, int(size), weight)
502
+ def text_w_cached(s: str, size: int, weight: str = "reg", family: str = "sans") -> float:
503
+ key = (s, int(size), weight, str(family or "sans"))
275
504
  r = _tw_cache.get(key)
276
505
  if r is None:
277
- r = text_w(s, font(size, weight))
506
+ r = text_w(s, font(size, weight, family))
278
507
  _tw_cache[key] = r
279
508
  return r
280
509
 
@@ -306,23 +535,29 @@ def check_bbox(box, safe=SAFE, tag=""):
306
535
  return []
307
536
 
308
537
 
309
- def fit_sub_lines(text: str, max_lines=2, max_w=1560, size=46, weight="reg"):
538
+ def fit_sub_lines(text: str, max_lines=2, max_w=1560, size=46, weight="reg",
539
+ family="sans"):
310
540
  """字幕折行:优先两行,超宽逐级缩字号。返回 (lines, font)。"""
311
541
  for sz in (size, int(size * 0.92), int(size * 0.84), int(size * 0.76)):
312
- f = font(sz, weight)
542
+ f = font(sz, weight, family)
313
543
  lines = wrap_cn(text, f, max_w)
314
544
  if len(lines) <= max_lines:
315
545
  return lines, f
316
- f = font(int(size * 0.76), weight)
546
+ f = font(int(size * 0.76), weight, family)
317
547
  return wrap_cn(text, f, max_w), f
318
548
 
319
549
 
320
- def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None):
550
+ def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None,
551
+ theme=None, family=None):
552
+ """烧字幕。v0.5.0 加 theme(stylekit 主题)与 family(字体族);
553
+ theme=None 时与 v0.4 逐像素一致。"""
321
554
  if not text or alpha <= 0.01:
322
555
  return img
323
556
  if band_w is None:
324
557
  band_w = SUB_BAND[2] - SUB_BAND[0]
325
- lines, f = fit_sub_lines(text, max_w=band_w, size=size)
558
+ th = theme if isinstance(theme, dict) else None
559
+ fam = family or (th or {}).get("family") or "sans"
560
+ lines, f = fit_sub_lines(text, max_w=band_w, size=size, family=fam)
326
561
  lines = lines[:2] # 硬约束:最多两行
327
562
  lh = int(f.size * 1.32)
328
563
  cx = (SUB_BAND[0] + SUB_BAND[2]) // 2
@@ -335,10 +570,17 @@ def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None):
335
570
  bx1 = cx + widest / 2 + 34
336
571
  by0 = base_y - 16
337
572
  by1 = base_y + total_h + 16
338
- d.rounded_rectangle([bx0, by0, bx1, by1], radius=10, fill=(0, 0, 0, int(150 * alpha)))
573
+ if th is None:
574
+ plate = (0, 0, 0, int(150 * alpha))
575
+ ink = (245, 248, 252, int(255 * alpha))
576
+ else:
577
+ # 浅色主题不能"黑底白字":背板取 bg2 半透,字取 ink
578
+ plate = tuple(th["bg2"]) + (int(170 * alpha),)
579
+ ink = tuple(th["ink"]) + (int(255 * alpha),)
580
+ d.rounded_rectangle([bx0, by0, bx1, by1], radius=10, fill=plate)
339
581
  for i, ln in enumerate(lines):
340
582
  lw = text_w(ln, f)
341
- d.text((cx - lw / 2, base_y + i * lh), ln, font=f, fill=(245, 248, 252, int(255 * alpha)))
583
+ d.text((cx - lw / 2, base_y + i * lh), ln, font=f, fill=ink)
342
584
  return img
343
585
 
344
586
 
@@ -349,6 +591,12 @@ def wav_write(path, samples, sr=SR, channels=None):
349
591
  v0.3.0:支持立体声(配乐/音效要声像)。老调用 wav_write(p, mono) 行为不变。
350
592
  """
351
593
  a = np.asarray(samples, dtype=np.float32)
594
+ # 非有限值必须报错:旧实现 np.clip 保留 NaN/Inf,astype(int16) 把 NaN 静默写成 0、
595
+ # 把 Inf 写成 undefined,wav 里出现"随机哑音"且函数还返回 nan 峰值(下游只看得到 no)。
596
+ if a.size and not np.all(np.isfinite(a)):
597
+ bad = int(a.size - np.count_nonzero(np.isfinite(a)))
598
+ raise ValueError("wav_write: 采样里有 %d 个 NaN/Inf(%s)——上游混音/合成有数值错误,"
599
+ "拒绝写出损坏的 WAV" % (bad, path))
352
600
  if a.ndim == 2:
353
601
  n_ch = a.shape[1]
354
602
  if channels is None:
@@ -480,6 +728,12 @@ class AudioBus:
480
728
  if i0 >= len(self.bus):
481
729
  return self
482
730
  s = np.asarray(src, dtype=np.float32)
731
+ if i0 < 0:
732
+ # 负起点 = 从 0 秒开始、裁掉前面 |i0| 个采样(与 sfxkit._place 同口径)。
733
+ # 旧实现把负 i0 直接当负下标切片,n 变成 0 后广播崩(place(src, -0.25) 必炸)。
734
+ if i0 <= -len(s):
735
+ return self
736
+ s, i0 = s[-i0:], 0
483
737
  if pan_pos is not None and self.channels == 2 and s.ndim == 1:
484
738
  theta = (float(np.clip(pan_pos, -1.0, 1.0)) + 1.0) * np.pi / 4.0
485
739
  s = np.stack([s * np.cos(theta), s * np.sin(theta)], axis=1)
@@ -507,6 +761,11 @@ class AudioBus:
507
761
  return self
508
762
  t = float(start)
509
763
  idx = 0
764
+ if not (float(seg) > 0.0):
765
+ # seg=0 时 t 不前进 -> while 变成死循环(100000 次上限只是"慢死",
766
+ # 6 秒片实测 >2 分钟)。schema 只约束 seg>=0,必须在入口拒绝。
767
+ raise ValueError("music_bed: seg 必须 > 0,收到 %r(v0.2 和弦床的段长,默认 4.0)"
768
+ % (seg,))
510
769
  while t < self.total:
511
770
  freqs = chords[idx % len(chords)]
512
771
  dur = min(seg + xfade, max(0.25, self.total - t + xfade))
@@ -599,6 +858,21 @@ def scenes_anchors(scenes):
599
858
  start=float(s["start"]), end=float(s["end"])) for s in scenes]
600
859
 
601
860
 
861
+ def content_seed(base: int, style: str, content_fp: str, recipe: str, t: float) -> int:
862
+ """内容派生 seed(v0.7):同影片同 cue 稳定,不同影片同一位置也换音色细节。
863
+
864
+ 公式与 sfxkit.content_seed 一致(references/audio-variation.md §3.1):
865
+ sha1("v0.7|<base>|<style>|<content_fp>|<recipe>|<t 三位小数>") -> 前 8 位 hex -> % 9000 + 1000
866
+
867
+ 为什么不用 seed_base + i:那样只跟 cue 的**出现顺序**有关,两部内容完全不同的片子
868
+ 只要 cues 一样,音效轨就逐样本相同(跨片"音效都一样"的直接来源)。
869
+ """
870
+ h = hashlib.sha1(
871
+ ("v0.7|%d|%s|%s|%s|%.3f" % (int(base), str(style), str(content_fp), str(recipe), float(t)))
872
+ .encode("utf-8")).hexdigest()
873
+ return int(h[:8], 16) % 9000 + 1000
874
+
875
+
602
876
  class CueBus:
603
877
  """提示点登记表:画面侧登记 -> 落盘 cues.json -> 音频侧按它合成音效。
604
878
 
@@ -676,11 +950,21 @@ class CueBus:
676
950
 
677
951
  # -- 合成 --
678
952
  def build_audio(self, total=None, sr=SR, base_db=None, default_gain_db=0.0,
679
- seed_base=1000, verbose=True):
953
+ seed_base=1000, verbose=True,
954
+ style=None, content_fp=None, key=None):
680
955
  """按提示点合成音效轨 -> (n,2) float32。
681
956
 
682
957
  依赖 scripts/sfxkit.py(v0.3.0 新增)。缺 sfxkit 时直接报错,不静默出哑片。
683
958
  未知提示点类型:告警一次并跳过(不 crash 整片混音)。
959
+ 提示点参数非法(配方不认识的关键字 / 取值连枚举校验都过不了,TypeError 或
960
+ ValueError):该条跳过并汇总 [WARN],其余正常落位——不静默改渲染默认音色,
961
+ 也不因一条 cue 写错丢整条音效轨。
962
+
963
+ v0.7 音频差异化(见 references/audio-variation.md):
964
+ - style / content_fp 同时给时,每条 cue 的 seed 由
965
+ content_seed(seed_base, style, content_fp, type, t) 派生 -> 不同影片同一 cue
966
+ 位置也拿到不同音色细节;任一为 None 时退回 seed_base + i(旧路径逐样本兼容)。
967
+ - key 非空时透传给配方(提示音调性跟随配乐,不再硬编码 Dm)。
684
968
  """
685
969
  try:
686
970
  import sfxkit as SX
@@ -695,19 +979,46 @@ class CueBus:
695
979
  n = int(round(dur * sr)) + int(sr * 0.5)
696
980
  bus = np.zeros((n, 2), dtype=np.float32)
697
981
  unknown, placed = set(), 0
982
+ bad_cues: list = [] # 参数非法/内部报错的 cue:跳过并汇总 WARN,不拖死整条音效轨
983
+ keyed = style is not None and content_fp is not None
984
+ if keyed and verbose:
985
+ print(" 音效 seed: 内容派生(style=%s / fp=%s)" % (style, str(content_fp)[:8]))
986
+ elif verbose and style is not None:
987
+ print(" 音效 seed: 位置派生(只给了 style 没给 content_fp)")
698
988
  for i, c in enumerate(self.resolved()):
699
989
  kind = str(c.get("type"))
700
990
  if kind not in reg:
701
991
  unknown.add(kind)
702
992
  continue
703
993
  params = {k: v for k, v in c.items()
704
- if k not in ("t", "type", "src", "gain", "pan", "base_db")}
994
+ if k not in ("t", "type", "src", "gain", "pan", "base_db", "key")}
705
995
  params.pop("dur_hint", None)
996
+ if keyed:
997
+ seed = content_seed(seed_base, style, content_fp, kind, float(c["t"]))
998
+ else:
999
+ seed = int(seed_base) + i
1000
+ if key is not None:
1001
+ params["key"] = key
706
1002
  try:
707
- x = SX.render(kind, seed=int(seed_base) + i, **params)
708
- except TypeError:
709
- # 兼容严格签名的配方:退化成只传 seed
710
- x = SX.render(kind, seed=int(seed_base) + i)
1003
+ x = SX.render(kind, seed=seed, **params)
1004
+ except (TypeError, ValueError) as err:
1005
+ # 只对"配方不认识这个关键字"逐个删参重试(v0.4 严格签名配方的兼容);
1006
+ # 参数值非法(cue 里 pitch=None 之类)或配方内部 TypeError 不是签名问题,
1007
+ # 必须报告——旧实现一路退化成"只传 seed",把坏参数静默渲染成默认音色
1008
+ # (用户改了 cue 参数却听不出变化,日志里一个字都没有)。
1009
+ extra = dict(params)
1010
+ while True:
1011
+ hit = re.search(r"unexpected keyword argument '([^']+)'", str(err))
1012
+ if not hit or hit.group(1) not in extra:
1013
+ bad_cues.append((kind, float(c["t"]), err))
1014
+ x = None
1015
+ break
1016
+ extra.pop(hit.group(1))
1017
+ try:
1018
+ x = SX.render(kind, seed=seed, **extra)
1019
+ break
1020
+ except (TypeError, ValueError) as err2:
1021
+ err = err2
711
1022
  if x is None or len(x) == 0:
712
1023
  continue
713
1024
  x = np.asarray(x, dtype=np.float32)
@@ -726,12 +1037,123 @@ class CueBus:
726
1037
  placed += 1
727
1038
  if unknown and verbose:
728
1039
  print(" [WARN] 未知提示点类型(已跳过): %s" % ", ".join(sorted(unknown)))
1040
+ if bad_cues and verbose:
1041
+ # 与"未知 type 跳过"同一条隔离策略:一条 cue 的参数写错不该让整条音效轨消失
1042
+ # (st_sfx 会把 build_audio 的异常当成"音效合成失败"整轨丢弃)。但也不能静默:
1043
+ # 逐条打印 cue 类型/时刻/原因,用户改完参数能立刻看到。
1044
+ print(" [WARN] %d 条提示点参数非法(已跳过,其余正常落位):" % len(bad_cues))
1045
+ for kind_b, t_b, err_b in bad_cues[:6]:
1046
+ print(" %-14s t=%.3fs -> %s" % (kind_b, t_b, str(err_b)[:110]))
729
1047
  if verbose:
730
1048
  print(" 音效轨:%d 个提示点落位 / %d 个登记(%.2fs)"
731
1049
  % (placed, len(self.items), dur))
732
1050
  return bus[: int(round(dur * sr))]
733
1051
 
734
1052
 
1053
+ def voice_env(voice, sr=SR, block_s=0.01, thresh_db=-42.0, floor_db=-60.0):
1054
+ """旁白的语音活跃度包络(0~1):给"旁白优先"混音用的侧链源。
1055
+
1056
+ 为什么需要它:v0.7 之前配乐基线只比旁白低 1.5 dB(`music.gain_db=-1.5`),
1057
+ 而视频是"以旁白为主"的——背景只要不明显低于人声,观众就得费力分辨字。
1058
+ `mix_film` 现在用这条包络在**有人声的地方**把配乐/音效再压下去,
1059
+ 没人声的间隙自动让背景回来(否则整片背景像被闷住)。
1060
+
1061
+ 实现:10 ms 块级 RMS -> dB -> 在 [floor_db, thresh_db] 之间线性映射成 0~1 ->
1062
+ 起音快 / 释放慢的单向平滑(与 `duck()` 同一套向量化手法,无逐采样循环)。
1063
+ """
1064
+ x = np.asarray(voice, dtype=np.float32)
1065
+ if x.ndim == 2:
1066
+ x = x.mean(axis=1)
1067
+ n = len(x)
1068
+ if n == 0:
1069
+ return np.zeros(0, dtype=np.float32)
1070
+ block = max(1, int(sr * float(block_s)))
1071
+ nb = n // block
1072
+ if nb == 0: # 极短旁白(< 10 ms):整段当一块并补零
1073
+ nb = 1 # (旧实现 reshape(1, block) 直接崩)
1074
+ x = np.concatenate([x, np.zeros(block - n, dtype=np.float32)])
1075
+ seg = x[: nb * block].reshape(nb, block)
1076
+ rms = np.sqrt(np.mean(seg * seg, axis=1) + 1e-12)
1077
+ db = 20.0 * np.log10(rms + 1e-12)
1078
+ lo = float(min(floor_db, thresh_db - 1.0))
1079
+ e = np.clip((db - lo) / (float(thresh_db) - lo), 0.0, 1.0)
1080
+
1081
+ def _smooth(v, tau_s):
1082
+ # 极短旁白时块数可能比核还少:np.convolve(mode="same") 返回 max(len(v), k) 长度,
1083
+ # 两个长度不同的平滑结果再做 np.maximum 会广播失败(v0.7.1 起 voice_env 崩在这里)。
1084
+ k = min(max(1, int(sr * tau_s / block)), len(v))
1085
+ w = np.ones(k, dtype=np.float32) / k
1086
+ return np.convolve(v, w, mode="same")
1087
+
1088
+ e = np.maximum(_smooth(e, 0.06), _smooth(e, 0.45)).astype(np.float32)
1089
+ return np.interp(np.arange(n), np.arange(nb) * block + block / 2.0, e).astype(np.float32)
1090
+
1091
+
1092
+ def duck_voice(track, voice, depth_db=6.0, sr=SR, thresh_db=-42.0, floor_db=-60.0):
1093
+ """旁白说话时压低背景(配乐 / 音效):旁白优先的侧链压缩(只压背景,不碰人声)。
1094
+
1095
+ depth_db 是"人声满电平时背景最多被压多少 dB";间隙里包络回 0,背景恢复原电平。
1096
+ 与 `duck()`(音效压配乐)叠加使用:人声 > 音效 > 配乐,三条让位链互不冲突。
1097
+ """
1098
+ b = np.asarray(track, dtype=np.float32)
1099
+ if b.ndim == 1:
1100
+ b2 = np.stack([b, b], axis=1)
1101
+ else:
1102
+ b2 = b
1103
+ n = len(b2)
1104
+ if n == 0 or depth_db <= 0:
1105
+ return b2
1106
+ e = voice_env(voice, sr=sr, thresh_db=thresh_db, floor_db=floor_db)[:n]
1107
+ if e.size == 0:
1108
+ return b2
1109
+ if e.size < n:
1110
+ # 旁白比背景短(单独调用时可能遇到):旁白结束之后没有侧链源,背景应原样恢复。
1111
+ e = np.concatenate([e, np.zeros(n - e.size, dtype=np.float32)])
1112
+ if float(np.max(e)) <= 1e-6:
1113
+ return b2
1114
+ gains = 10.0 ** (-float(depth_db) * e / 20.0)
1115
+ return (b2 * gains[:, None]).astype(np.float32)
1116
+
1117
+
1118
+ def voice_snr(voice, bg, sr=SR, thresh_db=-42.0, floor_db=-60.0):
1119
+ """在"有人声"的采样区间量 人声/背景 的响度差(dB)。旁白优先的客观指标。
1120
+
1121
+ 只在语音活跃度 >= 0.5 的区间统计:静音间隙里背景当然是唯一声源,
1122
+ 把间隙算进来会稀释问题(真正让人听不清的,是人说话那一刻背景有多响)。
1123
+ 人声总长不足 50 ms 时返回 None(没有可判定的样本)。
1124
+ """
1125
+ v = np.asarray(voice, dtype=np.float64)
1126
+ b = np.asarray(bg, dtype=np.float64)
1127
+ if v.ndim == 2:
1128
+ v = v.mean(axis=1)
1129
+ if b.ndim == 2:
1130
+ b = b.mean(axis=1)
1131
+ n = min(len(v), len(b))
1132
+ if n <= 0:
1133
+ return None
1134
+ v, b = v[:n], b[:n]
1135
+ e = voice_env(v, sr=sr, thresh_db=thresh_db, floor_db=floor_db)
1136
+ act = e >= 0.5
1137
+ if int(np.sum(act)) < int(0.05 * sr):
1138
+ return None
1139
+ vr = float(np.sqrt(np.mean(v[act] ** 2)))
1140
+ br = float(np.sqrt(np.mean(b[act] ** 2)))
1141
+ return 20.0 * np.log10((vr + 1e-12) / (br + 1e-12))
1142
+
1143
+
1144
+ def guard_snr(voice, bg, target_db=12.0, cap_db=12.0, sr=SR,
1145
+ thresh_db=-42.0, floor_db=-60.0):
1146
+ """旁白清晰度兜底:人声/背景不足 target_db 时,返回背景应再压多少 dB。
1147
+
1148
+ 返回 (trim_db, snr_db):trim_db=0 表示已达标;cap_db 防止把背景压到无声
1149
+ (压过头会变成"配乐忽有忽无",比听不清更假)。判定口径同 `voice_snr()`。
1150
+ """
1151
+ snr = voice_snr(voice, bg, sr=sr, thresh_db=thresh_db, floor_db=floor_db)
1152
+ if snr is None or snr >= float(target_db):
1153
+ return 0.0, (snr if snr is not None else float("nan"))
1154
+ return float(min(max(0.0, float(cap_db)), float(target_db) - snr)), snr
1155
+
1156
+
735
1157
  def duck(music, sfx, depth_db=4.0, attack=0.03, release=0.35, sr=SR):
736
1158
  """音效响起时配乐自动让位(最多 depth_db),避免重击被配乐吃掉。
737
1159
 
@@ -749,13 +1171,18 @@ def duck(music, sfx, depth_db=4.0, attack=0.03, release=0.35, sr=SR):
749
1171
  if env.size == 0 or float(env.max()) <= 1e-9:
750
1172
  return m2
751
1173
  block = max(1, int(sr * 0.01))
752
- nb = max(1, n // block)
1174
+ nb = n // block
1175
+ if nb == 0: # 极短音效(< 10 ms):整段当一块并补零
1176
+ nb = 1
1177
+ env = np.concatenate([env, np.zeros(block - n, dtype=np.float32)])
753
1178
  e = env[: nb * block].reshape(nb, block).max(axis=1)
754
1179
  e = e / (float(np.percentile(e, 99.5)) + 1e-9)
755
1180
  e = np.clip(e, 0.0, 1.0)
756
1181
  # 单向平滑:起音快、释放慢(对包络做两次不同时间常数的指数平滑,仍保持向量化)
757
1182
  def _smooth(x, tau_s):
758
- k = max(1, int(sr * tau_s / block))
1183
+ # 与 voice_env 同一处理:短片段(< release 0.35 s)时核不能比信号长,
1184
+ # 否则 np.convolve(mode="same") 返回核长、np.maximum 广播失败(短片混音直接崩)。
1185
+ k = min(max(1, int(sr * tau_s / block)), len(x))
759
1186
  w = np.ones(k, dtype=np.float32) / k
760
1187
  return np.convolve(x, w, mode="same")
761
1188
  e = np.maximum(_smooth(e, attack), _smooth(e, release) * 0.7)
@@ -766,17 +1193,35 @@ def duck(music, sfx, depth_db=4.0, attack=0.03, release=0.35, sr=SR):
766
1193
 
767
1194
  def mix_film(voice, music=None, sfx=None, total=None, sr=SR,
768
1195
  voice_gain=1.0, music_gain_db=0.0, sfx_gain_db=0.0,
769
- duck_db=3.0, peak_target=0.89, fade=0.4, verbose=True):
1196
+ duck_db=3.0, peak_target=0.89, fade=0.4, verbose=True,
1197
+ voice_duck_db=6.0, voice_duck_sfx_db=3.0,
1198
+ voice_thresh_db=-42.0, voice_floor_db=-60.0,
1199
+ guard_snr_db=12.0, guard_cap_db=30.0):
770
1200
  """三总线混音:旁白 + 配乐 + 音效 -> 立体声 float32。
771
1201
 
772
- 顺序照 ai-history-film 的成片混音:音乐比音效低一点,音效响时音乐让位,
773
- 最后统一做峰值保护(真正的响度标准化交给 ffmpeg 的 loudnorm)。
1202
+ **旁白优先**(v0.7.1 起是硬行为,不是文档口号):
1203
+ 1. 旁白电平固定 `voice_gain=1.0`,不被任何侧链影响;
1204
+ 2. 有人声的地方,配乐再压 `voice_duck_db`(默认 6 dB)、音效再压 `voice_duck_sfx_db`(默认 3 dB)
1205
+ —— 保证"人声比背景响 ≥12 dB"这条可听清线;没人声的间隙自动恢复;
1206
+ 3. 音效响时配乐额外让位(`duck_db`,v0.7 默认 4 dB);
1207
+ 4. 最后统一做峰值保护(真正的响度标准化交给 ffmpeg 的 loudnorm)。
1208
+
1209
+ `voice_duck_db=0` / `voice_duck_sfx_db=0` 可单独关掉旁白优先让位(旧片对比用)。
1210
+
1211
+ **SNR 兜底(guard_snr_db=12,0 = 关)**:按台词活跃区实测"人声/背景"响度差,
1212
+ 不足 12 dB 就整体再压背景(最多 guard_cap_db),保证"听清旁白"不依赖配乐素材的天然电平——
1213
+ 这是本仓库唯一一条会在混音后自己改电平的门禁。触发时会打印实际 SNR 与补压量。
774
1214
  """
775
1215
  def _to_stereo(x):
776
1216
  a = np.asarray(x, dtype=np.float32)
777
1217
  if a.ndim == 1:
778
1218
  return np.stack([a, a], axis=1)
779
- return a if a.shape[1] >= 2 else np.concatenate([a, a], axis=1)
1219
+ if a.shape[1] == 1:
1220
+ return np.concatenate([a, a], axis=1)
1221
+ # 多于 2 声道(例如把 5.1 的 music.wav 塞进 work/):只取前两声道。
1222
+ # 旧实现原样返回 (n,3+),back += mm 直接广播崩(mix_film 是公共 API,
1223
+ # 而 wav_read 不限制声道数,用户完全可能踩到)。口径与 AudioBus._fit 一致。
1224
+ return a[:, :2] if a.shape[1] > 2 else a
780
1225
 
781
1226
  n = None
782
1227
  v = None if voice is None else _to_stereo(voice)
@@ -791,18 +1236,57 @@ def mix_film(voice, music=None, sfx=None, total=None, sr=SR,
791
1236
  if not n:
792
1237
  return np.zeros((0, 2), dtype=np.float32)
793
1238
 
794
- out = np.zeros((n, 2), dtype=np.float32)
1239
+ # v0.7.1 旁白优先:先算背景(配乐 + 音效各自让位),再用 SNR 兜底确认"人声压得住"。
1240
+ # 侧链与 SNR 都用"实际交付的人声"(乘过 voice_gain):voice_gain=0.5 时旧实现用未缩放
1241
+ # 人声算 SNR,日志写"人声/背景=12 dB 达标"、交付出去只有 6 dB(兜底承诺失效)。
1242
+ venv_kw = dict(sr=sr, thresh_db=float(voice_thresh_db), floor_db=float(voice_floor_db))
1243
+ vg = None
1244
+ if v is not None:
1245
+ vg = v[:n] * float(voice_gain)
1246
+ back = np.zeros((n, 2), dtype=np.float32)
795
1247
  if s is not None:
796
- out += s[:n] * (10.0 ** (float(sfx_gain_db) / 20.0))
1248
+ ss = s[:n] * (10.0 ** (float(sfx_gain_db) / 20.0))
1249
+ if vg is not None and float(voice_duck_sfx_db) > 0:
1250
+ ss = duck_voice(ss, vg, depth_db=float(voice_duck_sfx_db), **venv_kw)
1251
+ back += ss
797
1252
  if m is not None:
798
1253
  mm = m[:n] * (10.0 ** (float(music_gain_db) / 20.0))
799
1254
  if s is not None:
800
1255
  mm = duck(mm, s[:n], depth_db=duck_db, sr=sr)
801
1256
  else:
802
1257
  mm = _to_stereo(mm)
803
- out += mm
1258
+ if vg is not None and float(voice_duck_db) > 0:
1259
+ mm = duck_voice(mm, vg, depth_db=float(voice_duck_db), **venv_kw)
1260
+ back += mm
1261
+ # SNR 兜底:实测人声活跃区的人声/背景比;不够就整体再压背景(不碰人声)。
1262
+ if vg is not None and back.any() and float(guard_snr_db) > 0:
1263
+ trim_db, snr_db = guard_snr(vg, back, target_db=float(guard_snr_db),
1264
+ cap_db=float(guard_cap_db), **venv_kw)
1265
+ if trim_db > 0.0:
1266
+ back *= 10.0 ** (-trim_db / 20.0)
1267
+ if verbose:
1268
+ if snr_db == snr_db: # 非 NaN = 有可判定的人声段
1269
+ note = ",达标"
1270
+ if trim_db > 0:
1271
+ note = ",背景再压 %.1f dB" % trim_db
1272
+ if trim_db >= float(guard_cap_db) - 1e-6:
1273
+ note += "(已到兜底上限:检查配乐/音效素材电平)"
1274
+ elif trim_db > 18.0:
1275
+ note += "(补压很大:建议调低 music.gain_db)"
1276
+ print(" 旁白优先: 人声/背景 = %.1f dB(目标 %.1f)%s"
1277
+ % (snr_db, float(guard_snr_db), note))
1278
+ else:
1279
+ # snr_db 是 NaN = 活跃区为空:旁白电平低于 voice_thresh_db 时,旁白优先
1280
+ # 与 SNR 兜底会**整体静默失效**(背景一点不压)。纯静音片是有意的,
1281
+ # 但"目的是说话却录得很轻"必须在日志里喊出来,否则等于没有门禁。
1282
+ vpk = float(np.max(np.abs(vg))) if vg.size else 0.0
1283
+ if vpk > 1e-4: # 有声音但到不了活跃区
1284
+ print(" [WARN] 旁白电平过低(峰值 %.1f dBFS,低于活跃度门限 %.0f dB)——"
1285
+ "旁白优先/SNR 兜底本次无法判定;检查 voice_gain / TTS 音量"
1286
+ % (20.0 * math.log10(vpk), float(voice_thresh_db)))
1287
+ out = back
804
1288
  if v is not None:
805
- out += v[:n] * float(voice_gain)
1289
+ out = out + v[:n] * float(voice_gain)
806
1290
 
807
1291
  raw = float(np.max(np.abs(out))) if out.size else 0.0
808
1292
  if fade > 0:
@@ -848,7 +1332,9 @@ def music_plan(script, has_music_file: bool = False):
848
1332
  """
849
1333
  mus = script.get("music") or {}
850
1334
  raw = str(mus.get("mode") or "").strip().lower()
851
- bpm = script.get("bpm")
1335
+ # v0.5.0:bpm 可以是 "auto"(由 stylekit 按风格梯度和内容指纹决定)。
1336
+ # 这里只判断"有没有 bpm 这个概念",具体数值由 st_score 解析后传入 scorekit。
1337
+ bpm = mus.get("bpm", script.get("bpm"))
852
1338
  if raw == "auto":
853
1339
  raw = ""
854
1340
  if raw in ("off", "none", "mute"):
@@ -879,6 +1365,82 @@ def music_plan(script, has_music_file: bool = False):
879
1365
  return "none", "缺省配乐:没有 bpm / music.chords / work/music.wav,本片无配乐"
880
1366
 
881
1367
 
1368
+ # ---------------------------------------------------------------- 风格 / 主题(v0.5.0)
1369
+ def _stylekit():
1370
+ """惰性 import stylekit(同目录;缺了就明确报错,而不是静默回落到单一样式)。"""
1371
+ try:
1372
+ import stylekit as ST
1373
+ return ST
1374
+ except Exception as e: # noqa: BLE001
1375
+ raise RuntimeError("找不到 stylekit.py(v0.5.0 起 scenes/render 都依赖它,"
1376
+ "请与 filmkit.py 放在同一目录):%s" % e)
1377
+
1378
+
1379
+ def resolve_theme(script, scene=None):
1380
+ """给场景取主题字典。
1381
+
1382
+ 优先级:scene["theme"](同明暗族才允许) > script 里解析出的风格主题。
1383
+ stylekit 不在时退回内置 tech-deep(只为了让老工作目录少一个文件也能跑)。
1384
+ """
1385
+ try:
1386
+ ST = _stylekit()
1387
+ plan = script.get("_style")
1388
+ if not isinstance(plan, dict):
1389
+ plan = ST.resolve_script_style(script)
1390
+ th = dict(ST.THEMES[plan["theme_id"]])
1391
+ sid = (scene or {}).get("theme")
1392
+ if sid and sid in ST.THEMES:
1393
+ alt = ST.THEMES[sid]
1394
+ if ST.family_compatible(th, alt):
1395
+ th = dict(alt)
1396
+ return th
1397
+ except Exception: # noqa: BLE001
1398
+ return _FALLBACK_THEME
1399
+
1400
+
1401
+ def style_plan(script):
1402
+ """解析剧本的风格计划(含 legacy 判定)。stylekit 缺失时返回 legacy 计划。"""
1403
+ try:
1404
+ ST = _stylekit()
1405
+ plan = script.get("_style")
1406
+ return plan if isinstance(plan, dict) else ST.resolve_script_style(script)
1407
+ except Exception: # noqa: BLE001
1408
+ return {"legacy": True, "style": "legacy", "theme_id": "tech-deep",
1409
+ "music": {"style": "legacy", "bpm": script.get("bpm") or 120.0,
1410
+ "key": (script.get("music") or {}).get("key") or "Dm",
1411
+ "mood": "steady",
1412
+ "seed_effective": int((script.get("music") or {}).get("seed", 7) or 7)},
1413
+ "voice": {"map": {}, "policy": {"auto": False, "jitter": 0.0, "max_distinct": 2}},
1414
+ "pacing": {"lead": float(script.get("lead", 0.62)),
1415
+ "tail": float(script.get("tail", 0.92)),
1416
+ "min_dur": float(script.get("min_dur", 3.2)),
1417
+ "hold": float(script.get("hold", 0.0)),
1418
+ "item_stagger": 0.32}}
1419
+
1420
+
1421
+ # v0.4 的深蓝橙,作为"没装 stylekit 也能出片"的兜底(与 assets/scenes.py 的老 THEME 同值)
1422
+ _FALLBACK_THEME = {
1423
+ "id": "tech-deep", "label": "科技深蓝", "keywords": (),
1424
+ "bg": (7, 14, 26), "bg2": (17, 33, 54), "surface": (26, 42, 64),
1425
+ "ink": (240, 245, 252), "muted": (150, 174, 200), "dim": (120, 142, 168),
1426
+ "accent": (243, 132, 60), "accent2": (86, 170, 232),
1427
+ "good": (108, 178, 110), "bad": (226, 96, 92), "rule": (255, 255, 255),
1428
+ "vignette": 0.40, "vignette_tint": (0, 0, 0), "grain": 5.0, "grain_freq": 0.31,
1429
+ "texture": "dots", "texture_alpha": 0.045,
1430
+ "family": "sans",
1431
+ "scale": {"display": (96, "bold"), "heading": (64, "bold"), "sub": (38, "light"),
1432
+ "body": (34, "reg"), "kicker": (28, "bold"), "chip": (27, "reg"),
1433
+ "brand": (23, "reg")},
1434
+ "line_gap": 1.30, "tracking": 0.0,
1435
+ "layout": "left-stack",
1436
+ "card": {"radius": 14, "fill_a": 16, "outline_a": 28, "pad": 34, "h": 96, "gap": 118},
1437
+ "safe_bias": 0,
1438
+ "ease": "out3", "entrance_dx": 0, "entrance_dy": 26, "push": 0.04,
1439
+ "item_stagger": 0.32, "transition": "black", "hud": "chip-left",
1440
+ "qa": {"luma_max": 0.030, "flicker_thresh": 6.0, "dark": True},
1441
+ }
1442
+
1443
+
882
1444
  # ---------------------------------------------------------------- 程序化音源
883
1445
  def tone(freq, dur, sr=SR, amp=0.2, kind="sine", decay=2.2, harmonics=(1.0,)):
884
1446
  """单音衰减。kind 参数保留以兼容 v0.1(目前只实现 sine)。"""
@@ -934,16 +1496,32 @@ def whoosh(dur=0.45, sr=SR, amp=0.06, seed=3):
934
1496
 
935
1497
  # ---------------------------------------------------------------- TTS
936
1498
  def tts_key(text, voice, rate, pitch) -> str:
1499
+ """v1 缓存键:4 参签名保持不变(selfcheck 与用户脚本都在用)。"""
937
1500
  h = hashlib.sha1(("%s|%s|%s|%s" % (text, voice, rate, pitch)).encode("utf-8"))
938
1501
  return h.hexdigest()[:16]
939
1502
 
940
1503
 
1504
+ def tts_key_v2(text, voice, rate, pitch, volume="+0%", trim=True, trim_db=-42.0,
1505
+ keep_ms=None) -> str:
1506
+ """v2 缓存键:把 volume / trim 开关也纳入。
1507
+
1508
+ 为什么必须升 v2(v0.4 的真 bug):trim 不进键,用户把 trim_silence 改成 false 之后,
1509
+ 老 meta 里 trimmed=True 仍会命中第一条分支,返回**裁剪版**——"关了裁剪其实没关"。
1510
+ """
1511
+ payload = "%s|%s|%s|%s|%s|%s|%s|%s" % ("v2", text, voice, rate, pitch,
1512
+ volume, int(bool(trim)), round(float(trim_db), 3))
1513
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
1514
+
1515
+
941
1516
  def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
942
- cache=True, retries=4, trim=True, trim_db=-42.0):
1517
+ cache=True, retries=4, trim=True, trim_db=-42.0, volume="+0%",
1518
+ persona=None, tone=None):
943
1519
  """逐句合成旁白(带输入校验的缓存 + 网络重试 + 可选尾静音裁剪)。
944
1520
 
945
1521
  v0.1 的缓存只判断文件是否存在,改一句台词会静默复用旧音频;
946
1522
  v0.2 起把 text/voice/rate/pitch 的哈希写进 meta,变了就重新合成。
1523
+ v0.5.0:加 volume(edge-tts 原生支持,之前一直没传)+ persona/tone 审计字段;
1524
+ 缓存键升 v2(把 volume/trim 也纳入,修掉"关了裁剪其实没关"的静默 bug)。
947
1525
  trim=True 时另外落一份裁剪后的 wav 并在 meta.path 指向它,dur 为裁剪后实测时长。
948
1526
  """
949
1527
  out_mp3 = str(out_mp3)
@@ -951,16 +1529,26 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
951
1529
  if d:
952
1530
  os.makedirs(d, exist_ok=True)
953
1531
  meta_path = out_mp3 + ".json"
954
- key = tts_key(text, voice, rate, pitch)
1532
+ vol = str(volume or "+0%")
1533
+ key = tts_key_v2(text, voice, rate, pitch, volume=vol, trim=trim, trim_db=trim_db)
1534
+ legacy_key = tts_key(text, voice, rate, pitch)
955
1535
 
956
1536
  if cache and os.path.exists(out_mp3) and os.path.getsize(out_mp3) > 2000:
957
1537
  try:
958
1538
  m = json.load(open(meta_path, encoding="utf-8"))
1539
+ # v2 命中:key 已经含 volume/trim,不需要再分叉判断 trimmed
959
1540
  if m.get("key") == key and m.get("text") == text:
960
1541
  if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
961
1542
  return m
962
1543
  if not m.get("trimmed") and not trim:
963
1544
  return m
1545
+ # v1 老缓存(升级前合成的)只有在参数完全等于 v1 语义时才复用,
1546
+ # 避免"改了一个 volume 却拿到老音频"这种升级期的静默错配。
1547
+ elif (legacy_key == tts_key(text, voice, rate, pitch)
1548
+ and str(m.get("key") or "") == legacy_key and m.get("text") == text
1549
+ and vol in ("+0%", "") and m.get("voice") == voice):
1550
+ if m.get("trimmed") and m.get("path") and os.path.exists(m["path"]):
1551
+ return m
964
1552
  except Exception:
965
1553
  pass
966
1554
 
@@ -972,7 +1560,7 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
972
1560
  import edge_tts
973
1561
 
974
1562
  async def go():
975
- c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
1563
+ c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=vol)
976
1564
  await c.save(out_mp3)
977
1565
 
978
1566
  asyncio.run(go())
@@ -995,23 +1583,42 @@ def tts(text, out_mp3, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
995
1583
  dur = len(pcm) / float(SR)
996
1584
 
997
1585
  m = dict(path=used, mp3=out_mp3, text=text, voice=voice, rate=rate, pitch=pitch,
998
- key=key, dur=dur, dur_raw=dur_raw, trimmed=bool(trim))
1586
+ volume=vol, key=key, spec_version=2, dur=dur, dur_raw=dur_raw,
1587
+ trimmed=bool(trim), trim_db=float(trim_db))
1588
+ if persona:
1589
+ m["persona"] = str(persona)
1590
+ if tone:
1591
+ m["tone"] = str(tone)
999
1592
  json.dump(m, open(meta_path, "w", encoding="utf-8"), ensure_ascii=False)
1000
1593
  return m
1001
1594
 
1002
1595
 
1003
1596
  def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0Hz",
1004
- workers=4, trim=True):
1005
- """并发逐句合成(并发只影响墙钟,不影响产物:每句独立缓存)。"""
1597
+ workers=4, trim=True, plan=None, trim_db=-42.0):
1598
+ """并发逐句合成(并发只影响墙钟,不影响产物:每句独立缓存)。
1599
+
1600
+ v0.5.0:plan=[{voice,rate,pitch,volume,persona,tone}, ...](与 lines 等长)时
1601
+ 逐句用各自的声线/语气;plan=None 时行为与 v0.4 完全一致。
1602
+ """
1006
1603
  from concurrent.futures import ThreadPoolExecutor
1007
1604
 
1008
1605
  tts_dir = os.path.join(workdir, "tts")
1009
1606
  os.makedirs(tts_dir, exist_ok=True)
1010
1607
  results = [None] * len(lines)
1608
+ specs = list(plan) if plan is not None else [None] * len(lines)
1609
+ if len(specs) != len(lines):
1610
+ raise ValueError("plan 长度 %d 与 lines 长度 %d 不一致" % (len(specs), len(lines)))
1011
1611
 
1012
1612
  def one(i, ln):
1613
+ spec = specs[i] or {}
1013
1614
  mp3 = os.path.join(tts_dir, str(ln["id"]) + ".mp3")
1014
- m = tts(ln["text"], mp3, voice=voice, rate=rate, pitch=pitch, trim=trim)
1615
+ m = tts(ln["text"], mp3,
1616
+ voice=spec.get("voice") or voice,
1617
+ rate=spec.get("rate") or rate,
1618
+ pitch=spec.get("pitch") or pitch,
1619
+ volume=spec.get("volume") or "+0%",
1620
+ trim=trim, trim_db=trim_db,
1621
+ persona=spec.get("persona"), tone=spec.get("tone"))
1015
1622
  row = dict(ln)
1016
1623
  row.update(m)
1017
1624
  print(" tts %-10s %6.2fs %s" % (ln["id"], m["dur"], ln["text"][:34]), flush=True)
@@ -1027,7 +1634,7 @@ def tts_batch(lines, workdir, voice="zh-CN-YunyangNeural", rate="+0%", pitch="+0
1027
1634
 
1028
1635
  # ---------------------------------------------------------------- 时间轴
1029
1636
  def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_dur=None,
1030
- hold=0.0, hold_map=None, bar=0.0):
1637
+ hold=0.0, hold_map=None, bar=0.0, lead_map=None):
1031
1638
  """由实测语音时长反推时间轴。第一段 start 必须严格为 0。
1032
1639
 
1033
1640
  hold / hold_map(v0.3.0):语音结束后额外停留的秒数——这就是 ai-history-film 的"停留窗口":
@@ -1039,14 +1646,18 @@ def build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0, tail_map=None, max_
1039
1646
  for ln in lines:
1040
1647
  voice = float(ln.get("dur") or 0.0)
1041
1648
  extra = float(ln.get("hold", (hold_map or {}).get(ln["id"], hold)) or 0.0)
1042
- d = max(float(min_dur), float(lead) + voice + float((tail_map or {}).get(ln["id"], tail)) + extra)
1649
+ # v0.5.0:lead 也支持逐句(行内 lead > lead_map > 全局),
1650
+ # 与 tail_map 同构 —— 不同语气档的"句前呼吸"本来就不该一样长。
1651
+ lead_i = float(ln.get("lead", (lead_map or {}).get(ln["id"], lead)))
1652
+ tail_i = float((tail_map or {}).get(ln["id"], tail))
1653
+ d = max(float(min_dur), lead_i + voice + tail_i + extra)
1043
1654
  if max_dur and d > float(max_dur):
1044
1655
  d = float(max_dur)
1045
1656
  if bar and float(bar) > 0:
1046
1657
  d = math.ceil(d / float(bar) - 1e-9) * float(bar)
1047
1658
  scenes.append(dict(ln, start=round(t, 3), end=round(t + d, 3), dur=round(d, 3),
1048
- voice_start=round(t + float(lead), 3),
1049
- voice_end=round(t + float(lead) + voice, 3)))
1659
+ voice_start=round(t + lead_i, 3),
1660
+ voice_end=round(t + lead_i + voice, 3)))
1050
1661
  t += d
1051
1662
  if scenes:
1052
1663
  scenes[0]["start"] = 0.0
@@ -1097,7 +1708,8 @@ def write_srt(scenes, path, max_hold=None):
1097
1708
  return len(rows)
1098
1709
 
1099
1710
 
1100
- def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52):
1711
+ def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52,
1712
+ theme=None):
1101
1713
  """直接写 ASS,并在头里声明 PlayResX/Y=1920/1080。
1102
1714
 
1103
1715
  这样交 libass 不会按 PlayResY=288 反推,避免"字号小得看不见"
@@ -1110,6 +1722,18 @@ def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52)
1110
1722
  s, cs = cs // 100, cs % 100
1111
1723
  return "%d:%02d:%02d.%02d" % (h, m, s, cs)
1112
1724
 
1725
+ primary, outline, back = "&H00FCF8F5", "&H00101010", "&H96000000"
1726
+ if isinstance(theme, dict):
1727
+ # ASS 的颜色是 &HAABBGGRR(BGR 顺序 + alpha 在前,alpha 0=不透明)
1728
+ def _ass(rgb, alpha=0):
1729
+ r, g, b = rgb
1730
+ return "&H%02X%02X%02X%02X" % (int(alpha), int(b), int(g), int(r))
1731
+ primary = _ass(theme["ink"])
1732
+ outline = _ass(theme["bg2"])
1733
+ back = _ass(theme["surface"], 0x60)
1734
+ fam = str(theme.get("family") or "sans")
1735
+ # 字体族 -> Windows 上的实际字体名(libass 认 name 不认路径)
1736
+ font_name = {"serif": "SimSun", "kai": "KaiTi", "round": "DengXian"}.get(fam, "Microsoft YaHei")
1113
1737
  head = [
1114
1738
  "[Script Info]",
1115
1739
  "ScriptType: v4.00+",
@@ -1122,8 +1746,8 @@ def write_ass(scenes, path, max_hold=None, font_name="Microsoft YaHei", size=52)
1122
1746
  "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
1123
1747
  "BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
1124
1748
  "BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
1125
- "Style: Default,%s,%d,&H00FCF8F5,&H000000FF,&H00101010,&H96000000,-1,0,0,0,100,100,0,0,"
1126
- "1,3,1,2,60,60,56,1" % (font_name, int(size)),
1749
+ "Style: Default,%s,%d,%s,&H000000FF,%s,%s,-1,0,0,0,100,100,0,0,"
1750
+ "1,3,1,2,60,60,56,1" % (font_name, int(size), primary, outline, back),
1127
1751
  "",
1128
1752
  "[Events]",
1129
1753
  "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
@@ -1237,8 +1861,8 @@ def mux(video, audio, out, crf=18, preset="medium", audio_bitrate="192k",
1237
1861
  af = loudnorm_two_pass(audio, target_i=target_i, target_tp=target_tp) if loudnorm else None
1238
1862
  if loudnorm and not af:
1239
1863
  # 不静默降级:量不到就喊出来(qa 的响度门禁还在,但这里先说清楚)
1240
- print(" [WARN] loudnorm 两遍测量失败(ffmpeg 输出里没解析到 JSON),本次不做响度标准化",
1241
- flush=True)
1864
+ print(" [WARN] loudnorm 两遍测量失败(拿不到可用测量值:全静音/极短音频会给出 -inf),"
1865
+ "本次不做响度标准化", flush=True)
1242
1866
 
1243
1867
  vd = media_dur(video)
1244
1868
  ad = media_dur(audio)
@@ -1276,6 +1900,17 @@ def loudnorm_two_pass(src, target_i=-16.0, target_tp=-1.5, target_lra=11.0):
1276
1900
  j = json.loads(m.group(0))
1277
1901
  except Exception:
1278
1902
  return None
1903
+ # 全静音 / 极短音频时 ffmpeg 会报 input_i=-inf、target_offset=inf:这些值塞进
1904
+ # measured_* 会被 ffmpeg 拒绝("Value -inf for parameter 'measured_I' out of range"),
1905
+ # mux 直接失败(旧实现只判"解析到 JSON",于是拿着 -inf 的滤镜串去编码)。
1906
+ # 量不到可用测量值就返回 None:mux 会打 WARN 跳过响度标准化,qa 的响度门禁照旧兜底。
1907
+ need = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
1908
+ try:
1909
+ bad = [k for k in need if not math.isfinite(float(j.get(k, 0.0)))]
1910
+ except (TypeError, ValueError):
1911
+ return None
1912
+ if bad:
1913
+ return None
1279
1914
  return ("loudnorm=I=%.1f:TP=%.1f:LRA=%.1f:measured_I=%s:measured_TP=%s:"
1280
1915
  "measured_LRA=%s:measured_thresh=%s:offset=%s:print_format=summary"
1281
1916
  % (target_i, target_tp, target_lra, j.get("input_i"), j.get("input_tp"),
@@ -1427,8 +2062,224 @@ def contact_sheet(frames, out_png, cols=4, thumb_w=480, labels=None):
1427
2062
  return out_png
1428
2063
 
1429
2064
 
2065
+ # ---------------------------------------------------------------- 拼接
2066
+ # 事故背景:混 fps 的片段以前直接 -c copy,拼出半速成片还不报错。
2067
+ # 现在拼前先查参数一致性;不一致或 copy 失败都自动降级重编码,只有重编码也失败才 raise。
2068
+ _LAST_CONCAT = {}
2069
+
2070
+
2071
+ def concat_last_state():
2072
+ """最近一次 concat_segments 的结果(mode/reencoded/issues/out/concat_txt)。
2073
+
2074
+ 返回副本;没跑过返回 {}。给测试和 backend 回显判断"这次到底有没有重编码"用,
2075
+ 不改变 concat_segments 的返回值(仍然是 str(out))。
2076
+ """
2077
+ return dict(_LAST_CONCAT)
2078
+
2079
+
2080
+ def _set_concat_state(**kw):
2081
+ _LAST_CONCAT.clear()
2082
+ _LAST_CONCAT.update(kw)
2083
+
2084
+
2085
+ def _stderr_brief(raw, limit=300):
2086
+ """ffmpeg stderr 摘要:真正的错误通常在一堆行里的最后几条,取末尾三行拼成一句。"""
2087
+ txt = (raw or b"").decode("utf-8", "replace").strip()
2088
+ lines = [ln.strip() for ln in txt.splitlines() if ln.strip()]
2089
+ if not lines:
2090
+ return "(ffmpeg 没有输出错误信息)"
2091
+ return " | ".join(lines[-3:])[:limit]
2092
+
2093
+
2094
+ def _concat_quote(path):
2095
+ """生成 ffmpeg concat 清单的一行:绝对路径 + 单引号转义。
2096
+
2097
+ concat demuxer 的引号规则照抄 shell:单引号内不能再出现单引号,
2098
+ 标准写法是把 ' 变成 '\''(先闭合、转义一个引号、再打开)。
2099
+ 路径里真的含单引号(Windows 文件名允许)也不会坏。
2100
+ """
2101
+ p = os.path.abspath(str(path)).replace("\\", "/")
2102
+ return "file '%s'\n" % p.replace("'", "'\\''")
2103
+
2104
+
2105
+ def _probe_concat_part(path):
2106
+ """单段探测(纯读):返回 dict,ok=False 时 why 说明原因。"""
2107
+ p = str(path)
2108
+ if not os.path.isfile(p):
2109
+ return dict(ok=False, path=p, why="文件不存在")
2110
+ pr = probe(p)
2111
+ streams = pr.get("streams") or []
2112
+ v = next((s for s in streams if s.get("codec_type") == "video"), None)
2113
+ if v is None:
2114
+ return dict(ok=False, path=p, why="ffprobe 读不出视频流(文件损坏或不是视频)")
2115
+ rate = str(v.get("r_frame_rate") or "0/0")
2116
+ fps = 0.0
2117
+ try:
2118
+ num, _, den = rate.partition("/")
2119
+ fps = float(num) / float(den or 1)
2120
+ except Exception: # noqa: BLE001 —— 脏帧率不崩,交给诊断
2121
+ fps = 0.0
2122
+ dur = 0.0
2123
+ try:
2124
+ dur = float((pr.get("format") or {}).get("duration") or 0)
2125
+ except Exception: # noqa: BLE001
2126
+ dur = 0.0
2127
+ a = next((s for s in streams if s.get("codec_type") == "audio"), None)
2128
+ return dict(ok=True, path=p, width=int(v.get("width") or 0),
2129
+ height=int(v.get("height") or 0), fps=round(fps, 3),
2130
+ fps_text=rate, codec=str(v.get("codec_name") or ""),
2131
+ audio=bool(a), dur=dur)
2132
+
2133
+
2134
+ def _fmt_fps(info):
2135
+ """帧率说人话:30/1 -> 30;30000/1001 -> 29.97;读不出来 -> 原始文本。"""
2136
+ try:
2137
+ fps = float(info.get("fps") or 0.0)
2138
+ except Exception: # noqa: BLE001
2139
+ fps = 0.0
2140
+ if fps <= 0:
2141
+ return str(info.get("fps_text") or "未知")
2142
+ if abs(fps - round(fps)) < 0.01:
2143
+ return "%d" % int(round(fps))
2144
+ return "%.2f" % fps
2145
+
2146
+
2147
+ def _concat_issues(parts):
2148
+ """逐段探测并与参照段比对(纯读)。返回 (infos, issues, ref_index)。
2149
+
2150
+ 参照段 = 第一个能读出来的段(正常就是第 1 段);每条 issue 都写清"哪段、差在哪"。
2151
+ """
2152
+ infos = [_probe_concat_part(p) for p in parts]
2153
+ issues = []
2154
+ ref = None
2155
+ for i, inf in enumerate(infos):
2156
+ if not inf.get("ok"):
2157
+ issues.append("第 %d 段%s:%s" % (i + 1, inf.get("why", "探测失败"),
2158
+ inf.get("path", parts[i])))
2159
+ continue
2160
+ if ref is None:
2161
+ ref = i
2162
+ continue
2163
+ r = infos[ref]
2164
+ who, base = "第 %d 段" % (i + 1), "第 %d 段" % (ref + 1)
2165
+ if (inf["width"], inf["height"]) != (r["width"], r["height"]):
2166
+ issues.append("%s分辨率 %dx%d != %s %dx%d"
2167
+ % (who, inf["width"], inf["height"], base, r["width"], r["height"]))
2168
+ if abs(float(inf["fps"]) - float(r["fps"])) > 0.01:
2169
+ issues.append("%s fps %s != %s fps %s"
2170
+ % (who, _fmt_fps(inf), base, _fmt_fps(r)))
2171
+ if inf["codec"] != r["codec"]:
2172
+ issues.append("%s视频编码 %s != %s %s"
2173
+ % (who, inf["codec"] or "?", base, r["codec"] or "?"))
2174
+ if bool(inf["audio"]) != bool(r["audio"]):
2175
+ issues.append("%s%s音轨,%s%s音轨"
2176
+ % (who, "有" if inf["audio"] else "无",
2177
+ base, "有" if r["audio"] else "无"))
2178
+ return infos, issues, ref
2179
+
2180
+
2181
+ def concat_preflight(parts):
2182
+ """拼接前的参数一致性检查(纯读:不写文件、不改全局状态、不碰 out)。
2183
+
2184
+ 逐段 ffprobe 比对 width / height / r_frame_rate / codec_name(外加有无音轨),
2185
+ 以第一个能读出来的段为参照。返回 (ok, issues):
2186
+ ok=False 表示不能直接 -c copy,调用方应降级重编码;issues 是可直接打印的中文诊断。
2187
+ 注意:这里只回答"能不能 copy",不检查画面内容 / 时间轴是否正确。
2188
+ """
2189
+ parts = [str(p) for p in parts]
2190
+ if not parts:
2191
+ return False, ["parts 为空(没有可拼接的片段)"]
2192
+ _infos, issues, _ref = _concat_issues(parts)
2193
+ return (not issues), issues
2194
+
2195
+
2196
+ def _concat_reencode(parts, out, infos, ref, lst):
2197
+ """降级路径:重编码后再拼(libx264 crf20 + aac 192k),返回 str(out)。
2198
+
2199
+ 不用"concat demuxer + 重编码":那仍然要求各段分辨率/像素格式一致,混分辨率时
2200
+ 会花屏或直接失败。这里用 concat 滤镜,逐段把画面统一到参照段的宽高/帧率
2201
+ (等比缩放 + 补边,不拉伸),音轨统一 48kHz 立体声;某段没音轨就补等长静音,
2202
+ 避免 v=1:a=1 的 concat 滤镜拒收。只有所有段都没音轨时才输出纯视频。
2203
+ """
2204
+ if ref is None:
2205
+ raise RuntimeError(
2206
+ "[concat] 降级失败:%d 段全都读不出视频流。先逐段跑 ffprobe -show_streams "
2207
+ "确认文件没坏;concat.txt 保留在:%s" % (len(parts), lst))
2208
+ r = infos[ref]
2209
+ tw, th = int(r.get("width") or 0), int(r.get("height") or 0)
2210
+ if tw <= 0 or th <= 0:
2211
+ tw, th = W, H
2212
+ # yuv420p 要求偶数宽高(参照段是奇数尺寸时裁掉 1px,别让重编码挂在最后一步)
2213
+ tw, th = max(2, tw - tw % 2), max(2, th - th % 2)
2214
+ fps_arg = str(r.get("fps_text") or "")
2215
+ if fps_arg in ("", "0/0"):
2216
+ fps_arg = "%g/1" % (float(r.get("fps") or 0) or float(FPS))
2217
+ any_audio = any(inf.get("ok") and inf.get("audio") for inf in infos)
2218
+ ref_dur = max(float(r.get("dur") or 0), 0.0)
2219
+ fc = []
2220
+ for i, inf in enumerate(infos):
2221
+ fc.append("[%d:v]fps=%s,scale=%d:%d:force_original_aspect_ratio=decrease,"
2222
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p[v%d]"
2223
+ % (i, fps_arg, tw, th, tw, th, i))
2224
+ if not any_audio:
2225
+ continue
2226
+ if inf.get("ok") and inf.get("audio"):
2227
+ fc.append("[%d:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a%d]"
2228
+ % (i, i))
2229
+ else:
2230
+ dur = max(float(inf.get("dur") or 0), ref_dur, 0.1)
2231
+ fc.append("anullsrc=r=48000:cl=stereo,atrim=duration=%.3f,asetpts=PTS-STARTPTS[a%d]"
2232
+ % (dur, i))
2233
+ labels = "".join("[v%d]%s" % (i, "[a%d]" % i if any_audio else "")
2234
+ for i in range(len(parts)))
2235
+ fc.append("%sconcat=n=%d:v=1:a=%d[v]%s"
2236
+ % (labels, len(parts), 1 if any_audio else 0, "[a]" if any_audio else ""))
2237
+ fc_text = ";".join(fc)
2238
+
2239
+ cmd = [FFMPEG, "-y", "-v", "error"]
2240
+ for src in parts:
2241
+ cmd += ["-i", str(src)]
2242
+ script_path = None
2243
+ try:
2244
+ if len(fc_text) > 8000:
2245
+ # 段数多时滤镜串会撞 Windows 命令行长度上限:落成临时脚本再引用
2246
+ fd, script_path = tempfile.mkstemp(prefix="filmkit_concat_", suffix=".txt")
2247
+ os.close(fd)
2248
+ with open(script_path, "w", encoding="utf-8") as fh:
2249
+ fh.write(fc_text)
2250
+ cmd += ["-filter_complex_script", script_path]
2251
+ else:
2252
+ cmd += ["-filter_complex", fc_text]
2253
+ cmd += ["-map", "[v]"]
2254
+ if any_audio:
2255
+ cmd += ["-map", "[a]", "-c:a", "aac", "-b:a", "192k"]
2256
+ cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", "20",
2257
+ "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(out)]
2258
+ proc = _run(cmd)
2259
+ finally:
2260
+ if script_path:
2261
+ try:
2262
+ os.remove(script_path)
2263
+ except OSError:
2264
+ pass
2265
+ if proc.returncode != 0:
2266
+ raise RuntimeError(
2267
+ "[concat] 重编码拼接也失败:%d 段既不能 -c copy,concat 滤镜重编码也没成。\n"
2268
+ " 先逐段跑 ffprobe -show_streams 找坏段/缺流;concat.txt 保留在:%s\n"
2269
+ " ffmpeg: %s" % (len(parts), lst, _stderr_brief(proc.stderr)))
2270
+ return str(out)
2271
+
2272
+
1430
2273
  def concat_segments(parts, out, workdir=None):
1431
- """分段无损拼接(各段编码参数必须一致,否则 copy 拼接会失败或花屏)。"""
2274
+ """分段拼接:参数一致走 -c copy(快、无损),不一致或 copy 失败自动降级重编码。
2275
+
2276
+ 向后兼容:签名和返回值不变(返回 str(out))。降级发生时打印 [concat] 诊断,
2277
+ 并把 mode/issues/reencoded 记到 concat_last_state(),供调用方和测试判断。
2278
+ """
2279
+ parts = [str(p) for p in parts]
2280
+ if not parts:
2281
+ raise RuntimeError("concat_segments: parts 为空(没有可拼接的片段)")
2282
+ _LAST_CONCAT.clear() # 失败时别让上一次的 mode 误导调用方
1432
2283
  lst = os.path.join(workdir or os.path.dirname(str(out)), "concat.txt")
1433
2284
  d = os.path.dirname(os.path.abspath(lst))
1434
2285
  if d:
@@ -1436,19 +2287,38 @@ def concat_segments(parts, out, workdir=None):
1436
2287
  # 必须写绝对路径:concat 文件里的相对路径按 ffmpeg 的 cwd 解析,
1437
2288
  # 而不是按 concat.txt 所在目录(实测会拼成 work2/work2/... 打不开)。
1438
2289
  with open(lst, "w", encoding="utf-8") as f:
1439
- for p in parts:
1440
- ap = os.path.abspath(str(p))
1441
- f.write("file '%s'\n" % ap.replace("\\", "/"))
1442
- p = _run([FFMPEG, "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", lst,
1443
- "-c", "copy", str(out)])
1444
- if p.returncode != 0:
1445
- raise RuntimeError("concat 失败: " + p.stderr.decode("utf-8", "replace")[:600])
2290
+ for src in parts:
2291
+ f.write(_concat_quote(src))
2292
+ infos, issues, ref = _concat_issues(parts)
2293
+ base_state = dict(out=str(out), parts=len(parts), issues=list(issues), concat_txt=lst)
2294
+ if not issues:
2295
+ proc = _run([FFMPEG, "-y", "-v", "error", "-f", "concat", "-safe", "0",
2296
+ "-i", lst, "-c", "copy", str(out)])
2297
+ if proc.returncode == 0:
2298
+ _set_concat_state(mode="copy", reencoded=False, **base_state)
2299
+ return str(out)
2300
+ detail = _stderr_brief(proc.stderr)
2301
+ print("[concat] -c copy 失败(%s),降级为重编码拼接(libx264 crf20 + aac 192k)"
2302
+ % detail, flush=True)
2303
+ base_state = dict(base_state, copy_error=detail)
2304
+ else:
2305
+ print("[concat] 拼接前检查发现 %d 处参数不一致:" % len(issues), flush=True)
2306
+ for it in issues:
2307
+ print(" [concat] " + it, flush=True)
2308
+ summ = ";".join(issues[:3])
2309
+ if len(issues) > 3:
2310
+ summ += ";等 %d 处" % len(issues)
2311
+ print("[concat] 参数不一致(%s),已降级为重编码(libx264 crf20 + aac 192k)"
2312
+ % summ, flush=True)
2313
+ print(" [concat] concat.txt 保留供排查:%s" % lst, flush=True)
2314
+ _concat_reencode(parts, out, infos, ref, lst)
2315
+ _set_concat_state(mode="reencode", reencoded=True, **base_state)
1446
2316
  return str(out)
1447
2317
 
1448
2318
 
1449
2319
  # ---------------------------------------------------------------- 总质检
1450
2320
  def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None,
1451
- expected_subtitles=None, min_bitrate=0, notes=None):
2321
+ expected_subtitles=None, min_bitrate=0, notes=None, theme=None):
1452
2322
  """分档质检:errors 必须修,warnings 看情况。
1453
2323
 
1454
2324
  v0.2 修正:
@@ -1510,6 +2380,13 @@ def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None
1510
2380
  warns.append("首尾之外检测到 %d 段长黑帧" % nb)
1511
2381
  if detect_silence(path) > 0:
1512
2382
  warns.append("检测到长静音段(确认是否有意留白)")
2383
+ if isinstance(theme, dict):
2384
+ # 主题体检的结论带进报告:这样"深色主题必然贴空窗阈值"这种事实不是靠嘴说
2385
+ lmax = float(theme.get("qa", {}).get("luma_max", 0.045))
2386
+ warns_t = ["主题 %s:空窗阈值 luma_max=%.3f、闪烁阈值 %.1f"
2387
+ % (theme.get("label") or theme.get("id"), lmax,
2388
+ float(theme.get("qa", {}).get("flicker_thresh", 6.0)))]
2389
+ warns.extend(warns_t)
1513
2390
  quality = None
1514
2391
  if ref_img is not None or (ref_png and os.path.exists(ref_png)):
1515
2392
  q = encode_quality(path, ref_png, ref_sec, ref_img=ref_img)