feat: 新增本地FunASR支持并优化网页端视频与字幕工作流

- 更新示例配置文件,添加本地FunASR后端配置项
- 重构fun_asr_subtitle服务,完整支持本地FunASR-Pack API调用
- 优化多语言翻译文件,更新界面相关译文
- 重写网页端视频选择组件,支持本地资源目录选择和直接上传
- 重构字幕转写UI,支持本地/在线百炼/直接上传三种模式
- 新增本地FunASR相关单元测试
This commit is contained in:
viccy 2026-06-05 18:46:56 +08:00
parent 89eebb8b41
commit e744960ac1
6 changed files with 744 additions and 141 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
"字幕文件内容似乎为空,请检查文件": "字幕文件内容似乎为空,请检查文件",