diff --git a/app/config/defaults.py b/app/config/defaults.py index 9a686f2..a001978 100644 --- a/app/config/defaults.py +++ b/app/config/defaults.py @@ -11,6 +11,21 @@ DEFAULT_VISION_OPENAI_MODEL_NAME = "Qwen/Qwen3.5-122B-A10B" DEFAULT_TEXT_LLM_PROVIDER = DEFAULT_OPENAI_COMPATIBLE_PROVIDER DEFAULT_TEXT_OPENAI_MODEL_NAME = "Pro/zai-org/GLM-5" +DEFAULT_LLM_GENERATION_CONFIG = { + "temperature": 1.0, + "top_p": 0.95, + "max_tokens": 65536, + "thinking_level": "auto", +} + +DEFAULT_LLM_THINKING_LEVELS = ["auto", "off", "low", "medium", "high"] + +DEFAULT_LLM_GENERATION_APP_CONFIG = { + f"{model_type}_openai_{param_name}": value + for model_type in ("vision", "text") + for param_name, value in DEFAULT_LLM_GENERATION_CONFIG.items() +} + DEFAULT_LLM_APP_CONFIG = { "vision_llm_provider": DEFAULT_VISION_LLM_PROVIDER, "vision_openai_model_name": DEFAULT_VISION_OPENAI_MODEL_NAME, @@ -21,6 +36,7 @@ DEFAULT_LLM_APP_CONFIG = { "text_openai_api_key": "", "text_openai_base_url": DEFAULT_OPENAI_COMPATIBLE_BASE_URL, } +DEFAULT_LLM_APP_CONFIG.update(DEFAULT_LLM_GENERATION_APP_CONFIG) def build_default_app_config(app_config: dict | None = None) -> dict: diff --git a/app/config/test_config_bootstrap_unittest.py b/app/config/test_config_bootstrap_unittest.py index 720a934..8398fea 100644 --- a/app/config/test_config_bootstrap_unittest.py +++ b/app/config/test_config_bootstrap_unittest.py @@ -53,9 +53,13 @@ hide_config = true self.assertEqual("openai", config_data["app"]["vision_llm_provider"]) self.assertEqual("Qwen/Qwen3.5-122B-A10B", config_data["app"]["vision_openai_model_name"]) self.assertEqual("https://api.siliconflow.cn/v1", config_data["app"]["vision_openai_base_url"]) + self.assertEqual(1.0, config_data["app"]["vision_openai_temperature"]) + self.assertEqual(0.95, config_data["app"]["vision_openai_top_p"]) self.assertEqual("openai", config_data["app"]["text_llm_provider"]) self.assertEqual("Pro/zai-org/GLM-5", config_data["app"]["text_openai_model_name"]) self.assertEqual("https://api.siliconflow.cn/v1", config_data["app"]["text_openai_base_url"]) + self.assertEqual(1.0, config_data["app"]["text_openai_temperature"]) + self.assertEqual(0.95, config_data["app"]["text_openai_top_p"]) self.assertEqual("Qwen/Qwen3.5-122B-A10B", saved_config["app"]["vision_openai_model_name"]) self.assertEqual("Pro/zai-org/GLM-5", saved_config["app"]["text_openai_model_name"]) self.assertTrue(saved_config["app"]["hide_config"]) diff --git a/app/models/schema.py b/app/models/schema.py index d22d03d..a41b1e1 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -164,6 +164,7 @@ class VideoClipParams(BaseModel): video_clip_json: Optional[list] = Field(default=[], description="LLM 生成的视频剪辑脚本内容") video_clip_json_path: Optional[str] = Field(default="", description="LLM 生成的视频剪辑脚本路径") video_origin_path: Optional[str] = Field(default="", description="原视频路径") + video_origin_paths: Optional[List[str]] = Field(default=[], description="原视频路径列表") video_aspect: Optional[VideoAspect] = Field(default=VideoAspect.portrait.value, description="视频比例") video_language: Optional[str] = Field(default="zh-CN", description="视频语言") @@ -206,4 +207,3 @@ class SubtitlePosition(str, Enum): TOP = "top" CENTER = "center" BOTTOM = "bottom" - diff --git a/app/services/llm/openai_compatible_provider.py b/app/services/llm/openai_compatible_provider.py index b91c6dc..9a2b183 100644 --- a/app/services/llm/openai_compatible_provider.py +++ b/app/services/llm/openai_compatible_provider.py @@ -22,7 +22,7 @@ from openai import ( ) from app.config import config -from app.config.defaults import normalize_openai_compatible_model_name +from app.config.defaults import DEFAULT_LLM_GENERATION_CONFIG, normalize_openai_compatible_model_name from .base import TextModelProvider, VisionModelProvider from .exceptions import APICallError, AuthenticationError, ContentFilterError, RateLimitError @@ -68,18 +68,59 @@ class _OpenAICompatibleBase: # SDK client 按请求参数动态构建,这里无需初始化全局状态。 pass + def _generation_config_value(self, model_type: str, param_name: str, override: Any = None) -> Any: + if override is not None: + return override + return config.app.get( + f"{model_type}_openai_{param_name}", + DEFAULT_LLM_GENERATION_CONFIG[param_name], + ) + + def _build_chat_completion_options( + self, + model_type: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + **kwargs, + ) -> Dict[str, Any]: + """Build common OpenAI-compatible generation options from config and overrides.""" + options: Dict[str, Any] = { + "temperature": float(self._generation_config_value(model_type, "temperature", temperature)), + } + + top_p = float(self._generation_config_value(model_type, "top_p", kwargs.get("top_p"))) + options["top_p"] = top_p + + configured_max_tokens = self._generation_config_value(model_type, "max_tokens", max_tokens) + if configured_max_tokens is not None and int(configured_max_tokens) > 0: + options["max_tokens"] = int(configured_max_tokens) + + extra_body: Dict[str, Any] = {} + + thinking_level = str( + self._generation_config_value(model_type, "thinking_level", kwargs.get("thinking_level")) or "auto" + ) + if thinking_level in {"low", "medium", "high"}: + extra_body["reasoning_effort"] = thinking_level + + if extra_body: + options["extra_body"] = extra_body + + return options + def _build_client( self, api_key_override: Optional[str] = None, base_url_override: Optional[str] = None, timeout_override: Optional[float] = None, + max_retries_override: Optional[int] = None, ) -> AsyncOpenAI: """按请求构建 AsyncOpenAI 客户端,支持动态覆盖 api_key / base_url。""" api_key = api_key_override or self.api_key base_url = base_url_override or self.base_url or None timeout_seconds: float = timeout_override or config.app.get("llm_text_timeout", 180) - max_retries: int = config.app.get("llm_max_retries", 3) + max_retries: int = max_retries_override or config.app.get("llm_max_retries", 3) return AsyncOpenAI( api_key=api_key, @@ -147,11 +188,17 @@ class OpenAICompatibleVisionProvider(_OpenAICompatibleBase, VisionModelProvider) ) try: + generation_overrides = dict(kwargs) + completion_options = self._build_chat_completion_options( + "vision", + temperature=generation_overrides.pop("temperature", None), + max_tokens=generation_overrides.pop("max_tokens", None), + **generation_overrides, + ) response = await client.chat.completions.create( model=model_name, messages=messages, - temperature=kwargs.get("temperature", 1.0), - max_tokens=kwargs.get("max_tokens", 4000), + **completion_options, ) if response.choices and response.choices[0].message and response.choices[0].message.content: return response.choices[0].message.content @@ -204,13 +251,22 @@ class OpenAICompatibleTextProvider(_OpenAICompatibleBase, TextModelProvider): timeout_override=config.app.get("llm_text_timeout", 180), ) + temperature_override = kwargs.pop("temperature", None) + if temperature_override is None and temperature != 1.0: + temperature_override = temperature + completion_kwargs: Dict[str, Any] = { "model": model_name, "messages": messages, - "temperature": temperature, } - if max_tokens: - completion_kwargs["max_tokens"] = max_tokens + completion_kwargs.update( + self._build_chat_completion_options( + "text", + temperature=temperature_override, + max_tokens=kwargs.pop("max_tokens", max_tokens), + **kwargs, + ) + ) if response_format == "json": completion_kwargs["response_format"] = {"type": "json_object"} diff --git a/app/services/llm/test_openai_compat_unittest.py b/app/services/llm/test_openai_compat_unittest.py index acef31a..14b3ab1 100644 --- a/app/services/llm/test_openai_compat_unittest.py +++ b/app/services/llm/test_openai_compat_unittest.py @@ -8,7 +8,7 @@ from app.config import config from app.services.llm.base import TextModelProvider from app.services.llm.manager import LLMServiceManager from app.services.llm.migration_adapter import LegacyLLMAdapter, VisionAnalyzerAdapter -from app.services.llm.openai_compatible_provider import OpenAICompatibleVisionProvider +from app.services.llm.openai_compatible_provider import OpenAICompatibleTextProvider, OpenAICompatibleVisionProvider from app.services.llm.providers import register_all_providers @@ -116,6 +116,59 @@ class OpenAICompatVisionConcurrencyTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(2, max_in_flight) +class OpenAICompatGenerationOptionTests(unittest.TestCase): + def setUp(self): + self._original_app = dict(config.app) + + def tearDown(self): + config.app.clear() + config.app.update(self._original_app) + + def test_build_options_uses_generation_defaults(self): + provider = OpenAICompatibleTextProvider(api_key="k", model_name="m") + for key in ( + "text_openai_temperature", + "text_openai_top_p", + "text_openai_max_tokens", + "text_openai_thinking_level", + ): + config.app.pop(key, None) + + options = provider._build_chat_completion_options("text") + + self.assertEqual(1.0, options["temperature"]) + self.assertEqual(0.95, options["top_p"]) + self.assertEqual(65536, options["max_tokens"]) + self.assertNotIn("extra_body", options) + + def test_build_options_uses_per_model_generation_config(self): + provider = OpenAICompatibleTextProvider(api_key="k", model_name="m") + config.app.update( + { + "text_openai_temperature": 0.3, + "text_openai_top_p": 0.8, + "text_openai_max_tokens": 2048, + "text_openai_thinking_level": "high", + } + ) + + options = provider._build_chat_completion_options("text") + + self.assertEqual(0.3, options["temperature"]) + self.assertEqual(0.8, options["top_p"]) + self.assertEqual(2048, options["max_tokens"]) + self.assertEqual({"reasoning_effort": "high"}, options["extra_body"]) + + def test_explicit_generation_options_override_config(self): + provider = OpenAICompatibleTextProvider(api_key="k", model_name="m") + config.app["text_openai_temperature"] = 0.3 + + options = provider._build_chat_completion_options("text", temperature=0.9, max_tokens=512) + + self.assertEqual(0.9, options["temperature"]) + self.assertEqual(512, options["max_tokens"]) + + class ExplicitVisionAdapterSettingsTests(unittest.IsolatedAsyncioTestCase): class _CapturingVisionProvider: last_init: tuple[str, str, str | None] | None = None diff --git a/app/services/subtitle_corrector.py b/app/services/subtitle_corrector.py new file mode 100644 index 0000000..5f80512 --- /dev/null +++ b/app/services/subtitle_corrector.py @@ -0,0 +1,231 @@ +"""LLM-powered SRT subtitle correction.""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from typing import Any + +from loguru import logger + +from app.services.llm.manager import LLMServiceManager +from app.services.llm.migration_adapter import _run_async_safely +from app.services.llm.unified_service import UnifiedLLMService +from app.services.subtitle_text import has_timecodes, normalize_subtitle_text, read_subtitle_text +from app.utils import utils + + +class SubtitleCorrectionError(RuntimeError): + """Raised when subtitle correction cannot produce a valid SRT.""" + + +_TIME_LINE_RE = re.compile( + r"^\s*\d{2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[,.]\d{3}(?:\s+.*)?$" +) +_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE) + + +@dataclass(frozen=True) +class SubtitleBlock: + order: int + index_line: str + time_line: str + text: str + + +def _ensure_llm_providers_registered() -> None: + if LLMServiceManager.is_registered(): + return + from app.services.llm.providers import register_all_providers + + register_all_providers() + + +def parse_srt_blocks(srt_content: str) -> list[SubtitleBlock]: + normalized = normalize_subtitle_text(srt_content) + if not normalized or not has_timecodes(normalized): + raise SubtitleCorrectionError("字幕内容为空或未检测到有效 SRT 时间轴") + + blocks: list[SubtitleBlock] = [] + raw_blocks = re.split(r"\n\s*\n", normalized) + for raw_block in raw_blocks: + lines = [line.rstrip() for line in raw_block.splitlines() if line.strip()] + if not lines: + continue + + if len(lines) >= 2 and _TIME_LINE_RE.match(lines[1]): + index_line = lines[0].strip() + time_line = lines[1].strip() + text = "\n".join(lines[2:]).strip() + elif _TIME_LINE_RE.match(lines[0]): + index_line = str(len(blocks) + 1) + time_line = lines[0].strip() + text = "\n".join(lines[1:]).strip() + else: + raise SubtitleCorrectionError(f"无法解析字幕块: {raw_block[:80]}") + + blocks.append( + SubtitleBlock( + order=len(blocks) + 1, + index_line=index_line, + time_line=time_line, + text=text, + ) + ) + + if not blocks: + raise SubtitleCorrectionError("字幕内容为空或未检测到有效字幕块") + return blocks + + +def _build_correction_prompt(blocks: list[SubtitleBlock]) -> str: + payload = [ + { + "id": block.order, + "time": block.time_line, + "text": block.text, + } + for block in blocks + ] + return f""" +请校准以下 SRT 字幕文本中的明显语音识别错误。字幕可能是中文、英文、日文、韩文或其他语言,也可能包含多语言混合内容。 + +校准要求: +1. 先结合全部字幕内容识别原语言和语境,保持原语言输出;多语言混合内容也要保持原有语言混合方式。 +2. 只纠正明显的 ASR 错字、拼写错误、同音或近音误识别、词形误识别、专有名词前后不一致。 +3. 不要润色、扩写、改写句意,不要翻译,不要增删剧情信息。 +4. 不要修改时间轴、序号、条目数量或条目顺序。 +5. 不确定的内容保持原样。 +6. 保留必要的说话人标记、标点和换行。 + +只输出严格 JSON,不要输出 Markdown 或解释文字。格式必须为: +{{"items":[{{"id":1,"text":"校准后的字幕文本"}}]}} + +待校准字幕条目: +{json.dumps(payload, ensure_ascii=False, indent=2)} +""".strip() + + +def _extract_json_text(raw_output: str) -> str: + text = str(raw_output or "").strip() + block_match = _JSON_BLOCK_RE.search(text) + if block_match: + return block_match.group(1).strip() + + if not text.startswith(("{", "[")): + starts = [pos for pos in (text.find("{"), text.find("[")) if pos >= 0] + if starts: + start = min(starts) + end = max(text.rfind("}"), text.rfind("]")) + if end > start: + return text[start:end + 1] + return text + + +def _parse_corrections(raw_output: str, expected_ids: set[int]) -> dict[int, str]: + json_text = _extract_json_text(raw_output) + try: + data = json.loads(json_text) + except json.JSONDecodeError as exc: + raise SubtitleCorrectionError("LLM 未返回有效 JSON 字幕校准结果") from exc + + if isinstance(data, dict) and "items" in data: + items = data["items"] + elif isinstance(data, list): + items = data + elif isinstance(data, dict): + items = [{"id": key, "text": value} for key, value in data.items()] + else: + raise SubtitleCorrectionError("LLM 字幕校准结果格式无效") + + corrections: dict[int, str] = {} + if not isinstance(items, list): + raise SubtitleCorrectionError("LLM 字幕校准结果缺少 items 列表") + + for item in items: + if not isinstance(item, dict): + continue + try: + item_id = int(item.get("id")) + except (TypeError, ValueError): + continue + if item_id in expected_ids: + corrections[item_id] = str(item.get("text") or "").strip() + + missing_ids = sorted(expected_ids - set(corrections.keys())) + if missing_ids: + raise SubtitleCorrectionError(f"LLM 字幕校准结果缺少字幕条目: {missing_ids[:10]}") + return corrections + + +def _render_srt(blocks: list[SubtitleBlock], corrections: dict[int, str]) -> str: + rendered_blocks = [] + for block in blocks: + corrected_text = corrections.get(block.order, "").strip() or block.text + rendered_blocks.append(f"{block.index_line}\n{block.time_line}\n{corrected_text}") + return "\n\n".join(rendered_blocks).rstrip() + "\n" + + +def correct_srt_content( + srt_content: str, + *, + provider: str = "", + api_key: str = "", + base_url: str = "", + temperature: float = 0.1, +) -> str: + blocks = parse_srt_blocks(srt_content) + _ensure_llm_providers_registered() + + logger.info(f"开始校准字幕,共 {len(blocks)} 条") + prompt = _build_correction_prompt(blocks) + raw_output = _run_async_safely( + UnifiedLLMService.generate_text, + prompt=prompt, + system_prompt="你是一位专业的多语言字幕校对员,擅长修正 ASR 语音识别造成的明显错字、拼写错误、同音或近音误识别,同时严格保留字幕结构和原语言。", + provider=provider, + temperature=temperature, + response_format="json", + api_key=api_key, + api_base=base_url, + ) + corrections = _parse_corrections(raw_output, {block.order for block in blocks}) + corrected_srt = _render_srt(blocks, corrections) + logger.info("字幕校准完成") + return corrected_srt + + +def write_srt_file(srt_content: str, subtitle_file: str = "") -> str: + if not subtitle_file: + subtitle_file = os.path.join(utils.subtitle_dir(), "subtitle_corrected.srt") + parent = os.path.dirname(subtitle_file) + if parent: + os.makedirs(parent, exist_ok=True) + with open(subtitle_file, "w", encoding="utf-8") as f: + f.write(srt_content) + return subtitle_file + + +def correct_subtitle_file( + subtitle_file: str, + output_file: str = "", + *, + provider: str = "", + api_key: str = "", + base_url: str = "", + temperature: float = 0.1, +) -> str: + if not subtitle_file or not os.path.isfile(subtitle_file): + raise SubtitleCorrectionError(f"字幕文件不存在: {subtitle_file}") + + decoded = read_subtitle_text(subtitle_file) + corrected_srt = correct_srt_content( + decoded.text, + provider=provider, + api_key=api_key, + base_url=base_url, + temperature=temperature, + ) + return write_srt_file(corrected_srt, output_file) diff --git a/app/services/test_subtitle_corrector_unittest.py b/app/services/test_subtitle_corrector_unittest.py new file mode 100644 index 0000000..9afda81 --- /dev/null +++ b/app/services/test_subtitle_corrector_unittest.py @@ -0,0 +1,100 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from app.services import subtitle_corrector as corrector + + +SAMPLE_SRT = """1 +00:00:01,000 --> 00:00:03,000 +今天我们来看张三的顾是 + +2 +00:00:04,000 --> 00:00:06,000 +他来到北精找李四 +""" + + +class SubtitleCorrectorTests(unittest.TestCase): + def test_correct_srt_content_preserves_timecodes_and_rebuilds_text(self): + llm_output = { + "items": [ + {"id": 1, "text": "今天我们来看张三的故事"}, + {"id": 2, "text": "他来到北京找李四"}, + ] + } + + with ( + mock.patch("app.services.subtitle_corrector._ensure_llm_providers_registered"), + mock.patch( + "app.services.subtitle_corrector._run_async_safely", + return_value=json.dumps(llm_output, ensure_ascii=False), + ) as run_llm, + ): + corrected = corrector.correct_srt_content( + SAMPLE_SRT, + provider="openai", + api_key="sk-test", + base_url="https://llm.example/v1", + ) + + self.assertIn("00:00:01,000 --> 00:00:03,000", corrected) + self.assertIn("今天我们来看张三的故事", corrected) + self.assertIn("他来到北京找李四", corrected) + self.assertNotIn("顾是", corrected) + + call_kwargs = run_llm.call_args.kwargs + self.assertEqual("openai", call_kwargs["provider"]) + self.assertEqual("sk-test", call_kwargs["api_key"]) + self.assertEqual("https://llm.example/v1", call_kwargs["api_base"]) + self.assertEqual("json", call_kwargs["response_format"]) + self.assertIn("多语言字幕校对员", call_kwargs["system_prompt"]) + self.assertIn("保持原语言", call_kwargs["prompt"]) + + def test_correct_srt_content_rejects_missing_items(self): + llm_output = {"items": [{"id": 1, "text": "今天我们来看张三的故事"}]} + + with ( + mock.patch("app.services.subtitle_corrector._ensure_llm_providers_registered"), + mock.patch( + "app.services.subtitle_corrector._run_async_safely", + return_value=json.dumps(llm_output, ensure_ascii=False), + ), + ): + with self.assertRaises(corrector.SubtitleCorrectionError): + corrector.correct_srt_content(SAMPLE_SRT, provider="openai") + + def test_correct_subtitle_file_writes_corrected_srt(self): + llm_output = { + "items": [ + {"id": 1, "text": "今天我们来看张三的故事"}, + {"id": 2, "text": "他来到北京找李四"}, + ] + } + + with tempfile.TemporaryDirectory() as tmp_dir: + input_file = Path(tmp_dir) / "input.srt" + output_file = Path(tmp_dir) / "output.srt" + input_file.write_text(SAMPLE_SRT, encoding="utf-8") + + with ( + mock.patch("app.services.subtitle_corrector._ensure_llm_providers_registered"), + mock.patch( + "app.services.subtitle_corrector._run_async_safely", + return_value=json.dumps(llm_output, ensure_ascii=False), + ), + ): + result_path = corrector.correct_subtitle_file( + str(input_file), + str(output_file), + provider="openai", + ) + + self.assertEqual(str(output_file), result_path) + self.assertIn("北京", output_file.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/config.example.toml b/config.example.toml index 805610b..2df60dc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -25,6 +25,10 @@ vision_openai_model_name = "Qwen/Qwen3.5-122B-A10B" vision_openai_api_key = "" # 填入对应 provider 的 API key vision_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL(官方 OpenAI 可留空) + vision_openai_temperature = 1.0 + vision_openai_top_p = 0.95 + vision_openai_max_tokens = 65536 + vision_openai_thinking_level = "auto" # auto/off/low/medium/high # ===== 文本模型配置 ===== text_llm_provider = "openai" @@ -40,6 +44,10 @@ text_openai_model_name = "Pro/zai-org/GLM-5" text_openai_api_key = "" # 填入对应 provider 的 API key text_openai_base_url = "https://api.siliconflow.cn/v1" # 可选:自定义 API base URL(官方 OpenAI 可留空) + text_openai_temperature = 1.0 + text_openai_top_p = 0.95 + text_openai_max_tokens = 65536 + text_openai_thinking_level = "auto" # auto/off/low/medium/high # ===== API Keys 参考 ===== # 主流 LLM Providers API Key 获取地址: diff --git a/webui/components/basic_settings.py b/webui/components/basic_settings.py index 39d9904..a8185bc 100644 --- a/webui/components/basic_settings.py +++ b/webui/components/basic_settings.py @@ -4,6 +4,8 @@ import streamlit as st import os from app.config import config from app.config.defaults import ( + DEFAULT_LLM_GENERATION_CONFIG, + DEFAULT_LLM_THINKING_LEVELS, DEFAULT_OPENAI_COMPATIBLE_BASE_URL, DEFAULT_OPENAI_COMPATIBLE_PROVIDER, DEFAULT_TEXT_LLM_PROVIDER, @@ -87,7 +89,7 @@ def validate_openai_compatible_model_name(model_name: str, model_type: str) -> t Args: model_name: 模型名称,应为 provider/model 格式 - model_type: 模型类型(如"视频分析"、"文案生成") + model_type: 模型类型(如"视觉分析"、"文案生成") Returns: (是否有效, 错误消息) @@ -149,6 +151,104 @@ def update_app_config_if_changed(key: str, value) -> bool: return True +def render_openai_compatible_protocol_field(tr, label_key: str, key: str) -> None: + """Render the fixed OpenAI-compatible protocol as a non-selectable field.""" + st.text_input( + tr(label_key), + value=tr("OpenAI compatible protocol"), + help=tr("OpenAI compatible protocol help"), + disabled=True, + key=key, + ) + + +def get_generation_config_value(model_prefix: str, param_name: str): + """Read a per-model generation parameter with a shared default.""" + config_key = f"{model_prefix}_openai_{param_name}" + if config_key in config.app: + return config.app.get(config_key) + + if model_prefix == "text" and param_name == "temperature": + return st.session_state.get("temperature", DEFAULT_LLM_GENERATION_CONFIG[param_name]) + + return DEFAULT_LLM_GENERATION_CONFIG[param_name] + + +def render_llm_generation_settings(tr, model_prefix: str) -> dict: + """Render generation parameters directly below a model's Base URL.""" + st.markdown(f"**{tr('Generation Settings')}**") + + row1 = st.columns(2) + with row1[0]: + temperature = st.slider( + tr("Sampling Temperature"), + min_value=0.0, + max_value=2.0, + value=float(get_generation_config_value(model_prefix, "temperature")), + step=0.05, + help=tr("Sampling Temperature Help"), + key=f"{model_prefix}_openai_temperature_input", + ) + with row1[1]: + top_p = st.slider( + tr("Top P"), + min_value=0.0, + max_value=1.0, + value=float(get_generation_config_value(model_prefix, "top_p")), + step=0.05, + help=tr("Top P Help"), + key=f"{model_prefix}_openai_top_p_input", + ) + + row2 = st.columns(2) + with row2[0]: + max_tokens = st.number_input( + tr("Max Output Tokens"), + min_value=0, + max_value=200000, + value=int(get_generation_config_value(model_prefix, "max_tokens")), + step=256, + help=tr("Max Output Tokens Help"), + key=f"{model_prefix}_openai_max_tokens_input", + ) + with row2[1]: + current_thinking_level = str(get_generation_config_value(model_prefix, "thinking_level") or "auto") + if current_thinking_level not in DEFAULT_LLM_THINKING_LEVELS: + current_thinking_level = "auto" + + thinking_level = st.selectbox( + tr("Thinking Level"), + options=DEFAULT_LLM_THINKING_LEVELS, + index=DEFAULT_LLM_THINKING_LEVELS.index(current_thinking_level), + format_func=lambda level: tr(f"Thinking Level {level.title()}"), + help=tr("Thinking Level Help"), + key=f"{model_prefix}_openai_thinking_level_input", + ) + + params = { + "temperature": round(float(temperature), 2), + "top_p": round(float(top_p), 2), + "max_tokens": int(max_tokens), + "thinking_level": thinking_level, + } + + if model_prefix == "text": + st.session_state["temperature"] = params["temperature"] + + return params + + +def save_llm_generation_settings(model_prefix: str, params: dict) -> bool: + """Persist per-model generation parameters in app config.""" + changed = False + for param_name, value in params.items(): + config_key = f"{model_prefix}_openai_{param_name}" + changed |= update_app_config_if_changed(config_key, value) + st.session_state[config_key] = value + + return changed + + def render_basic_settings(tr): """渲染基础设置面板""" with st.expander(tr("Basic Settings"), expanded=False): @@ -162,20 +262,18 @@ def render_basic_settings(tr): render_proxy_settings(tr) with middle_config_panel: - render_vision_llm_settings(tr) # 视频分析模型设置 + render_vision_llm_settings(tr) # 视觉分析模型设置 with right_config_panel: render_text_llm_settings(tr) # 文案生成模型设置 - render_generation_settings(tr) - def render_generation_settings(tr): """渲染通用生成参数。""" st.divider() st.subheader(tr("Generation Settings")) if 'temperature' not in st.session_state: - st.session_state['temperature'] = 0.7 + st.session_state['temperature'] = DEFAULT_LLM_GENERATION_CONFIG["temperature"] st.slider("temperature", 0.0, 2.0, key="temperature") @@ -455,7 +553,7 @@ def test_openai_compatible_text_model(api_key: str, base_url: str, model_name: s return False, f"连接失败: {error_msg}" def render_vision_llm_settings(tr): - """渲染视频分析模型设置(OpenAI 兼容 统一配置)""" + """渲染视觉分析模型设置(OpenAI 兼容 统一配置)""" st.subheader(tr("Vision Model Settings")) # 固定使用 OpenAI 兼容 提供商 @@ -467,23 +565,20 @@ def render_vision_llm_settings(tr): vision_base_url = config.app.get("vision_openai_base_url", DEFAULT_OPENAI_COMPATIBLE_BASE_URL) # 固定 provider 为 openai,模型输入框保留完整模型名称 - current_provider, current_model = get_openai_compatible_ui_values( + _current_provider, current_model = get_openai_compatible_ui_values( full_vision_model_name, DEFAULT_VISION_OPENAI_MODEL_NAME, provider=DEFAULT_VISION_LLM_PROVIDER, ) - - # 定义支持的 provider 列表 - OPENAI_COMPATIBLE_PROVIDERS = ["openai"] + selected_provider = DEFAULT_VISION_LLM_PROVIDER # 渲染配置输入框 col1, col2 = st.columns([1, 2]) with col1: - selected_provider = st.selectbox( - tr("Vision Model Provider"), - options=OPENAI_COMPATIBLE_PROVIDERS, - index=OPENAI_COMPATIBLE_PROVIDERS.index(current_provider) if current_provider in OPENAI_COMPATIBLE_PROVIDERS else 0, - key="vision_provider_select" + render_openai_compatible_protocol_field( + tr, + "Vision Model Provider", + key="vision_openai_protocol_display", ) with col2: @@ -532,6 +627,8 @@ def render_vision_llm_settings(tr): info_example = vision_placeholder or "https://your-openai-compatible-endpoint/v1" st.info(tr("Please fill OpenAI compatible gateway").format(example=info_example)) + vision_generation_params = render_llm_generation_settings(tr, "vision") + # 添加测试连接按钮 if st.button(tr("Test Connection"), key="test_vision_connection"): test_errors = [] @@ -559,7 +656,7 @@ def render_vision_llm_settings(tr): st.error(message) except Exception as e: st.error(f"{tr('Connection test error')}: {str(e)}") - logger.error(f"OpenAI 兼容 视频分析模型连接测试失败: {str(e)}") + logger.error(f"OpenAI 兼容 视觉分析模型连接测试失败: {str(e)}") # 验证和保存配置 validation_errors = [] @@ -568,7 +665,7 @@ def render_vision_llm_settings(tr): # 验证模型名称 if st_vision_model_name: # 这里的验证逻辑可能需要微调,因为我们现在是自动组合的 - is_valid, error_msg = validate_openai_compatible_model_name(st_vision_model_name, "视频分析") + is_valid, error_msg = validate_openai_compatible_model_name(st_vision_model_name, "视觉分析") if is_valid: config_changed |= update_app_config_if_changed( "vision_openai_model_name", @@ -580,7 +677,7 @@ def render_vision_llm_settings(tr): # 验证 API 密钥 if st_vision_api_key: - is_valid, error_msg = validate_api_key(st_vision_api_key, "视频分析") + is_valid, error_msg = validate_api_key(st_vision_api_key, "视觉分析") if is_valid: config_changed |= update_app_config_if_changed( "vision_openai_api_key", @@ -592,7 +689,7 @@ def render_vision_llm_settings(tr): # 验证 Base URL(可选) if st_vision_base_url: - is_valid, error_msg = validate_base_url(st_vision_base_url, "视频分析") + is_valid, error_msg = validate_base_url(st_vision_base_url, "视觉分析") if is_valid: config_changed |= update_app_config_if_changed( "vision_openai_base_url", @@ -602,6 +699,8 @@ def render_vision_llm_settings(tr): else: validation_errors.append(error_msg) + config_changed |= save_llm_generation_settings("vision", vision_generation_params) + # 显示验证错误 show_config_validation_errors(validation_errors) @@ -615,7 +714,7 @@ def render_vision_llm_settings(tr): st.success(tr("Vision model config saved")) except Exception as e: st.error(f"{tr('Failed to save config')}: {str(e)}") - logger.error(f"保存视频分析配置失败: {str(e)}") + logger.error(f"保存视觉分析配置失败: {str(e)}") def test_text_model_connection(api_key, base_url, model_name, provider, tr): @@ -734,23 +833,20 @@ def render_text_llm_settings(tr): text_base_url = config.app.get("text_openai_base_url", DEFAULT_OPENAI_COMPATIBLE_BASE_URL) # 固定 provider 为 openai,模型输入框保留完整模型名称 - current_provider, current_model = get_openai_compatible_ui_values( + _current_provider, current_model = get_openai_compatible_ui_values( full_text_model_name, DEFAULT_TEXT_OPENAI_MODEL_NAME, provider=DEFAULT_TEXT_LLM_PROVIDER, ) - - # 定义支持的 provider 列表 - OPENAI_COMPATIBLE_PROVIDERS = ["openai"] + selected_provider = DEFAULT_TEXT_LLM_PROVIDER # 渲染配置输入框 col1, col2 = st.columns([1, 2]) with col1: - selected_provider = st.selectbox( - tr("Text Model Provider"), - options=OPENAI_COMPATIBLE_PROVIDERS, - index=OPENAI_COMPATIBLE_PROVIDERS.index(current_provider) if current_provider in OPENAI_COMPATIBLE_PROVIDERS else 0, - key="text_provider_select" + render_openai_compatible_protocol_field( + tr, + "Text Model Provider", + key="text_openai_protocol_display", ) with col2: @@ -801,6 +897,8 @@ def render_text_llm_settings(tr): info_example = text_placeholder or "https://your-openai-compatible-endpoint/v1" st.info(tr("Please fill OpenAI compatible gateway").format(example=info_example)) + text_generation_params = render_llm_generation_settings(tr, "text") + # 添加测试连接按钮 if st.button(tr("Test Connection"), key="test_text_connection"): test_errors = [] @@ -870,6 +968,8 @@ def render_text_llm_settings(tr): else: text_validation_errors.append(error_msg) + text_config_changed |= save_llm_generation_settings("text", text_generation_params) + # 显示验证错误 show_config_validation_errors(text_validation_errors) diff --git a/webui/components/script_settings.py b/webui/components/script_settings.py index 3c91c44..9b03457 100644 --- a/webui/components/script_settings.py +++ b/webui/components/script_settings.py @@ -18,6 +18,114 @@ from webui.tools.generate_short_summary import analyze_short_drama_plot, generat SCRIPT_TABLE_BASE_COLUMNS = ["_id", "timestamp", "picture", "narration", "OST"] +VIDEO_UPLOAD_TYPES = ["mp4", "mov", "avi", "flv", "mkv", "mpeg4"] +VIDEO_GLOB_PATTERNS = [f"*.{suffix}" for suffix in VIDEO_UPLOAD_TYPES] + + +def _normalize_video_paths(paths): + if isinstance(paths, str): + paths = [paths] + if not paths: + return [] + + normalized_paths = [] + seen = set() + for path in paths: + if not isinstance(path, str): + continue + path = path.strip() + if not path or path in seen: + continue + normalized_paths.append(path) + seen.add(path) + return normalized_paths + + +def _set_video_origin_state(paths, params=None): + video_paths = _normalize_video_paths(paths) + first_video_path = video_paths[0] if video_paths else "" + st.session_state['video_origin_paths'] = video_paths + st.session_state['video_origin_path'] = first_video_path + if params is not None: + params.video_origin_path = first_video_path + params.video_origin_paths = video_paths + + +def _selected_video_paths(): + video_paths = _normalize_video_paths(st.session_state.get('video_origin_paths', [])) + if not video_paths: + video_paths = _normalize_video_paths(st.session_state.get('video_origin_path', '')) + return video_paths + + +def _uploaded_files_signature(uploaded_files): + return "|".join(f"{uploaded_file.name}:{uploaded_file.size}" for uploaded_file in uploaded_files) + + +def _unique_file_path(directory, filename): + safe_filename = os.path.basename(filename).strip() + if not safe_filename: + safe_filename = f"video_{int(time.time())}.mp4" + + os.makedirs(directory, exist_ok=True) + file_name, file_extension = os.path.splitext(safe_filename) + candidate_path = os.path.join(directory, safe_filename) + if not os.path.exists(candidate_path): + return candidate_path + + timestamp = time.strftime("%Y%m%d%H%M%S") + counter = 1 + while True: + suffix = f"_{timestamp}" if counter == 1 else f"_{timestamp}_{counter}" + candidate_path = os.path.join(directory, f"{file_name}{suffix}{file_extension}") + if not os.path.exists(candidate_path): + return candidate_path + counter += 1 + + +def _format_file_list_for_display(paths, max_items=3): + file_names = [os.path.basename(path) for path in _normalize_video_paths(paths)] + if len(file_names) <= max_items: + return ", ".join(file_names) + visible_names = ", ".join(file_names[:max_items]) + return f"{visible_names} +{len(file_names) - max_items}" + + +def _read_subtitle_file(path): + try: + return read_subtitle_text(path).text + except Exception: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def _build_combined_subtitle_content(subtitle_paths): + sections = [] + subtitle_contents = {} + for subtitle_path in subtitle_paths: + if not subtitle_path or not os.path.exists(subtitle_path): + continue + content = _read_subtitle_file(subtitle_path) + subtitle_contents[subtitle_path] = content + sections.append(f"# {os.path.basename(subtitle_path)}\n{content}".strip()) + return "\n\n".join(sections), subtitle_contents + + +def _selected_subtitle_paths(): + subtitle_paths = _normalize_video_paths(st.session_state.get('subtitle_paths', [])) + if not subtitle_paths: + subtitle_paths = _normalize_video_paths(st.session_state.get('subtitle_path', '')) + return subtitle_paths + + +def _set_subtitle_state(subtitle_paths): + subtitle_paths = _normalize_video_paths(subtitle_paths) + subtitle_content, subtitle_contents = _build_combined_subtitle_content(subtitle_paths) + st.session_state['subtitle_path'] = subtitle_paths[0] if subtitle_paths else None + st.session_state['subtitle_paths'] = subtitle_paths + st.session_state['subtitle_content'] = subtitle_content if subtitle_content else None + st.session_state['subtitle_contents'] = subtitle_contents + st.session_state['subtitle_file_processed'] = bool(subtitle_paths) def render_script_panel(tr): @@ -242,12 +350,12 @@ def render_video_file(tr, params): } source_labels = list(source_options.keys()) default_source_label = source_labels[0] - source_default_version = "upload_first_v1" + source_default_version = "upload_first_v2" if st.session_state.get('_video_source_default_version') != source_default_version: if ( st.session_state.get('video_source_selection') not in source_options - or not st.session_state.get('video_origin_path') + or not _selected_video_paths() ): st.session_state['video_source_selection'] = default_source_label st.session_state['_video_source_default_version'] = source_default_version @@ -258,7 +366,7 @@ def render_video_file(tr, params): source_caption = ( tr("Select a video from resource videos directory") if source_options[current_source] == "resource" - else tr("Upload a new video file up to 2GB") + else tr("Upload new video files up to 2GB each") ) st.markdown(f"**{tr('Video Source')}** :gray[{source_caption}]") @@ -275,7 +383,7 @@ def render_video_file(tr, params): if source_options[source] == "resource": video_files = [] - for suffix in ["*.mp4", "*.mov", "*.avi", "*.flv", "*.mkv", "*.mpeg4"]: + for suffix in VIDEO_GLOB_PATTERNS: video_files.extend(glob.glob(os.path.join(utils.video_dir(), suffix))) video_files = sorted(video_files, key=os.path.getctime, reverse=True) @@ -299,59 +407,62 @@ def render_video_file(tr, params): ) if video_path: - st.session_state['video_origin_path'] = video_path - params.video_origin_path = video_path + _set_video_origin_state([video_path], params) else: - st.session_state['video_origin_path'] = "" - params.video_origin_path = "" + _set_video_origin_state([], params) if not video_files: st.info(tr("No video files found in resource videos directory")) return if source_options[source] == "upload": - uploaded_file = st.file_uploader( + uploaded_files = st.file_uploader( tr("Upload Video"), - type=["mp4", "mov", "avi", "flv", "mkv", "mpeg4"], - accept_multiple_files=False, + type=VIDEO_UPLOAD_TYPES, + accept_multiple_files=True, key="video_file_uploader", ) - if uploaded_file is None: - st.session_state['video_origin_path'] = "" - params.video_origin_path = "" + if not uploaded_files: + _set_video_origin_state([], params) st.session_state['video_file_processed'] = False st.session_state['uploaded_video_path'] = "" + st.session_state['uploaded_video_paths'] = [] st.session_state['uploaded_video_signature'] = "" else: - uploaded_signature = f"{uploaded_file.name}:{uploaded_file.size}" - uploaded_video_path = st.session_state.get('uploaded_video_path', '') + uploaded_signature = _uploaded_files_signature(uploaded_files) + uploaded_video_paths = _normalize_video_paths(st.session_state.get('uploaded_video_paths', [])) is_processed = ( st.session_state.get('video_file_processed', False) and st.session_state.get('uploaded_video_signature') == uploaded_signature - and uploaded_video_path + and uploaded_video_paths + and all(os.path.exists(path) for path in uploaded_video_paths) ) if is_processed: - st.session_state['video_origin_path'] = uploaded_video_path - params.video_origin_path = uploaded_video_path + _set_video_origin_state(uploaded_video_paths, params) else: - safe_filename = os.path.basename(uploaded_file.name) - video_file_path = os.path.join(utils.video_dir(), safe_filename) - file_name, file_extension = os.path.splitext(safe_filename) + video_paths = [] + for uploaded_file in uploaded_files: + video_file_path = _unique_file_path(utils.video_dir(), uploaded_file.name) + with open(video_file_path, "wb") as f: + f.write(uploaded_file.read()) + video_paths.append(video_file_path) - if os.path.exists(video_file_path): - timestamp = time.strftime("%Y%m%d%H%M%S") - file_name_with_timestamp = f"{file_name}_{timestamp}" - video_file_path = os.path.join(utils.video_dir(), file_name_with_timestamp + file_extension) - - with open(video_file_path, "wb") as f: - f.write(uploaded_file.read()) - st.session_state['video_origin_path'] = video_file_path - params.video_origin_path = video_file_path - st.session_state['uploaded_video_path'] = video_file_path + _set_video_origin_state(video_paths, params) + st.session_state['uploaded_video_path'] = video_paths[0] if video_paths else "" + st.session_state['uploaded_video_paths'] = video_paths st.session_state['uploaded_video_signature'] = uploaded_signature st.session_state['video_file_processed'] = True + current_video_paths = _selected_video_paths() + if current_video_paths: + st.info( + tr("Selected videos for processing").format( + count=len(current_video_paths), + files=_format_file_list_for_display(current_video_paths), + ) + ) + def render_short_generate_options(tr): """ @@ -457,18 +568,38 @@ def short_drama_summary(tr): def render_subtitle_preview(tr): """渲染可折叠的当前字幕预览;没有字幕时提示用户先转写或上传。""" - subtitle_path = st.session_state.get('subtitle_path', '') + subtitle_paths = _selected_subtitle_paths() subtitle_content = st.session_state.get('subtitle_content', '') + subtitle_contents = st.session_state.get('subtitle_contents', {}) + if not isinstance(subtitle_contents, dict): + subtitle_contents = {} - if subtitle_path and not subtitle_content and os.path.exists(subtitle_path): - subtitle_content = read_subtitle_text(subtitle_path).text + if subtitle_paths and (not subtitle_content or not subtitle_contents): + subtitle_content, subtitle_contents = _build_combined_subtitle_content(subtitle_paths) st.session_state['subtitle_content'] = subtitle_content + st.session_state['subtitle_contents'] = subtitle_contents with st.expander(tr("Subtitle Preview"), expanded=False): - if not subtitle_path or not subtitle_content: + if not subtitle_paths or not subtitle_content: st.info(tr("Please transcribe or upload subtitles first")) return + if len(subtitle_paths) > 1: + for index, path in enumerate(subtitle_paths, start=1): + content = subtitle_contents.get(path, "") + if not content and os.path.exists(path): + content = _read_subtitle_file(path) + st.markdown(f"**{index}. {os.path.basename(path)}**") + st.text_area( + tr("Subtitle Preview"), + value=content, + height=180, + label_visibility="collapsed", + disabled=True, + key=f"subtitle_content_preview_{index}", + ) + return + st.text_area( tr("Subtitle Preview"), key="subtitle_content", @@ -496,9 +627,7 @@ def render_subtitle_upload(tr): if 'subtitle_path' in st.session_state and st.session_state['subtitle_path']: st.info(tr("Uploaded subtitle").format(file=os.path.basename(st.session_state['subtitle_path']))) if st.button(tr("清除已上传字幕")): - st.session_state['subtitle_path'] = None - st.session_state['subtitle_content'] = None - st.session_state['subtitle_file_processed'] = False + _set_subtitle_state([]) st.rerun() # 只有当有文件上传且尚未处理时才执行处理逻辑 @@ -539,9 +668,7 @@ def render_subtitle_upload(tr): f"({tr('Encoding')}: {detected_encoding.upper()}, " f"{tr('Size')}: {len(script_content)} {tr('Characters')})" ) - st.session_state['subtitle_path'] = script_file_path - st.session_state['subtitle_content'] = script_content - st.session_state['subtitle_file_processed'] = True # 标记已处理 + _set_subtitle_state([script_file_path]) # 避免使用rerun,使用更新状态的方式 # st.rerun() @@ -688,9 +815,7 @@ def render_video_script_editor(tr): def render_fun_asr_transcription(tr): """使用 Fun-ASR 从本地音视频转写生成字幕。""" def clear_fun_asr_subtitle_state(): - st.session_state['subtitle_path'] = None - st.session_state['subtitle_content'] = None - st.session_state['subtitle_file_processed'] = False + _set_subtitle_state([]) from app.services import fun_asr_subtitle @@ -714,7 +839,7 @@ def render_fun_asr_transcription(tr): api_url = config.fun_asr.get("api_url", fun_asr_subtitle.LOCAL_FUN_ASR_API_URL) hotword = config.fun_asr.get("hotword", "") enable_spk = bool(config.fun_asr.get("enable_spk", False)) - media_path = st.session_state.get('video_origin_path', '') + media_paths = _selected_video_paths() subtitle_cols = st.columns([3, 2], vertical_alignment="top") @@ -768,23 +893,92 @@ def render_fun_asr_transcription(tr): ) if backend != "upload": - if media_path: - st.info( - tr("Using selected video for subtitle transcription").format( - file=os.path.basename(media_path) + if media_paths: + if len(media_paths) == 1: + st.info( + tr("Using selected video for subtitle transcription").format( + file=os.path.basename(media_paths[0]) + ) + ) + else: + st.info( + tr("Using selected videos for subtitle transcription").format( + count=len(media_paths), + files=_format_file_list_for_display(media_paths), + ) ) - ) else: st.warning(tr("Please select or upload a video first")) - can_transcribe = backend != "upload" and bool(media_path) + # 上传字幕面板会在本轮渲染中更新 session_state,这里重新读取一次,保证按钮状态同步。 + subtitle_paths = _selected_subtitle_paths() + can_transcribe = backend != "upload" and bool(media_paths) + can_correct_subtitles = bool(subtitle_paths) with subtitle_cols[1]: - transcribe_clicked = st.button( - tr("Transcribe subtitles"), - key="fun_asr_transcribe", - disabled=not can_transcribe, - use_container_width=True, - ) + action_cols = st.columns(2) + with action_cols[0]: + transcribe_clicked = st.button( + tr("Transcribe subtitles"), + key="fun_asr_transcribe", + disabled=not can_transcribe, + use_container_width=True, + ) + with action_cols[1]: + correct_clicked = st.button( + tr("Calibrate subtitles"), + key="subtitle_correct", + disabled=not can_correct_subtitles, + use_container_width=True, + ) + + if correct_clicked: + from app.services import subtitle_corrector + + text_provider = config.app.get('text_llm_provider', 'openai').lower() + text_api_key = config.app.get(f'text_{text_provider}_api_key') + text_base_url = config.app.get(f'text_{text_provider}_base_url') + + corrected_paths = [] + try: + spinner_text = tr("Calibrating subtitles...") + with st.spinner(spinner_text): + progress_bar = st.progress(0) if len(subtitle_paths) > 1 else None + for index, subtitle_path in enumerate(subtitle_paths, start=1): + subtitle_name = f"{os.path.splitext(os.path.basename(subtitle_path))[0]}_corrected.srt" + output_path = _unique_file_path(utils.subtitle_dir(), subtitle_name) + corrected_path = subtitle_corrector.correct_subtitle_file( + subtitle_file=subtitle_path, + output_file=output_path, + provider=text_provider, + api_key=text_api_key, + base_url=text_base_url, + ) + corrected_paths.append(corrected_path) + if progress_bar: + progress_bar.progress(index / len(subtitle_paths)) + + if progress_bar: + progress_bar.empty() + + _set_subtitle_state(corrected_paths) + success_placeholder = st.empty() + if len(corrected_paths) == 1: + success_placeholder.success( + tr("Subtitle calibration succeeded").format(file=os.path.basename(corrected_paths[0])) + ) + else: + success_placeholder.success( + tr("Subtitle calibration succeeded for multiple files").format( + count=len(corrected_paths), + files=_format_file_list_for_display(corrected_paths), + ) + ) + time.sleep(3) + success_placeholder.empty() + except Exception as e: + logger.error(f"字幕校准失败: {traceback.format_exc()}") + st.error(f"{tr('Subtitle calibration failed')}: {str(e)}") + return if not transcribe_clicked: return @@ -797,9 +991,17 @@ def render_fun_asr_transcription(tr): clear_fun_asr_subtitle_state() st.error(tr("Please enter local FunASR-Pack API URL")) return - if not media_path or not os.path.exists(media_path): + missing_paths = [path for path in media_paths if not os.path.exists(path)] + if not media_paths or missing_paths: clear_fun_asr_subtitle_state() - st.error(tr("Selected video file does not exist")) + if missing_paths: + st.error( + tr("Selected video files do not exist").format( + files=_format_file_list_for_display(missing_paths) + ) + ) + else: + st.error(tr("Selected video file does not exist")) return try: @@ -813,47 +1015,70 @@ def render_fun_asr_transcription(tr): config.fun_asr["model"] = "fun-asr" config.save_config() - subtitle_name = f"{os.path.splitext(os.path.basename(media_path))[0]}_fun_asr.srt" - subtitle_path = os.path.join(utils.subtitle_dir(), subtitle_name) - spinner_text = ( tr("Transcribing with local FunASR-Pack...") if backend == "local" else tr("Transcribing with Fun-ASR...") ) with st.spinner(spinner_text): - if backend == "local": - generated_path = fun_asr_subtitle.create_with_local_fun_asr( - local_file=media_path, - subtitle_file=subtitle_path, - api_url=str(api_url).strip(), - hotword=str(hotword).strip(), - enable_spk=bool(enable_spk), - ) - else: - generated_path = fun_asr_subtitle.create_with_fun_asr( - local_file=media_path, - subtitle_file=subtitle_path, - api_key=api_key.strip(), - ) + progress_bar = st.progress(0) if len(media_paths) > 1 else None + generated_paths = [] + for index, media_path in enumerate(media_paths, start=1): + subtitle_name = f"{os.path.splitext(os.path.basename(media_path))[0]}_fun_asr.srt" + subtitle_path = _unique_file_path(utils.subtitle_dir(), subtitle_name) - if not generated_path or not os.path.exists(generated_path): + if backend == "local": + generated_path = fun_asr_subtitle.create_with_local_fun_asr( + local_file=media_path, + subtitle_file=subtitle_path, + api_url=str(api_url).strip(), + hotword=str(hotword).strip(), + enable_spk=bool(enable_spk), + ) + else: + generated_path = fun_asr_subtitle.create_with_fun_asr( + local_file=media_path, + subtitle_file=subtitle_path, + api_key=api_key.strip(), + ) + + if not generated_path or not os.path.exists(generated_path): + raise RuntimeError(tr("Fun-ASR failed without subtitle file")) + + generated_paths.append(generated_path) + if progress_bar: + progress_bar.progress(index / len(media_paths)) + + if progress_bar: + progress_bar.empty() + + if not generated_paths: clear_fun_asr_subtitle_state() st.error(tr("Fun-ASR failed without subtitle file")) return - with open(generated_path, "r", encoding="utf-8") as f: - subtitle_content = f.read() + subtitle_content, subtitle_contents = _build_combined_subtitle_content(generated_paths) + if not subtitle_content.strip(): + clear_fun_asr_subtitle_state() + st.error(tr("Fun-ASR failed without subtitle file")) + return - st.session_state['subtitle_path'] = generated_path - st.session_state['subtitle_content'] = subtitle_content - st.session_state['subtitle_file_processed'] = True + _set_subtitle_state(generated_paths) success_placeholder = st.empty() - success_placeholder.success( - tr("Subtitle transcription succeeded").format(file=os.path.basename(generated_path)) - ) + if len(generated_paths) == 1: + success_placeholder.success( + tr("Subtitle transcription succeeded").format(file=os.path.basename(generated_paths[0])) + ) + else: + success_placeholder.success( + tr("Subtitle transcription succeeded for multiple files").format( + count=len(generated_paths), + files=_format_file_list_for_display(generated_paths), + ) + ) time.sleep(3) success_placeholder.empty() + st.rerun() except Exception as e: clear_fun_asr_subtitle_state() logger.error(f"Fun-ASR 字幕转写失败: {traceback.format_exc()}") @@ -1007,6 +1232,7 @@ def get_script_params(): 'video_language': st.session_state.get('video_language', ''), 'video_clip_json_path': st.session_state.get('video_clip_json_path', ''), 'video_origin_path': st.session_state.get('video_origin_path', ''), + 'video_origin_paths': _selected_video_paths(), 'video_name': st.session_state.get('video_name', ''), 'video_plot': st.session_state.get('video_plot', '') } diff --git a/webui/i18n/en.json b/webui/i18n/en.json index dbce928..fe53cbc 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -97,12 +97,14 @@ "Select from resource directory": "Select from resource directory", "Select a video from resource videos directory": "Select a video from the ./resource/videos directory", "Upload a new video file up to 2GB": "Upload a new video file, up to 2GB", + "Upload new video files up to 2GB each": "Upload one or more video files, up to 2GB each", "Select Video": "Select Video", "Choose a video file": "Choose a video file", "Upload Video": "Upload Video", "No video files found in resource videos directory": "No video files found in the ./resource/videos directory", "Upload Local Files": "Upload Local Files", "File Uploaded Successfully": "File Uploaded Successfully", + "Selected videos for processing": "Selected {count} video(s): {files}", "Frame Interval (seconds)": "Frame Interval (seconds)", "Generate Video Script": "Generate Video Script", "Video Theme": "Video Theme", @@ -131,17 +133,28 @@ "HTTP_PROXY": "HTTP Proxy", "HTTPs_PROXY": "HTTPS Proxy", "Vision Model Settings": "Vision Model Settings", - "Vision Model Provider": "Vision Model Provider", + "Vision Model Provider": "API Protocol", "Vision API Key": "Vision API Key", "Vision Base URL": "Vision Base URL", "Vision Model Name": "Vision Model Name", "Text Generation Model Settings": "Text Generation Model Settings", "LLM Model Name": "LLM Model Name", "LLM Model API Key": "LLM Model API Key", - "Text Model Provider": "Text Model Provider", + "Text Model Provider": "API Protocol", "Text API Key": "Text API Key", "Text Base URL": "Text Base URL", "Text Model Name": "Text Model Name", + "Top P": "Top P", + "Top K": "Top K", + "Max Output Tokens": "Max Output Tokens", + "Max Output Tokens Help": "Maximum generated output length. 0 uses the provider default.", + "Thinking Level": "Thinking Level", + "Thinking Level Help": "Controls reasoning effort. Auto sends no extra thinking parameter; low/medium/high tries reasoning_effort.", + "Thinking Level Auto": "Auto", + "Thinking Level Off": "Off", + "Thinking Level Low": "Low", + "Thinking Level Medium": "Medium", + "Thinking Level High": "High", "Skip the first few seconds": "Skip the first few seconds", "Difference threshold": "Difference Threshold", "Vision processing batch size": "Vision Processing Batch Size", @@ -283,14 +296,16 @@ "Jianying Draft Settings": "Jianying Draft Settings", "Jianying Draft Folder Path": "Jianying Draft Folder Path", "Jianying Draft Folder Path Help": "Jianying draft folder path, for example: C:\\Users\\Username\\Documents\\JianyingPro Drafts", - "Custom API endpoint help": "Custom API endpoint (optional). Required when using a self-hosted or third-party proxy.", + "Custom API endpoint help": "OpenAI-compatible endpoint URL. Use a full /v1 URL for third-party or self-hosted gateways; leave empty for the official OpenAI API.", "Recommended API endpoint": "Recommended endpoint", - "OpenAI compatible gateway help": "{model_type} uses an OpenAI-compatible gateway provider, so a complete endpoint URL is required.", + "OpenAI compatible gateway help": "{model_type} uses an OpenAI-compatible API, so a complete endpoint URL is required.", "Vision model": "Vision model", "Text model": "Text model", "Model Name Input Help": "Enter the full model name.\n\nCommon examples:", - "OpenAI compatible providers help": "Supports common OpenAI-compatible gateways such as OpenAI, DeepSeek, OpenRouter, and SiliconFlow.", - "Provider API Key Help": "API key for the selected provider.\n\nWhere to get one:", + "OpenAI compatible providers help": "The vendor is not limited here; OpenAI, DeepSeek, OpenRouter, SiliconFlow, or a self-hosted gateway all work as long as the endpoint is OpenAI-compatible.", + "OpenAI compatible protocol": "OpenAI-compatible", + "OpenAI compatible protocol help": "This does not require the official OpenAI model; any service that supports the OpenAI Chat Completions compatible API can be used.", + "Provider API Key Help": "API key for the model service.\n\nCommon places to get one:", "Please fill OpenAI compatible gateway": "Please fill in the OpenAI-compatible gateway URL above, for example: {example}", "Please enter API key": "Please enter the API key first", "Please enter model name": "Please enter the model name first", @@ -324,9 +339,12 @@ "Ali Bailian API Key Help": "Enter your Ali Bailian API Key. After saving, it will be written to the local config.toml file.", "Upload media to transcribe": "Upload audio/video to transcribe", "Using selected video for subtitle transcription": "Using current video for subtitle transcription: {file}", + "Using selected videos for subtitle transcription": "Using {count} current videos for subtitle transcription: {files}", "Please select or upload a video first": "Please select or upload a video file above first", "Selected video file does not exist": "The selected video file does not exist. Please select or upload it again", + "Selected video files do not exist": "These selected video files do not exist. Please select or upload them again: {files}", "Transcribe subtitles": "Transcribe Subtitles", + "Calibrate subtitles": "Calibrate Subtitles", "Please enter Ali Bailian API Key": "Please enter the Ali Bailian API Key first", "Please enter local FunASR-Pack API URL": "Please enter the local FunASR-Pack API URL first", "Please upload media to transcribe": "Please upload the audio or video file to transcribe first", @@ -334,6 +352,11 @@ "Transcribing with Fun-ASR...": "Transcribing subtitles with Ali Bailian Fun-ASR, please wait...", "Fun-ASR failed without subtitle file": "Fun-ASR transcription failed: no subtitle file was generated", "Subtitle transcription succeeded": "Subtitle transcription succeeded: {file}", + "Subtitle transcription succeeded for multiple files": "Subtitle transcription succeeded for {count} files: {files}", + "Calibrating subtitles...": "Calibrating subtitles with the LLM, please wait...", + "Subtitle calibration succeeded": "Subtitle calibration succeeded: {file}", + "Subtitle calibration succeeded for multiple files": "Subtitle calibration succeeded for {count} files: {files}", + "Subtitle calibration failed": "Subtitle calibration failed", "Transcribed subtitles storage hint": "Previously transcribed subtitles are saved in {path}; drag a file from that folder to upload", "剧情理解": "Plot Analysis", "剧情理解结果": "Plot Analysis Result", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 33eb74a..27328ad 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -84,12 +84,14 @@ "Select from resource directory": "从资源目录选择", "Select a video from resource videos directory": "选择 ./resource/videos 目录中的视频", "Upload a new video file up to 2GB": "上传一个新的视频文件,限制 2GB", + "Upload new video files up to 2GB each": "上传一个或多个视频文件,单个文件限制 2GB", "Select Video": "选择视频", "Choose a video file": "选择一个视频文件", "Upload Video": "上传视频", "No video files found in resource videos directory": "未在 ./resource/videos 目录中找到视频文件", "Upload Local Files": "上传本地文件", "File Uploaded Successfully": "文件上传成功", + "Selected videos for processing": "已选择 {count} 个视频: {files}", "timestamp": "时间戳", "Picture description": "图片描述", "Narration": "视频文案", @@ -119,18 +121,29 @@ "Proxy Settings": "代理设置", "HTTP_PROXY": "HTTP 代理", "HTTPs_PROXY": "HTTPS 代理", - "Vision Model Settings": "视频分析模型设置", - "Vision Model Provider": "视频分析模型提供商", - "Vision API Key": "视频分析 API 密钥", - "Vision Base URL": "视频分析接口地址", - "Vision Model Name": "视频分析模型名称", + "Vision Model Settings": "视觉分析模型设置", + "Vision Model Provider": "接口规范", + "Vision API Key": "视觉分析 API 密钥", + "Vision Base URL": "视觉分析接口地址", + "Vision Model Name": "视觉分析模型名称", "Text Generation Model Settings": "文案生成模型设置", "LLM Model Name": "大语言模型名称", "LLM Model API Key": "大语言模型 API 密钥", - "Text Model Provider": "文案生成模型提供商", + "Text Model Provider": "接口规范", "Text API Key": "文案生成 API 密钥", "Text Base URL": "文案生成接口地址", "Text Model Name": "文案生成模型名称", + "Top P": "Top P", + "Top K": "Top K", + "Max Output Tokens": "最大输出 Token", + "Max Output Tokens Help": "单次生成的最大输出长度,0 表示使用服务端默认值", + "Thinking Level": "思考等级", + "Thinking Level Help": "控制推理/思考强度。自动表示不额外发送思考参数,低/中/高会尝试传递 reasoning_effort", + "Thinking Level Auto": "自动", + "Thinking Level Off": "关闭", + "Thinking Level Low": "低", + "Thinking Level Medium": "中", + "Thinking Level High": "高", "Account ID": "账户 ID", "Skip the first few seconds": "跳过开头多少秒", "Difference threshold": "差异阈值", @@ -265,19 +278,21 @@ "Jianying Draft Settings": "剪映草稿设置", "Jianying Draft Folder Path": "剪映草稿文件夹路径", "Jianying Draft Folder Path Help": "剪映草稿文件夹路径,例如:C:\\Users\\用户名\\Documents\\JianyingPro Drafts", - "Custom API endpoint help": "自定义 API 端点(可选),当使用自建或第三方代理时需要填写", + "Custom API endpoint help": "OpenAI 兼容接口地址。使用第三方或自建网关时填写完整 /v1 地址;使用 OpenAI 官方接口可留空。", "Recommended API endpoint": "推荐接口地址", - "OpenAI compatible gateway help": "{model_type} 选择的提供商基于 OpenAI 兼容网关,必须填写完整的接口地址。", - "Vision model": "视频分析模型", + "OpenAI compatible gateway help": "{model_type} 使用 OpenAI 兼容接口,请填写完整的接口地址。", + "Vision model": "视觉分析模型", "Text model": "文案生成模型", "Model Name Input Help": "输入完整模型名称\n\n常用示例:", - "OpenAI compatible providers help": "支持常见 OpenAI 兼容网关(如 OpenAI/DeepSeek/OpenRouter/SiliconFlow)", - "Provider API Key Help": "对应 provider 的 API 密钥\n\n获取地址:", + "OpenAI compatible providers help": "这里不限定模型厂商;OpenAI、DeepSeek、OpenRouter、SiliconFlow 或自建网关均可,只需提供兼容 OpenAI 的接口地址和模型名称。", + "OpenAI compatible protocol": "OpenAI 兼容", + "OpenAI compatible protocol help": "不是限定 OpenAI 官方模型;只要模型服务支持 OpenAI Chat Completions 兼容接口即可。", + "Provider API Key Help": "模型服务的 API 密钥\n\n常见获取地址:", "Please fill OpenAI compatible gateway": "请在上方填写 OpenAI 兼容网关地址,例如:{example}", "Please enter API key": "请先输入 API 密钥", "Please enter model name": "请先输入模型名称", "Connection test error": "测试连接时发生错误", - "Vision model config saved": "视频分析模型配置已保存(OpenAI 兼容)", + "Vision model config saved": "视觉分析模型配置已保存(OpenAI 兼容)", "Text model config saved": "文案生成模型配置已保存(OpenAI 兼容)", "Failed to save config": "保存配置失败", "Custom Position (% from top)": "自定义位置(距顶部百分比)", @@ -306,9 +321,12 @@ "Ali Bailian API Key Help": "请输入你自己的阿里百炼 API Key;保存配置后会写入本地 config.toml", "Upload media to transcribe": "上传需要转录的音频/视频", "Using selected video for subtitle transcription": "将使用当前视频生成字幕: {file}", + "Using selected videos for subtitle transcription": "将使用当前 {count} 个视频生成字幕: {files}", "Please select or upload a video first": "请先在上方选择或上传视频文件", "Selected video file does not exist": "当前视频文件不存在,请重新选择或上传", - "Transcribe subtitles": "转写生成字幕", + "Selected video files do not exist": "以下视频文件不存在,请重新选择或上传: {files}", + "Transcribe subtitles": "转录字幕", + "Calibrate subtitles": "校准字幕", "Please enter Ali Bailian API Key": "请先输入阿里百炼 API Key", "Please enter local FunASR-Pack API URL": "请先输入本地 FunASR-Pack API 地址", "Please upload media to transcribe": "请先上传需要转录的音频或视频文件", @@ -316,6 +334,11 @@ "Transcribing with Fun-ASR...": "正在使用阿里百炼 Fun-ASR 转写字幕,请稍候...", "Fun-ASR failed without subtitle file": "Fun-ASR 转写失败:未生成字幕文件", "Subtitle transcription succeeded": "字幕转写成功: {file}", + "Subtitle transcription succeeded for multiple files": "字幕转写成功,共 {count} 个文件: {files}", + "Calibrating subtitles...": "正在使用大模型校准字幕,请稍候...", + "Subtitle calibration succeeded": "字幕校准成功: {file}", + "Subtitle calibration succeeded for multiple files": "字幕校准成功,共 {count} 个文件: {files}", + "Subtitle calibration failed": "字幕校准失败", "Transcribed subtitles storage hint": "之前转录生成的字幕保存在 {path},可从该目录拖入上传", "剧情理解": "剧情理解", "剧情理解结果": "剧情理解结果",