mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-28 08:56:00 +00:00
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:
parent
e6d15fe246
commit
0bd001ce33
@ -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:
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
231
app/services/subtitle_corrector.py
Normal file
231
app/services/subtitle_corrector.py
Normal 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)
|
||||
100
app/services/test_subtitle_corrector_unittest.py
Normal file
100
app/services/test_subtitle_corrector_unittest.py
Normal 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()
|
||||
@ -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 获取地址:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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', '')
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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},可从该目录拖入上传",
|
||||
"剧情理解": "剧情理解",
|
||||
"剧情理解结果": "剧情理解结果",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user