From e744960ac1d845d4731d416411d76939ba4cc79b Mon Sep 17 00:00:00 2001 From: viccy Date: Fri, 5 Jun 2026 18:46:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=9C=AC=E5=9C=B0Fun?= =?UTF-8?q?ASR=E6=94=AF=E6=8C=81=E5=B9=B6=E4=BC=98=E5=8C=96=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E7=AB=AF=E8=A7=86=E9=A2=91=E4=B8=8E=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新示例配置文件,添加本地FunASR后端配置项 - 重构fun_asr_subtitle服务,完整支持本地FunASR-Pack API调用 - 优化多语言翻译文件,更新界面相关译文 - 重写网页端视频选择组件,支持本地资源目录选择和直接上传 - 重构字幕转写UI,支持本地/在线百炼/直接上传三种模式 - 新增本地FunASR相关单元测试 --- app/services/fun_asr_subtitle.py | 296 ++++++++++++- .../test_fun_asr_subtitle_unittest.py | 110 ++++- config.example.toml | 9 +- webui/components/script_settings.py | 396 ++++++++++++------ webui/i18n/en.json | 37 +- webui/i18n/zh.json | 37 +- 6 files changed, 744 insertions(+), 141 deletions(-) diff --git a/app/services/fun_asr_subtitle.py b/app/services/fun_asr_subtitle.py index 7af2637..2567bc8 100644 --- a/app/services/fun_asr_subtitle.py +++ b/app/services/fun_asr_subtitle.py @@ -1,15 +1,17 @@ -"""Aliyun Bailian Fun-ASR subtitle transcription helpers. +"""Fun-ASR subtitle transcription helpers. -This module intentionally uses the REST API because the official Fun-ASR +The Bailian path intentionally uses the REST API because the official Fun-ASR recorded-file API supports temporary `oss://` resources only through REST. """ from __future__ import annotations import os +import shutil import time from dataclasses import dataclass from typing import Any, Optional +from urllib.parse import urljoin, urlparse, urlunparse import requests from loguru import logger @@ -21,6 +23,7 @@ UPLOAD_POLICY_URL = f"{DASHSCOPE_BASE_URL}/api/v1/uploads" TRANSCRIPTION_URL = f"{DASHSCOPE_BASE_URL}/api/v1/services/audio/asr/transcription" TASK_URL_TEMPLATE = f"{DASHSCOPE_BASE_URL}/api/v1/tasks/{{task_id}}" MODEL_NAME = "fun-asr" +LOCAL_FUN_ASR_API_URL = "http://127.0.0.1:7860" TERMINAL_FAILED_STATUSES = {"FAILED", "CANCELED", "UNKNOWN"} PUNCTUATION_BREAKS = set(",。!?;,.!?;") @@ -89,6 +92,85 @@ def _session_post(session, url: str, **kwargs): return session.post(url, **kwargs) +def _require_local_file(local_file: str) -> None: + if not os.path.isfile(local_file): + raise FunAsrError(f"待转写文件不存在: {local_file}") + + +def _normalize_local_api_url(api_url: str = "") -> str: + api_url = (api_url or LOCAL_FUN_ASR_API_URL).strip().rstrip("/") + if not api_url: + raise FunAsrError("请先填写本地 FunASR-Pack API 地址") + if "://" not in api_url: + api_url = f"http://{api_url}" + return api_url + + +def _local_base_url(api_url: str = "") -> str: + api_url = _normalize_local_api_url(api_url) + parsed = urlparse(api_url) + path = parsed.path.rstrip("/") + if path.endswith("/asr"): + path = path[:-4].rstrip("/") + return urlunparse(parsed._replace(path=path, params="", query="", fragment="")).rstrip("/") + + +def _local_asr_url(api_url: str = "") -> str: + api_url = _normalize_local_api_url(api_url) + if urlparse(api_url).path.rstrip("/").endswith("/asr"): + return api_url + return f"{api_url}/asr" + + +def _absolute_local_download_url(api_url: str, download_url: str) -> str: + download_url = (download_url or "").strip() + if not download_url: + return "" + if urlparse(download_url).scheme: + return download_url + return urljoin(f"{_local_base_url(api_url)}/", download_url) + + +def _raise_for_local_http(response: requests.Response, action: str) -> None: + status_code = getattr(response, "status_code", 200) + if status_code and status_code >= 400: + detail = "" + try: + data = response.json() + if isinstance(data, dict): + detail = str(data.get("detail") or "") + except Exception: + detail = "" + suffix = f": {detail}" if detail else "" + raise FunAsrError(f"{action}失败{suffix},请确认本地 FunASR-Pack 服务可用") + + try: + response.raise_for_status() + except Exception as exc: + raise FunAsrError(f"{action}失败,请确认本地 FunASR-Pack 服务可用") from exc + + +def _local_json(response: requests.Response, action: str) -> dict[str, Any]: + _raise_for_local_http(response, action) + try: + data = response.json() + except Exception as exc: + raise FunAsrError(f"{action}返回了无效 JSON") from exc + if not isinstance(data, dict): + raise FunAsrError(f"{action}返回格式无效") + return data + + +def _response_text(response: requests.Response) -> str: + text = getattr(response, "text", None) + if isinstance(text, str): + return text + content = getattr(response, "content", b"") + if isinstance(content, bytes): + return content.decode("utf-8") + return str(content) + + def request_upload_policy(api_key: str, model: str = MODEL_NAME, session=requests) -> UploadPolicy: """Request Bailian temporary-storage upload policy for the target model.""" api_key = _require_api_key(api_key) @@ -418,6 +500,216 @@ def write_srt_file(srt_content: str, subtitle_file: str = "") -> str: return subtitle_file +def copy_srt_file(source_file: str, subtitle_file: str = "") -> str: + """Copy an existing SRT file into NarratoAI's subtitle directory.""" + if not os.path.isfile(source_file): + raise FunAsrError(f"本地 FunASR-Pack 返回的字幕文件不存在: {source_file}") + if not subtitle_file: + subtitle_file = os.path.join(utils.subtitle_dir(), f"fun_asr_local_{int(time.time())}.srt") + parent = os.path.dirname(subtitle_file) + if parent: + os.makedirs(parent, exist_ok=True) + if os.path.abspath(source_file) != os.path.abspath(subtitle_file): + shutil.copyfile(source_file, subtitle_file) + return subtitle_file + + +def request_local_fun_asr_health(api_url: str = LOCAL_FUN_ASR_API_URL, session=requests) -> dict[str, Any]: + """Fetch FunASR-Pack health metadata from the local service.""" + response = _session_get(session, f"{_local_base_url(api_url)}/health", timeout=10) + return _local_json(response, "检查本地 FunASR-Pack 服务") + + +def request_local_fun_asr( + local_file: str, + api_url: str = LOCAL_FUN_ASR_API_URL, + hotword: str = "", + enable_spk: Optional[bool] = None, + timeout: float = 600.0, + session=requests, +) -> dict[str, Any]: + """Call the local FunASR-Pack `/asr` API and return its JSON result.""" + _require_local_file(local_file) + data: dict[str, str] = {} + if hotword.strip(): + data["hotword"] = hotword.strip() + if enable_spk is not None: + data["enable_spk"] = "true" if enable_spk else "false" + + with open(local_file, "rb") as file_obj: + files = {"file": (_safe_upload_name(local_file), file_obj)} + response = _session_post( + session, + _local_asr_url(api_url), + data=data, + files=files, + timeout=timeout, + ) + return _local_json(response, "调用本地 FunASR-Pack ASR API") + + +def download_local_srt( + download_url: str, + api_url: str = LOCAL_FUN_ASR_API_URL, + subtitle_file: str = "", + session=requests, +) -> str: + """Download an SRT exposed by FunASR-Pack and save it as a NarratoAI subtitle.""" + absolute_url = _absolute_local_download_url(api_url, download_url) + if not absolute_url: + raise FunAsrError("本地 FunASR-Pack 结果缺少 SRT 下载地址") + response = _session_get(session, absolute_url, timeout=60) + _raise_for_local_http(response, "下载本地 FunASR-Pack SRT") + srt_content = _response_text(response) + if not srt_content.strip(): + raise FunAsrError("本地 FunASR-Pack 返回了空 SRT") + return write_srt_file(srt_content, subtitle_file) + + +def _local_result_items(result_json: dict[str, Any]): + raw = result_json.get("raw") + if isinstance(raw, dict): + yield raw + elif isinstance(raw, list): + for item in raw: + if isinstance(item, dict): + yield item + elif result_json.get("text"): + yield result_json + + +def _blocks_from_local_timestamp(item: dict[str, Any], max_chars: int, max_duration: float) -> list[dict[str, Any]]: + text = str(item.get("text") or "").strip() + timestamps = item.get("timestamp") or [] + if not text or not isinstance(timestamps, list): + return [] + + non_space_chars = [char for char in text if char.strip()] + consume_punctuation = len(timestamps) >= len(non_space_chars) + blocks: list[dict[str, Any]] = [] + current: Optional[dict[str, Any]] = None + timestamp_index = 0 + last_end = 0.0 + max_duration_ms = max_duration * 1000 + + for char in text: + if not char.strip(): + continue + + is_punctuation = char in PUNCTUATION_BREAKS + consume_timestamp = consume_punctuation or not is_punctuation + if consume_timestamp and timestamp_index < len(timestamps): + pair = timestamps[timestamp_index] + timestamp_index += 1 + if not isinstance(pair, (list, tuple)) or len(pair) < 2: + continue + start_ms = _timestamp_ms(pair[0], "local.timestamp.start") + end_ms = _timestamp_ms(pair[1], "local.timestamp.end") + last_end = end_ms + else: + start_ms = last_end + end_ms = last_end if is_punctuation else last_end + 200 + last_end = end_ms + + if current is None: + current = {"start": start_ms, "end": end_ms, "text": char} + else: + should_split_before = ( + len(current["text"] + char) > max_chars + or (end_ms - current["start"]) > max_duration_ms + ) + if should_split_before: + _flush_block(blocks, current) + current = {"start": start_ms, "end": end_ms, "text": char} + else: + current["text"] += char + current["end"] = end_ms + + if current and is_punctuation: + _flush_block(blocks, current) + current = None + + if current: + _flush_block(blocks, current) + return blocks + + +def local_fun_asr_result_to_srt( + result_json: dict[str, Any], + max_chars: int = 20, + max_duration: float = 3.5, +) -> str: + """Convert a FunASR-Pack JSON response into SRT when the API SRT is unavailable.""" + blocks: list[dict[str, Any]] = [] + for item in _local_result_items(result_json): + item_blocks = _blocks_from_local_timestamp(item, max_chars, max_duration) + if not item_blocks: + text = str(item.get("text") or "").strip() + if text: + item_blocks = _blocks_from_sentence( + { + "begin_time": 0, + "end_time": max(1500, len(text) * 180), + "text": text, + }, + max_chars=max_chars, + ) + blocks.extend(item_blocks) + + if not blocks: + raise FunAsrError("本地 FunASR-Pack 转写结果为空:未找到可用字幕内容") + + lines = [] + for index, block in enumerate(blocks, start=1): + lines.append(_srt_block(index, block["start"], block["end"], block["text"])) + return "\n".join(lines).rstrip() + "\n" + + +def create_with_local_fun_asr( + local_file: str, + subtitle_file: str = "", + api_url: str = LOCAL_FUN_ASR_API_URL, + hotword: str = "", + enable_spk: Optional[bool] = None, + timeout: float = 600.0, + session=requests, +) -> Optional[str]: + """Create an SRT file through a locally running FunASR-Pack API.""" + try: + result_json = request_local_fun_asr( + local_file=local_file, + api_url=api_url, + hotword=hotword, + enable_spk=enable_spk, + timeout=timeout, + session=session, + ) + + srt_file = result_json.get("srt_file") + if isinstance(srt_file, str) and srt_file and os.path.isfile(srt_file): + output_file = copy_srt_file(srt_file, subtitle_file) + else: + downloads = result_json.get("downloads") or {} + download_url = downloads.get("srt") if isinstance(downloads, dict) else "" + if download_url: + output_file = download_local_srt( + download_url, + api_url=api_url, + subtitle_file=subtitle_file, + session=session, + ) + else: + srt_content = local_fun_asr_result_to_srt(result_json) + output_file = write_srt_file(srt_content, subtitle_file) + + logger.info(f"本地 FunASR-Pack 字幕文件已生成: {output_file}") + return output_file + except FunAsrError: + raise + except Exception as exc: + raise FunAsrError("本地 FunASR-Pack 字幕转写失败,请检查服务地址、文件或模型状态") from exc + + def create_with_fun_asr( local_file: str, subtitle_file: str = "", diff --git a/app/services/test_fun_asr_subtitle_unittest.py b/app/services/test_fun_asr_subtitle_unittest.py index 83062bd..d59550d 100644 --- a/app/services/test_fun_asr_subtitle_unittest.py +++ b/app/services/test_fun_asr_subtitle_unittest.py @@ -12,9 +12,11 @@ from app.services import fun_asr_subtitle as fasr class FakeResponse: - def __init__(self, payload=None, status_code=200): + def __init__(self, payload=None, status_code=200, text=None): self.payload = payload or {} self.status_code = status_code + self.text = text + self.content = text.encode("utf-8") if isinstance(text, str) else b"" def json(self): return self.payload @@ -375,6 +377,110 @@ class FunAsrServiceTests(unittest.TestCase): fasr.download_transcription_result("https://result.example/bad.json", session=MalformedDownloadSession({})) +class LocalFunAsrServiceTests(unittest.TestCase): + def test_request_local_fun_asr_posts_file_and_options(self): + class LocalSession: + def __init__(self): + self.calls = [] + + def post(self, url, **kwargs): + self.calls.append(("POST", url, kwargs)) + return FakeResponse({"text": "你好", "srt_file": "/tmp/out.srt"}) + + with tempfile.TemporaryDirectory() as tmp_dir: + local_file = Path(tmp_dir) / "audio.wav" + local_file.write_bytes(b"audio") + session = LocalSession() + + result = fasr.request_local_fun_asr( + str(local_file), + api_url="127.0.0.1:7860", + hotword="NarratoAI", + enable_spk=True, + timeout=123, + session=session, + ) + + self.assertEqual("你好", result["text"]) + self.assertEqual("POST", session.calls[0][0]) + self.assertEqual("http://127.0.0.1:7860/asr", session.calls[0][1]) + self.assertEqual({"hotword": "NarratoAI", "enable_spk": "true"}, session.calls[0][2]["data"]) + self.assertEqual(123, session.calls[0][2]["timeout"]) + self.assertIn("file", session.calls[0][2]["files"]) + + def test_create_with_local_fun_asr_copies_pack_srt_file(self): + class LocalSession: + def __init__(self, srt_file): + self.srt_file = srt_file + self.calls = [] + + def post(self, url, **kwargs): + self.calls.append(("POST", url, kwargs)) + return FakeResponse({"text": "你好", "srt_file": str(self.srt_file)}) + + with tempfile.TemporaryDirectory() as tmp_dir: + local_file = Path(tmp_dir) / "audio.wav" + local_file.write_bytes(b"audio") + pack_srt = Path(tmp_dir) / "pack.srt" + pack_srt.write_text("1\n00:00:00,000 --> 00:00:01,000\n你好\n", encoding="utf-8") + subtitle_file = Path(tmp_dir) / "out.srt" + + result_path = fasr.create_with_local_fun_asr( + str(local_file), + subtitle_file=str(subtitle_file), + api_url="http://127.0.0.1:7860", + session=LocalSession(pack_srt), + ) + + self.assertEqual(str(subtitle_file), result_path) + self.assertEqual(pack_srt.read_text(encoding="utf-8"), subtitle_file.read_text(encoding="utf-8")) + + def test_create_with_local_fun_asr_downloads_relative_srt(self): + class LocalSession: + def __init__(self): + self.calls = [] + + def post(self, url, **kwargs): + self.calls.append(("POST", url, kwargs)) + return FakeResponse({"text": "你好", "downloads": {"srt": "/download/result.srt"}}) + + def get(self, url, **kwargs): + self.calls.append(("GET", url, kwargs)) + return FakeResponse(text="1\n00:00:00,000 --> 00:00:01,000\n你好\n") + + with tempfile.TemporaryDirectory() as tmp_dir: + local_file = Path(tmp_dir) / "audio.wav" + local_file.write_bytes(b"audio") + subtitle_file = Path(tmp_dir) / "out.srt" + session = LocalSession() + + result_path = fasr.create_with_local_fun_asr( + str(local_file), + subtitle_file=str(subtitle_file), + api_url="http://127.0.0.1:7860/asr", + session=session, + ) + + self.assertEqual(str(subtitle_file), result_path) + self.assertEqual("http://127.0.0.1:7860/download/result.srt", session.calls[1][1]) + self.assertIn("你好", subtitle_file.read_text(encoding="utf-8")) + + def test_local_fun_asr_result_to_srt_uses_raw_timestamps(self): + result = { + "raw": [ + { + "text": "你好,世界。", + "timestamp": [[0, 300], [300, 600], [600, 900], [900, 1200]], + } + ] + } + + srt = fasr.local_fun_asr_result_to_srt(result, max_chars=20) + + self.assertIn("00:00:00,000 --> 00:00:00,600\n你好,", srt) + self.assertIn("世界。", srt) + + class FunAsrConfigTests(unittest.TestCase): def test_save_config_persists_fun_asr_section(self): original_config_file = cfg.config_file @@ -395,6 +501,8 @@ class FunAsrConfigTests(unittest.TestCase): def test_config_example_fun_asr_section_parses(self): config_data = tomllib.loads(Path("config.example.toml").read_text(encoding="utf-8")) + self.assertEqual("local", config_data["fun_asr"]["backend"]) + self.assertEqual("http://127.0.0.1:7860", config_data["fun_asr"]["api_url"]) self.assertEqual("fun-asr", config_data["fun_asr"]["model"]) self.assertIn("api_key", config_data["fun_asr"]) diff --git a/config.example.toml b/config.example.toml index c503129..805610b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -95,8 +95,13 @@ model_name = "qwen3-tts-flash" [fun_asr] - # 阿里百炼 Fun-ASR 字幕转录配置 - # 访问 https://bailian.console.aliyun.com/?tab=model#/api-key 获取你的 API 密钥 + # Fun-ASR 字幕转录配置 + # backend = "local" 使用本地 FunASR-Pack API;backend = "bailian" 使用阿里百炼在线 fun-asr + backend = "local" + api_url = "http://127.0.0.1:7860" + hotword = "" + enable_spk = false + # 使用阿里百炼在线 fun-asr 时,访问 https://bailian.console.aliyun.com/?tab=model#/api-key 获取 API Key api_key = "" model = "fun-asr" diff --git a/webui/components/script_settings.py b/webui/components/script_settings.py index 772906b..94a8910 100644 --- a/webui/components/script_settings.py +++ b/webui/components/script_settings.py @@ -56,12 +56,12 @@ def render_script_file(tr, params): MODE_SHORT = "short" MODE_SUMMARY = "summary" - # 模式选项映射 + # 模式选项映射,按工作流优先级展示 mode_options = { - tr("Select/Upload Script"): MODE_FILE, + tr("Short Drama Summary"): MODE_SUMMARY, tr("Auto Generate"): MODE_AUTO, tr("Short Generate"): MODE_SHORT, - tr("Short Drama Summary"): MODE_SUMMARY, + tr("Select/Upload Script"): MODE_FILE, } # 获取当前状态 @@ -80,8 +80,7 @@ def render_script_file(tr, params): else: default_index = mode_keys.index(tr("Select/Upload Script")) - # 1. 渲染功能选择下拉框 - # 使用 segmented_control 替代 selectbox,提供更好的视觉体验 + # 1. 渲染功能选择组件 default_mode_label = mode_keys[default_index] default_mode = mode_options[default_mode_label] @@ -106,17 +105,16 @@ def render_script_file(tr, params): st.session_state.video_clip_json_path = new_mode params.video_clip_json_path = new_mode else: - # 如果用户取消选择(segmented_control 允许取消),恢复到默认或上一个状态 - # 这里我们强制保持当前状态,或者重置为默认 - st.session_state.script_mode_selection = default_mode_label + st.session_state.video_clip_json_path = default_mode + params.video_clip_json_path = default_mode # 渲染组件 - selected_mode_label = st.segmented_control( + selected_mode_label = st.selectbox( tr("Video Type"), options=mode_keys, + index=None, key="script_mode_selection", on_change=update_script_mode, - required=True ) # 处理旧状态为空的兜底情况 @@ -231,50 +229,115 @@ def render_script_file(tr, params): def render_video_file(tr, params): """渲染视频文件选择""" - video_list = [(tr("None"), ""), (tr("Upload Local Files"), "upload_local")] + source_options = { + tr("Select from resource directory"): "resource", + tr("Upload Local Files"): "upload", + } + source_labels = list(source_options.keys()) + default_source_label = source_labels[0] - # 获取已有视频文件 - for suffix in ["*.mp4", "*.mov", "*.avi", "*.mkv"]: - video_files = glob.glob(os.path.join(utils.video_dir(), suffix)) - for file in video_files: - display_name = file.replace(config.root_dir, "") - video_list.append((display_name, file)) + if ( + 'video_source_selection' not in st.session_state + or st.session_state['video_source_selection'] not in source_options + ): + st.session_state['video_source_selection'] = default_source_label - selected_video_index = st.selectbox( - tr("Video File"), - index=0, - options=range(len(video_list)), - format_func=lambda x: video_list[x][0] + current_source = st.session_state['video_source_selection'] + 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") ) + st.markdown(f"**{tr('Video Source')}** :gray[{source_caption}]") - video_path = video_list[selected_video_index][1] - st.session_state['video_origin_path'] = video_path - params.video_origin_path = video_path + source = st.selectbox( + tr("Video Source"), + options=source_labels, + index=None, + key="video_source_selection", + label_visibility="collapsed", + ) + if not source: + source = default_source_label - if video_path == "upload_local": - uploaded_file = st.file_uploader( - tr("Upload Local Files"), - type=["mp4", "mov", "avi", "flv", "mkv"], - accept_multiple_files=False, + if source_options[source] == "resource": + video_files = [] + for suffix in ["*.mp4", "*.mov", "*.avi", "*.flv", "*.mkv", "*.mpeg4"]: + video_files.extend(glob.glob(os.path.join(utils.video_dir(), suffix))) + + video_files = sorted(video_files, key=os.path.getctime, reverse=True) + saved_video_path = st.session_state.get('video_origin_path', '') + selected_video_path = st.session_state.get('resource_video_selection') + if selected_video_path not in video_files: + st.session_state['resource_video_selection'] = ( + saved_video_path if saved_video_path in video_files else None + ) + + def format_video_name(path): + return path.replace(config.root_dir, "") + + video_path = st.selectbox( + tr("Select Video"), + options=video_files, + index=None, + placeholder=tr("Choose a video file"), + format_func=format_video_name, + key="resource_video_selection", ) - if uploaded_file is not None: - 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) + if video_path: + st.session_state['video_origin_path'] = video_path + params.video_origin_path = video_path + else: + st.session_state['video_origin_path'] = "" + params.video_origin_path = "" + if not video_files: + st.info(tr("No video files found in resource videos directory")) + return - 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) + if source_options[source] == "upload": + uploaded_file = st.file_uploader( + tr("Upload Video"), + type=["mp4", "mov", "avi", "flv", "mkv", "mpeg4"], + accept_multiple_files=False, + key="video_file_uploader", + ) - with open(video_file_path, "wb") as f: - f.write(uploaded_file.read()) - st.success(tr("File Uploaded Successfully")) + if uploaded_file is None: + st.session_state['video_origin_path'] = "" + params.video_origin_path = "" + st.session_state['video_file_processed'] = False + st.session_state['uploaded_video_path'] = "" + 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', '') + is_processed = ( + st.session_state.get('video_file_processed', False) + and st.session_state.get('uploaded_video_signature') == uploaded_signature + and uploaded_video_path + ) + + if is_processed: + st.session_state['video_origin_path'] = uploaded_video_path + params.video_origin_path = uploaded_video_path + 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) + + 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 - time.sleep(1) - st.rerun() + st.session_state['uploaded_video_path'] = video_file_path + st.session_state['uploaded_video_signature'] = uploaded_signature + st.session_state['video_file_processed'] = True def render_short_generate_options(tr): @@ -336,7 +399,18 @@ def short_drama_summary(tr): st.session_state['subtitle_file_processed'] = False render_fun_asr_transcription(tr) - + + # 名称输入框 + video_theme = st.text_input(tr("短剧名称")) + st.session_state['video_theme'] = video_theme + # 数字输入框 + temperature = st.slider("temperature", 0.0, 2.0, 0.7) + st.session_state['temperature'] = temperature + return video_theme + + +def render_subtitle_upload(tr): + """上传并保存用户提供的 SRT 字幕文件。""" subtitle_file = st.file_uploader( tr("上传字幕文件"), type=["srt"], @@ -401,102 +475,180 @@ def short_drama_summary(tr): except Exception as e: st.error(f"{tr('Upload failed')}: {str(e)}") - # 名称输入框 - video_theme = st.text_input(tr("短剧名称")) - st.session_state['video_theme'] = video_theme - # 数字输入框 - temperature = st.slider("temperature", 0.0, 2.0, 0.7) - st.session_state['temperature'] = temperature - return video_theme - def render_fun_asr_transcription(tr): - """使用阿里百炼 Fun-ASR 从本地音视频转写生成字幕。""" + """使用 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 - with st.expander(tr("Ali Bailian Fun-ASR Subtitle Transcription"), expanded=False): - st.caption(tr("Fun-ASR upload caption")) - st.markdown( - f"{tr('API Key URL')}: " - "[https://bailian.console.aliyun.com/?tab=model#/api-key]" - "(https://bailian.console.aliyun.com/?tab=model#/api-key)" + from app.services import fun_asr_subtitle + + backend_options = { + tr("Local FunASR-Pack API"): "local", + tr("Ali Bailian Online Fun-ASR"): "bailian", + tr("上传字幕文件"): "upload", + } + saved_backend = str(config.fun_asr.get("backend", "")).strip().lower() + if saved_backend not in {"local", "bailian", "upload"}: + saved_backend = ( + "bailian" + if config.fun_asr.get("api_key") and not config.fun_asr.get("api_url") + else "local" ) - api_key = st.text_input( - tr("Ali Bailian API Key"), - value=config.fun_asr.get("api_key", ""), - type="password", - help=tr("Ali Bailian API Key Help"), - key="fun_asr_api_key", - ) - uploaded_media = st.file_uploader( - tr("Upload media to transcribe"), - type=[ - "aac", "amr", "avi", "flac", "flv", "m4a", "mkv", "mov", - "mp3", "mp4", "mpeg", "ogg", "opus", "wav", "webm", "wma", "wmv", - ], - accept_multiple_files=False, - key="fun_asr_media_uploader", - ) + backend_values = list(backend_options.values()) + backend_labels = list(backend_options.keys()) + backend = saved_backend + api_key = "" + 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', '') - if st.button(tr("Transcribe subtitles"), key="fun_asr_transcribe"): - if not api_key.strip(): - clear_fun_asr_subtitle_state() - st.error(tr("Please enter Ali Bailian API Key")) - return - if uploaded_media is None: - clear_fun_asr_subtitle_state() - st.error(tr("Please upload media to transcribe")) - return + subtitle_cols = st.columns([3, 2], vertical_alignment="top") - try: - clear_fun_asr_subtitle_state() - from app.services import fun_asr_subtitle + with subtitle_cols[0]: + with st.expander(tr("Ali Bailian Fun-ASR Subtitle Transcription"), expanded=False): + backend_label = st.radio( + tr("Subtitle Processing Method"), + options=backend_labels, + index=backend_values.index(saved_backend), + horizontal=True, + key="fun_asr_backend", + ) + backend = backend_options[backend_label] - config.fun_asr["api_key"] = api_key.strip() - config.fun_asr["model"] = "fun-asr" - config.save_config() + if backend == "upload": + render_subtitle_upload(tr) + elif backend == "local": + st.caption(tr("Local Fun-ASR upload caption")) + api_url = st.text_input( + tr("Local FunASR-Pack API URL"), + value=api_url, + help=tr("Local FunASR-Pack API URL Help"), + key="fun_asr_api_url", + ) + hotword = st.text_input( + tr("Fun-ASR Hotword"), + value=hotword, + help=tr("Fun-ASR Hotword Help"), + key="fun_asr_hotword", + ) + enable_spk = st.checkbox( + tr("Enable speaker diarization"), + value=enable_spk, + help=tr("Enable speaker diarization Help"), + key="fun_asr_enable_spk", + ) + else: + st.caption(tr("Fun-ASR upload caption")) + st.markdown( + f"{tr('API Key URL')}: " + "[https://bailian.console.aliyun.com/?tab=model#/api-key]" + "(https://bailian.console.aliyun.com/?tab=model#/api-key)" + ) - temp_dir = utils.temp_dir("fun_asr") - safe_filename = os.path.basename(uploaded_media.name) - media_path = os.path.join(temp_dir, safe_filename) - file_name, file_extension = os.path.splitext(safe_filename) - if os.path.exists(media_path): - timestamp = time.strftime("%Y%m%d%H%M%S") - media_path = os.path.join(temp_dir, f"{file_name}_{timestamp}{file_extension}") + api_key = st.text_input( + tr("Ali Bailian API Key"), + value=config.fun_asr.get("api_key", ""), + type="password", + help=tr("Ali Bailian API Key Help"), + key="fun_asr_api_key", + ) - with open(media_path, "wb") as f: - f.write(uploaded_media.getbuffer()) - - 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) - - with st.spinner(tr("Transcribing with Fun-ASR...")): - generated_path = fun_asr_subtitle.create_with_fun_asr( - local_file=media_path, - subtitle_file=subtitle_path, - api_key=api_key.strip(), + if backend != "upload": + if media_path: + st.info( + tr("Using selected video for subtitle transcription").format( + file=os.path.basename(media_path) + ) ) + else: + st.warning(tr("Please select or upload a video first")) - if not generated_path or not os.path.exists(generated_path): - clear_fun_asr_subtitle_state() - st.error(tr("Fun-ASR failed without subtitle file")) - return + can_transcribe = backend != "upload" and bool(media_path) + with subtitle_cols[1]: + transcribe_clicked = st.button( + tr("Transcribe subtitles"), + key="fun_asr_transcribe", + disabled=not can_transcribe, + use_container_width=True, + ) - with open(generated_path, "r", encoding="utf-8") as f: - subtitle_content = f.read() + if not transcribe_clicked: + return - st.session_state['subtitle_path'] = generated_path - st.session_state['subtitle_content'] = subtitle_content - st.session_state['subtitle_file_processed'] = True - st.success(tr("Subtitle transcription succeeded").format(file=os.path.basename(generated_path))) - except Exception as e: - clear_fun_asr_subtitle_state() - logger.error(f"Fun-ASR 字幕转写失败: {traceback.format_exc()}") - st.error(f"{tr('Fun-ASR transcription failed')}: {str(e)}") + if backend == "bailian" and not api_key.strip(): + clear_fun_asr_subtitle_state() + st.error(tr("Please enter Ali Bailian API Key")) + return + if backend == "local" and not str(api_url).strip(): + 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): + clear_fun_asr_subtitle_state() + st.error(tr("Selected video file does not exist")) + return + + try: + clear_fun_asr_subtitle_state() + + config.fun_asr["backend"] = backend + config.fun_asr["api_url"] = str(api_url).strip() + config.fun_asr["api_key"] = api_key.strip() + config.fun_asr["hotword"] = str(hotword).strip() + config.fun_asr["enable_spk"] = bool(enable_spk) + 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(), + ) + + if not generated_path or not os.path.exists(generated_path): + 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() + + st.session_state['subtitle_path'] = generated_path + st.session_state['subtitle_content'] = subtitle_content + st.session_state['subtitle_file_processed'] = True + success_placeholder = st.empty() + success_placeholder.success( + tr("Subtitle transcription succeeded").format(file=os.path.basename(generated_path)) + ) + time.sleep(3) + success_placeholder.empty() + except Exception as e: + clear_fun_asr_subtitle_state() + logger.error(f"Fun-ASR 字幕转写失败: {traceback.format_exc()}") + st.error(f"{tr('Fun-ASR transcription failed')}: {str(e)}") def render_script_buttons(tr, params): diff --git a/webui/i18n/en.json b/webui/i18n/en.json index bfab52e..cfa122b 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -8,11 +8,11 @@ "Script Files": "Script Files", "Generate Video Script and Keywords": "Click to use AI to generate **Video Script** and **Video Keywords** based on the **subject**", "Auto Detect": "Auto Detect", - "Auto Generate": "Auto Generate", + "Auto Generate": "Frame Analysis", "Video Script": "Video Script (:blue[①Optional, use AI to generate ②Proper punctuation helps in generating subtitles])", "Save Script": "Save Script", "Crop Video": "Crop Video", - "Video File": "Video File (:blue[1️⃣Supports uploading video files (limit 2G) 2️⃣For large files, it is recommended to directly import them into the ./resource/videos directory])", + "Video File": "Video File", "Plot Description": "Plot Description (:blue[Can be obtained from https://www.tvmao.com/])", "Generate Video Keywords": "Click to use AI to generate **Video Keywords** based on the **script**", "Please Enter the Video Subject": "Please enter the video script first", @@ -84,6 +84,13 @@ "Synthesizing Voice": "Synthesizing voice, please wait...", "TTS Provider": "TTS Provider", "Hide Log": "Hide Log", + "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", + "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", "Frame Interval (seconds)": "Frame Interval (seconds)", @@ -172,8 +179,8 @@ "Batch Size": "Batch Size", "Batch Size (More keyframes consume more tokens)": "Batch Size (smaller batches consume more tokens)", "Short Drama Summary": "Short Drama Summary", - "Video Type": "Video Type", - "Select/Upload Script": "Select/Upload Script", + "Video Type": "Creation Type", + "Select/Upload Script": "Custom Script", "原生Gemini模型连接成功": "Native Gemini model connection succeeded", "原生Gemini模型连接失败": "Native Gemini model connection failed", "OpenAI兼容Gemini代理连接成功": "OpenAI-compatible Gemini proxy connection succeeded", @@ -181,7 +188,7 @@ "Connection failed": "Connection failed", "自定义片段": "Custom Clips", "设置需要生成的短视频片段数量": "Set the number of short video clips to generate", - "上传字幕文件": "Upload Subtitle File", + "上传字幕文件": "Upload SRT", "清除已上传字幕": "Clear Uploaded Subtitle", "无法读取字幕文件,请检查文件编码(支持 UTF-8、UTF-16、GBK、GB2312)": "Unable to read the subtitle file. Please check the file encoding. Supported encodings: UTF-8, UTF-16, GBK, GB2312.", "字幕文件内容似乎为空,请检查文件": "The subtitle file appears to be empty. Please check the file.", @@ -289,15 +296,31 @@ "Encoding": "Encoding", "Size": "Size", "Characters": "characters", - "Ali Bailian Fun-ASR Subtitle Transcription": "Ali Bailian Fun-ASR Subtitle Transcription", - "Fun-ASR upload caption": "After uploading a local audio/video file, it will be uploaded to temporary Ali Bailian storage and converted to SRT subtitles with fun-asr.", + "Ali Bailian Fun-ASR Subtitle Transcription": "Subtitle Processing", + "Subtitle Processing Method": "Subtitle Processing Method", + "Fun-ASR Backend": "Fun-ASR Backend", + "Local FunASR-Pack API": "Local", + "Ali Bailian Online Fun-ASR": "Online", + "Local Fun-ASR upload caption": "The current video above will be converted to SRT subtitles through the locally running FunASR-Pack API.", + "Fun-ASR upload caption": "The current video above will be uploaded to temporary Ali Bailian storage and converted to SRT subtitles with fun-asr.", + "Local FunASR-Pack API URL": "Local FunASR-Pack API URL", + "Local FunASR-Pack API URL Help": "For example, http://127.0.0.1:7860. A full /asr endpoint URL is also supported.", + "Fun-ASR Hotword": "Hotword", + "Fun-ASR Hotword Help": "Optional hotwords passed to the local FunASR-Pack API.", + "Enable speaker diarization": "Enable speaker diarization", + "Enable speaker diarization Help": "Requires the local FunASR-Pack service to enable and load the spk model.", "API Key URL": "API Key URL", "Ali Bailian API Key": "Ali Bailian API Key", "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}", + "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", "Transcribe subtitles": "Transcribe 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", + "Transcribing with local FunASR-Pack...": "Transcribing subtitles with local FunASR-Pack, please wait...", "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}", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 4c732cc..602b616 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -11,7 +11,7 @@ "Video Theme": "视频主题", "Generation Prompt": "自定义提示词", "Save Script": "保存脚本", - "Video File": "视频文件(:blue[1️⃣支持上传视频文件(限制2G) 2️⃣大文件建议直接导入 ./resource/videos 目录])", + "Video File": "视频文件", "Plot Description": "剧情描述 (:blue[可从 https://www.tvmao.com/ 获取])", "Generate Video Keywords": "点击使用AI根据**文案**生成【视频关键】", "Please Enter the Video Subject": "请先填写视频文案", @@ -80,6 +80,13 @@ "Synthesizing Voice": "语音合成中,请稍候...", "TTS Provider": "语音合成提供商", "Hide Log": "隐藏日志", + "Select from resource directory": "从资源目录选择", + "Select a video from resource videos directory": "选择 ./resource/videos 目录中的视频", + "Upload a new video file up to 2GB": "上传一个新的视频文件,限制 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": "文件上传成功", "timestamp": "时间戳", @@ -156,14 +163,14 @@ "Generate Short Video Script": "AI生成短剧混剪脚本", "Adjust the volume of the original audio": "调整原始音频的音量", "Original Volume": "视频音量", - "Auto Generate": "逐帧解说", + "Auto Generate": "逐帧分析", "Frame Interval (seconds)": "帧间隔 (秒)", "Frame Interval (seconds) (More keyframes consume more tokens)": "帧间隔 (秒) (更多关键帧消耗更多令牌)", "Batch Size": "批处理大小", "Batch Size (More keyframes consume more tokens)": "批处理大小, 每批处理越少消耗 token 越多", "Short Drama Summary": "短剧解说", - "Video Type": "视频类型", - "Select/Upload Script": "选择/上传脚本", + "Video Type": "创作类型", + "Select/Upload Script": "自定义脚本", "Script loaded successfully": "脚本加载成功", "Failed to load script": "加载脚本失败", "Failed to save script": "保存脚本失败", @@ -271,15 +278,31 @@ "Encoding": "编码", "Size": "大小", "Characters": "字符", - "Ali Bailian Fun-ASR Subtitle Transcription": "阿里百炼 Fun-ASR 字幕转录", - "Fun-ASR upload caption": "上传本地音频/视频后,将自动上传到阿里百炼临时存储并通过 fun-asr 生成 SRT 字幕。", + "Ali Bailian Fun-ASR Subtitle Transcription": "字幕处理", + "Subtitle Processing Method": "字幕处理方式", + "Fun-ASR Backend": "Fun-ASR 后端", + "Local FunASR-Pack API": "本地转写", + "Ali Bailian Online Fun-ASR": "在线转写", + "Local Fun-ASR upload caption": "将使用上方当前视频,通过本机运行的 FunASR-Pack API 生成 SRT 字幕。", + "Fun-ASR upload caption": "将使用上方当前视频,自动上传到阿里百炼临时存储并通过 fun-asr 生成 SRT 字幕。", + "Local FunASR-Pack API URL": "本地 FunASR-Pack API 地址", + "Local FunASR-Pack API URL Help": "例如 http://127.0.0.1:7860;也可以直接填到 /asr 的完整地址。", + "Fun-ASR Hotword": "热词", + "Fun-ASR Hotword Help": "可选,传给本地 FunASR-Pack 的热词参数。", + "Enable speaker diarization": "启用说话人分段", + "Enable speaker diarization Help": "需要本地 FunASR-Pack 已启用并加载 spk 模型。", "API Key URL": "API Key 获取地址", "Ali Bailian API Key": "阿里百炼 API Key", "Ali Bailian API Key Help": "请输入你自己的阿里百炼 API Key;保存配置后会写入本地 config.toml", "Upload media to transcribe": "上传需要转录的音频/视频", + "Using selected video for subtitle transcription": "将使用当前视频生成字幕: {file}", + "Please select or upload a video first": "请先在上方选择或上传视频文件", + "Selected video file does not exist": "当前视频文件不存在,请重新选择或上传", "Transcribe subtitles": "转写生成字幕", "Please enter Ali Bailian API Key": "请先输入阿里百炼 API Key", + "Please enter local FunASR-Pack API URL": "请先输入本地 FunASR-Pack API 地址", "Please upload media to transcribe": "请先上传需要转录的音频或视频文件", + "Transcribing with local FunASR-Pack...": "正在使用本地 FunASR-Pack 转写字幕,请稍候...", "Transcribing with Fun-ASR...": "正在使用阿里百炼 Fun-ASR 转写字幕,请稍候...", "Fun-ASR failed without subtitle file": "Fun-ASR 转写失败:未生成字幕文件", "Subtitle transcription succeeded": "字幕转写成功: {file}", @@ -357,7 +380,7 @@ "Voice synthesis successful": "✅ 语音合成成功!", "Voice synthesis failed": "❌ 语音合成失败,请检查配置", "SoulVoice pitch not supported": "ℹ️ SoulVoice 引擎不支持音调调节", - "上传字幕文件": "上传字幕文件", + "上传字幕文件": "上传字幕", "清除已上传字幕": "清除已上传字幕", "无法读取字幕文件,请检查文件编码(支持 UTF-8、UTF-16、GBK、GB2312)": "无法读取字幕文件,请检查文件编码(支持 UTF-8、UTF-16、GBK、GB2312)", "字幕文件内容似乎为空,请检查文件": "字幕文件内容似乎为空,请检查文件",