返回 MoneyPrinterTurbo
Main.py
根目录 / webui / Main.py
1 import os
2 import sys
3 import webbrowser
4 from uuid import UUID, uuid4
5
6 import requests
7 import streamlit as st
8 from loguru import logger
9
10 # Add the root directory of the project to the system path to allow importing modules from the project
11 root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
12 if root_dir not in sys.path:
13 sys.path.append(root_dir)
14 print("******** sys.path ********")
15 print(sys.path)
16 print("")
17
18 from app.config import config
19 from app.models.schema import (
20 MaterialInfo,
21 VideoAspect,
22 VideoConcatMode,
23 VideoParams,
24 VideoTransitionMode,
25 )
26 from app.services import llm, voice
27 from app.services import task as tm
28 from app.utils import utils
29
30 st.set_page_config(
31 page_title="MoneyPrinterTurbo",
32 page_icon="🤖",
33 layout="wide",
34 initial_sidebar_state="auto",
35 menu_items={
36 "Report a bug": "https://github.com/harry0703/MoneyPrinterTurbo/issues",
37 "About": "# MoneyPrinterTurbo\nSimply provide a topic or keyword for a video, and it will "
38 "automatically generate the video copy, video materials, video subtitles, "
39 "and video background music before synthesizing a high-definition short "
40 "video.\n\nhttps://github.com/harry0703/MoneyPrinterTurbo",
41 },
42 )
43
44
45 streamlit_style = """
46 <style>
47 h1 {
48 padding-top: 0 !important;
49 }
50 </style>
51 """
52 st.markdown(streamlit_style, unsafe_allow_html=True)
53
54 # 定义资源目录
55 font_dir = os.path.join(root_dir, "resource", "fonts")
56 song_dir = os.path.join(root_dir, "resource", "songs")
57 i18n_dir = os.path.join(root_dir, "webui", "i18n")
58 config_file = os.path.join(root_dir, "webui", ".streamlit", "webui.toml")
59 system_locale = utils.get_system_locale()
60 DEFAULT_CHATTERBOX_BASE_URL = "http://127.0.0.1:4123/v1"
61 DEFAULT_CHATTERBOX_MODEL = "chatterbox"
62 DEFAULT_CHATTERBOX_VOICES = ["default-Female"]
63
64
65 def _parse_chatterbox_voices(voices):
66 # Chatterbox 是自托管服务,音色列表由用户在 WebUI 中手动输入。
67 # 这里统一兼容 TOML 数组和输入框里的逗号分隔字符串,避免下拉框、
68 # 试听按钮和后续生成流程使用不同格式导致状态不一致。
69 if isinstance(voices, str):
70 return [v.strip() for v in voices.split(",") if v.strip()]
71 return [str(v).strip() for v in voices or [] if str(v).strip()]
72
73
74 def _sync_chatterbox_config_from_session_state():
75 # Streamlit 的按钮会触发整页 rerun,而 Chatterbox 配置输入框位于
76 # “试听语音合成”按钮之后。如果试听时只读取 config.chatterbox,可能拿不到
77 # 用户刚在输入框里填入的 base_url/model/voices。先从 session_state 同步一次,
78 # 可以保证按钮逻辑和输入框显示逻辑使用同一份最新配置。
79 config.chatterbox["base_url"] = (
80 st.session_state.get(
81 "chatterbox_base_url_input",
82 config.chatterbox.get("base_url") or DEFAULT_CHATTERBOX_BASE_URL,
83 )
84 or ""
85 ).strip()
86 config.chatterbox["api_key"] = st.session_state.get(
87 "chatterbox_api_key_input", config.chatterbox.get("api_key", "")
88 )
89 config.chatterbox["model_id"] = (
90 st.session_state.get(
91 "chatterbox_model_input",
92 config.chatterbox.get("model_id") or DEFAULT_CHATTERBOX_MODEL,
93 )
94 or DEFAULT_CHATTERBOX_MODEL
95 ).strip()
96 config.chatterbox["voices"] = _parse_chatterbox_voices(
97 st.session_state.get(
98 "chatterbox_voices_input",
99 config.chatterbox.get("voices") or DEFAULT_CHATTERBOX_VOICES,
100 )
101 )
102
103
104 def _detect_audio_mime(audio_file: str, audio_bytes: bytes) -> str:
105 # 有些 OpenAI-compatible TTS 服务,例如 travisvn/chatterbox-tts-api,
106 # 即使请求 response_format=mp3,也会返回 WAV 内容。WebUI 试听如果固定
107 # 使用 audio/mp3,浏览器可能无法播放,因此这里按文件头识别真实格式。
108 header = audio_bytes[:12]
109 if header.startswith(b"RIFF") and header[8:12] == b"WAVE":
110 return "audio/wav"
111 if header.startswith(b"ID3") or header[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"):
112 return "audio/mp3"
113 if header.startswith(b"OggS"):
114 return "audio/ogg"
115 ext = os.path.splitext(audio_file)[1].lower()
116 return {
117 ".wav": "audio/wav",
118 ".m4a": "audio/mp4",
119 ".aac": "audio/aac",
120 ".ogg": "audio/ogg",
121 ".flac": "audio/flac",
122 }.get(ext, "audio/mp3")
123
124
125 if "video_subject" not in st.session_state:
126 st.session_state["video_subject"] = ""
127 if "video_script" not in st.session_state:
128 st.session_state["video_script"] = ""
129 if "video_terms" not in st.session_state:
130 st.session_state["video_terms"] = ""
131 if "video_script_prompt" not in st.session_state:
132 st.session_state["video_script_prompt"] = ""
133 if "custom_system_prompt" not in st.session_state:
134 st.session_state["custom_system_prompt"] = llm.DEFAULT_SCRIPT_SYSTEM_PROMPT
135 if "use_custom_system_prompt" not in st.session_state:
136 st.session_state["use_custom_system_prompt"] = False
137 if "match_materials_to_script" not in st.session_state:
138 st.session_state["match_materials_to_script"] = bool(
139 config.app.get("match_materials_to_script", False)
140 )
141 if "ui_language" not in st.session_state:
142 st.session_state["ui_language"] = config.ui.get("language", system_locale)
143 if "local_video_materials" not in st.session_state:
144 # 记住用户最近一次已经落盘的本地素材,避免仅修改文案后二次生成时丢失素材列表。
145 st.session_state["local_video_materials"] = []
146
147 # 加载语言文件
148 locales = utils.load_locales(i18n_dir)
149
150 # 创建一个顶部栏,包含标题和语言选择
151 title_col, lang_col = st.columns([3, 1])
152
153 with title_col:
154 st.title(f"MoneyPrinterTurbo v{config.project_version}")
155
156 with lang_col:
157 display_languages = []
158 selected_index = 0
159 for i, code in enumerate(locales.keys()):
160 display_languages.append(f"{code} - {locales[code].get('Language')}")
161 if code == st.session_state.get("ui_language", ""):
162 selected_index = i
163
164 selected_language = st.selectbox(
165 "Language / 语言",
166 options=display_languages,
167 index=selected_index,
168 key="top_language_selector",
169 label_visibility="collapsed",
170 )
171 if selected_language:
172 code = selected_language.split(" - ")[0].strip()
173 st.session_state["ui_language"] = code
174 config.ui["language"] = code
175
176 support_locales = [
177 "zh-CN",
178 "zh-HK",
179 "zh-TW",
180 "de-DE",
181 "en-US",
182 "fr-FR",
183 "ru-RU",
184 "vi-VN",
185 "th-TH",
186 "tr-TR",
187 ]
188
189
190 def get_all_fonts():
191 fonts = []
192 for root, dirs, files in os.walk(font_dir):
193 for file in files:
194 if file.endswith(".ttf") or file.endswith(".ttc"):
195 fonts.append(file)
196 fonts.sort()
197 return fonts
198
199
200 def get_all_songs():
201 songs = []
202 for root, dirs, files in os.walk(song_dir):
203 for file in files:
204 if file.endswith(".mp3"):
205 songs.append(file)
206 return songs
207
208
209 def open_task_folder(task_id):
210 try:
211 # task_id 应始终是服务端生成的 UUID。这里先做格式校验,避免异常值
212 # 通过路径拼接访问任务目录之外的位置,也避免后续打开目录时触发
213 # 平台 shell 对特殊字符的解释。
214 normalized_task_id = str(UUID(str(task_id)))
215 tasks_root = os.path.abspath(os.path.join(root_dir, "storage", "tasks"))
216 path = os.path.abspath(os.path.join(tasks_root, normalized_task_id))
217
218 # 即使 UUID 校验通过,也再次确认最终路径仍在任务根目录内,避免
219 # 未来调用方调整 task_id 来源时引入路径穿越风险。
220 if not path.startswith(tasks_root + os.sep):
221 logger.warning(f"invalid task folder path: {path}")
222 return
223
224 if os.path.isdir(path):
225 webbrowser.open(f"file://{path}")
226 except Exception as e:
227 logger.error(e)
228
229
230 def scroll_to_bottom():
231 js = """
232 <script>
233 console.log("scroll_to_bottom");
234 function scroll(dummy_var_to_force_repeat_execution){
235 var sections = parent.document.querySelectorAll('section.main');
236 console.log(sections);
237 for(let index = 0; index<sections.length; index++) {
238 sections[index].scrollTop = sections[index].scrollHeight;
239 }
240 }
241 scroll(1);
242 </script>
243 """
244 st.components.v1.html(js, height=0, width=0)
245
246
247 def init_log():
248 logger.remove()
249 _lvl = "DEBUG"
250
251 def format_record(record):
252 # 获取日志记录中的文件全路径
253 file_path = record["file"].path
254 # 将绝对路径转换为相对于项目根目录的路径
255 relative_path = os.path.relpath(file_path, root_dir)
256 # 更新记录中的文件路径
257 record["file"].path = f"./{relative_path}"
258 # 返回修改后的格式字符串
259 # 您可以根据需要调整这里的格式
260 record["message"] = record["message"].replace(root_dir, ".")
261
262 _format = (
263 "<green>{time:%Y-%m-%d %H:%M:%S}</> | "
264 + "<level>{level}</> | "
265 + '"{file.path}:{line}":<blue> {function}</> '
266 + "- <level>{message}</>"
267 + "\n"
268 )
269 return _format
270
271 logger.add(
272 sys.stdout,
273 level=_lvl,
274 format=format_record,
275 colorize=True,
276 )
277
278
279 init_log()
280
281 locales = utils.load_locales(i18n_dir)
282
283
284 def tr(key):
285 loc = locales.get(st.session_state["ui_language"], {})
286 return loc.get("Translation", {}).get(key, key)
287
288 @st.cache_data(ttl=300, show_spinner=False)
289 def get_groq_model_ids(api_key: str, base_url: str) -> list[str]:
290 if not api_key:
291 return []
292
293 normalized_base_url = (base_url or "https://api.groq.com/openai/v1").strip().rstrip("/")
294 models_url = f"{normalized_base_url}/models"
295
296 try:
297 response = requests.get(
298 models_url,
299 headers={"Authorization": f"Bearer {api_key}"},
300 timeout=10,
301 )
302 response.raise_for_status()
303 payload = response.json()
304 data = payload.get("data", [])
305
306 model_ids = []
307 for item in data:
308 if isinstance(item, dict):
309 model_id = item.get("id")
310 if isinstance(model_id, str) and model_id.strip():
311 model_ids.append(model_id.strip())
312
313 return sorted(set(model_ids))
314 except Exception as e:
315 logger.warning(f"failed to fetch groq models: {e}")
316 return []
317
318 # 创建基础设置折叠框
319 if not config.app.get("hide_config", False):
320 with st.expander(tr("Basic Settings"), expanded=False):
321 config_panels = st.columns(3)
322 left_config_panel = config_panels[0]
323 middle_config_panel = config_panels[1]
324 right_config_panel = config_panels[2]
325
326 # 左侧面板 - 日志设置
327 with left_config_panel:
328 # 是否隐藏配置面板
329 hide_config = st.checkbox(
330 tr("Hide Basic Settings"), value=config.app.get("hide_config", False)
331 )
332 config.app["hide_config"] = hide_config
333
334 # 是否禁用日志显示
335 hide_log = st.checkbox(
336 tr("Hide Log"), value=config.ui.get("hide_log", False)
337 )
338 config.ui["hide_log"] = hide_log
339
340 # 中间面板 - LLM 设置
341
342 with middle_config_panel:
343 st.write(tr("LLM Settings"))
344 # 下拉框展示文本和后端 provider id 分开维护,避免 UI 文案变化
345 # 污染 `config.app["llm_provider"]` 这类稳定配置值。
346 llm_provider_options = [
347 ("OpenAI", "openai"),
348 ("AIHubMix", "aihubmix"),
349 ("AIML API", "aimlapi"),
350 ("EvoLink", "evolink"),
351 ("VolcEngine", "volcengine"),
352 ("Moonshot", "moonshot"),
353 ("Azure", "azure"),
354 ("Qwen", "qwen"),
355 ("DeepSeek", "deepseek"),
356 ("ModelScope", "modelscope"),
357 ("Gemini", "gemini"),
358 ("Grok", "grok"),
359 ("Groq", "groq"),
360 ("Ollama", "ollama"),
361 ("G4f", "g4f"),
362 ("OneAPI", "oneapi"),
363 ("Cloudflare", "cloudflare"),
364 ("ERNIE", "ernie"),
365 ("MiniMax", "minimax"),
366 ("MiMo", "mimo"),
367 ("Pollinations", "pollinations"),
368 ("LiteLLM", "litellm"),
369 ]
370 llm_provider_ids = [provider_id for _, provider_id in llm_provider_options]
371 llm_provider_labels = {
372 provider_id: label for label, provider_id in llm_provider_options
373 }
374 saved_llm_provider = config.app.get("llm_provider", "openai").lower()
375 if saved_llm_provider not in llm_provider_ids:
376 saved_llm_provider = "openai"
377
378 # Streamlit 会把没有 key 的 selectbox 视为一个由 label/options/index
379 # 共同决定的临时控件。如果每次选择后都根据 config.app 重新计算 index,
380 # 用户第一次切换 provider 后控件可能被重建,表现为“必须选择两次才生效”。
381 # 这里用稳定的 provider id 作为真实选项,并给控件固定 key;展示文案只
382 # 通过 format_func 转换,避免 UI 文案变化影响状态。
383 if st.session_state.get("llm_provider_select") not in (
384 None,
385 *llm_provider_ids,
386 ):
387 del st.session_state["llm_provider_select"]
388
389 llm_provider = st.selectbox(
390 tr("LLM Provider"),
391 options=llm_provider_ids,
392 index=llm_provider_ids.index(saved_llm_provider),
393 format_func=lambda provider_id: llm_provider_labels[provider_id],
394 key="llm_provider_select",
395 )
396 llm_helper = st.container()
397 config.app["llm_provider"] = llm_provider
398
399 llm_api_key = config.app.get(f"{llm_provider}_api_key", "")
400 llm_secret_key = config.app.get(
401 f"{llm_provider}_secret_key", ""
402 ) # only for baidu ernie
403 llm_base_url = config.app.get(f"{llm_provider}_base_url", "")
404 llm_model_name = config.app.get(f"{llm_provider}_model_name", "")
405 llm_account_id = config.app.get(f"{llm_provider}_account_id", "")
406
407 tips = ""
408 if llm_provider == "ollama":
409 if not llm_model_name:
410 llm_model_name = "qwen:7b"
411 if not llm_base_url:
412 llm_base_url = config.get_default_ollama_base_url()
413
414 with llm_helper:
415 docker_hint = ""
416 if config.is_running_in_container():
417 docker_hint = "\n > 检测到容器环境,未配置 Base Url 时会默认使用 `http://host.docker.internal:11434/v1`\n"
418 tips = f"""
419 ##### Ollama配置说明
420 - **API Key**: 随便填写,比如 123
421 - **Base Url**: 一般为 http://localhost:11434/v1
422 - 如果 `MoneyPrinterTurbo` 和 `Ollama` **不在同一台机器上**,需要填写 `Ollama` 机器的IP地址
423 - 如果 `MoneyPrinterTurbo` 是 `Docker` 部署,建议填写 `http://host.docker.internal:11434/v1`{docker_hint}
424 - **Model Name**: 使用 `ollama list` 查看,比如 `qwen:7b`
425 """
426
427 if llm_provider == "openai":
428 if not llm_model_name:
429 llm_model_name = "gpt-3.5-turbo"
430 with llm_helper:
431 tips = """
432 ##### OpenAI 配置说明
433 > 需要VPN开启全局流量模式
434 - **API Key**: [点击到官网申请](https://platform.openai.com/api-keys)
435 - **Base Url**: 官方 OpenAI 可留空;如果使用 OpenAI 兼容供应商(例如 OpenRouter),请填写对应的兼容接口地址
436 - **Model Name**: 填写**有权限**的模型;如果使用兼容供应商,请填写该平台支持的模型 ID
437 """
438
439 if llm_provider == "aihubmix":
440 if not llm_model_name:
441 llm_model_name = "gpt-5.4-mini"
442 if not llm_base_url:
443 llm_base_url = "https://aihubmix.com/v1"
444 with llm_helper:
445 tips = """
446 ##### AIHubMix 配置说明
447 - **API Key**: 在 AIHubMix 控制台创建 API Key
448 - **Base Url**: 预填 https://aihubmix.com/v1
449 - **Model Name**: 默认 gpt-5.4-mini,也可以填写 AIHubMix 支持的其它模型 ID
450 """
451
452 if llm_provider == "aimlapi":
453 if not llm_model_name:
454 llm_model_name = "openai/gpt-4o-mini"
455 if not llm_base_url:
456 llm_base_url = "https://api.aimlapi.com/v1"
457 with llm_helper:
458 tips = """
459 ##### AIML API Configuration
460 - **API Key**: create one at https://aimlapi.com/app/keys
461 - **Base Url**: https://api.aimlapi.com/v1
462 - **Model Name**: for example `openai/gpt-4o-mini`, `openai/gpt-4o`, `anthropic/claude-sonnet-4.5`, or `google/gemini-3-flash-preview`
463 """
464
465 if llm_provider == "evolink":
466 if not llm_model_name:
467 llm_model_name = "gpt-5.5"
468 if not llm_base_url:
469 llm_base_url = "https://direct.evolink.ai/v1"
470 with llm_helper:
471 tips = """
472 ##### EvoLink 配置说明
473 - **API Key**: [点击到官网申请](https://evolink.ai/dashboard/keys)
474 - **Base Url**: 默认 https://direct.evolink.ai/v1
475 - **Model Name**: 默认 gpt-5.5,也可以填写 EvoLink 支持的其它模型 ID
476 """
477
478 if llm_provider == "volcengine":
479 if not llm_model_name:
480 llm_model_name = "doubao-seed-2-1-turbo-260628"
481 if not llm_base_url:
482 llm_base_url = "https://ark.cn-beijing.volces.com/api/v3"
483 with llm_helper:
484 tips = """
485 ##### VolcEngine Ark 配置说明
486 - **注册链接**: [点击注册 火山引擎](https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=MoneyPrinterTurbo)
487 - **API Key**: 在火山引擎方舟控制台创建 API Key
488 - **Base Url**: 默认 https://ark.cn-beijing.volces.com/api/v3
489 - **Model Name**: 填写 Ark 控制台已开通的模型 ID,例如 doubao-seed-2-1-turbo-260628
490 """
491
492 if llm_provider == "moonshot":
493 if not llm_model_name:
494 llm_model_name = "moonshot-v1-8k"
495 with llm_helper:
496 tips = """
497 ##### Moonshot 配置说明
498 - **API Key**: [点击到官网申请](https://platform.moonshot.cn/console/api-keys)
499 - **Base Url**: 固定为 https://api.moonshot.cn/v1
500 - **Model Name**: 比如 moonshot-v1-8k,[点击查看模型列表](https://platform.moonshot.cn/docs/intro#%E6%A8%A1%E5%9E%8B%E5%88%97%E8%A1%A8)
501 """
502 if llm_provider == "oneapi":
503 if not llm_model_name:
504 llm_model_name = (
505 "claude-3-5-sonnet-20240620" # 默认模型,可以根据需要调整
506 )
507 with llm_helper:
508 tips = """
509 ##### OneAPI 配置说明
510 - **API Key**: 填写您的 OneAPI 密钥
511 - **Base Url**: 填写 OneAPI 的基础 URL
512 - **Model Name**: 填写您要使用的模型名称,例如 claude-3-5-sonnet-20240620
513 """
514
515 if llm_provider == "qwen":
516 if not llm_model_name:
517 llm_model_name = "qwen-max"
518 with llm_helper:
519 tips = """
520 ##### 通义千问Qwen 配置说明
521 - **API Key**: [点击到官网申请](https://dashscope.console.aliyun.com/apiKey)
522 - **Base Url**: 留空
523 - **Model Name**: 比如 qwen-max,[点击查看模型列表](https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction#3ef6d0bcf91wy)
524 """
525
526 if llm_provider == "g4f":
527 if not llm_model_name:
528 llm_model_name = "gpt-3.5-turbo"
529 with llm_helper:
530 tips = """
531 ##### gpt4free 配置说明
532 > [GitHub开源项目](https://github.com/xtekky/gpt4free),可以免费使用GPT模型,但是**稳定性较差**
533 - **API Key**: 随便填写,比如 123
534 - **Base Url**: 留空
535 - **Model Name**: 比如 gpt-3.5-turbo,[点击查看模型列表](https://github.com/xtekky/gpt4free/blob/main/g4f/models.py#L308)
536 """
537 if llm_provider == "azure":
538 with llm_helper:
539 tips = """
540 ##### Azure 配置说明
541 > [点击查看如何部署模型](https://learn.microsoft.com/zh-cn/azure/ai-services/openai/how-to/create-resource)
542 - **API Key**: [点击到Azure后台创建](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI)
543 - **Base Url**: 留空
544 - **Model Name**: 填写你实际的部署名
545 """
546
547 if llm_provider == "gemini":
548 if not llm_model_name:
549 llm_model_name = "gemini-1.0-pro"
550
551 with llm_helper:
552 tips = """
553 ##### Gemini 配置说明
554 > 需要VPN开启全局流量模式
555 - **API Key**: [点击到官网申请](https://ai.google.dev/)
556 - **Base Url**: 留空
557 - **Model Name**: 比如 gemini-1.0-pro
558 """
559
560 if llm_provider == "grok":
561 if not llm_model_name:
562 llm_model_name = "grok-4.3"
563 if not llm_base_url:
564 llm_base_url = "https://api.x.ai/v1"
565
566 with llm_helper:
567 tips = """
568 ##### Grok 配置说明
569 - **API Key**: 填写您的 GrokAPI 密钥
570 - **Base Url**: 填写 GrokAPI 的基础 URL
571 - **Model Name**: 比如 grok-4.3
572 """
573
574 if llm_provider == "groq":
575 if not llm_model_name:
576 llm_model_name = "llama-3.3-70b-versatile"
577 if not llm_base_url:
578 llm_base_url = "https://api.groq.com/openai/v1"
579
580 with llm_helper:
581 tips = """
582 ##### Groq 配置说明
583 - **API Key**: [点击到官网申请](https://console.groq.com/keys)
584 - **Base Url**: 固定为 https://api.groq.com/openai/v1
585 - **Model Name**: 比如 llama-3.3-70b-versatile
586 """
587
588 if llm_provider == "deepseek":
589 if not llm_model_name:
590 llm_model_name = "deepseek-chat"
591 if not llm_base_url:
592 llm_base_url = "https://api.deepseek.com"
593 with llm_helper:
594 tips = """
595 ##### DeepSeek 配置说明
596 - **API Key**: [点击到官网申请](https://platform.deepseek.com/api_keys)
597 - **Base Url**: 固定为 https://api.deepseek.com
598 - **Model Name**: 固定为 deepseek-chat
599 """
600
601 if llm_provider == "mimo":
602 if not llm_model_name:
603 llm_model_name = "mimo-v2.5-pro"
604 if not llm_base_url:
605 llm_base_url = "https://api.xiaomimimo.com/v1"
606 with llm_helper:
607 tips = """
608 ##### Xiaomi MiMo 配置说明
609 - **API Key**: [点击到官网申请](https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call)
610 - **Base Url**: 固定为 https://api.xiaomimimo.com/v1
611 - **Model Name**: 默认 mimo-v2.5-pro,也可以按官方文档填写其它可用模型
612 """
613
614 if llm_provider == "modelscope":
615 if not llm_model_name:
616 llm_model_name = "Qwen/Qwen3-32B"
617 if not llm_base_url:
618 llm_base_url = "https://api-inference.modelscope.cn/v1/"
619 with llm_helper:
620 tips = """
621 ##### ModelScope 配置说明
622 - **API Key**: [点击到官网申请](https://modelscope.cn/docs/model-service/API-Inference/intro)
623 - **Base Url**: 固定为 https://api-inference.modelscope.cn/v1/
624 - **Model Name**: 比如 Qwen/Qwen3-32B,[点击查看模型列表](https://modelscope.cn/models?filter=inference_type&page=1)
625 """
626
627 if llm_provider == "ernie":
628 with llm_helper:
629 tips = """
630 ##### 百度文心一言 配置说明
631 - **API Key**: [点击到官网申请](https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application)
632 - **Secret Key**: [点击到官网申请](https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application)
633 - **Base Url**: 填写 **请求地址** [点击查看文档](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/jlil56u11#%E8%AF%B7%E6%B1%82%E8%AF%B4%E6%98%8E)
634 """
635
636 if llm_provider == "pollinations":
637 if not llm_model_name:
638 llm_model_name = "default"
639 with llm_helper:
640 tips = """
641 ##### Pollinations AI Configuration
642 - **API Key**: Optional - Leave empty for public access
643 - **Base Url**: Default is https://text.pollinations.ai/openai
644 - **Model Name**: Use 'openai-fast' or specify a model name
645 """
646
647 if llm_provider == "litellm":
648 if not llm_model_name:
649 llm_model_name = "openai/gpt-4o-mini"
650 with llm_helper:
651 tips = """
652 ##### LiteLLM Configuration
653 > [LiteLLM](https://github.com/BerriAI/litellm) routes to 100+ LLM providers via a unified interface.
654 > Set your provider's API key as an env var: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `AWS_ACCESS_KEY_ID`, etc.
655 - **Model Name**: LiteLLM format — `openai/gpt-4o`, `anthropic/claude-sonnet-4-20250514`, `bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0`, `gemini/gemini-2.5-flash`. See [full provider list](https://docs.litellm.ai/docs/providers)
656 """
657
658 if tips and config.ui["language"] == "zh":
659 st.info(tips)
660
661 st_llm_api_key = st.text_input(
662 tr("API Key"), value=llm_api_key, type="password"
663 )
664 st_llm_base_url = st.text_input(tr("Base Url"), value=llm_base_url)
665 st_llm_model_name = ""
666 if llm_provider != "ernie":
667 if llm_provider == "groq":
668 effective_api_key = st_llm_api_key or llm_api_key
669 effective_base_url = st_llm_base_url or llm_base_url
670 groq_models = get_groq_model_ids(
671 api_key=effective_api_key,
672 base_url=effective_base_url,
673 )
674
675 if groq_models:
676 selected_index = 0
677 if llm_model_name in groq_models:
678 selected_index = groq_models.index(llm_model_name)
679
680 st_llm_model_name = st.selectbox(
681 tr("Model Name"),
682 options=groq_models,
683 index=selected_index,
684 key="groq_model_name_select",
685 )
686 else:
687 st_llm_model_name = st.text_input(
688 tr("Model Name"),
689 value=llm_model_name,
690 key="groq_model_name_input",
691 )
692 if effective_api_key:
693 st.caption(
694 "Unable to load Groq model list right now. You can still enter a model name manually — note it won't be validated until generation."
695 )
696 else:
697 st.caption(
698 "Add a Groq API key to load available models automatically."
699 )
700 else:
701 st_llm_model_name = st.text_input(
702 tr("Model Name"),
703 value=llm_model_name,
704 key=f"{llm_provider}_model_name_input",
705 )
706 if st_llm_model_name:
707 config.app[f"{llm_provider}_model_name"] = st_llm_model_name
708 else:
709 st_llm_model_name = None
710
711 if st_llm_api_key:
712 config.app[f"{llm_provider}_api_key"] = st_llm_api_key
713 if st_llm_base_url:
714 config.app[f"{llm_provider}_base_url"] = st_llm_base_url
715 if st_llm_model_name:
716 config.app[f"{llm_provider}_model_name"] = st_llm_model_name
717 if llm_provider == "ernie":
718 st_llm_secret_key = st.text_input(
719 tr("Secret Key"), value=llm_secret_key, type="password"
720 )
721 config.app[f"{llm_provider}_secret_key"] = st_llm_secret_key
722
723 if llm_provider == "cloudflare":
724 st_llm_account_id = st.text_input(
725 tr("Account ID"), value=llm_account_id
726 )
727 if st_llm_account_id:
728 config.app[f"{llm_provider}_account_id"] = st_llm_account_id
729
730 # 右侧面板 - API 密钥设置
731 with right_config_panel:
732
733 def get_keys_from_config(cfg_key):
734 api_keys = config.app.get(cfg_key, [])
735 if isinstance(api_keys, str):
736 api_keys = [api_keys]
737 api_key = ", ".join(api_keys)
738 return api_key
739
740 def save_keys_to_config(cfg_key, value):
741 value = value.replace(" ", "")
742 if value:
743 config.app[cfg_key] = value.split(",")
744
745 st.write(tr("Video Source Settings"))
746
747 pexels_api_key = get_keys_from_config("pexels_api_keys")
748 pexels_api_key = st.text_input(
749 tr("Pexels API Key"), value=pexels_api_key, type="password"
750 )
751 save_keys_to_config("pexels_api_keys", pexels_api_key)
752
753 pixabay_api_key = get_keys_from_config("pixabay_api_keys")
754 pixabay_api_key = st.text_input(
755 tr("Pixabay API Key"), value=pixabay_api_key, type="password"
756 )
757 save_keys_to_config("pixabay_api_keys", pixabay_api_key)
758
759 coverr_api_key = get_keys_from_config("coverr_api_keys")
760 coverr_api_key = st.text_input(
761 tr("Coverr API Key"), value=coverr_api_key, type="password"
762 )
763 save_keys_to_config("coverr_api_keys", coverr_api_key)
764
765 llm_provider = config.app.get("llm_provider", "").lower()
766 panel = st.columns(3)
767 left_panel = panel[0]
768 middle_panel = panel[1]
769 right_panel = panel[2]
770
771 params = VideoParams(video_subject="")
772 params.match_materials_to_script = bool(
773 st.session_state.get("match_materials_to_script", False)
774 )
775 uploaded_files = []
776 uploaded_audio_file = None
777
778 with left_panel:
779 with st.container(border=True):
780 st.write(tr("Video Script Settings"))
781 params.video_subject = st.text_input(
782 tr("Video Subject"),
783 key="video_subject",
784 ).strip()
785
786 video_languages = [
787 (tr("Auto Detect"), ""),
788 ]
789 for code in support_locales:
790 video_languages.append((code, code))
791
792 selected_index = st.selectbox(
793 tr("Script Language"),
794 index=0,
795 options=range(
796 len(video_languages)
797 ), # Use the index as the internal option value
798 format_func=lambda x: video_languages[x][
799 0
800 ], # The label is displayed to the user
801 )
802 params.video_language = video_languages[selected_index][1]
803
804 with st.expander(tr("Advanced Script Settings"), expanded=False):
805 params.paragraph_number = st.slider(
806 tr("Script Paragraph Number"),
807 min_value=llm.MIN_SCRIPT_PARAGRAPH_NUMBER,
808 max_value=llm.MAX_SCRIPT_PARAGRAPH_NUMBER,
809 value=st.session_state.get("paragraph_number_input", 1),
810 key="paragraph_number_input",
811 )
812 params.video_script_prompt = st.text_area(
813 tr("Custom Script Requirements"),
814 height=100,
815 max_chars=llm.MAX_SCRIPT_PROMPT_LENGTH,
816 placeholder=tr("Custom Script Requirements Placeholder"),
817 key="video_script_prompt",
818 ).strip()
819
820 use_custom_system_prompt = st.checkbox(
821 tr("Use Custom System Prompt"),
822 help=tr("Use Custom System Prompt Help"),
823 key="use_custom_system_prompt",
824 )
825
826 if use_custom_system_prompt:
827 custom_system_prompt = st.text_area(
828 tr("Custom System Prompt"),
829 height=240,
830 max_chars=llm.MAX_SCRIPT_SYSTEM_PROMPT_LENGTH,
831 key="custom_system_prompt",
832 ).strip()
833 params.custom_system_prompt = custom_system_prompt
834 else:
835 params.custom_system_prompt = ""
836
837 if st.button(
838 tr("Generate Video Script and Keywords"), key="auto_generate_script"
839 ):
840 with st.spinner(tr("Generating Video Script and Keywords")):
841 script = llm.generate_script(
842 video_subject=params.video_subject,
843 language=params.video_language,
844 paragraph_number=params.paragraph_number,
845 video_script_prompt=params.video_script_prompt,
846 custom_system_prompt=params.custom_system_prompt,
847 )
848 terms = llm.generate_terms(
849 params.video_subject,
850 script,
851 amount=8 if params.match_materials_to_script else 5,
852 match_script_order=params.match_materials_to_script,
853 )
854 if "Error: " in script:
855 st.error(tr(script))
856 elif "Error: " in terms:
857 st.error(tr(terms))
858 else:
859 st.session_state["video_script"] = script
860 st.session_state["video_terms"] = ", ".join(terms)
861 params.video_script = st.text_area(
862 tr("Video Script"), value=st.session_state["video_script"], height=280
863 )
864 if st.button(tr("Generate Video Keywords"), key="auto_generate_terms"):
865 if not params.video_script:
866 st.error(tr("Please Enter the Video Subject"))
867 st.stop()
868
869 with st.spinner(tr("Generating Video Keywords")):
870 terms = llm.generate_terms(
871 params.video_subject,
872 params.video_script,
873 amount=8 if params.match_materials_to_script else 5,
874 match_script_order=params.match_materials_to_script,
875 )
876 if "Error: " in terms:
877 st.error(tr(terms))
878 else:
879 st.session_state["video_terms"] = ", ".join(terms)
880
881 params.video_terms = st.text_area(
882 tr("Video Keywords"), value=st.session_state["video_terms"]
883 )
884
885 with middle_panel:
886 with st.container(border=True):
887 st.write(tr("Video Settings"))
888 video_concat_modes = [
889 (tr("Sequential"), "sequential"),
890 (tr("Random"), "random"),
891 ]
892 video_sources = [
893 (tr("Pexels"), "pexels"),
894 (tr("Pixabay"), "pixabay"),
895 (tr("Coverr"), "coverr"),
896 (tr("Local file"), "local"),
897 (tr("TikTok"), "douyin"),
898 (tr("Bilibili"), "bilibili"),
899 (tr("Xiaohongshu"), "xiaohongshu"),
900 ]
901
902 saved_video_source_name = config.app.get("video_source", "pexels")
903 saved_video_source_index = [v[1] for v in video_sources].index(
904 saved_video_source_name
905 )
906
907 selected_index = st.selectbox(
908 tr("Video Source"),
909 options=range(len(video_sources)),
910 format_func=lambda x: video_sources[x][0],
911 index=saved_video_source_index,
912 )
913 params.video_source = video_sources[selected_index][1]
914 config.app["video_source"] = params.video_source
915
916 if params.video_source == "local":
917 # Streamlit 的文件类型校验对扩展名大小写敏感,这里同时放行大小写两种形式。
918 local_file_types = ["mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png"]
919 uploaded_files = st.file_uploader(
920 tr("Upload Local Files"),
921 type=local_file_types + [file_type.upper() for file_type in local_file_types],
922 accept_multiple_files=True,
923 )
924
925 selected_index = st.selectbox(
926 tr("Video Concat Mode"),
927 index=1,
928 options=range(
929 len(video_concat_modes)
930 ), # Use the index as the internal option value
931 format_func=lambda x: video_concat_modes[x][
932 0
933 ], # The label is displayed to the user
934 )
935 params.video_concat_mode = VideoConcatMode(
936 video_concat_modes[selected_index][1]
937 )
938
939 # 视频转场模式
940 video_transition_modes = [
941 (tr("None"), VideoTransitionMode.none.value),
942 (tr("Shuffle"), VideoTransitionMode.shuffle.value),
943 (tr("FadeIn"), VideoTransitionMode.fade_in.value),
944 (tr("FadeOut"), VideoTransitionMode.fade_out.value),
945 (tr("SlideIn"), VideoTransitionMode.slide_in.value),
946 (tr("SlideOut"), VideoTransitionMode.slide_out.value),
947 ]
948 selected_index = st.selectbox(
949 tr("Video Transition Mode"),
950 options=range(len(video_transition_modes)),
951 format_func=lambda x: video_transition_modes[x][0],
952 index=0,
953 )
954 params.video_transition_mode = VideoTransitionMode(
955 video_transition_modes[selected_index][1]
956 )
957
958 video_aspect_ratios = [
959 (tr("Portrait"), VideoAspect.portrait.value),
960 (tr("Landscape"), VideoAspect.landscape.value),
961 ]
962 # Coverr 库 99% 是 16:9 横屏,默认竖屏会让画面被大量黑边包围。
963 # 用 source-specific widget key 让每个 source 各自记忆 aspect 选择:
964 # - 首次切到 coverr → 默认 Landscape(index=1)
965 # - 其他 source 沿用 Portrait(index=0)
966 # - 用户在某 source 下手动改过 aspect,session_state 会记住,
967 # 下次回到同一 source 时尊重用户选择,不会再被强制覆盖。
968 default_aspect_index = 1 if params.video_source == "coverr" else 0
969 selected_index = st.selectbox(
970 tr("Video Ratio"),
971 options=range(
972 len(video_aspect_ratios)
973 ), # Use the index as the internal option value
974 format_func=lambda x: video_aspect_ratios[x][
975 0
976 ], # The label is displayed to the user
977 index=default_aspect_index,
978 key=f"video_aspect_for_{params.video_source}",
979 )
980 params.video_aspect = VideoAspect(video_aspect_ratios[selected_index][1])
981
982 params.video_clip_duration = st.selectbox(
983 tr("Clip Duration"), options=[2, 3, 4, 5, 6, 7, 8, 9, 10], index=1
984 )
985 params.video_count = st.selectbox(
986 tr("Number of Videos Generated Simultaneously"),
987 options=[1, 2, 3, 4, 5],
988 index=0,
989 )
990
991 with st.expander(tr("Advanced Video Settings"), expanded=False):
992 # 默认关闭,避免影响老用户的随机素材体验。开启后只改变关键词和素材
993 # 下载/拼接顺序,用于改善画面主题早于或晚于旁白的问题。
994 params.match_materials_to_script = st.checkbox(
995 tr("Match Materials to Script Order"),
996 help=tr("Match Materials to Script Order Help"),
997 key="match_materials_to_script",
998 )
999 config.app["match_materials_to_script"] = params.match_materials_to_script
1000
1001 video_codec_options = [
1002 ("libx264 (CPU)", "libx264"),
1003 ("NVIDIA NVENC (h264_nvenc)", "h264_nvenc"),
1004 ("AMD AMF (h264_amf)", "h264_amf"),
1005 ("Intel QSV (h264_qsv)", "h264_qsv"),
1006 ("Windows MediaFoundation (h264_mf)", "h264_mf"),
1007 ("macOS VideoToolbox (h264_videotoolbox)", "h264_videotoolbox"),
1008 ]
1009 saved_video_codec = config.app.get("video_codec", "libx264")
1010 saved_video_codec_values = [item[1] for item in video_codec_options]
1011 if saved_video_codec not in saved_video_codec_values:
1012 saved_video_codec = "libx264"
1013 selected_codec_index = saved_video_codec_values.index(saved_video_codec)
1014 selected_codec_index = st.selectbox(
1015 tr("Video Encoder"),
1016 options=range(len(video_codec_options)),
1017 index=selected_codec_index,
1018 format_func=lambda x: video_codec_options[x][0],
1019 help=tr("Video Encoder Help"),
1020 )
1021 config.app["video_codec"] = video_codec_options[selected_codec_index][1]
1022 with st.container(border=True):
1023 st.write(tr("Audio Settings"))
1024
1025 # 添加TTS服务器选择下拉框
1026 tts_servers = [
1027 (voice.NO_VOICE_NAME, tr("No Voice")),
1028 ("azure-tts-v1", "Azure TTS V1"),
1029 ("azure-tts-v2", "Azure TTS V2"),
1030 ("siliconflow", "SiliconFlow TTS"),
1031 ("gemini-tts", "Google Gemini TTS"),
1032 ("mimo-tts", "Xiaomi MiMo TTS"),
1033 ("elevenlabs", "ElevenLabs TTS"),
1034 ("chatterbox", "Chatterbox TTS"),
1035 ]
1036
1037 # 获取保存的TTS服务器,默认为v1
1038 saved_tts_server = config.ui.get("tts_server", "azure-tts-v1")
1039 saved_tts_server_index = 0
1040 for i, (server_value, _) in enumerate(tts_servers):
1041 if server_value == saved_tts_server:
1042 saved_tts_server_index = i
1043 break
1044
1045 selected_tts_server_index = st.selectbox(
1046 tr("TTS Servers"),
1047 options=range(len(tts_servers)),
1048 format_func=lambda x: tts_servers[x][1],
1049 index=saved_tts_server_index,
1050 )
1051
1052 selected_tts_server = tts_servers[selected_tts_server_index][0]
1053 config.ui["tts_server"] = selected_tts_server
1054
1055 # 根据选择的TTS服务器获取声音列表
1056 filtered_voices = []
1057
1058 if selected_tts_server == voice.NO_VOICE_NAME:
1059 # 无配音是显式模式,只提供一个稳定 sentinel。这样普通 TTS 的空配置
1060 # 不会被误判为静音,后端也能继续通过同一条音频/字幕流程生成视频。
1061 filtered_voices = [voice.NO_VOICE_NAME]
1062 elif selected_tts_server == "siliconflow":
1063 # 获取硅基流动的声音列表
1064 filtered_voices = voice.get_siliconflow_voices()
1065 elif selected_tts_server == "gemini-tts":
1066 # 获取Gemini TTS的声音列表
1067 filtered_voices = voice.get_gemini_voices()
1068 elif selected_tts_server == "mimo-tts":
1069 # 获取 Xiaomi MiMo TTS 的预置音色列表
1070 filtered_voices = voice.get_mimo_voices()
1071 elif selected_tts_server == "elevenlabs":
1072 # Read from session_state first so the API key is available before
1073 # the Play Voice button runs (which is earlier in the script than
1074 # the API key text_input widget).
1075 saved_elevenlabs_api_key = st.session_state.get(
1076 "elevenlabs_api_key_input",
1077 config.elevenlabs.get("api_key", ""),
1078 )
1079 if saved_elevenlabs_api_key:
1080 config.elevenlabs["api_key"] = saved_elevenlabs_api_key
1081 cache_key = f"elevenlabs_voices_{saved_elevenlabs_api_key}"
1082 if cache_key not in st.session_state:
1083 st.session_state[cache_key] = voice.get_elevenlabs_voices(
1084 saved_elevenlabs_api_key
1085 )
1086 filtered_voices = st.session_state[cache_key]
1087 elif selected_tts_server == "chatterbox":
1088 # 自托管 Chatterbox 服务的预置音色(来自 [chatterbox] voices 配置)
1089 _sync_chatterbox_config_from_session_state()
1090 filtered_voices = voice.get_chatterbox_voices()
1091 else:
1092 # 获取Azure的声音列表
1093 all_voices = voice.get_all_azure_voices(filter_locals=None)
1094
1095 # 根据选择的TTS服务器筛选声音
1096 for v in all_voices:
1097 if selected_tts_server == "azure-tts-v2":
1098 # V2版本的声音名称中包含"v2"
1099 if "V2" in v:
1100 filtered_voices.append(v)
1101 else:
1102 # V1版本的声音名称中不包含"v2"
1103 if "V2" not in v:
1104 filtered_voices.append(v)
1105
1106 if selected_tts_server == voice.NO_VOICE_NAME:
1107 friendly_names = {voice.NO_VOICE_NAME: tr("No Voice")}
1108 else:
1109 def _friendly(v):
1110 if voice.is_elevenlabs_voice(v):
1111 parts = v.split(":", 2)
1112 return parts[2] if len(parts) >= 3 else v
1113 if voice.is_chatterbox_voice(v):
1114 name = v.split(":", 1)[1] if ":" in v else v
1115 return name.replace("-Female", "").replace("-Male", "")
1116 return (
1117 v.replace("Female", tr("Female"))
1118 .replace("Male", tr("Male"))
1119 .replace("Neural", "")
1120 )
1121 friendly_names = {v: _friendly(v) for v in filtered_voices}
1122
1123 saved_voice_name = config.ui.get("voice_name", "")
1124 saved_voice_name_index = 0
1125
1126 # 检查保存的声音是否在当前筛选的声音列表中
1127 if saved_voice_name in friendly_names:
1128 saved_voice_name_index = list(friendly_names.keys()).index(saved_voice_name)
1129 else:
1130 # 如果不在,则根据当前UI语言选择一个默认声音
1131 for i, v in enumerate(filtered_voices):
1132 if v.lower().startswith(st.session_state["ui_language"].lower()):
1133 saved_voice_name_index = i
1134 break
1135
1136 # 如果没有找到匹配的声音,使用第一个声音
1137 if saved_voice_name_index >= len(friendly_names) and friendly_names:
1138 saved_voice_name_index = 0
1139
1140 # 确保有声音可选
1141 if friendly_names:
1142 selected_friendly_name = st.selectbox(
1143 tr("Speech Synthesis"),
1144 options=list(friendly_names.values()),
1145 index=min(saved_voice_name_index, len(friendly_names) - 1)
1146 if friendly_names
1147 else 0,
1148 )
1149
1150 voice_name = list(friendly_names.keys())[
1151 list(friendly_names.values()).index(selected_friendly_name)
1152 ]
1153 params.voice_name = voice_name
1154 config.ui["voice_name"] = voice_name
1155 else:
1156 # 如果没有声音可选,显示提示信息
1157 st.warning(
1158 tr(
1159 "No voices available for the selected TTS server. Please select another server."
1160 )
1161 )
1162 voice_name = ""
1163 params.voice_name = ""
1164 config.ui["voice_name"] = ""
1165
1166 # 无配音模式会生成静音占位音频,不展示试听按钮,避免用户误以为需要测试声音。
1167 if (
1168 friendly_names
1169 and selected_tts_server != voice.NO_VOICE_NAME
1170 and st.button(tr("Play Voice"))
1171 ):
1172 if selected_tts_server == "chatterbox":
1173 _sync_chatterbox_config_from_session_state()
1174 play_content = params.video_subject
1175 if not play_content:
1176 play_content = params.video_script
1177 if not play_content:
1178 # For ElevenLabs voices, detect language from the display name
1179 # so the test text matches the voice's language.
1180 if voice.is_elevenlabs_voice(voice_name):
1181 parts = voice_name.split(":", 2)
1182 display = parts[2] if len(parts) >= 3 else ""
1183 _vi_chars = set("àáâãèéêìíòóôõùúýăđơưÀÁÂÃÈÉÊÌÍÒÓÔÕÙÚÝĂĐƠƯ")
1184 if any(c in _vi_chars for c in display):
1185 play_content = "Xin chào, đây là đoạn âm thanh thử nghiệm giọng nói."
1186 else:
1187 play_content = tr("Voice Example")
1188 else:
1189 play_content = tr("Voice Example")
1190 with st.spinner(tr("Synthesizing Voice")):
1191 temp_dir = utils.storage_dir("temp", create=True)
1192 audio_file = os.path.join(temp_dir, f"tmp-voice-{str(uuid4())}.mp3")
1193 sub_maker = voice.tts(
1194 text=play_content,
1195 voice_name=voice_name,
1196 voice_rate=params.voice_rate,
1197 voice_file=audio_file,
1198 voice_volume=params.voice_volume,
1199 )
1200 # if the voice file generation failed, try again with a default content.
1201 if not sub_maker:
1202 play_content = "This is a example voice. if you hear this, the voice synthesis failed with the original content."
1203 sub_maker = voice.tts(
1204 text=play_content,
1205 voice_name=voice_name,
1206 voice_rate=params.voice_rate,
1207 voice_file=audio_file,
1208 voice_volume=params.voice_volume,
1209 )
1210
1211 if sub_maker and os.path.exists(audio_file):
1212 with open(audio_file, "rb") as f:
1213 audio_bytes = f.read()
1214 if audio_bytes:
1215 st.audio(
1216 audio_bytes,
1217 format=_detect_audio_mime(audio_file, audio_bytes),
1218 )
1219 else:
1220 logger.error(f"voice preview audio file is empty: {audio_file}")
1221 if os.path.exists(audio_file):
1222 os.remove(audio_file)
1223
1224 # 当选择V2版本或者声音是V2声音时,显示服务区域和API key输入框
1225 if selected_tts_server == "azure-tts-v2" or (
1226 voice_name and voice.is_azure_v2_voice(voice_name)
1227 ):
1228 saved_azure_speech_region = config.azure.get("speech_region", "")
1229 saved_azure_speech_key = config.azure.get("speech_key", "")
1230 azure_speech_region = st.text_input(
1231 tr("Speech Region"),
1232 value=saved_azure_speech_region,
1233 key="azure_speech_region_input",
1234 )
1235 azure_speech_key = st.text_input(
1236 tr("Speech Key"),
1237 value=saved_azure_speech_key,
1238 type="password",
1239 key="azure_speech_key_input",
1240 )
1241 config.azure["speech_region"] = azure_speech_region
1242 config.azure["speech_key"] = azure_speech_key
1243
1244 # 当选择硅基流动时,显示API key输入框和说明信息
1245 if selected_tts_server == "siliconflow" or (
1246 voice_name and voice.is_siliconflow_voice(voice_name)
1247 ):
1248 saved_siliconflow_api_key = config.siliconflow.get("api_key", "")
1249
1250 siliconflow_api_key = st.text_input(
1251 tr("SiliconFlow API Key"),
1252 value=saved_siliconflow_api_key,
1253 type="password",
1254 key="siliconflow_api_key_input",
1255 )
1256
1257 # 显示硅基流动的说明信息
1258 st.info(
1259 tr("SiliconFlow TTS Settings")
1260 + ":\n"
1261 + "- "
1262 + tr("Speed: Range [0.25, 4.0], default is 1.0")
1263 + "\n"
1264 + "- "
1265 + tr("Volume: Uses Speech Volume setting, default 1.0 maps to gain 0")
1266 )
1267
1268 config.siliconflow["api_key"] = siliconflow_api_key
1269
1270 # 当选择 Xiaomi MiMo TTS 时,复用 MiMo LLM provider 的 API Key。
1271 # 这样用户如果同时使用 MiMo 生成文案和语音,只需要维护一份密钥。
1272 if selected_tts_server == "mimo-tts" or (
1273 voice_name and voice.is_mimo_voice(voice_name)
1274 ):
1275 saved_mimo_api_key = config.app.get("mimo_api_key", "")
1276
1277 mimo_api_key = st.text_input(
1278 tr("MiMo API Key"),
1279 value=saved_mimo_api_key,
1280 type="password",
1281 key="mimo_tts_api_key_input",
1282 )
1283
1284 st.info(
1285 tr("MiMo TTS Settings")
1286 + ":\n"
1287 + "- "
1288 + tr("Uses Xiaomi MiMo V2.5 TTS preset voices")
1289 + "\n"
1290 + "- "
1291 + tr("Speed and volume are currently handled by the provider defaults")
1292 )
1293
1294 config.app["mimo_api_key"] = mimo_api_key
1295
1296 # ElevenLabs API key section
1297 if selected_tts_server == "elevenlabs" or (
1298 voice_name and voice.is_elevenlabs_voice(voice_name)
1299 ):
1300 saved_elevenlabs_api_key = config.elevenlabs.get("api_key", "")
1301
1302 elevenlabs_api_key = st.text_input(
1303 tr("ElevenLabs API Key"),
1304 value=saved_elevenlabs_api_key,
1305 type="password",
1306 key="elevenlabs_api_key_input",
1307 )
1308
1309 _elevenlabs_models = [
1310 "eleven_multilingual_v2",
1311 "eleven_flash_v2_5",
1312 "eleven_v3",
1313 ]
1314 saved_elevenlabs_model = config.elevenlabs.get(
1315 "model_id", "eleven_multilingual_v2"
1316 )
1317 if saved_elevenlabs_model not in _elevenlabs_models:
1318 saved_elevenlabs_model = "eleven_multilingual_v2"
1319 elevenlabs_model = st.selectbox(
1320 tr("ElevenLabs Model"),
1321 options=_elevenlabs_models,
1322 index=_elevenlabs_models.index(saved_elevenlabs_model),
1323 key="elevenlabs_model_select",
1324 )
1325 config.elevenlabs["model_id"] = elevenlabs_model
1326
1327 st.info(
1328 "ElevenLabs TTS Settings:\n"
1329 "- Get your API key at https://elevenlabs.io/app/settings/api-keys\n"
1330 "- Mark voices as ★ Favorite in the ElevenLabs voice library to make them appear here"
1331 )
1332
1333 if elevenlabs_api_key != saved_elevenlabs_api_key:
1334 for k in list(st.session_state.keys()):
1335 if k.startswith("elevenlabs_voices_"):
1336 del st.session_state[k]
1337
1338 config.elevenlabs["api_key"] = elevenlabs_api_key
1339
1340 # Chatterbox API settings section (self-hosted, OpenAI-compatible)
1341 if selected_tts_server == "chatterbox" or (
1342 voice_name and voice.is_chatterbox_voice(voice_name)
1343 ):
1344 chatterbox_base_url = st.text_input(
1345 tr("Chatterbox Base URL"),
1346 value=config.chatterbox.get("base_url") or DEFAULT_CHATTERBOX_BASE_URL,
1347 key="chatterbox_base_url_input",
1348 placeholder="http://localhost:4123/v1",
1349 )
1350 config.chatterbox["base_url"] = (chatterbox_base_url or "").strip()
1351
1352 chatterbox_api_key = st.text_input(
1353 tr("Chatterbox API Key"),
1354 value=config.chatterbox.get("api_key", ""),
1355 type="password",
1356 key="chatterbox_api_key_input",
1357 )
1358 config.chatterbox["api_key"] = chatterbox_api_key
1359
1360 chatterbox_model = st.text_input(
1361 tr("Chatterbox Model"),
1362 value=config.chatterbox.get("model_id") or DEFAULT_CHATTERBOX_MODEL,
1363 key="chatterbox_model_input",
1364 )
1365 config.chatterbox["model_id"] = (
1366 chatterbox_model or DEFAULT_CHATTERBOX_MODEL
1367 ).strip()
1368
1369 _saved_chatterbox_voices = (
1370 _parse_chatterbox_voices(config.chatterbox.get("voices"))
1371 or DEFAULT_CHATTERBOX_VOICES
1372 )
1373 if isinstance(_saved_chatterbox_voices, list):
1374 _saved_chatterbox_voices = ", ".join(_saved_chatterbox_voices)
1375 chatterbox_voices = st.text_input(
1376 tr("Chatterbox Voices"),
1377 value=str(_saved_chatterbox_voices or ""),
1378 key="chatterbox_voices_input",
1379 placeholder="default-Female, narrator-Male",
1380 )
1381 config.chatterbox["voices"] = _parse_chatterbox_voices(chatterbox_voices)
1382
1383 st.info(
1384 "Chatterbox TTS Settings (self-hosted):\n"
1385 "- Run an OpenAI-compatible Chatterbox server (e.g. "
1386 "devnen/Chatterbox-TTS-Server or travisvn/chatterbox-tts-api) and "
1387 "set Base URL to its /v1 endpoint\n"
1388 "- Voices is a comma-separated list of voice names your server "
1389 "exposes; add a -Female or -Male suffix only to label the gender "
1390 "in this dropdown\n"
1391 "- Speech Volume is not applied for Chatterbox (the OpenAI "
1392 "/audio/speech API has no volume field); use Speech Rate instead"
1393 )
1394
1395 params.voice_volume = st.selectbox(
1396 tr("Speech Volume"),
1397 options=[0.6, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 4.0, 5.0],
1398 index=2,
1399 )
1400
1401 params.voice_rate = st.selectbox(
1402 tr("Speech Rate"),
1403 options=[0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.8, 2.0],
1404 index=2,
1405 )
1406
1407 custom_audio_file_types = ["mp3", "wav", "m4a", "aac", "flac", "ogg"]
1408 uploaded_audio_file = st.file_uploader(
1409 tr("Custom Audio File"),
1410 type=custom_audio_file_types
1411 + [file_type.upper() for file_type in custom_audio_file_types],
1412 accept_multiple_files=False,
1413 key="custom_audio_file_uploader",
1414 )
1415 if uploaded_audio_file:
1416 st.audio(uploaded_audio_file, format="audio/mp3")
1417 st.info(
1418 tr(
1419 "Custom audio will be used directly. TTS synthesis will be skipped for this task."
1420 )
1421 )
1422
1423 bgm_options = [
1424 (tr("No Background Music"), ""),
1425 (tr("Random Background Music"), "random"),
1426 (tr("Custom Background Music"), "custom"),
1427 ]
1428 selected_index = st.selectbox(
1429 tr("Background Music"),
1430 index=1,
1431 options=range(
1432 len(bgm_options)
1433 ), # Use the index as the internal option value
1434 format_func=lambda x: bgm_options[x][
1435 0
1436 ], # The label is displayed to the user
1437 )
1438 # Get the selected background music type
1439 params.bgm_type = bgm_options[selected_index][1]
1440
1441 # Show or hide components based on the selection
1442 if params.bgm_type == "custom":
1443 custom_bgm_file = st.text_input(
1444 tr("Custom Background Music File"), key="custom_bgm_file_input"
1445 )
1446 if custom_bgm_file:
1447 # 这里不直接用 os.path.exists 判断,因为用户常见输入是
1448 # output000.mp3,这个文件名需要由服务层映射到 resource/songs
1449 # 目录后再校验。服务层会统一限制目录和文件类型,避免任意路径读取。
1450 params.bgm_file = custom_bgm_file.strip()
1451 # st.write(f":red[已选择自定义背景音乐]:**{custom_bgm_file}**")
1452 params.bgm_volume = st.selectbox(
1453 tr("Background Music Volume"),
1454 options=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
1455 index=2,
1456 )
1457
1458 with right_panel:
1459 with st.container(border=True):
1460 st.write(tr("Subtitle Settings"))
1461 params.subtitle_enabled = st.checkbox(tr("Enable Subtitles"), value=True)
1462 font_names = get_all_fonts()
1463 saved_font_name = config.ui.get("font_name", "MicrosoftYaHeiBold.ttc")
1464 saved_font_name_index = 0
1465 if saved_font_name in font_names:
1466 saved_font_name_index = font_names.index(saved_font_name)
1467 params.font_name = st.selectbox(
1468 tr("Font"), font_names, index=saved_font_name_index
1469 )
1470 config.ui["font_name"] = params.font_name
1471
1472 subtitle_positions = [
1473 (tr("Top"), "top"),
1474 (tr("Center"), "center"),
1475 (tr("Bottom"), "bottom"),
1476 (tr("Custom"), "custom"),
1477 ]
1478 saved_subtitle_position = config.ui.get("subtitle_position", "bottom")
1479 saved_position_index = 2
1480 for i, (_, pos_value) in enumerate(subtitle_positions):
1481 if pos_value == saved_subtitle_position:
1482 saved_position_index = i
1483 break
1484 selected_index = st.selectbox(
1485 tr("Position"),
1486 index=saved_position_index,
1487 options=range(len(subtitle_positions)),
1488 format_func=lambda x: subtitle_positions[x][0],
1489 )
1490 params.subtitle_position = subtitle_positions[selected_index][1]
1491 config.ui["subtitle_position"] = params.subtitle_position
1492
1493 if params.subtitle_position == "custom":
1494 saved_custom_position = config.ui.get("custom_position", 70.0)
1495 custom_position = st.text_input(
1496 tr("Custom Position (% from top)"),
1497 value=str(saved_custom_position),
1498 key="custom_position_input",
1499 )
1500 try:
1501 params.custom_position = float(custom_position)
1502 if params.custom_position < 0 or params.custom_position > 100:
1503 st.error(tr("Please enter a value between 0 and 100"))
1504 else:
1505 config.ui["custom_position"] = params.custom_position
1506 except ValueError:
1507 st.error(tr("Please enter a valid number"))
1508
1509 font_cols = st.columns([0.3, 0.7])
1510 with font_cols[0]:
1511 saved_text_fore_color = config.ui.get("text_fore_color", "#FFFFFF")
1512 params.text_fore_color = st.color_picker(
1513 tr("Font Color"), saved_text_fore_color
1514 )
1515 config.ui["text_fore_color"] = params.text_fore_color
1516
1517 with font_cols[1]:
1518 saved_font_size = config.ui.get("font_size", 60)
1519 params.font_size = st.slider(tr("Font Size"), 30, 100, saved_font_size)
1520 config.ui["font_size"] = params.font_size
1521
1522 stroke_cols = st.columns([0.3, 0.7])
1523 with stroke_cols[0]:
1524 params.stroke_color = st.color_picker(tr("Stroke Color"), "#000000")
1525 with stroke_cols[1]:
1526 params.stroke_width = st.slider(tr("Stroke Width"), 0.0, 10.0, 1.5)
1527
1528 subtitle_bg_cols = st.columns([0.4, 0.6])
1529 saved_subtitle_background_enabled = config.ui.get(
1530 "subtitle_background_enabled", True
1531 )
1532 with subtitle_bg_cols[0]:
1533 subtitle_background_enabled = st.checkbox(
1534 tr("Enable Subtitle Background"),
1535 value=saved_subtitle_background_enabled,
1536 )
1537 config.ui["subtitle_background_enabled"] = subtitle_background_enabled
1538 if subtitle_background_enabled:
1539 with subtitle_bg_cols[1]:
1540 saved_subtitle_background_color = config.ui.get(
1541 "subtitle_background_color", "#000000"
1542 )
1543 params.text_background_color = st.color_picker(
1544 tr("Subtitle Background Color"),
1545 saved_subtitle_background_color,
1546 )
1547 config.ui["subtitle_background_color"] = params.text_background_color
1548 else:
1549 params.text_background_color = False
1550
1551 saved_rounded_subtitle_background = config.ui.get(
1552 "rounded_subtitle_background", False
1553 )
1554 # 背景关闭时,圆角背景没有可渲染的底色。这里禁用控件并保留原配置,
1555 # 用户下次重新开启字幕背景后,可以继续使用之前保存的圆角偏好。
1556 params.rounded_subtitle_background = st.checkbox(
1557 tr("Rounded Subtitle Background"),
1558 value=(
1559 saved_rounded_subtitle_background
1560 if subtitle_background_enabled
1561 else False
1562 ),
1563 help=tr("Rounded Subtitle Background Help"),
1564 disabled=not subtitle_background_enabled,
1565 )
1566 if subtitle_background_enabled:
1567 config.ui["rounded_subtitle_background"] = (
1568 params.rounded_subtitle_background
1569 )
1570 with st.expander(tr("Click to show API Key management"), expanded=False):
1571 st.subheader(tr("Manage Pexels, Pixabay and Coverr API Keys"))
1572
1573 col1, col2, col3 = st.tabs([
1574 tr("Pexels API Keys"),
1575 tr("Pixabay API Keys"),
1576 tr("Coverr API Keys"),
1577 ])
1578
1579 with col1:
1580 st.subheader(tr("Pexels API Keys"))
1581 if config.app["pexels_api_keys"]:
1582 st.write(tr("Current Keys:"))
1583 for key in config.app["pexels_api_keys"]:
1584 st.code(key)
1585 else:
1586 st.info(tr("No Pexels API Keys currently"))
1587
1588 new_key = st.text_input(tr("Add Pexels API Key"), key="pexels_new_key")
1589 if st.button(tr("Add Pexels API Key")):
1590 if new_key and new_key not in config.app["pexels_api_keys"]:
1591 config.app["pexels_api_keys"].append(new_key)
1592 config.save_config()
1593 st.success(tr("Pexels API Key added successfully"))
1594 elif new_key in config.app["pexels_api_keys"]:
1595 st.warning(tr("This API Key already exists"))
1596 else:
1597 st.error(tr("Please enter a valid API Key"))
1598
1599 if config.app["pexels_api_keys"]:
1600 delete_key = st.selectbox(
1601 tr("Select Pexels API Key to delete"), config.app["pexels_api_keys"], key="pexels_delete_key"
1602 )
1603 if st.button(tr("Delete Selected Pexels API Key")):
1604 config.app["pexels_api_keys"].remove(delete_key)
1605 config.save_config()
1606 st.success(tr("Pexels API Key deleted successfully"))
1607
1608 with col2:
1609 st.subheader(tr("Pixabay API Keys"))
1610
1611 if config.app["pixabay_api_keys"]:
1612 st.write(tr("Current Keys:"))
1613 for key in config.app["pixabay_api_keys"]:
1614 st.code(key)
1615 else:
1616 st.info(tr("No Pixabay API Keys currently"))
1617
1618 new_key = st.text_input(tr("Add Pixabay API Key"), key="pixabay_new_key")
1619 if st.button(tr("Add Pixabay API Key")):
1620 if new_key and new_key not in config.app["pixabay_api_keys"]:
1621 config.app["pixabay_api_keys"].append(new_key)
1622 config.save_config()
1623 st.success(tr("Pixabay API Key added successfully"))
1624 elif new_key in config.app["pixabay_api_keys"]:
1625 st.warning(tr("This API Key already exists"))
1626 else:
1627 st.error(tr("Please enter a valid API Key"))
1628
1629 if config.app["pixabay_api_keys"]:
1630 delete_key = st.selectbox(
1631 tr("Select Pixabay API Key to delete"), config.app["pixabay_api_keys"], key="pixabay_delete_key"
1632 )
1633 if st.button(tr("Delete Selected Pixabay API Key")):
1634 config.app["pixabay_api_keys"].remove(delete_key)
1635 config.save_config()
1636 st.success(tr("Pixabay API Key deleted successfully"))
1637
1638 with col3:
1639 st.subheader(tr("Coverr API Keys"))
1640
1641 # 与 pexels/pixabay 不同,coverr_api_keys 是 PR 新增配置项,
1642 # 老用户的 config.toml 不一定包含,这里先兜底初始化为空列表,
1643 # 防止下面 .append / 索引访问触发 KeyError。
1644 if "coverr_api_keys" not in config.app or config.app["coverr_api_keys"] is None:
1645 config.app["coverr_api_keys"] = []
1646
1647 if config.app["coverr_api_keys"]:
1648 st.write(tr("Current Keys:"))
1649 for key in config.app["coverr_api_keys"]:
1650 st.code(key)
1651 else:
1652 st.info(tr("No Coverr API Keys currently"))
1653
1654 new_key = st.text_input(tr("Add Coverr API Key"), key="coverr_new_key")
1655 if st.button(tr("Add Coverr API Key")):
1656 if new_key and new_key not in config.app["coverr_api_keys"]:
1657 config.app["coverr_api_keys"].append(new_key)
1658 config.save_config()
1659 st.success(tr("Coverr API Key added successfully"))
1660 elif new_key in config.app["coverr_api_keys"]:
1661 st.warning(tr("This API Key already exists"))
1662 else:
1663 st.error(tr("Please enter a valid API Key"))
1664
1665 if config.app["coverr_api_keys"]:
1666 delete_key = st.selectbox(
1667 tr("Select Coverr API Key to delete"), config.app["coverr_api_keys"], key="coverr_delete_key"
1668 )
1669 if st.button(tr("Delete Selected Coverr API Key")):
1670 config.app["coverr_api_keys"].remove(delete_key)
1671 config.save_config()
1672 st.success(tr("Coverr API Key deleted successfully"))
1673
1674 start_button = st.button(tr("Generate Video"), use_container_width=True, type="primary")
1675 if start_button:
1676 config.save_config()
1677 task_id = str(uuid4())
1678 if not params.video_subject and not params.video_script:
1679 st.error(tr("Video Script and Subject Cannot Both Be Empty"))
1680 scroll_to_bottom()
1681 st.stop()
1682
1683 if params.video_source not in ["pexels", "pixabay", "coverr", "local"]:
1684 st.error(tr("Please Select a Valid Video Source"))
1685 scroll_to_bottom()
1686 st.stop()
1687
1688 if params.video_source == "pexels" and not config.app.get("pexels_api_keys", ""):
1689 st.error(tr("Please Enter the Pexels API Key"))
1690 scroll_to_bottom()
1691 st.stop()
1692
1693 if params.video_source == "pixabay" and not config.app.get("pixabay_api_keys", ""):
1694 st.error(tr("Please Enter the Pixabay API Key"))
1695 scroll_to_bottom()
1696 st.stop()
1697
1698 if params.video_source == "coverr" and not config.app.get("coverr_api_keys", ""):
1699 st.error(tr("Please Enter the Coverr API Key"))
1700 scroll_to_bottom()
1701 st.stop()
1702
1703 if uploaded_audio_file:
1704 task_dir = utils.task_dir(task_id)
1705 # 上传文件名来自浏览器,不能直接拼到磁盘路径里;这里只保留扩展名,
1706 # 并使用固定文件名保存到当前任务目录,避免路径穿越或特殊字符问题。
1707 _, audio_ext = os.path.splitext(os.path.basename(uploaded_audio_file.name))
1708 audio_ext = audio_ext.lower() or ".mp3"
1709 custom_audio_path = os.path.join(task_dir, f"custom-audio{audio_ext}")
1710 with open(custom_audio_path, "wb") as f:
1711 f.write(uploaded_audio_file.getbuffer())
1712 params.custom_audio_file = custom_audio_path
1713
1714 if uploaded_files:
1715 local_videos_dir = utils.storage_dir("local_videos", create=True)
1716 # 每次重新上传时都以本次选择的素材为准,避免旧素材不断重复追加。
1717 params.video_materials = []
1718 persisted_local_materials = []
1719 for file in uploaded_files:
1720 file_path = os.path.join(local_videos_dir, f"{file.file_id}_{file.name}")
1721 with open(file_path, "wb") as f:
1722 f.write(file.getbuffer())
1723 m = MaterialInfo()
1724 m.provider = "local"
1725 m.url = file_path
1726 params.video_materials.append(m)
1727 persisted_local_materials.append(
1728 {
1729 "provider": m.provider,
1730 "url": m.url,
1731 "duration": m.duration,
1732 }
1733 )
1734 # 将已上传并保存到本地的视频素材写入会话,供后续只改文案时直接复用。
1735 st.session_state["local_video_materials"] = persisted_local_materials
1736 elif params.video_source == "local" and st.session_state["local_video_materials"]:
1737 # 当用户没有重新上传文件时,复用最近一次已经保存到磁盘的本地素材列表。
1738 params.video_materials = []
1739 for material in st.session_state["local_video_materials"]:
1740 m = MaterialInfo()
1741 m.provider = material.get("provider", "local")
1742 m.url = material.get("url", "")
1743 m.duration = material.get("duration", 0)
1744 if m.url:
1745 params.video_materials.append(m)
1746
1747 log_container = st.empty()
1748 log_records = []
1749
1750 def log_received(msg):
1751 if config.ui["hide_log"]:
1752 return
1753 with log_container:
1754 log_records.append(msg)
1755 st.code("\n".join(log_records))
1756
1757 logger.add(log_received)
1758
1759 st.toast(tr("Generating Video"))
1760 logger.info(tr("Start Generating Video"))
1761 logger.info(utils.to_json(params))
1762 scroll_to_bottom()
1763
1764 result = tm.start(task_id=task_id, params=params)
1765 if not result or "videos" not in result:
1766 st.error(tr("Video Generation Failed"))
1767 logger.error(tr("Video Generation Failed"))
1768 scroll_to_bottom()
1769 st.stop()
1770
1771 video_files = result.get("videos", [])
1772 st.success(tr("Video Generation Completed"))
1773 try:
1774 if video_files:
1775 player_cols = st.columns(len(video_files) * 2 + 1)
1776 for i, url in enumerate(video_files):
1777 player_cols[i * 2 + 1].video(url)
1778 except Exception:
1779 pass
1780
1781 open_task_folder(task_id)
1782 logger.info(tr("Video Generation Completed"))
1783 scroll_to_bottom()
1784
1785 config.save_config()
1786
1786 lines PYTHON