feat(webui, llm, subtitle): 新增字幕校准、多视频支持与LLM生成参数配置

- 添加字幕校准服务,支持通过LLM校对SRT格式字幕文件,支持批量处理
- 为视频参数模型新增video_origin_paths字段,支持多视频上传与批量处理
- 为OpenAI兼容LLM提供商添加temperature、top_p、max_tokens和thinking_level参数配置支持
- 重构WebUI模型设置页面,将通用生成参数配置拆分到各模型的独立配置项中
- 更新示例配置文件与默认配置,新增对应参数的默认值
- 完善多语言国际化文案,添加批量操作与字幕校准相关翻译
- 添加相关单元测试以覆盖新功能与配置项
This commit is contained in:
viccy 2026-06-05 23:15:11 +08:00
parent e6d15fe246
commit 0bd001ce33
12 changed files with 983 additions and 143 deletions

View File

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

View File

@ -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"])

View File

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

View File

@ -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"}

View File

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

View File

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

View File

@ -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()

View File

@ -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 获取地址:

View File

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

View File

@ -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', '')
}

View File

@ -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",

View File

@ -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},可从该目录拖入上传",
"剧情理解": "剧情理解",
"剧情理解结果": "剧情理解结果",