free-short-video 5.1.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 (48) hide show
  1. package/.dockerignore +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +531 -0
  4. package/bin/cli.js +191 -0
  5. package/core/__init__.py +35 -0
  6. package/core/api/__init__.py +7 -0
  7. package/core/api/agnes_chat.py +294 -0
  8. package/core/api/agnes_image.py +256 -0
  9. package/core/api/agnes_models.py +84 -0
  10. package/core/api/agnes_video.py +535 -0
  11. package/core/api/error_collector.py +215 -0
  12. package/core/api/rate_limiter.py +142 -0
  13. package/core/artifacts.py +574 -0
  14. package/core/audio/__init__.py +9 -0
  15. package/core/audio/subtitle.py +1103 -0
  16. package/core/audio/tts.py +194 -0
  17. package/core/audio/voices.py +330 -0
  18. package/core/compositor/__init__.py +6 -0
  19. package/core/compositor/concatenator.py +653 -0
  20. package/core/compositor/processor.py +124 -0
  21. package/core/compositor/watermark.py +221 -0
  22. package/core/config.py +474 -0
  23. package/core/image_generator.py +8 -0
  24. package/core/path_security.py +58 -0
  25. package/core/pipeline.py +8 -0
  26. package/core/pipelines/__init__.py +466 -0
  27. package/core/pipelines/anchor_video.py +406 -0
  28. package/core/pipelines/creative_video.py +1968 -0
  29. package/core/pipelines/manuscript_video.py +540 -0
  30. package/core/pipelines/multi_scene.py +380 -0
  31. package/core/pipelines/poetry_video.py +504 -0
  32. package/core/pipelines/simple_video.py +143 -0
  33. package/core/screenwriter.py +1860 -0
  34. package/core/task_manager.py +211 -0
  35. package/core/video_generator.py +8 -0
  36. package/models/__init__.py +75 -0
  37. package/models/task.py +548 -0
  38. package/package.json +36 -0
  39. package/requirements.txt +13 -0
  40. package/resource/fonts/MicrosoftYaHeiNormal.ttc +0 -0
  41. package/resource/fonts/STHeitiMedium.ttc +0 -0
  42. package/server.py +2026 -0
  43. package/static/favicon.ico +0 -0
  44. package/static/icon.png +0 -0
  45. package/static/index.html +5277 -0
  46. package/utils/__init__.py +0 -0
  47. package/utils/image.py +36 -0
  48. package/utils/video.py +28 -0
@@ -0,0 +1,1103 @@
1
+ """core.audio.subtitle — SRT 字幕生成 + moviepy 叠加
2
+
3
+ 将 edge_tts SubMaker cues 转换为 SRT 格式,并通过 moviepy SubtitlesClip 叠加到视频。
4
+
5
+ v2.1: 支持细粒度字幕分割,避免 5 秒视频只有 1 条字幕的问题。
6
+ v3.0: 支持任意位置(四角/百分比/坐标)、逐场景精拆分、突出字幕时⻓加成。
7
+ """
8
+
9
+ import datetime
10
+ import itertools
11
+ import logging
12
+ import math
13
+ import os
14
+ import re as _re
15
+ from typing import List, Optional, Tuple
16
+
17
+ import srt
18
+ from moviepy import VideoFileClip, CompositeVideoClip
19
+ from moviepy.video.tools.subtitles import SubtitlesClip
20
+
21
+ from models.task import SubtitleStyle
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # ── 细粒度字幕分割参数 ──
26
+ # 每条字幕最大持续时长(秒)— v3.0 降至 1.8 以支持更细拆分
27
+ _MAX_SUB_DURATION = 1.8
28
+ # 每条字幕最大字符数(中文场景)— v3.0 降至 14 以支持更细拆分
29
+ _MAX_SUB_CHARS = 14
30
+ # 最少字数字幕阈值:如果词级 cues 太少(如只有 3 个 cues for 14s),
31
+ # 说明 edge_tts 本身提供的粒度已足够,不需要额外细化(避免空洞字幕)
32
+ _MIN_WORD_CUES_FOR_FINE = 6
33
+ # 突出字幕时长倍率
34
+ _PROMINENT_DURATION_MULTIPLIER = 1.4
35
+ # 突出检测:文本长度 ≤ 此值时视为"短句突出"
36
+ _PROMINENT_MAX_CHARS = 12
37
+ # 单段词级字幕的重叠缓冲(仅视觉缓冲,不再掩盖不同步)
38
+ _FINE_OVERLAP_SEC = 0.12
39
+
40
+
41
+ class SubtitleGenerator:
42
+ """字幕生成器:cues → SRT + moviepy 叠加。"""
43
+
44
+ @staticmethod
45
+ def _split_long_text(txt: str, max_chars_per_line: int = 14,
46
+ video_width: int = None, fontsize: int = None) -> str:
47
+ """将过长的字幕文本拆分为多行,避免单行溢出屏幕。
48
+
49
+ 对 CJK 文本按字符数拆分,对非 CJK 文本按单词边界拆分。最多拆为 2 行,
50
+ 并尽量让每行在目标视频宽度 / 字号下不超过一行(宽度感知)。
51
+
52
+ Args:
53
+ txt: 原始字幕文本
54
+ max_chars_per_line: 每行最大字符数(CJK)或单词数(非 CJK)的旧式预算;
55
+ 当提供 video_width + fontsize 时,将以宽度感知预算覆盖此值。
56
+ video_width: 视频宽度(用于估算每行可容纳字符数)
57
+ fontsize: 字幕字号(用于估算每行可容纳字符数)
58
+
59
+ Returns:
60
+ 可能含 \\n 的文本
61
+ """
62
+ if not txt or "\n" in txt:
63
+ return txt
64
+
65
+ has_cjk = any('\u4e00' <= ch <= '\u9fff' or '\u3400' <= ch <= '\u4dbf' for ch in txt)
66
+
67
+ # 宽度感知:根据视频宽度与字号计算每行可容纳字符数(参考中文短字幕规范)
68
+ if video_width and fontsize:
69
+ available_w = max(80, int(video_width) - 40)
70
+ if has_cjk:
71
+ per_line = max(8, available_w // fontsize)
72
+ else:
73
+ # 拉丁字符宽约为字号的 0.5 倍,留出安全系数 0.52
74
+ per_line = max(12, int(available_w / (fontsize * 0.52)))
75
+ else:
76
+ per_line = max_chars_per_line
77
+
78
+ if has_cjk:
79
+ if len(txt) <= per_line:
80
+ return txt
81
+ # 拆为 2 行,尽量等长,在中间附近找标点或自然断点
82
+ mid = len(txt) // 2
83
+ for offset in range(min(4, mid)):
84
+ for candidate in (mid + offset, mid - offset):
85
+ if 0 < candidate < len(txt) and txt[candidate - 1] in ',。、;!?,. ;!?':
86
+ return txt[:candidate] + "\n" + txt[candidate:]
87
+ return txt[:mid] + "\n" + txt[mid:]
88
+ else:
89
+ words = txt.split()
90
+ if len(txt) <= per_line:
91
+ return txt
92
+ # 按字符预算分两行(每行不超过 per_line 字符),优先在词边界断开
93
+ taken = []
94
+ cur = 0
95
+ for w in words:
96
+ add = len(w) + (1 if cur else 0)
97
+ if cur == 0 or cur + add <= per_line:
98
+ taken.append(w)
99
+ cur += add
100
+ else:
101
+ break
102
+ if not taken:
103
+ taken = [words[0]]
104
+ line1 = " ".join(taken)
105
+ line2 = " ".join(words[len(taken):])
106
+ if not line2:
107
+ return line1
108
+ return line1 + "\n" + line2
109
+
110
+ @staticmethod
111
+ def _chunk_text(text: str, max_entry: int, has_cjk: bool) -> List[str]:
112
+ """将一段字幕文本切分为若干 ≤ max_entry 字符的块(用于强制 ≤2 行)。
113
+
114
+ CJK 按字符贪心切分(尽量在标点处断开);非 CJK 按单词贪心切分。
115
+ """
116
+ text = text.strip()
117
+ if not text:
118
+ return []
119
+ if len(text) <= max_entry:
120
+ return [text]
121
+ if has_cjk:
122
+ chunks = []
123
+ cur = ""
124
+ for ch in text:
125
+ if len(cur) + 1 <= max_entry:
126
+ cur += ch
127
+ else:
128
+ chunks.append(cur)
129
+ cur = ch
130
+ if cur:
131
+ chunks.append(cur)
132
+ return chunks
133
+ words = text.split()
134
+ chunks = []
135
+ cur = ""
136
+ for w in words:
137
+ if not cur:
138
+ cur = w
139
+ elif len(cur) + 1 + len(w) <= max_entry:
140
+ cur += " " + w
141
+ else:
142
+ chunks.append(cur)
143
+ cur = w
144
+ if cur:
145
+ chunks.append(cur)
146
+ return chunks
147
+
148
+ @staticmethod
149
+ def enforce_max_lines(srt_content: str, max_lines: int = 2,
150
+ video_width: int = 768, fontsize: int = 42) -> str:
151
+ """确保每条字幕(SRT entry)渲染后不超过 max_lines 行,适配所有语言。
152
+
153
+ 参考中文短字幕规范:将过长的字幕块按目标视频宽度 / 字号估算的「每行字符数」
154
+ 上限切分为多个更短的块,并在原时间区间内按块长度比例重新分配时间。
155
+ 这样无论 CJK 还是拉丁 / 西里尔等文字体系,单条字幕都不会溢出成多行。
156
+
157
+ Args:
158
+ srt_content: 原始 SRT 文本
159
+ max_lines: 每条字幕允许的最大行数(默认 2)
160
+ video_width: 视频宽度(像素)
161
+ fontsize: 字幕字号(像素)
162
+
163
+ Returns:
164
+ 处理后的 SRT 文本(条目数可能增加,但每条均 ≤ max_lines 行)
165
+ """
166
+ import srt as _srt
167
+
168
+ if not srt_content or not srt_content.strip():
169
+ return srt_content
170
+ try:
171
+ subs = list(_srt.parse(srt_content))
172
+ except Exception:
173
+ return srt_content
174
+ if not subs:
175
+ return srt_content
176
+
177
+ available_w = max(80, int(video_width or 768) - 40)
178
+ fs = int(fontsize or 42)
179
+ new_subs = []
180
+
181
+ for sub in subs:
182
+ text = (sub.content or "").strip()
183
+ if not text:
184
+ new_subs.append(sub)
185
+ continue
186
+ has_cjk = any('\u4e00' <= ch <= '\u9fff' or '\u3400' <= ch <= '\u4dbf' for ch in text)
187
+ if has_cjk:
188
+ per_line = max(8, available_w // fs)
189
+ else:
190
+ per_line = max(12, int(available_w / (fs * 0.52)))
191
+ max_entry = per_line * max_lines
192
+
193
+ if len(text) <= max_entry:
194
+ new_subs.append(sub)
195
+ continue
196
+
197
+ chunks = SubtitleGenerator._chunk_text(text, max_entry, has_cjk)
198
+ if len(chunks) <= 1:
199
+ new_subs.append(sub)
200
+ continue
201
+
202
+ total = sum(len(c) for c in chunks) or 1
203
+ start = sub.start
204
+ end = sub.end
205
+ dur = (end - start).total_seconds()
206
+ cursor = start
207
+ for c in chunks:
208
+ frac = len(c) / total
209
+ c_dur = dur * frac
210
+ c_end = cursor + datetime.timedelta(seconds=c_dur)
211
+ new_subs.append(_srt.Subtitle(index=0, start=cursor, end=c_end, content=c))
212
+ cursor = c_end
213
+
214
+ for i, s in enumerate(new_subs, 1):
215
+ s.index = i
216
+ return _srt.compose(new_subs)
217
+
218
+ @staticmethod
219
+ def cue_to_srt_time(seconds: float) -> str:
220
+ """将秒数转换为 SRT 时间格式 HH:MM:SS,mmm。"""
221
+ h = int(seconds // 3600)
222
+ m = int((seconds % 3600) // 60)
223
+ s = int(seconds % 60)
224
+ ms = int((seconds % 1) * 1000)
225
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
226
+
227
+ @staticmethod
228
+ def _cue_total_seconds(td) -> float:
229
+ """将 timedelta 转为秒数(兼容 srt.Subtitle 的 start/end 字段)。"""
230
+ if isinstance(td, datetime.timedelta):
231
+ return td.total_seconds()
232
+ return float(td)
233
+
234
+ @staticmethod
235
+ def _generate_fine_srt_from_word_cues(
236
+ word_cues: list,
237
+ max_duration: float = _MAX_SUB_DURATION,
238
+ max_chars: int = _MAX_SUB_CHARS,
239
+ ) -> str:
240
+ """从词级 cues 生成细粒度 SRT。
241
+
242
+ 将 edge_tts SubMaker.cues(词级时间戳列表)分组为短字幕段落,
243
+ 每组不超过 max_duration 秒和 max_chars 字符,优先在较长停顿处断开。
244
+
245
+ Args:
246
+ word_cues: edge_tts SubMaker.cues 列表(srt.Subtitle 对象)
247
+ max_duration: 每条字幕最大持续时长(秒)
248
+ max_chars: 每条字幕最大字符数
249
+
250
+ Returns:
251
+ SRT 格式字符串
252
+ """
253
+ if not word_cues:
254
+ return ""
255
+
256
+ # 将 cues 转为 (start_s, end_s, text) 三元组
257
+ items = []
258
+ for cue in word_cues:
259
+ start_s = SubtitleGenerator._cue_total_seconds(cue.start)
260
+ end_s = SubtitleGenerator._cue_total_seconds(cue.end)
261
+ text = cue.content.strip()
262
+ if text:
263
+ items.append((start_s, end_s, text))
264
+
265
+ if not items:
266
+ return ""
267
+
268
+ # 复用统一分组逻辑(多段路径 generate_cue_aware_srt 也调用同一方法,保证一致性)
269
+ return SubtitleGenerator._group_items_to_srt(
270
+ items, max_duration=max_duration, max_chars=max_chars,
271
+ overlap_sec=_FINE_OVERLAP_SEC,
272
+ )
273
+
274
+ @staticmethod
275
+ def _group_items_to_srt(
276
+ items: List[Tuple[float, float, str]],
277
+ max_duration: float = _MAX_SUB_DURATION,
278
+ max_chars: int = _MAX_SUB_CHARS,
279
+ overlap_sec: float = 0.12,
280
+ ) -> str:
281
+ """将 [(start_s, end_s, text), ...] 贪心分组为可读字幕段(SRT 字符串)。
282
+
283
+ 从 _generate_fine_srt_from_word_cues 抽出,单段/多段共用。
284
+ 分组约束:≤ max_duration 秒、≤ max_chars 字符、词间停顿 >0.4s 断句。
285
+ 保留尾部合并与突出时长加成逻辑。
286
+ """
287
+ if not items:
288
+ return ""
289
+
290
+ # 计算词间停顿(gap),用于决定在哪里断开字幕组
291
+ gaps = []
292
+ for i in range(1, len(items)):
293
+ gap = items[i][0] - items[i - 1][1]
294
+ gaps.append(max(gap, 0.0))
295
+
296
+ # 贪心分组:按 max_duration 和 max_chars 约束
297
+ groups = []
298
+ group_start_s = items[0][0]
299
+ group_end_s = items[0][1]
300
+ group_text_parts = [items[0][2]]
301
+ group_chars = len(items[0][2])
302
+
303
+ for i in range(1, len(items)):
304
+ s_s, e_s, txt = items[i]
305
+ gap = gaps[i - 1]
306
+
307
+ prospective_dur = e_s - group_start_s
308
+ prospective_chars = group_chars + len(txt)
309
+
310
+ # 决定是否断开:满足任一条件则断开
311
+ # 1. 持续时长超限
312
+ # 2. 字符数超限
313
+ # 3. 前一个词之间有较大停顿(>0.4s),且当前组已积累了一些内容
314
+ should_break = (
315
+ prospective_dur > max_duration
316
+ or prospective_chars > max_chars
317
+ or (gap > 0.4 and group_chars > 4 and len(items) > 8)
318
+ )
319
+
320
+ if should_break and group_text_parts:
321
+ groups.append((group_start_s, group_end_s, "".join(group_text_parts)))
322
+ group_start_s = s_s
323
+ group_end_s = e_s
324
+ group_text_parts = [txt]
325
+ group_chars = len(txt)
326
+ else:
327
+ group_end_s = e_s
328
+ group_text_parts.append(txt)
329
+ group_chars += len(txt)
330
+
331
+ # 最后剩余组
332
+ if group_text_parts:
333
+ groups.append((group_start_s, group_end_s, "".join(group_text_parts)))
334
+
335
+ # 后处理:合并过短的尾部组
336
+ while len(groups) >= 2:
337
+ last_dur = groups[-1][1] - groups[-1][0]
338
+ last_chars = len(groups[-1][2])
339
+ prev_dur = groups[-2][1] - groups[-2][0]
340
+ prev_chars = len(groups[-2][2])
341
+ merged_dur = groups[-1][1] - groups[-2][0]
342
+ merged_chars = prev_chars + last_chars
343
+ if (last_dur < 0.8
344
+ and merged_dur <= max_duration * 1.2
345
+ and merged_chars <= max_chars * 1.5):
346
+ merged_start = groups[-2][0]
347
+ merged_end = groups[-1][1]
348
+ merged_text = groups[-2][2] + groups[-1][2]
349
+ groups[-2] = (merged_start, merged_end, merged_text)
350
+ groups.pop()
351
+ else:
352
+ break
353
+
354
+ # 突出时长加成
355
+ if groups:
356
+ for gi, (s_s, e_s, txt) in enumerate(groups):
357
+ multiplier = SubtitleGenerator._detect_prominence(txt)
358
+ if multiplier > 1.0:
359
+ new_dur = (e_s - s_s) * multiplier
360
+ e_s = s_s + new_dur
361
+ if gi + 1 < len(groups):
362
+ e_s = min(e_s, groups[gi + 1][1])
363
+ groups[gi] = (s_s, e_s, txt)
364
+
365
+ # 前后段重叠:每条字幕结束时间向后延伸 overlap_sec(仅视觉缓冲)
366
+ for gi in range(len(groups) - 1):
367
+ s_s, e_s, txt = groups[gi]
368
+ next_e = groups[gi + 1][1]
369
+ new_e = min(e_s + overlap_sec, next_e)
370
+ if new_e > e_s:
371
+ groups[gi] = (s_s, new_e, txt)
372
+
373
+ entries = []
374
+ for idx, (s_s, e_s, txt) in enumerate(groups, 1):
375
+ if e_s - s_s < 0.3:
376
+ e_s = s_s + 0.3
377
+
378
+ start_time = SubtitleGenerator.cue_to_srt_time(s_s)
379
+ end_time = SubtitleGenerator.cue_to_srt_time(e_s)
380
+ entries.append(f"{idx}\n{start_time} --> {end_time}\n{txt}\n")
381
+
382
+ return "\n".join(entries)
383
+
384
+ @staticmethod
385
+ def _detect_prominence(text: str) -> float:
386
+ """检测字幕文本是否"突出",返回时长倍率(≥1.0)。
387
+
388
+ 突出规则:
389
+ - 短句(≤ _PROMINENT_MAX_CHARS 字)且以!??! 结尾 → 1.5x
390
+ - 短句(≤ _PROMINENT_MAX_CHARS 字)→ 1.3x
391
+ - 包含"注意、重要、关键、突然、竟然、原来"等关键词 → 1.3x
392
+ - 其他 → 1.0x(正常)
393
+ """
394
+ t = text.strip()
395
+ if not t:
396
+ return 1.0
397
+ low = t.lower()
398
+ key_words = {"注意", "重要", "关键", "突然", "竟然", "原来",
399
+ "attention", "important", "suddenly", "finally", "warning"}
400
+ if len(t) <= _PROMINENT_MAX_CHARS:
401
+ if t[-1] in "!?!?":
402
+ return 1.5
403
+ return 1.3
404
+ if any(kw in low for kw in key_words):
405
+ return 1.3
406
+ return 1.0
407
+
408
+ @staticmethod
409
+ def _generate_scene_aware_srt(
410
+ scene_texts: List[str],
411
+ scene_durations: List[float],
412
+ word_cues: object = None,
413
+ max_chars_per_group: int = _MAX_SUB_CHARS,
414
+ scene_start_times: Optional[List[float]] = None,
415
+ overlap_sec: float = 0.12,
416
+ ) -> str:
417
+ """为每个场景/段落生成细粒度 SRT,支持场景内再拆分为子段。
418
+
419
+ 策略(无需 TTS cues 也能工作):
420
+ 1. 每个场景的文本按句子(。!?.!?)拆分为候选句
421
+ 2. 在场景时长内均匀分布各句
422
+ 3. 检测突出文本并赋予更长显示时间
423
+ 4. 合并为全量 SRT(偏移到场景在时间轴上的位置)
424
+
425
+ Args:
426
+ scene_texts: 每个场景的旁白文本列表。
427
+ scene_durations: 每个场景的时长(秒)。
428
+ word_cues: 可选 TTS SubMaker cues,如有则从中推导精确时间。
429
+ max_chars_per_group: 每组最大字符数。
430
+ scene_start_times: 每个场景在时间轴上的起始时间(秒)。
431
+ 若未提供则按 scene_durations 累积计算。
432
+ overlap_sec: 前后段字幕重叠时长,同时展示多条降低音画不同步影响。
433
+
434
+ Returns:
435
+ SRT 格式字符串。
436
+ """
437
+ if not scene_texts or not scene_durations:
438
+ return ""
439
+
440
+ if scene_start_times is None:
441
+ scene_start_times = []
442
+ acc = 0.0
443
+ for d in scene_durations:
444
+ scene_start_times.append(acc)
445
+ acc += d
446
+
447
+ entries = []
448
+ global_idx = 1
449
+
450
+ for si, text in enumerate(scene_texts):
451
+ if not text.strip():
452
+ continue
453
+ scene_dur = scene_durations[si]
454
+ scene_start = scene_start_times[si]
455
+ scene_end = scene_start + scene_dur
456
+
457
+ # 按句子拆分
458
+ sentences = [s.strip() for s in _re.split(r'(?<=[。!?.!?])', text) if s.strip()]
459
+ if not sentences:
460
+ sentences = [text.strip()]
461
+ if not sentences:
462
+ continue
463
+
464
+ # 进一步将长句拆为子段(按逗号/分号)
465
+ all_segments = []
466
+ for sent in sentences:
467
+ if len(sent) > max_chars_per_group:
468
+ sub_parts = _re.split(r'(?<=[,、;,;])', sent)
469
+ temp = ""
470
+ for part in sub_parts:
471
+ if not part.strip():
472
+ continue
473
+ if not temp or len(temp) + len(part) <= max_chars_per_group:
474
+ temp += part
475
+ else:
476
+ if temp.strip():
477
+ all_segments.append(temp.strip())
478
+ temp = part
479
+ if temp.strip():
480
+ all_segments.append(temp.strip())
481
+ else:
482
+ all_segments.append(sent.strip())
483
+
484
+ if not all_segments:
485
+ continue
486
+
487
+ # 在场景时长内均匀分配各子段
488
+ seg_count = len(all_segments)
489
+ # 保留 10% padding 让最后一段有呼吸感
490
+ usable_dur = scene_dur * 0.9
491
+ base_dur = usable_dur / seg_count
492
+
493
+ # Pass 1: 计算各段起始/结束时间(无重叠)
494
+ raw_segments: list[tuple[float, float, str]] = []
495
+ current_time = scene_start
496
+ for idx, seg in enumerate(all_segments):
497
+ seg_dur = base_dur
498
+
499
+ mult = SubtitleGenerator._detect_prominence(seg)
500
+ if mult > 1.0 and idx > 0:
501
+ borrowed = seg_dur * 0.2
502
+ seg_dur += borrowed
503
+ elif mult > 1.0:
504
+ seg_dur *= mult
505
+
506
+ seg_start = current_time
507
+ seg_end = min(seg_start + seg_dur, scene_end - 0.05)
508
+ if seg_end <= seg_start:
509
+ seg_end = seg_start + 0.3
510
+
511
+ raw_segments.append((seg_start, seg_end, seg))
512
+ current_time = seg_end + 0.05
513
+
514
+ # Pass 2: 前后段重叠 — 每条字幕结束时间向后延伸 overlap_sec
515
+ # 使用 next 段的 end 而非 start 作为上限,确保可见重叠
516
+ for si in range(len(raw_segments) - 1):
517
+ s_s, e_s, txt = raw_segments[si]
518
+ next_e = raw_segments[si + 1][1]
519
+ new_e = min(e_s + overlap_sec, next_e)
520
+ if new_e > e_s:
521
+ raw_segments[si] = (s_s, new_e, txt)
522
+
523
+ for seg_start, seg_end, seg in raw_segments:
524
+ start_srt = SubtitleGenerator.cue_to_srt_time(seg_start)
525
+ end_srt = SubtitleGenerator.cue_to_srt_time(seg_end)
526
+ entries.append(f"{global_idx}\n{start_srt} --> {end_srt}\n{seg}\n")
527
+ global_idx += 1
528
+
529
+ return "\n".join(entries)
530
+
531
+ @staticmethod
532
+ def _scene_char_ranges(segment_texts: List[str]) -> "Optional[list]":
533
+ """计算各场景在「归一化整段文本」中的字符区间 [c_start, c_end)。
534
+
535
+ 归一化:去除空白与所有非「单词字符/中日韩汉字」的符号,仅保留读音文本,
536
+ 使 cues 的累计归一化字符位置能与场景文本对齐(免疫 TTS 插入的停顿/标点漂移)。
537
+
538
+ Returns:
539
+ 每场景的 (c_start, c_end) 列表;segment_texts 为空时返回 None。
540
+ """
541
+ if not segment_texts:
542
+ return None
543
+ norm_len = lambda x: len(_re.sub(r"\s+|[^\w\u4e00-\u9fff]", "", x))
544
+ ranges = []
545
+ cum = 0
546
+ for t in segment_texts:
547
+ n = norm_len(t)
548
+ if n == 0:
549
+ ranges.append((cum, cum))
550
+ else:
551
+ ranges.append((cum, cum + n))
552
+ cum += n
553
+ return ranges
554
+
555
+ @staticmethod
556
+ def generate_cue_aware_srt(
557
+ word_cues: object,
558
+ segment_texts: List[str],
559
+ scene_start_times: "Optional[list]" = None,
560
+ scene_durations: "Optional[list]" = None,
561
+ max_duration: float = _MAX_SUB_DURATION,
562
+ max_chars: int = _MAX_SUB_CHARS,
563
+ overlap_sec: float = 0.12,
564
+ audio_duration: "Optional[float]" = None,
565
+ ) -> str:
566
+ """基于 edge_tts 词级 cues 的精确时间线对齐字幕生成(替代 _generate_scene_aware_srt)。
567
+
568
+ 原理:cues 是合成音频的源头时间线(音频从 t=0 连续播放,最终成片时间轴 =
569
+ 音频时间轴),因此每个 cue 的时间即其在成片中的显示时间,无需额外偏移。
570
+ 本方法仅负责「按场景归属 + 段内分组」,使字幕断句与场景切换对齐,且每句
571
+ 时间戳贴合真实朗读位置。
572
+
573
+ 场景归属两种策略:
574
+ - 策略 A(默认,文本锚定):按各场景归一化字符区间归属 cues,免疫 TTS 在
575
+ 场景间插入的停顿漂移。
576
+ - 策略 B(兜底,时间区间):当 segment_texts 无法还原整段文本时,用
577
+ scene_start_times / scene_durations 累加得到的时间区间归属。
578
+
579
+ Args:
580
+ word_cues: edge_tts SubMaker(含 .cues)。
581
+ segment_texts: 各场景旁白文本(有序)。
582
+ scene_start_times: 各场景在成片时间轴的起始秒(策略 B 用)。
583
+ scene_durations: 各场景时长(策略 B 用,未给 scene_start_times 时累加)。
584
+ max_duration / max_chars: 段内分组约束。
585
+ overlap_sec: 段间重叠缓冲(仅视觉)。
586
+ audio_duration: 实际音频时长,用于把最后一条 cue 钳制到音频尾部(残余归一化)。
587
+
588
+ Returns:
589
+ SRT 字符串;cues 不足时返回空串,由调用方回退 legacy。
590
+ """
591
+ raw_cues = getattr(word_cues, "cues", None) or []
592
+ if not raw_cues:
593
+ return ""
594
+
595
+ # 1) cues → (start, end, text) 三元组(音频时间轴)
596
+ items = []
597
+ for cue in raw_cues:
598
+ s = SubtitleGenerator._cue_total_seconds(cue.start)
599
+ e = SubtitleGenerator._cue_total_seconds(cue.end)
600
+ t = (cue.content or "").strip()
601
+ if t:
602
+ items.append((s, e, t))
603
+ if not items:
604
+ return ""
605
+
606
+ # 残余归一化:把最后一条 cue 的 end 钳到实际音频时长(避免尾差留白未覆盖)
607
+ if audio_duration and audio_duration > 0 and items[-1][1] > audio_duration:
608
+ items[-1] = (items[-1][0], audio_duration, items[-1][2])
609
+
610
+ n = len(segment_texts)
611
+ if n == 0:
612
+ return ""
613
+
614
+ # 2) 场景归属
615
+ scene_char_ranges = SubtitleGenerator._scene_char_ranges(segment_texts)
616
+ if scene_char_ranges is not None:
617
+ # 策略 A:文本锚定。累计归一化字符位置,定位每个 cue 所属场景。
618
+ norm_len = lambda x: len(_re.sub(r"\s+|[^\w\u4e00-\u9fff]", "", x))
619
+ cue_char_pos = []
620
+ run = 0
621
+ for _, _, t in items:
622
+ run += norm_len(t)
623
+ cue_char_pos.append(run)
624
+ scene_cue_idx = [[] for _ in range(n)]
625
+ for ci, cp in enumerate(cue_char_pos):
626
+ for si in range(n):
627
+ c0, c1 = scene_char_ranges[si]
628
+ if c0 <= cp <= c1:
629
+ scene_cue_idx[si].append(ci)
630
+ break
631
+ else:
632
+ # 落在场景间隙(理论极少):归属到累计位置最近的场景
633
+ best = 0
634
+ best_diff = abs(scene_char_ranges[0][1] - cp)
635
+ for si in range(n):
636
+ diff = abs((scene_char_ranges[si][0] + scene_char_ranges[si][1]) / 2 - cp)
637
+ if diff < best_diff:
638
+ best_diff = diff
639
+ best = si
640
+ scene_cue_idx[best].append(ci)
641
+ else:
642
+ # 策略 B:时间区间兜底
643
+ if scene_start_times is not None:
644
+ starts = list(scene_start_times)
645
+ elif scene_durations is not None:
646
+ starts = list(itertools.accumulate(scene_durations, initial=0.0))[:-1]
647
+ else:
648
+ starts = [0.0] * n
649
+ scene_cue_idx = [[] for _ in range(n)]
650
+ for ci, (s, _, _) in enumerate(items):
651
+ placed = False
652
+ for si in range(n):
653
+ s0 = starts[si]
654
+ s1 = starts[si + 1] if si + 1 < len(starts) else items[-1][1] + 1.0
655
+ if s0 <= s < s1:
656
+ scene_cue_idx[si].append(ci)
657
+ placed = True
658
+ break
659
+ if not placed:
660
+ scene_cue_idx[-1].append(ci)
661
+
662
+ # 3) 逐场景:取 cues 子集(保持时间序)→ 段内分组 → 直接沿用 cues 时间(offset=0)
663
+ entries = []
664
+ global_idx = 1
665
+ for si in range(n):
666
+ idxs = scene_cue_idx[si]
667
+ if not idxs:
668
+ continue
669
+ scene_items = [items[ci] for ci in idxs]
670
+ local_srt = SubtitleGenerator._group_items_to_srt(
671
+ scene_items, max_duration=max_duration, max_chars=max_chars,
672
+ overlap_sec=overlap_sec,
673
+ )
674
+ try:
675
+ subs = list(srt.parse(local_srt))
676
+ except Exception:
677
+ subs = []
678
+ for sub in subs:
679
+ entries.append((global_idx, sub.start, sub.end, sub.content))
680
+ global_idx += 1
681
+
682
+ # 残余归一化:把所有字幕时间钳到实际音频时长内,
683
+ # 覆盖「突出时长加成」可能把末句 end 拉出音频尾部的情况
684
+ if audio_duration and audio_duration > 0:
685
+ ad = datetime.timedelta(seconds=audio_duration)
686
+ clamped = []
687
+ for idx, s, e, t in entries:
688
+ s = min(s, ad)
689
+ e = min(e, ad)
690
+ if e <= s:
691
+ e = s + datetime.timedelta(seconds=0.3)
692
+ clamped.append((idx, s, e, t))
693
+ entries = clamped
694
+
695
+ entries.sort(key=lambda x: x[1])
696
+ return "\n".join(
697
+ f"{idx}\n{SubtitleGenerator.cue_to_srt_time(s.total_seconds())} --> "
698
+ f"{SubtitleGenerator.cue_to_srt_time(e.total_seconds())}\n{t}\n"
699
+ for idx, s, e, t in entries
700
+ )
701
+
702
+ @staticmethod
703
+ def cues_to_srt(cues, output_path: str) -> str:
704
+ """将 edge_tts SubMaker cues 转换为 SRT 文件。
705
+
706
+ 优先使用词级 cues(edge_tts 7.x 的 SubMaker.cues)进行细粒度分割,
707
+ 确保每 2-3 秒至少有一条字幕,避免出现 5 秒视频只有 1 条字幕的问题。
708
+
709
+ 对于 edge_tts 6.x,回退到 WebVTT 解析方式。
710
+
711
+ Args:
712
+ cues: edge_tts SubMaker 实例或空 dict
713
+ output_path: SRT 文件输出路径
714
+
715
+ Returns:
716
+ SRT 文件路径
717
+ """
718
+ logger.info(f"[Subtitle] Converting cues to SRT: {output_path}")
719
+
720
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
721
+
722
+ srt_content = ""
723
+ subtitles_count = 0
724
+ used_fine_grained = False
725
+
726
+ # ── 策略 1: 使用词级 cues 做细粒度 SRT(推荐)──
727
+ # edge_tts 7.x 的 SubMaker.cues 包含 WordBoundary 词级时间戳
728
+ raw_word_cues = getattr(cues, "cues", None)
729
+ if raw_word_cues and isinstance(raw_word_cues, list) and len(raw_word_cues) >= _MIN_WORD_CUES_FOR_FINE:
730
+ try:
731
+ srt_content = SubtitleGenerator._generate_fine_srt_from_word_cues(raw_word_cues)
732
+ if srt_content.strip():
733
+ subtitles_count = srt_content.count("\n\n") + 1 if "\n\n" in srt_content else (
734
+ 1 if srt_content.strip() else 0
735
+ )
736
+ used_fine_grained = True
737
+ logger.info(f"[Subtitle] Fine-grained SRT generated from {len(raw_word_cues)} word cues")
738
+ except Exception as e:
739
+ logger.warning(f"[Subtitle] Fine-grained SRT generation failed: {e}, falling back")
740
+
741
+ # ── 策略 2: 回退到 edge_tts 默认 SRT 生成 ──
742
+ if not srt_content.strip():
743
+ try:
744
+ if hasattr(cues, "get_srt"):
745
+ srt_content = cues.get_srt()
746
+ subtitles_count = srt_content.count("\n\n") + 1 if srt_content.strip() else 0
747
+ elif hasattr(cues, "generate_subs"):
748
+ vtt_content = cues.generate_subs()
749
+ subtitles = SubtitleGenerator._parse_vtt_to_srt(vtt_content)
750
+ srt_content = srt.compose(subtitles)
751
+ subtitles_count = len(subtitles)
752
+ else:
753
+ subtitles_count = 0
754
+ except Exception as e:
755
+ # edge_tts 7.x + 某些 srt 库版本的 Subtitle 对象结构不兼容
756
+ # (proprietary 字段冲突),回退到手动从 raw_cues 构造 SRT
757
+ logger.warning(f"[Subtitle] Default SRT generation failed: {e}, "
758
+ f"falling back to raw cues")
759
+ if raw_word_cues and isinstance(raw_word_cues, list) and len(raw_word_cues) > 0:
760
+ try:
761
+ srt_content = SubtitleGenerator._generate_fine_srt_from_word_cues(
762
+ raw_word_cues,
763
+ max_duration=10.0, # 放宽限制,因为这是最后的手段
764
+ max_chars=60,
765
+ )
766
+ if srt_content.strip():
767
+ subtitles_count = srt_content.count("\n\n") + 1 if "\n\n" in srt_content else 1
768
+ logger.info(f"[Subtitle] Fallback SRT from raw cues: {subtitles_count} entries")
769
+ except Exception as e2:
770
+ logger.error(f"[Subtitle] Raw cues fallback also failed: {e2}")
771
+ subtitles_count = 0
772
+ else:
773
+ subtitles_count = 0
774
+
775
+ with open(output_path, "w", encoding="utf-8") as f:
776
+ f.write(srt_content)
777
+
778
+ method_tag = "fine-grained" if used_fine_grained else "default"
779
+ logger.info(f"[Subtitle] SRT saved: {output_path} ({subtitles_count} entries, {method_tag})")
780
+ return output_path
781
+
782
+ @staticmethod
783
+ def text_to_srt(text: str, output_path: str, duration_sec: float, chars_per_sec: float = 4.0) -> str:
784
+ """从纯文本生成 SRT(不依赖 TTS SubMaker cues)。
785
+
786
+ 当旁白关闭但字幕开启时使用。文本时长由字符数估算,
787
+ 字幕按固定间隔均匀分布。
788
+
789
+ Args:
790
+ text: 纯文本内容
791
+ output_path: SRT 文件输出路径
792
+ duration_sec: 总时长(秒)
793
+ chars_per_sec: 朗读速度(字符/秒),默认 4.0
794
+
795
+ Returns:
796
+ SRT 文件路径
797
+ """
798
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
799
+
800
+ if not text.strip() or duration_sec <= 0:
801
+ with open(output_path, "w", encoding="utf-8") as f:
802
+ f.write("")
803
+ return output_path
804
+
805
+ # 按句号/问号/感叹号拆分句子
806
+ sentences = []
807
+ for part in _re.split(r'(?<=[。!?.!?])', text):
808
+ part = part.strip()
809
+ if part:
810
+ sentences.append(part)
811
+
812
+ if not sentences:
813
+ sentences = [text.strip()]
814
+
815
+ # 估算每个句子的时长
816
+ total_chars = len(text)
817
+ total_duration = max(duration_sec, 1.0)
818
+
819
+ entries = []
820
+ current_time = 0.0
821
+
822
+ for idx, sentence in enumerate(sentences):
823
+ sentence_duration = max(len(sentence) / chars_per_sec, 1.0)
824
+ # 均匀缩放使所有句子总时长匹配 duration_sec
825
+ sentence_duration = sentence_duration / (total_chars / chars_per_sec) * total_duration
826
+
827
+ start_s = current_time
828
+ end_s = min(start_s + sentence_duration, total_duration - 0.01)
829
+ if end_s <= start_s:
830
+ break
831
+
832
+ start_time = SubtitleGenerator.cue_to_srt_time(start_s)
833
+ end_time = SubtitleGenerator.cue_to_srt_time(end_s)
834
+ entries.append(f"{idx + 1}\n{start_time} --> {end_time}\n{sentence}\n")
835
+ current_time = end_s
836
+
837
+ srt_content = "\n".join(entries)
838
+
839
+ with open(output_path, "w", encoding="utf-8") as f:
840
+ f.write(srt_content)
841
+
842
+ logger.info(f"[Subtitle] text_to_srt: {output_path} ({len(entries)} entries, {duration_sec:.1f}s)")
843
+ return output_path
844
+
845
+ @staticmethod
846
+ def _parse_vtt_to_srt(vtt_content: str) -> list:
847
+ """解析 WebVTT 内容为 srt.Subtitle 列表。"""
848
+ subtitles = []
849
+ lines = vtt_content.strip().split("\n")
850
+ idx = 0
851
+
852
+ # 跳过 WEBVTT 头部
853
+ i = 0
854
+ while i < len(lines) and (lines[i].strip().startswith("WEBVTT") or lines[i].strip() == ""):
855
+ i += 1
856
+
857
+ while i < len(lines):
858
+ line = lines[i].strip()
859
+ if not line:
860
+ i += 1
861
+ continue
862
+
863
+ # 时间轴行:00:00:00.000 --> 00:00:02.500
864
+ if "-->" in line:
865
+ parts = line.split("-->")
866
+ if len(parts) == 2:
867
+ start_str = parts[0].strip().replace(".", ",")
868
+ end_str = parts[1].strip().replace(".", ",")
869
+
870
+ # 收集文本行
871
+ text_lines = []
872
+ i += 1
873
+ while i < len(lines) and lines[i].strip():
874
+ text_lines.append(lines[i].strip())
875
+ i += 1
876
+
877
+ text = " ".join(text_lines)
878
+ if text:
879
+ idx += 1
880
+ # 解析时间
881
+ start = SubtitleGenerator._parse_time(start_str)
882
+ end = SubtitleGenerator._parse_time(end_str)
883
+ subtitles.append(srt.Subtitle(index=idx, start=start, end=end, content=text))
884
+ continue
885
+ i += 1
886
+
887
+ return subtitles
888
+
889
+ @staticmethod
890
+ def _parse_time(time_str: str) -> "datetime.timedelta":
891
+ """解析 SRT/VTT 时间字符串为 timedelta。"""
892
+ import datetime
893
+
894
+ time_str = time_str.strip()
895
+ # 支持 HH:MM:SS,mmm 或 HH:MM:SS.mmm 或 MM:SS.mmm 格式
896
+ if "," in time_str:
897
+ time_str = time_str.replace(",", ".")
898
+ parts = time_str.split(":")
899
+ if len(parts) == 3:
900
+ h, m, s = parts
901
+ total_seconds = int(h) * 3600 + int(m) * 60 + float(s)
902
+ elif len(parts) == 2:
903
+ m, s = parts
904
+ total_seconds = int(m) * 60 + float(s)
905
+ else:
906
+ total_seconds = float(parts[0])
907
+
908
+ return datetime.timedelta(seconds=total_seconds)
909
+
910
+ @staticmethod
911
+ def resolve_position(
912
+ pos,
913
+ video_width: int,
914
+ video_height: int,
915
+ safe_margin_x: int = 40,
916
+ safe_margin_y: int = 80,
917
+ ) -> tuple:
918
+ """将各种格式的字幕位置解析为 moviepy (h, v) 坐标。
919
+
920
+ 支持格式:
921
+ - 标准: ("center", "bottom"), ("left", "top"), ("right", "center")
922
+ - 偏移: ("center", "bottom-80"), ("left+20", "top+10"), ("right-30", "bottom-50")
923
+ - 百分比: ("50%", "30%") — 表示水平 50%, 垂直 30%
924
+ - 像素坐标: ("center", 200) — 垂直 200px
925
+ - 四角: "top-left", "top-right", "bottom-left", "bottom-right"
926
+ - 纯字符串: "center", "top", "bottom", "top-left" 等
927
+
928
+ Args:
929
+ safe_margin_x: 水平方向像素边界留白,防止大字号字幕溢出。
930
+ safe_margin_y: 垂直方向像素边界留白。
931
+ """
932
+ default = ("center", "bottom")
933
+
934
+ # ── 字符串格式(如 "top-left", "center")──
935
+ if isinstance(pos, str):
936
+ p = pos.strip().lower()
937
+ corner_map = {
938
+ "top-left": ("left", "top"), "top-right": ("right", "top"),
939
+ "bottom-left": ("left", "bottom"), "bottom-right": ("right", "bottom"),
940
+ "center": ("center", "center"), "middle": ("center", "center"),
941
+ "top": ("center", "top"), "bottom": ("center", "bottom"),
942
+ "left": ("left", "center"), "right": ("right", "center"),
943
+ }
944
+ if p in corner_map:
945
+ return corner_map[p]
946
+ # 尝试解析 "bottom-80" 纯字符串
947
+ m_bot = _re.match(r'^bottom\s*[-–]\s*(\d+)$', p)
948
+ if m_bot and video_height > 0:
949
+ offset = int(m_bot.group(1))
950
+ return ("center", max(video_height - offset, 0))
951
+ m_top = _re.match(r'^top\s*\+\s*(\d+)$', p)
952
+ if m_top:
953
+ offset = int(m_top.group(1))
954
+ return ("center", offset)
955
+ return default
956
+
957
+ # ── 二元组 ──
958
+ if not isinstance(pos, (list, tuple)) or len(pos) != 2:
959
+ return default
960
+
961
+ h_raw, v_raw = pos[0], pos[1]
962
+
963
+ # 解析水平位置
964
+ def resolve_h(h_val) -> str:
965
+ if isinstance(h_val, (int, float)):
966
+ return h_val
967
+ hs = str(h_val).strip().lower()
968
+ if hs.endswith("%"):
969
+ pct = float(hs.replace("%", ""))
970
+ return int(video_width * pct / 100)
971
+ # left+N / right-N
972
+ m_l = _re.match(r'^left\s*\+\s*(\d+)$', hs)
973
+ if m_l:
974
+ return int(m_l.group(1))
975
+ m_r = _re.match(r'^right\s*[-–]\s*(\d+)$', hs)
976
+ if m_r:
977
+ return max(video_width - int(m_r.group(1)), 0)
978
+ if hs in ("left", "right", "center"):
979
+ return hs
980
+ return "center"
981
+
982
+ def resolve_v(v_val) -> str:
983
+ if isinstance(v_val, (int, float)):
984
+ return v_val
985
+ vs = str(v_val).strip().lower()
986
+ if vs.endswith("%"):
987
+ pct = float(vs.replace("%", ""))
988
+ return int(video_height * pct / 100)
989
+ m_bot = _re.match(r'^bottom\s*[-–]\s*(\d+)$', vs)
990
+ if m_bot and video_height > 0:
991
+ offset = int(m_bot.group(1))
992
+ return max(video_height - offset, 0)
993
+ m_top = _re.match(r'^top\s*\+\s*(\d+)$', vs)
994
+ if m_top:
995
+ return int(m_top.group(1))
996
+ if vs in ("top", "bottom", "center"):
997
+ return vs
998
+ return "bottom"
999
+
1000
+ h_resolved = resolve_h(h_raw)
1001
+ v_resolved = resolve_v(v_raw)
1002
+
1003
+ # safe-margin clamping for pixel positions
1004
+ if isinstance(h_resolved, (int, float)):
1005
+ h_resolved = max(safe_margin_x, min(h_resolved, video_width - safe_margin_x))
1006
+ if isinstance(v_resolved, (int, float)):
1007
+ v_resolved = max(safe_margin_y, min(v_resolved, video_height - safe_margin_y))
1008
+
1009
+ return (h_resolved, v_resolved)
1010
+
1011
+ @staticmethod
1012
+ def overlay_subtitles_to_video(
1013
+ video_path: str,
1014
+ srt_path: str,
1015
+ style: SubtitleStyle,
1016
+ output_path: str,
1017
+ ) -> str:
1018
+ """将 SRT 字幕叠加到视频文件。
1019
+
1020
+ Args:
1021
+ video_path: 输入视频路径
1022
+ srt_path: SRT 字幕文件路径
1023
+ style: SubtitleStyle 字幕样式配置
1024
+ output_path: 输出视频路径
1025
+
1026
+ Returns:
1027
+ 输出视频路径
1028
+ """
1029
+ logger.info(f"[Subtitle] Overlaying subtitles: {video_path} + {srt_path} → {output_path}")
1030
+
1031
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
1032
+
1033
+ try:
1034
+ video_clip = VideoFileClip(video_path)
1035
+
1036
+ # 解析字体路径
1037
+ from core.config import resolve_font_path
1038
+ font_path = resolve_font_path(style.font)
1039
+
1040
+ # 兼容旧格式 bg_color 字符串(如 "black@0.5")
1041
+ bg = style.bg_color
1042
+ if isinstance(bg, str):
1043
+ if "@" in bg:
1044
+ parts = bg.split("@", 1)
1045
+ rgb = {"black": (0, 0, 0), "white": (255, 255, 255)}.get(parts[0].strip().lower(), (0, 0, 0))
1046
+ bg = (*rgb, int(float(parts[1]) * 255))
1047
+ else:
1048
+ bg = (0, 0, 0, 128)
1049
+
1050
+ # 根据视频宽度动态计算每行最大字符数
1051
+ available_w = video_clip.w - 40
1052
+ # 粗略估算:CJK 字符宽 ≈ fontsize,latin 字符宽 ≈ fontsize * 0.5
1053
+ cjk_max_chars = max(8, available_w // style.fontsize)
1054
+
1055
+ # moviepy 的 SubtitlesClip 读取 SRT 文件
1056
+ def make_text_clip(txt):
1057
+ from moviepy import TextClip
1058
+ # 长文本自动拆为多行(宽度感知:参考中文短字幕规范,适配所有语言)
1059
+ wrapped = SubtitleGenerator._split_long_text(
1060
+ txt, cjk_max_chars,
1061
+ video_width=video_clip.w, fontsize=style.fontsize,
1062
+ )
1063
+ return TextClip(
1064
+ text=wrapped,
1065
+ font=font_path,
1066
+ font_size=style.fontsize,
1067
+ color=style.color,
1068
+ stroke_color=style.stroke_color,
1069
+ stroke_width=style.stroke_width,
1070
+ bg_color=bg,
1071
+ method="caption",
1072
+ size=(available_w, None),
1073
+ text_align="center",
1074
+ )
1075
+
1076
+ subtitles_clip = SubtitlesClip(srt_path, make_textclip=make_text_clip)
1077
+
1078
+ # 使用新的解析器支持任意位置
1079
+ position = SubtitleGenerator.resolve_position(
1080
+ style.position, video_clip.w, video_clip.h
1081
+ )
1082
+
1083
+ final = CompositeVideoClip([video_clip, subtitles_clip.with_position(position)])
1084
+ final.write_videofile(
1085
+ output_path,
1086
+ codec="libx264",
1087
+ audio_codec="aac",
1088
+ audio_bitrate="192k",
1089
+ audio_fps=44100,
1090
+ fps=30,
1091
+ logger="bar",
1092
+ )
1093
+
1094
+ video_clip.close()
1095
+ final.close()
1096
+
1097
+ logger.info(f"[Subtitle] Overlay complete: {output_path}")
1098
+ return output_path
1099
+
1100
+ except Exception as e:
1101
+ # P10: 不再静默降级复制原视频,向上抛异常让调用方决定
1102
+ logger.error(f"[Subtitle] Overlay failed: {e}")
1103
+ raise