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,194 @@
1
+ """core.audio.tts — TTS 统一接口:EdgeTTSEngine + SilentTTSEngine
2
+
3
+ 基于 edge_tts(免费 Azure Edge TTS)和静音占位两种实现。
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ from abc import ABC, abstractmethod
10
+ from typing import Optional, Tuple
11
+
12
+ import edge_tts
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class TTSEngine(ABC):
18
+ """TTS 抽象基类。"""
19
+
20
+ @abstractmethod
21
+ async def generate(
22
+ self, text: str, output_path: str, voice: str = "zh-CN-XiaoxiaoNeural", rate: str = "+0%"
23
+ ) -> Tuple[str, object]:
24
+ """生成音频文件,返回 (audio_path, sub_maker_or_cues)。"""
25
+ ...
26
+
27
+
28
+ class EdgeTTSEngine(TTSEngine):
29
+ """基于 edge_tts 的免费 TTS 引擎。
30
+
31
+ generate() 返回 (audio_path, sub_maker),其中 sub_maker 是 edge_tts.SubMaker 实例,
32
+ 包含逐词时间戳 cues,可用于生成 SRT 字幕。
33
+ """
34
+
35
+ async def generate(
36
+ self, text: str, output_path: str, voice: str = "zh-CN-XiaoxiaoNeural", rate: str = "+0%"
37
+ ) -> Tuple[str, "edge_tts.SubMaker"]:
38
+ """生成 TTS 音频 + SubMaker(含 cues 时间戳)。
39
+
40
+ Args:
41
+ text: 要朗读的文本
42
+ output_path: 输出音频文件路径(.mp3)
43
+ voice: edge_tts 语音角色
44
+ rate: 语速调节(如 "+0%", "+20%", "-10%")
45
+
46
+ Returns:
47
+ (audio_path, sub_maker) 元组
48
+
49
+ Raises:
50
+ RuntimeError: TTS 生成失败时抛出,调用方应降级到 SilentTTSEngine。
51
+ """
52
+ logger.info(f"[TTS] Generating audio: voice={voice}, rate={rate}, text={len(text)} chars...")
53
+
54
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
55
+
56
+ max_attempts = 2
57
+ for attempt in range(max_attempts):
58
+ try:
59
+ communicate = edge_tts.Communicate(text, voice=voice, rate=rate)
60
+ sub_maker = edge_tts.SubMaker()
61
+
62
+ tmp_path = output_path + ".tmp"
63
+ with open(tmp_path, "wb") as audio_file:
64
+ async for chunk in communicate.stream():
65
+ if chunk["type"] == "audio":
66
+ audio_file.write(chunk["data"])
67
+ elif chunk["type"] in ("WordBoundary", "SentenceBoundary"):
68
+ sub_maker.feed(chunk)
69
+
70
+ # 原子替换,避免半成品被误用
71
+ os.replace(tmp_path, output_path)
72
+ logger.info(f"[TTS] Audio saved: {output_path}")
73
+ return output_path, sub_maker
74
+ except Exception as e:
75
+ # 清理半成品文件
76
+ for p in (tmp_path, output_path):
77
+ if os.path.exists(p):
78
+ try:
79
+ os.remove(p)
80
+ except OSError:
81
+ pass
82
+ if attempt < max_attempts - 1:
83
+ logger.warning(f"[TTS] edge_tts attempt {attempt + 1}/{max_attempts} failed: {e}, retrying...")
84
+ await asyncio.sleep(3)
85
+ continue
86
+ logger.error(f"[TTS] edge_tts failed after {max_attempts} attempts: {e}")
87
+ raise RuntimeError(f"EdgeTTS generation failed: {e}") from e
88
+
89
+ async def harvest_cues(
90
+ self, text: str, voice: str = "zh-CN-XiaoxiaoNeural", rate: str = "+0%"
91
+ ) -> "edge_tts.SubMaker":
92
+ """仅采集逐词时间戳,不生成/不落盘音频字节(路径 B:音频关、字幕开)。
93
+
94
+ edge_tts 的 WordBoundary 与音频数据交织于同一 stream,需消费完整 stream
95
+ 才能收齐全部 cues;本方法只把 WordBoundary/SentenceBoundary 喂给 SubMaker,
96
+ 音频数据直接丢弃。返回含 .cues 的 sub_maker,供 generate_cue_aware_srt 使用。
97
+
98
+ 字幕只需时间线真值,不需音频字节——这正是「cues 采集」与「音频输出」解耦的核心。
99
+
100
+ Args:
101
+ text: 要朗读的文本
102
+ voice: edge_tts 语音角色
103
+ rate: 语速调节
104
+
105
+ Returns:
106
+ 含逐词 cues 的 edge_tts.SubMaker 实例
107
+
108
+ Raises:
109
+ RuntimeError: 采集失败时抛出,调用方应降级到 SilentTTSEngine(字幕回退 legacy)。
110
+ """
111
+ logger.info(
112
+ f"[TTS] Harvesting cues only: voice={voice}, rate={rate}, text={len(text)} chars..."
113
+ )
114
+
115
+ max_attempts = 2
116
+ for attempt in range(max_attempts):
117
+ try:
118
+ communicate = edge_tts.Communicate(text, voice=voice, rate=rate)
119
+ sub_maker = edge_tts.SubMaker()
120
+ async for chunk in communicate.stream():
121
+ if chunk["type"] in ("WordBoundary", "SentenceBoundary"):
122
+ sub_maker.feed(chunk)
123
+ cue_count = len(getattr(sub_maker, "cues", []) or [])
124
+ logger.info(f"[TTS] Cues harvested: {cue_count} cues")
125
+ return sub_maker
126
+ except Exception as e:
127
+ if attempt < max_attempts - 1:
128
+ logger.warning(
129
+ f"[TTS] harvest_cues attempt {attempt + 1}/{max_attempts} "
130
+ f"failed: {e}, retrying..."
131
+ )
132
+ await asyncio.sleep(3)
133
+ continue
134
+ logger.error(f"[TTS] harvest_cues failed after {max_attempts} attempts: {e}")
135
+ raise RuntimeError(f"EdgeTTS cue harvest failed: {e}") from e
136
+
137
+
138
+ class SilentTTSEngine(TTSEngine):
139
+ """静音占位 TTS 引擎。
140
+
141
+ 生成指定时长的静音音频,返回空 cues。用于用户关闭旁白时仍需要字幕时间轴的场景。
142
+ """
143
+
144
+ async def generate(
145
+ self,
146
+ text: str,
147
+ output_path: str,
148
+ voice: str = "zh-CN-XiaoxiaoNeural",
149
+ rate: str = "+0%",
150
+ duration_sec: Optional[float] = None,
151
+ ) -> Tuple[str, dict]:
152
+ """生成静音音频。
153
+
154
+ Args:
155
+ text: 文本(用于估算时长,如果 duration_sec 未提供)
156
+ output_path: 输出音频文件路径
157
+ voice: 忽略(静音模式)
158
+ rate: 忽略(静音模式)
159
+ duration_sec: 指定静音时长(秒),如果不提供则按文本长度估算
160
+
161
+ Returns:
162
+ (audio_path, empty_cues_dict) 元组
163
+ """
164
+ if duration_sec is None:
165
+ # 估算时长:中文 4 字/秒
166
+ duration_sec = max(len(text) / 4.0, 1.0)
167
+
168
+ logger.info(f"[TTS] Generating silent audio: {duration_sec:.1f}s → {output_path}")
169
+
170
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
171
+
172
+ # 使用 ffmpeg 生成静音音频
173
+ proc = await asyncio.create_subprocess_exec(
174
+ "ffmpeg", "-y",
175
+ "-f", "lavfi",
176
+ "-i", f"anullsrc=r=44100:cl=mono",
177
+ "-t", str(duration_sec),
178
+ "-c:a", "libmp3lame",
179
+ "-q:a", "4",
180
+ output_path,
181
+ stdout=asyncio.subprocess.DEVNULL,
182
+ stderr=asyncio.subprocess.PIPE,
183
+ )
184
+ _, stderr = await proc.communicate()
185
+
186
+ # P9: 检查 ffmpeg 返回码,失败时抛出异常而非静默返回
187
+ if proc.returncode != 0:
188
+ err_msg = stderr.decode(errors="replace")[:500] if stderr else ""
189
+ raise RuntimeError(
190
+ f"[TTS] ffmpeg silent generation failed (code {proc.returncode}): {err_msg}"
191
+ )
192
+
193
+ # 返回空 cues(用 None 而非 {},让下游正确识别为无 SubMaker 可用)
194
+ return output_path, None
@@ -0,0 +1,330 @@
1
+ """core.audio.voices — 音色目录与兼容性
2
+
3
+ 基于 edge_tts.list_voices() 动态加载全部可用音色,按项目 13 种 i18n 语言分组,
4
+ 并内置跨语言兼容性矩阵与「文本脚本 → 兼容性」检测,供后端 /api/voices* 接口与
5
+ 任务创建时的 voice/text 校验复用。
6
+
7
+ 设计背景见 docs/voice_selector_design.md。核心结论:
8
+ - 同一文字体系内互通,跨体系基本不通(CJK→en 是唯一例外)。
9
+ - edge-tts 跨体系调用直接抛异常,无降级,因此必须前置校验。
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ import re
15
+
16
+ import edge_tts
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ═══════════════════════════════════════════════════
21
+ # 项目语言定义
22
+ # ═══════════════════════════════════════════════════
23
+
24
+ # code -> (展示名, 文字体系)
25
+ # 文字体系: cjk / latin / cyrillic
26
+ PROJECT_LANGUAGES = {
27
+ "zh": {"label": "中文", "script": "cjk"},
28
+ "en": {"label": "English", "script": "latin"},
29
+ "ja": {"label": "日本語", "script": "cjk"},
30
+ "ko": {"label": "한국어", "script": "cjk"},
31
+ "ru": {"label": "Русский", "script": "cyrillic"},
32
+ "de": {"label": "Deutsch", "script": "latin"},
33
+ "fr": {"label": "Français", "script": "latin"},
34
+ "nl": {"label": "Nederlands", "script": "latin"},
35
+ "es": {"label": "Español", "script": "latin"},
36
+ "pt": {"label": "Português", "script": "latin"},
37
+ "it": {"label": "Italiano", "script": "latin"},
38
+ "id": {"label": "Bahasa Indonesia", "script": "latin"},
39
+ "ms": {"label": "Bahasa Melayu", "script": "latin"},
40
+ }
41
+
42
+ # 拉丁体系包含的全部项目语言(彼此完全互通)
43
+ _LATIN_LANGS = [c for c, v in PROJECT_LANGUAGES.items() if v["script"] == "latin"]
44
+
45
+ # ═══════════════════════════════════════════════════
46
+ # 预设试听文本(与音色语言严格匹配)
47
+ # ═══════════════════════════════════════════════════
48
+
49
+ VOICE_PREVIEW_TEXTS = {
50
+ "zh": "你好,我是{name},这是一段音色试听。",
51
+ "en": "Hello, I'm {name}, this is a voice preview sample.",
52
+ "ja": "こんにちは、{name}です。これはボイスプレビューです。",
53
+ "ko": "안녕하세요, 저는 {name}입니다. 이것은 음성 미리보기입니다.",
54
+ "ru": "Здравствуйте, я {name}, это образец голоса.",
55
+ "de": "Hallo, ich bin {name}, dies ist eine Sprachvorschau.",
56
+ "fr": "Bonjour, je suis {name}, ceci est un aperçu vocal.",
57
+ "nl": "Hallo, ik ben {name}, dit is een stemvoorbeeld.",
58
+ "es": "Hola, soy {name}, esta es una muestra de voz.",
59
+ "pt": "Olá, eu sou {name}, esta é uma amostra de voz.",
60
+ "it": "Ciao, sono {name}, questo è un esempio vocale.",
61
+ "id": "Halo, saya {name}, ini adalah sampel suara.",
62
+ "ms": "Helo, saya {name}, ini adalah sampel suara.",
63
+ }
64
+
65
+ # ═══════════════════════════════════════════════════
66
+ # 兼容性矩阵(语言级)
67
+ # ═══════════════════════════════════════════════════
68
+ # 每个语言可读的目标语言集合(实测结论见设计文档 2.2 节)。
69
+ # 拉丁体系 9 种语言完全互通,故共享同一集合。
70
+
71
+ _LATIN_COMPAT = list(_LATIN_LANGS) # 自身 + 其他 8 种拉丁语言
72
+
73
+ LANG_COMPAT = {
74
+ "zh": ["zh", "en"],
75
+ "en": list(_LATIN_COMPAT),
76
+ "ja": ["ja", "zh", "en"],
77
+ "ko": ["ko", "zh", "en"],
78
+ "ru": ["ru"],
79
+ "de": list(_LATIN_COMPAT),
80
+ "fr": list(_LATIN_COMPAT),
81
+ "nl": list(_LATIN_COMPAT),
82
+ "es": list(_LATIN_COMPAT),
83
+ "pt": list(_LATIN_COMPAT),
84
+ "it": list(_LATIN_COMPAT),
85
+ "id": list(_LATIN_COMPAT),
86
+ "ms": list(_LATIN_COMPAT),
87
+ }
88
+
89
+
90
+ # ═══════════════════════════════════════════════════
91
+ # 文本脚本检测(用于任务提交时校验任意文本)
92
+ # ═══════════════════════════════════════════════════
93
+
94
+ # 各文字体系对应的可读 voice 语言集合
95
+ _SCRIPT_COMPAT_VOICES = {
96
+ "zh": {"zh", "ja", "ko"}, # 汉字 → 中/日/韩音色
97
+ "ja": {"ja"}, # 假名 → 仅日语音色
98
+ "ko": {"ko"}, # 谚文 → 仅韩语音色
99
+ "latin": set(_LATIN_LANGS) | {"zh", "ja", "ko"}, # 拉丁字母 → 全部拉丁 + CJK(均可读英文)
100
+ "ru": {"ru"}, # 西里尔 → 仅俄文
101
+ }
102
+
103
+
104
+ def detect_text_script(text: str) -> str:
105
+ """粗略判断文本的 dominant 文字体系。
106
+
107
+ Returns: 'zh' | 'ja' | 'ko' | 'latin' | 'ru' | 'unknown'
108
+ """
109
+ if not text or not text.strip():
110
+ return "unknown"
111
+ # 优先级:谚文 > 假名 > 汉字 > 西里尔 > 拉丁
112
+ if re.search(r"[가-힣]", text):
113
+ return "ko"
114
+ if re.search(r"[぀-ヿ]", text):
115
+ return "ja"
116
+ if re.search(r"[一-鿿]", text):
117
+ return "zh"
118
+ if re.search(r"[Ѐ-ӿ]", text):
119
+ return "ru"
120
+ if re.search(r"[A-Za-z]", text):
121
+ return "latin"
122
+ return "unknown"
123
+
124
+
125
+ # ═══════════════════════════════════════════════════
126
+ # voice id 解析
127
+ # ═══════════════════════════════════════════════════
128
+
129
+ def get_voice_lang(voice_id: str):
130
+ """从 voice id(如 zh-CN-XiaoxiaoNeural)解析项目语言 code。
131
+
132
+ 返回 PROJECT_LANGUAGES 中的 code,无法识别时返回 None。
133
+ """
134
+ if not voice_id:
135
+ return None
136
+ lang_part = voice_id.split("-")[0].lower()
137
+ return lang_part if lang_part in PROJECT_LANGUAGES else None
138
+
139
+
140
+ # ═══════════════════════════════════════════════════
141
+ # 兼容性判定
142
+ # ═══════════════════════════════════════════════════
143
+
144
+ def is_voice_compatible(voice_id: str, target_lang: str) -> bool:
145
+ """语言级兼容性:voice 能否朗读 target_lang 语言的内容。"""
146
+ vlang = get_voice_lang(voice_id)
147
+ if vlang is None or target_lang not in PROJECT_LANGUAGES:
148
+ # 未知 voice 或未知目标语言:仅当完全相同时视为兼容
149
+ return vlang == target_lang
150
+ supported = LANG_COMPAT.get(vlang, [vlang])
151
+ return target_lang in supported
152
+
153
+
154
+ def is_voice_compatible_with_text(voice_id: str, text: str) -> bool:
155
+ """文本级兼容性:voice 能否朗读给定文本的 dominant 文字体系。
156
+
157
+ 用于任务提交时校验(稿件正文已知,创意/诗歌等由 LLM 按页面语言生成)。
158
+ """
159
+ vlang = get_voice_lang(voice_id)
160
+ if vlang is None:
161
+ return True # 未知音色不阻断
162
+ script = detect_text_script(text)
163
+ allowed = _SCRIPT_COMPAT_VOICES.get(script)
164
+ if allowed is None:
165
+ return True # unknown 脚本不阻断
166
+ return vlang in allowed
167
+
168
+
169
+ # ═══════════════════════════════════════════════════
170
+ # 离线 fallback 目录(edge_tts 不可用时使用)
171
+ # ═══════════════════════════════════════════════════
172
+
173
+ _FALLBACK_VOICES = [
174
+ {"id": "zh-CN-XiaoxiaoNeural", "name": "Xiaoxiao", "local_name": "晓晓",
175
+ "region": "普通话", "region_code": "zh-CN", "gender": "female",
176
+ "style_tags": ["Warm"], "preview_text": "你好,我是晓晓,这是一段音色试听。", "lang": "zh"},
177
+ {"id": "zh-CN-YunyangNeural", "name": "Yunyang", "local_name": "云扬",
178
+ "region": "普通话", "region_code": "zh-CN", "gender": "male",
179
+ "style_tags": ["Professional"], "preview_text": "你好,我是云扬,这是一段音色试听。", "lang": "zh"},
180
+ {"id": "zh-CN-XiaoyiNeural", "name": "Xiaoyi", "local_name": "小艺",
181
+ "region": "普通话", "region_code": "zh-CN", "gender": "female",
182
+ "style_tags": ["Lively"], "preview_text": "你好,我是小艺,这是一段音色试听。", "lang": "zh"},
183
+ {"id": "zh-CN-YunxiNeural", "name": "Yunxi", "local_name": "云希",
184
+ "region": "普通话", "region_code": "zh-CN", "gender": "male",
185
+ "style_tags": ["Sunshine"], "preview_text": "你好,我是云希,这是一段音色试听。", "lang": "zh"},
186
+ ]
187
+
188
+
189
+ def _build_fallback_catalog() -> dict:
190
+ return {
191
+ "languages": [
192
+ {"code": "zh", "label": "中文", "count": len(_FALLBACK_VOICES), "voices": _FALLBACK_VOICES}
193
+ ],
194
+ "compat_hint": LANG_COMPAT,
195
+ "fallback": True,
196
+ }
197
+
198
+
199
+ # ═══════════════════════════════════════════════════
200
+ # 目录构建
201
+ # ═══════════════════════════════════════════════════
202
+
203
+ def _region_label(locale: str, lang: str) -> str:
204
+ """生成 region 展示名。"""
205
+ if lang == "zh":
206
+ if locale.startswith("zh-HK"):
207
+ return "粤语"
208
+ if locale.startswith("zh-TW"):
209
+ return "台湾"
210
+ return "普通话"
211
+ # 其它语言直接用 locale 代码(如 en-US / es-MX),准确且无歧义
212
+ return locale
213
+
214
+
215
+ def _voice_to_dict(v: dict) -> dict | None:
216
+ """将 edge_tts 单条 voice 转为目录条目,非项目语言返回 None。"""
217
+ short = v.get("ShortName", "")
218
+ if not short:
219
+ return None
220
+ lang_part = short.split("-")[0].lower()
221
+ if lang_part not in PROJECT_LANGUAGES:
222
+ return None # 跳过非项目语言(如 ar/fa/hi 等)
223
+
224
+ locale = v.get("Locale", "")
225
+ gender = "female" if str(v.get("Gender", "")).lower() == "female" else "male"
226
+ name = short.split("-")[-1].replace("Neural", "")
227
+ region_label = _region_label(locale, lang_part)
228
+
229
+ tag = v.get("VoiceTag", {}) or {}
230
+ personalities = list(tag.get("VoicePersonalities", []) or [])
231
+ categories = list(tag.get("ContentCategories", []) or [])
232
+ style_tags = personalities + categories
233
+
234
+ preview = VOICE_PREVIEW_TEXTS.get(lang_part, VOICE_PREVIEW_TEXTS["zh"]).format(name=name)
235
+
236
+ return {
237
+ "id": short,
238
+ "name": name,
239
+ "local_name": name,
240
+ "region": region_label,
241
+ "region_code": locale,
242
+ "gender": gender,
243
+ "style_tags": style_tags,
244
+ "preview_text": preview,
245
+ "lang": lang_part,
246
+ }
247
+
248
+
249
+ async def load_voice_catalog(force: bool = False) -> dict:
250
+ """异步加载并构建分组音色目录,结果缓存到模块级变量。"""
251
+ global _VOICE_CATALOG, _VOICE_INDEX
252
+ if _VOICE_CATALOG is not None and not force:
253
+ return _VOICE_CATALOG
254
+
255
+ try:
256
+ raw = await edge_tts.list_voices()
257
+ except Exception as e:
258
+ logger.warning(f"[Voices] edge_tts.list_voices failed ({e}); using fallback catalog")
259
+ _VOICE_CATALOG = _build_fallback_catalog()
260
+ _VOICE_INDEX = {v["id"]: v for v in _FALLBACK_VOICES}
261
+ return _VOICE_CATALOG
262
+
263
+ if not raw:
264
+ _VOICE_CATALOG = _build_fallback_catalog()
265
+ _VOICE_INDEX = {v["id"]: v for v in _FALLBACK_VOICES}
266
+ return _VOICE_CATALOG
267
+
268
+ # 按语言分组
269
+ groups: dict[str, list] = {code: [] for code in PROJECT_LANGUAGES}
270
+ index: dict[str, dict] = {}
271
+ for v in raw:
272
+ entry = _voice_to_dict(v)
273
+ if entry is None:
274
+ continue
275
+ groups[entry["lang"]].append(entry)
276
+ index[entry["id"]] = entry
277
+
278
+ languages = []
279
+ for code, voices in groups.items():
280
+ if not voices:
281
+ continue
282
+ # 同语言内按 gender 再按 name 排序,体验更一致
283
+ voices.sort(key=lambda x: (x["gender"] != "female", x["name"].lower()))
284
+ languages.append({
285
+ "code": code,
286
+ "label": PROJECT_LANGUAGES[code]["label"],
287
+ "count": len(voices),
288
+ "voices": voices,
289
+ })
290
+
291
+ # 保持设计文档约定的高频语言顺序
292
+ _order = ["zh", "en", "ja", "ko", "ru", "es", "fr", "de", "nl", "pt", "it", "id", "ms"]
293
+ languages.sort(key=lambda g: _order.index(g["code"]) if g["code"] in _order else 99)
294
+
295
+ _VOICE_CATALOG = {
296
+ "languages": languages,
297
+ "compat_hint": LANG_COMPAT,
298
+ "fallback": False,
299
+ }
300
+ _VOICE_INDEX = index
301
+ logger.info(f"[Voices] Loaded catalog: {sum(g['count'] for g in languages)} voices across {len(languages)} languages")
302
+ return _VOICE_CATALOG
303
+
304
+
305
+ def get_voice_catalog() -> dict:
306
+ """同步获取目录(已在服务启动时加载;未加载时返回 fallback 避免崩溃)。"""
307
+ if _VOICE_CATALOG is None:
308
+ logger.warning("[Voices] Catalog not loaded yet; returning fallback")
309
+ return _build_fallback_catalog()
310
+ return _VOICE_CATALOG
311
+
312
+
313
+ def get_voice_by_id(voice_id: str) -> dict | None:
314
+ """按 id 查询单个音色条目。"""
315
+ if _VOICE_INDEX is None:
316
+ get_voice_catalog()
317
+ return _VOICE_INDEX.get(voice_id)
318
+
319
+
320
+ # 模块级缓存
321
+ _VOICE_CATALOG: dict | None = None
322
+ _VOICE_INDEX: dict | None = None
323
+
324
+
325
+ def warmup_voice_catalog():
326
+ """在同步上下文(如程序导入时)预加载目录。失败不抛异常。"""
327
+ try:
328
+ asyncio.run(load_voice_catalog())
329
+ except Exception as e:
330
+ logger.warning(f"[Voices] warmup failed ({e}); will use fallback")
@@ -0,0 +1,6 @@
1
+ """core.compositor — 视频拼接层"""
2
+
3
+ from core.compositor.concatenator import VideoConcatenator
4
+ from core.compositor.processor import VideoProcessor
5
+
6
+ __all__ = ["VideoConcatenator", "VideoProcessor"]