| 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 |