feat(subtitle, asr, bgm): 添加字幕遮罩、自动转录功能,优化背景音乐设置

- 新增字幕遮罩功能,可在烧录新字幕前遮盖原视频自带的字幕区域,支持横屏/竖屏自定义配置与预览调试
- 新增自动字幕转录功能,支持本地FunASR和阿里百炼在线转写,在最终视频合并完成后自动生成并压入成片字幕
- 重构背景音乐设置面板,新增从资源目录选择BGM、上传本地BGM文件的功能,新增BGM试听预览,优化交互流程
- 更新配置示例文件、数据Schema与多语言翻译文件,完善前后端参数传递逻辑
This commit is contained in:
viccy 2026-06-06 01:08:35 +08:00
parent 5b2487e879
commit 33c17c2636
8 changed files with 1239 additions and 58 deletions

View File

@ -183,6 +183,25 @@ class VideoClipParams(BaseModel):
bgm_file: Optional[str] = Field(default="", description="背景音乐文件")
subtitle_enabled: bool = True
subtitle_mask_enabled: bool = False
subtitle_mask_landscape_x_percent: float = 10.0
subtitle_mask_landscape_y_percent: float = 78.0
subtitle_mask_landscape_width_percent: float = 80.0
subtitle_mask_landscape_height_percent: float = 14.0
subtitle_mask_landscape_blur_radius: int = 18
subtitle_mask_landscape_opacity_percent: int = 82
subtitle_mask_portrait_x_percent: float = 8.0
subtitle_mask_portrait_y_percent: float = 79.0
subtitle_mask_portrait_width_percent: float = 84.0
subtitle_mask_portrait_height_percent: float = 16.0
subtitle_mask_portrait_blur_radius: int = 26
subtitle_mask_portrait_opacity_percent: int = 84
subtitle_auto_transcribe_enabled: bool = False
subtitle_auto_transcribe_backend: str = "local"
subtitle_auto_transcribe_api_url: str = ""
subtitle_auto_transcribe_api_key: str = ""
subtitle_auto_transcribe_hotword: str = ""
subtitle_auto_transcribe_enable_spk: bool = False
font_name: str = "SimHei" # 默认使用黑体
font_size: int = 36
text_fore_color: str = "white" # 文本前景色

View File

@ -13,6 +13,7 @@ import traceback
import tempfile
from typing import Optional, Dict, Any
from loguru import logger
import numpy as np
from moviepy import (
VideoFileClip,
AudioFileClip,
@ -22,13 +23,207 @@ from moviepy import (
afx
)
from moviepy.video.tools.subtitles import SubtitlesClip
from PIL import ImageFont
from PIL import ImageFont, Image, ImageDraw, ImageEnhance, ImageFilter
from app.utils import utils
from app.models.schema import AudioVolumeDefaults
from app.services.audio_normalizer import AudioNormalizer, normalize_audio_for_mixing
SUBTITLE_MASK_DEFAULTS = {
"landscape": {
"x_percent": 10.0,
"y_percent": 78.0,
"width_percent": 80.0,
"height_percent": 14.0,
"blur_radius": 18,
"opacity_percent": 82,
},
"portrait": {
"x_percent": 8.0,
"y_percent": 79.0,
"width_percent": 84.0,
"height_percent": 16.0,
"blur_radius": 26,
"opacity_percent": 84,
},
}
def _clamp(value, minimum, maximum):
return min(max(value, minimum), maximum)
def _get_numeric_option(options, key, default, integer=False):
try:
value = float(options.get(key, default))
except (TypeError, ValueError):
value = float(default)
return int(round(value)) if integer else value
def _get_subtitle_mask_region_options(options, orientation):
defaults = SUBTITLE_MASK_DEFAULTS[orientation]
prefix = f"subtitle_mask_{orientation}_"
x_percent = _clamp(_get_numeric_option(options, f"{prefix}x_percent", defaults["x_percent"]), 0, 99)
y_percent = _clamp(_get_numeric_option(options, f"{prefix}y_percent", defaults["y_percent"]), 0, 99)
width_percent = _clamp(
_get_numeric_option(options, f"{prefix}width_percent", defaults["width_percent"]),
2,
100 - x_percent,
)
height_percent = _clamp(
_get_numeric_option(options, f"{prefix}height_percent", defaults["height_percent"]),
2,
100 - y_percent,
)
blur_radius = _clamp(
_get_numeric_option(options, f"{prefix}blur_radius", defaults["blur_radius"], integer=True),
0,
200,
)
opacity_percent = _clamp(
_get_numeric_option(options, f"{prefix}opacity_percent", defaults["opacity_percent"], integer=True),
0,
100,
)
return {
"x_percent": x_percent,
"y_percent": y_percent,
"width_percent": width_percent,
"height_percent": height_percent,
"blur_radius": blur_radius,
"opacity_percent": opacity_percent,
}
def _resolve_subtitle_mask_region(video_width, video_height, options):
orientation = "portrait" if video_height > video_width else "landscape"
region = _get_subtitle_mask_region_options(options, orientation)
x = _clamp(round(video_width * region["x_percent"] / 100), 0, max(0, video_width - 2))
y = _clamp(round(video_height * region["y_percent"] / 100), 0, max(0, video_height - 2))
width = _clamp(round(video_width * region["width_percent"] / 100), 2, max(2, video_width - x))
height = _clamp(round(video_height * region["height_percent"] / 100), 2, max(2, video_height - y))
base_height = 1920 if orientation == "portrait" else 1080
blur_radius = (
0
if region["blur_radius"] == 0
else max(1, round(region["blur_radius"] * (video_height / base_height)))
)
corner_radius = max(8, round(min(height * 0.32, blur_radius * 1.4 or height * 0.24)))
feather = max(6, round(max(blur_radius * 0.85, 8)))
padding = blur_radius
padded_x = max(0, x - padding)
padded_y = max(0, y - padding)
padded_width = _clamp(width + padding * 2, 2, video_width - padded_x)
padded_height = _clamp(height + padding * 2, 2, video_height - padded_y)
return {
"orientation": orientation,
"x": int(x),
"y": int(y),
"width": int(width),
"height": int(height),
"blur_radius": int(blur_radius),
"opacity": _clamp(region["opacity_percent"] / 100, 0, 1),
"corner_radius": int(corner_radius),
"feather": int(feather),
"padded_x": int(padded_x),
"padded_y": int(padded_y),
"padded_width": int(padded_width),
"padded_height": int(padded_height),
}
def _build_subtitle_mask_alpha(region):
alpha = Image.new("L", (region["padded_width"], region["padded_height"]), 0)
draw = ImageDraw.Draw(alpha)
left = region["x"] - region["padded_x"]
top = region["y"] - region["padded_y"]
right = left + region["width"]
bottom = top + region["height"]
draw.rounded_rectangle(
(left, top, right, bottom),
radius=region["corner_radius"],
fill=255,
)
if region["feather"] > 0:
alpha = alpha.filter(ImageFilter.GaussianBlur(radius=max(1, region["feather"] / 2)))
return alpha
def apply_subtitle_mask(video_clip, options):
"""Apply a Speclip-style blurred subtitle mask before subtitle burn-in."""
if not options.get("subtitle_mask_enabled", False):
return video_clip
video_width, video_height = video_clip.size
region = _resolve_subtitle_mask_region(video_width, video_height, options)
logger.info(
"字幕遮罩已启用: "
f"{region['orientation']} x={region['x']} y={region['y']} "
f"w={region['width']} h={region['height']} blur={region['blur_radius']}"
)
alpha = _build_subtitle_mask_alpha(region)
tint_alpha = _clamp(round((0.05 + region["opacity"] * 0.07) * 100) / 100, 0.05, 0.14)
blur_sigma = (
max(4, round(region["blur_radius"] * (0.9 + region["opacity"] * 0.35)))
if region["blur_radius"] > 0
else 0
)
brightness = 1.0 + 0.03 + region["opacity"] * 0.04
contrast = 0.975 - region["opacity"] * 0.035
saturation = 1.0 + region["opacity"] * 0.03
obliterate_width = max(24, round(region["padded_width"] * 0.12))
obliterate_height = max(12, round(region["padded_height"] * 0.18))
def mask_frame(get_frame, t):
frame = np.asarray(get_frame(t))
if frame.dtype != np.uint8:
frame = np.clip(frame, 0, 255).astype(np.uint8)
image = Image.fromarray(frame).convert("RGB")
crop_box = (
region["padded_x"],
region["padded_y"],
region["padded_x"] + region["padded_width"],
region["padded_y"] + region["padded_height"],
)
mask_image = image.crop(crop_box)
mask_image = mask_image.resize(
(obliterate_width, obliterate_height),
Image.Resampling.BICUBIC,
).resize(
(region["padded_width"], region["padded_height"]),
Image.Resampling.LANCZOS,
)
if blur_sigma > 0:
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=blur_sigma))
mask_image = mask_image.filter(ImageFilter.BoxBlur(4))
mask_image = ImageEnhance.Brightness(mask_image).enhance(brightness)
mask_image = ImageEnhance.Contrast(mask_image).enhance(contrast)
mask_image = ImageEnhance.Color(mask_image).enhance(saturation)
blurred = mask_image.convert("RGBA")
blurred.putalpha(alpha)
tint = Image.new("RGBA", blurred.size, (255, 255, 255, 0))
tint_alpha_mask = alpha.point(lambda value: int(value * tint_alpha))
tint.putalpha(tint_alpha_mask)
masked_region = Image.alpha_composite(blurred, tint)
output = image.convert("RGBA")
output.alpha_composite(masked_region, dest=(region["padded_x"], region["padded_y"]))
return np.asarray(output.convert("RGB"))
return video_clip.transform(mask_frame)
def is_valid_subtitle_file(subtitle_path: str) -> bool:
"""
检查字幕文件是否有效
@ -121,6 +316,7 @@ def merge_materials(
threads = options.get('threads', 2)
fps = options.get('fps', 30)
subtitle_enabled = options.get('subtitle_enabled', True)
subtitle_mask_enabled = bool(options.get('subtitle_mask_enabled', False))
# 配置日志 - 便于调试问题
logger.info(f"音量配置详情:")
@ -130,6 +326,7 @@ def merge_materials(
logger.info(f" - 是否保留原声: {keep_original_audio}")
logger.info(f"字幕配置详情:")
logger.info(f" - 是否启用字幕: {subtitle_enabled}")
logger.info(f" - 是否启用字幕遮罩: {subtitle_mask_enabled}")
logger.info(f" - 字幕文件路径: {subtitle_path}")
# 音量参数验证
@ -279,6 +476,9 @@ def merge_materials(
# 处理视频尺寸
video_width, video_height = video_clip.size
if subtitle_enabled and subtitle_mask_enabled:
video_clip = apply_subtitle_mask(video_clip, options)
# 字幕处理函数
def create_text_clip(subtitle_item):

View File

@ -15,6 +15,121 @@ from app.services import state as sm
from app.utils import utils
def _is_auto_transcription_enabled(params: VideoClipParams) -> bool:
return bool(
getattr(params, "subtitle_enabled", True)
and getattr(params, "subtitle_auto_transcribe_enabled", False)
)
def _get_auto_transcription_backend(params: VideoClipParams) -> str:
backend = str(getattr(params, "subtitle_auto_transcribe_backend", "") or "").strip().lower()
if backend not in {"local", "bailian"}:
backend = "local"
return backend
def _build_subtitle_mask_options(params: VideoClipParams, enabled=None) -> dict:
mask_configured = bool(
getattr(params, "subtitle_enabled", True)
and getattr(params, "subtitle_mask_enabled", False)
)
mask_enabled = mask_configured if enabled is None else mask_configured and enabled
return {
'subtitle_mask_enabled': mask_enabled,
'subtitle_mask_landscape_x_percent': getattr(params, "subtitle_mask_landscape_x_percent", 10.0),
'subtitle_mask_landscape_y_percent': getattr(params, "subtitle_mask_landscape_y_percent", 78.0),
'subtitle_mask_landscape_width_percent': getattr(params, "subtitle_mask_landscape_width_percent", 80.0),
'subtitle_mask_landscape_height_percent': getattr(params, "subtitle_mask_landscape_height_percent", 14.0),
'subtitle_mask_landscape_blur_radius': getattr(params, "subtitle_mask_landscape_blur_radius", 18),
'subtitle_mask_landscape_opacity_percent': getattr(params, "subtitle_mask_landscape_opacity_percent", 82),
'subtitle_mask_portrait_x_percent': getattr(params, "subtitle_mask_portrait_x_percent", 8.0),
'subtitle_mask_portrait_y_percent': getattr(params, "subtitle_mask_portrait_y_percent", 79.0),
'subtitle_mask_portrait_width_percent': getattr(params, "subtitle_mask_portrait_width_percent", 84.0),
'subtitle_mask_portrait_height_percent': getattr(params, "subtitle_mask_portrait_height_percent", 16.0),
'subtitle_mask_portrait_blur_radius': getattr(params, "subtitle_mask_portrait_blur_radius", 26),
'subtitle_mask_portrait_opacity_percent': getattr(params, "subtitle_mask_portrait_opacity_percent", 84),
}
def _transcribe_final_video(task_id: str, video_path: str, params: VideoClipParams) -> str:
"""Transcribe the fully merged video into an SRT file."""
from app.services import fun_asr_subtitle
if not video_path or not path.exists(video_path):
raise FileNotFoundError(f"自动转录视频不存在: {video_path}")
backend = _get_auto_transcription_backend(params)
subtitle_file = path.join(utils.task_dir(task_id), "auto_transcribed_final.srt")
logger.info(f"开始自动转录最终视频: {video_path}, backend={backend}")
if backend == "local":
api_url = str(
getattr(params, "subtitle_auto_transcribe_api_url", "")
or config.fun_asr.get("api_url", fun_asr_subtitle.LOCAL_FUN_ASR_API_URL)
).strip()
if not api_url:
raise ValueError("请先输入本地 FunASR-Pack API 地址")
generated_path = fun_asr_subtitle.create_with_local_fun_asr(
local_file=video_path,
subtitle_file=subtitle_file,
api_url=api_url,
hotword=str(getattr(params, "subtitle_auto_transcribe_hotword", "") or "").strip(),
enable_spk=bool(getattr(params, "subtitle_auto_transcribe_enable_spk", False)),
)
else:
api_key = str(
getattr(params, "subtitle_auto_transcribe_api_key", "")
or config.fun_asr.get("api_key", "")
).strip()
if not api_key:
raise ValueError("请先输入阿里百炼 API Key")
generated_path = fun_asr_subtitle.create_with_fun_asr(
local_file=video_path,
subtitle_file=subtitle_file,
api_key=api_key,
)
if not generated_path or not path.exists(generated_path):
raise RuntimeError("自动转录失败:未生成字幕文件")
logger.info(f"自动转录字幕生成成功: {generated_path}")
return generated_path
def _merge_auto_transcribed_subtitles(
source_video_path: str,
output_video_path: str,
subtitle_path: str,
params: VideoClipParams,
) -> str:
subtitle_options = {
'voice_volume': 1.0,
'bgm_volume': 0.0,
'original_audio_volume': 1.0,
'keep_original_audio': True,
'subtitle_enabled': True,
'subtitle_font': params.font_name,
'subtitle_font_size': params.font_size,
'subtitle_color': params.text_fore_color,
'subtitle_bg_color': None,
'subtitle_position': params.subtitle_position,
'custom_position': params.custom_position,
'threads': params.n_threads,
**_build_subtitle_mask_options(params, enabled=True),
}
return generate_video.merge_materials(
video_path=source_video_path,
audio_path="",
subtitle_path=subtitle_path,
bgm_path="",
output_path=output_video_path,
options=subtitle_options
)
def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: dict = None):
"""
后台任务统一视频裁剪处理- 优化版本
@ -200,10 +315,19 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
6. 合并字幕/BGM/配音/视频
"""
output_video_path = path.join(utils.task_dir(task_id), f"combined.mp4")
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {output_video_path}")
auto_transcription_enabled = _is_auto_transcription_enabled(params)
merge_output_video_path = (
path.join(utils.task_dir(task_id), "combined_without_auto_subtitles.mp4")
if auto_transcription_enabled
else output_video_path
)
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {merge_output_video_path}")
# bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
bgm_path = utils.get_bgm_file()
bgm_path = utils.get_bgm_file(
bgm_type=getattr(params, "bgm_type", "random"),
bgm_file=getattr(params, "bgm_file", ""),
)
# 获取优化的音量配置
optimized_volumes = get_recommended_volumes_for_content('mixed')
@ -232,24 +356,39 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
'bgm_volume': final_bgm_volume, # 背景音乐音量(优化后)
'original_audio_volume': final_original_volume, # 视频原声音量(优化后)
'keep_original_audio': True, # 是否保留原声
'subtitle_enabled': params.subtitle_enabled, # 是否启用字幕 - 修复字幕开关bug
'subtitle_enabled': params.subtitle_enabled and not auto_transcription_enabled,
'subtitle_font': params.font_name, # 这里使用相对字体路径,会自动在 font_dir() 目录下查找
'subtitle_font_size': params.font_size,
'subtitle_color': params.text_fore_color,
'subtitle_bg_color': None, # 直接使用None表示透明背景
'subtitle_position': params.subtitle_position,
'custom_position': params.custom_position,
'threads': params.n_threads
'threads': params.n_threads,
**_build_subtitle_mask_options(params, enabled=not auto_transcription_enabled),
}
generate_video.merge_materials(
video_path=combined_video_path,
audio_path=merged_audio_path,
subtitle_path=merged_subtitle_path,
bgm_path=bgm_path,
output_path=output_video_path,
output_path=merge_output_video_path,
options=options
)
auto_subtitle_path = ""
if auto_transcription_enabled:
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=90)
logger.info("\n\n## 7. 自动转录最终视频字幕")
auto_subtitle_path = _transcribe_final_video(task_id, merge_output_video_path, params)
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=95)
logger.info(f"\n\n## 8. 压入自动转录字幕 -> {output_video_path}")
_merge_auto_transcribed_subtitles(
source_video_path=merge_output_video_path,
output_video_path=output_video_path,
subtitle_path=auto_subtitle_path,
params=params,
)
final_video_paths.append(output_video_path)
combined_video_paths.append(combined_video_path)
@ -259,6 +398,8 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
"videos": final_video_paths,
"combined_videos": combined_video_paths
}
if auto_subtitle_path:
kwargs["subtitles"] = [auto_subtitle_path]
sm.state.update_task(task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs)
return kwargs
@ -416,9 +557,18 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
6. 合并字幕/BGM/配音/视频
"""
output_video_path = path.join(utils.task_dir(task_id), f"combined.mp4")
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {output_video_path}")
auto_transcription_enabled = _is_auto_transcription_enabled(params)
merge_output_video_path = (
path.join(utils.task_dir(task_id), "combined_without_auto_subtitles.mp4")
if auto_transcription_enabled
else output_video_path
)
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {merge_output_video_path}")
bgm_path = utils.get_bgm_file()
bgm_path = utils.get_bgm_file(
bgm_type=getattr(params, "bgm_type", "random"),
bgm_file=getattr(params, "bgm_file", ""),
)
# 获取优化的音量配置
optimized_volumes = get_recommended_volumes_for_content('mixed')
@ -446,24 +596,39 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
'bgm_volume': final_bgm_volume,
'original_audio_volume': final_original_volume,
'keep_original_audio': True,
'subtitle_enabled': params.subtitle_enabled,
'subtitle_enabled': params.subtitle_enabled and not auto_transcription_enabled,
'subtitle_font': params.font_name,
'subtitle_font_size': params.font_size,
'subtitle_color': params.text_fore_color,
'subtitle_bg_color': None,
'subtitle_position': params.subtitle_position,
'custom_position': params.custom_position,
'threads': params.n_threads
'threads': params.n_threads,
**_build_subtitle_mask_options(params, enabled=not auto_transcription_enabled),
}
generate_video.merge_materials(
video_path=combined_video_path,
audio_path=merged_audio_path,
subtitle_path=merged_subtitle_path,
bgm_path=bgm_path,
output_path=output_video_path,
output_path=merge_output_video_path,
options=options
)
auto_subtitle_path = ""
if auto_transcription_enabled:
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=90)
logger.info("\n\n## 7. 自动转录最终视频字幕")
auto_subtitle_path = _transcribe_final_video(task_id, merge_output_video_path, params)
sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=95)
logger.info(f"\n\n## 8. 压入自动转录字幕 -> {output_video_path}")
_merge_auto_transcribed_subtitles(
source_video_path=merge_output_video_path,
output_video_path=output_video_path,
subtitle_path=auto_subtitle_path,
params=params,
)
final_video_paths.append(output_video_path)
combined_video_paths.append(combined_video_path)
@ -473,6 +638,8 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
"videos": final_video_paths,
"combined_videos": combined_video_paths
}
if auto_subtitle_path:
kwargs["subtitles"] = [auto_subtitle_path]
sm.state.update_task(task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs)
return kwargs

View File

@ -105,6 +105,7 @@
[fun_asr]
# Fun-ASR 字幕转录配置
# backend = "local" 使用本地 FunASR-Pack APIbackend = "bailian" 使用阿里百炼在线 fun-asr
auto_transcribe_enabled = false
backend = "local"
api_url = "http://127.0.0.1:7860"
hotword = ""
@ -171,6 +172,21 @@
doubaotts_voice_type = "BV700_V2_streaming"
doubaotts_rate = 1.0
# 字幕遮罩配置:用于在烧录新字幕前遮盖原视频自带字幕
subtitle_mask_enabled = false
subtitle_mask_landscape_x_percent = 10
subtitle_mask_landscape_y_percent = 78
subtitle_mask_landscape_width_percent = 80
subtitle_mask_landscape_height_percent = 14
subtitle_mask_landscape_blur_radius = 18
subtitle_mask_landscape_opacity_percent = 82
subtitle_mask_portrait_x_percent = 8
subtitle_mask_portrait_y_percent = 79
subtitle_mask_portrait_width_percent = 84
subtitle_mask_portrait_height_percent = 16
subtitle_mask_portrait_blur_radius = 26
subtitle_mask_portrait_opacity_percent = 84
##########################################
# 代理和网络配置
##########################################

View File

@ -1,12 +1,12 @@
import streamlit as st
import os
import shutil
import json
from uuid import uuid4
from app.config import config
from app.services import voice
from app.models.schema import AudioVolumeDefaults
from app.utils import utils
from webui.utils.cache import get_songs_cache
INDEXTTS2_REFERENCE_AUDIO_SOURCE_DIR = "/Users/viccy/Downloads/tts-mp3-clone/mp3"
@ -36,6 +36,10 @@ INDEXTTS2_REFERENCE_AUDIO_MAP = [
("sarah-en-female.mp3", "莎拉", "Sarah"),
]
INDEXTTS2_REFERENCE_AUDIO_EXTENSIONS = (".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg")
BGM_RESOURCE_DIR = "/Users/viccy/Downloads/tts-mp3-clone/bgms-safe"
BGM_TRACKS_JSON = os.path.join(BGM_RESOURCE_DIR, "tracks.json")
BGM_UPLOAD_SUBDIR = "uploaded_bgms"
BGM_AUDIO_EXTENSIONS = (".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg")
def get_soulvoice_voices():
@ -212,11 +216,106 @@ def copy_indextts2_reference_audio(source_path):
return target_path
def load_bgm_tracks_metadata():
"""读取 BGM 资源描述信息。"""
if not os.path.isfile(BGM_TRACKS_JSON):
return {}
try:
with open(BGM_TRACKS_JSON, "r", encoding="utf-8") as f:
tracks = json.load(f)
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(tracks, list):
return {}
metadata = {}
for track in tracks:
if not isinstance(track, dict):
continue
filename = track.get("fileName")
if filename:
metadata[filename] = track
return metadata
def get_bgm_resource_options():
"""获取 BGM 资源目录中的音频选项。"""
options = []
metadata = load_bgm_tracks_metadata()
added_files = set()
for filename, track in metadata.items():
audio_path = os.path.join(BGM_RESOURCE_DIR, filename)
if not os.path.isfile(audio_path):
continue
options.append({
"filename": filename,
"path": audio_path,
"title": track.get("title") or os.path.splitext(filename)[0],
"style": track.get("style", ""),
"category": track.get("category", ""),
})
added_files.add(filename)
if os.path.isdir(BGM_RESOURCE_DIR):
for filename in sorted(os.listdir(BGM_RESOURCE_DIR)):
if filename in added_files:
continue
if not filename.lower().endswith(BGM_AUDIO_EXTENSIONS):
continue
audio_path = os.path.join(BGM_RESOURCE_DIR, filename)
if not os.path.isfile(audio_path):
continue
options.append({
"filename": filename,
"path": audio_path,
"title": os.path.splitext(filename)[0],
"style": "",
"category": "",
})
return options
def format_bgm_resource_option(option):
"""格式化 BGM 资源下拉显示名。"""
title = option.get("title") or os.path.splitext(option.get("filename", ""))[0]
style = option.get("style", "")
category = option.get("category", "")
if style:
return f"{title} ({style})"
if category:
return f"{title} ({category})"
return title
def get_bgm_resource_index(options, saved_bgm_file):
"""根据已保存的 BGM 文件匹配下拉选项索引。"""
if not options:
return 0
saved_filename = os.path.basename(saved_bgm_file or "")
for index, option in enumerate(options):
if option["filename"] == saved_filename:
return index
return 0
def get_audio_mime_type(audio_path):
"""根据音频文件扩展名返回 MIME 类型"""
extension = os.path.splitext(audio_path or "")[1].lower()
if extension == ".wav":
return "audio/wav"
if extension == ".flac":
return "audio/flac"
if extension == ".ogg":
return "audio/ogg"
if extension == ".m4a":
@ -240,6 +339,20 @@ def render_reference_audio_preview_button(reference_audio, key, tr):
st.session_state["indextts2_reference_audio_preview_path"] = reference_audio
def render_bgm_preview_button(bgm_file, key, tr):
"""渲染 BGM 试听按钮。"""
can_preview = bool(bgm_file and os.path.isfile(bgm_file))
if st.button(
" ",
key=key,
icon=":material/play_arrow:",
help=tr("Preview Background Music Help"),
disabled=not can_preview,
use_container_width=True,
):
st.session_state["bgm_preview_path"] = bgm_file
def is_valid_azure_voice_name(voice_name: str) -> bool:
"""检查是否为有效的Azure音色名称格式"""
if not voice_name or not isinstance(voice_name, str):
@ -262,7 +375,13 @@ def render_audio_panel(tr):
# 渲染TTS设置
render_tts_settings(tr)
# 渲染背景音乐设置
# 背景音乐独立成框,放在音频设置下方
render_bgm_panel(tr)
def render_bgm_panel(tr):
"""渲染背景音乐设置面板"""
with st.container(border=True):
render_bgm_settings(tr)
@ -1356,29 +1475,106 @@ def render_voice_preview(tr, voice_name):
def render_bgm_settings(tr):
"""渲染背景音乐设置"""
# 背景音乐选项
bgm_options = [
(tr("No Background Music"), ""),
(tr("Random Background Music"), "random"),
(tr("Custom Background Music"), "custom"),
]
saved_bgm_file = st.session_state.get('bgm_file', '')
saved_bgm_source = st.session_state.get('bgm_source', 'resource')
if st.session_state.get('bgm_type') == "":
saved_bgm_source = "none"
selected_index = st.selectbox(
tr("Background Music"),
index=1,
options=range(len(bgm_options)),
format_func=lambda x: bgm_options[x][0],
bgm_source_options = {
tr("Select from Resource Directory"): "resource",
tr("Upload Background Music"): "upload",
tr("No Background Music"): "none",
}
if saved_bgm_source not in bgm_source_options.values():
saved_bgm_source = "resource"
default_bgm_source_label = next(
label
for label, source_value in bgm_source_options.items()
if source_value == saved_bgm_source
)
# 获取选择的背景音乐类型
bgm_type = bgm_options[selected_index][1]
st.session_state['bgm_type'] = bgm_type
st.markdown(f"**{tr('Background Music')}**")
bgm_source_label = st.pills(
tr("Background Music Source"),
options=list(bgm_source_options.keys()),
selection_mode="single",
default=default_bgm_source_label,
key="bgm_source_selection",
help=tr("Background Music Source Help"),
label_visibility="collapsed",
width="stretch",
)
if not bgm_source_label:
bgm_source_label = default_bgm_source_label
# 自定义背景音乐处理
if bgm_type == "custom":
custom_bgm_file = st.text_input(tr("Custom Background Music File"))
if custom_bgm_file and os.path.exists(custom_bgm_file):
st.session_state['bgm_file'] = custom_bgm_file
bgm_source = bgm_source_options[bgm_source_label]
bgm_file = ""
bgm_name = ""
if bgm_source == "resource":
bgm_options = get_bgm_resource_options()
if bgm_options:
selected_bgm_index = get_bgm_resource_index(bgm_options, saved_bgm_file)
select_col, preview_col = st.columns([5, 1])
with select_col:
selected_bgm_option = bgm_options[st.selectbox(
tr("Background Music"),
options=range(len(bgm_options)),
index=selected_bgm_index,
format_func=lambda x: format_bgm_resource_option(bgm_options[x]),
help=tr("Background Music Path Help"),
label_visibility="collapsed"
)]
bgm_file = selected_bgm_option["path"]
bgm_name = selected_bgm_option["title"]
with preview_col:
render_bgm_preview_button(
bgm_file,
"resource_bgm_preview",
tr,
)
else:
st.warning(tr("No Background Music Resources Found"))
if bgm_source == "upload":
if st.session_state.get('bgm_source') != "upload":
saved_bgm_file = ""
bgm_file = saved_bgm_file if saved_bgm_file and os.path.isfile(saved_bgm_file) else ""
bgm_name = os.path.splitext(os.path.basename(bgm_file))[0] if bgm_file else ""
upload_col, preview_col = st.columns([5, 1])
with upload_col:
uploaded_file = st.file_uploader(
tr("Upload Background Music File"),
type=[extension.lstrip(".") for extension in BGM_AUDIO_EXTENSIONS],
help=tr("Upload Background Music Help"),
label_visibility="collapsed"
)
if uploaded_file is not None:
target_dir = utils.storage_dir(BGM_UPLOAD_SUBDIR, create=True)
bgm_file = os.path.join(target_dir, f"uploaded_{uploaded_file.name}")
with open(bgm_file, "wb") as f:
f.write(uploaded_file.getbuffer())
bgm_name = os.path.splitext(uploaded_file.name)[0]
st.success(tr("Background Music uploaded").format(path=bgm_file))
with preview_col:
render_bgm_preview_button(
bgm_file,
"upload_bgm_preview",
tr,
)
preview_bgm_path = st.session_state.get("bgm_preview_path", "")
if preview_bgm_path == bgm_file and os.path.isfile(preview_bgm_path):
with open(preview_bgm_path, "rb") as audio_file:
st.audio(audio_file.read(), format=get_audio_mime_type(preview_bgm_path))
bgm_type = "" if bgm_source == "none" or not bgm_file else "custom"
st.session_state['bgm_source'] = bgm_source
st.session_state['bgm_type'] = bgm_type
st.session_state['bgm_file'] = bgm_file if bgm_type else ""
st.session_state['bgm_name'] = bgm_name if bgm_type else ""
# 背景音乐音量 - 使用统一的默认值
bgm_volume = st.slider(
@ -1399,6 +1595,7 @@ def get_audio_params():
'voice_volume': st.session_state.get('voice_volume', AudioVolumeDefaults.VOICE_VOLUME),
'voice_rate': st.session_state.get('voice_rate', 1.0),
'voice_pitch': st.session_state.get('voice_pitch', 1.0),
'bgm_name': st.session_state.get('bgm_name', ''),
'bgm_type': st.session_state.get('bgm_type', 'random'),
'bgm_file': st.session_state.get('bgm_file', ''),
'bgm_volume': st.session_state.get('bgm_volume', AudioVolumeDefaults.BGM_VOLUME),

View File

@ -1,47 +1,507 @@
from loguru import logger
import streamlit as st
from app.config import config
from app.utils import utils
from webui.utils.cache import get_fonts_cache
import hashlib
import os
SUBTITLE_MASK_DEFAULTS = {
"landscape": {
"x_percent": 10,
"y_percent": 78,
"width_percent": 80,
"height_percent": 14,
"blur_radius": 18,
"opacity_percent": 82,
},
"portrait": {
"x_percent": 8,
"y_percent": 79,
"width_percent": 84,
"height_percent": 16,
"blur_radius": 26,
"opacity_percent": 84,
},
}
VIDEO_PREVIEW_UPLOAD_TYPES = ["mp4", "mov", "avi", "flv", "mkv", "mpeg4"]
def render_subtitle_panel(tr):
"""渲染字幕设置面板"""
with st.container(border=True):
st.write(tr("Subtitle Settings"))
st.info(tr("Subtitle TTS support notice"))
# 检查是否选择了 SoulVoice qwen3_tts引擎
from app.services import voice
# current_voice = st.session_state.get('voice_name', '')
tts_engine = config.ui.get('tts_engine', '')
is_disabled_subtitle = is_disabled_subtitle_settings(tts_engine)
if is_disabled_subtitle:
# SoulVoice 引擎时显示禁用提示
st.warning(tr("TTS engine does not support precise subtitles").format(engine=tts_engine))
st.info(tr("Manual subtitle editing recommendation"))
# 强制禁用字幕
st.session_state['subtitle_enabled'] = False
enable_subtitles = st.checkbox(tr("Enable Subtitles"), value=True)
st.session_state['subtitle_enabled'] = enable_subtitles
# 显示禁用状态的复选框
st.checkbox(
tr("Enable Subtitles"),
value=False,
disabled=True,
help=tr("Disabled subtitles help")
)
if enable_subtitles:
render_subtitle_mask_settings(tr)
render_auto_transcription_settings(tr)
render_font_settings(tr)
render_position_settings(tr)
render_style_settings(tr)
else:
# 其他引擎正常显示字幕选项
enable_subtitles = st.checkbox(tr("Enable Subtitles"), value=True)
st.session_state['subtitle_enabled'] = enable_subtitles
st.session_state['subtitle_mask_enabled'] = False
config.ui["subtitle_mask_enabled"] = False
st.session_state['subtitle_auto_transcribe_enabled'] = False
config.fun_asr["auto_transcribe_enabled"] = False
if enable_subtitles:
render_font_settings(tr)
render_position_settings(tr)
render_style_settings(tr)
def _subtitle_mask_key(orientation, field):
return f"subtitle_mask_{orientation}_{field}"
def _get_subtitle_mask_value(orientation, field):
key = _subtitle_mask_key(orientation, field)
return config.ui.get(key, SUBTITLE_MASK_DEFAULTS[orientation][field])
def _set_subtitle_mask_value(orientation, field, value):
key = _subtitle_mask_key(orientation, field)
config.ui[key] = value
st.session_state[key] = value
def _format_preview_time(seconds):
seconds = max(0.0, float(seconds or 0))
minutes = int(seconds // 60)
remaining_seconds = seconds - minutes * 60
return f"{minutes:02d}:{remaining_seconds:04.1f}"
def _get_current_preview_video_path():
uploaded_path = st.session_state.get("subtitle_mask_preview_video_path")
if uploaded_path and os.path.exists(uploaded_path):
return uploaded_path
video_path = st.session_state.get("video_origin_path", "")
if isinstance(video_path, str) and video_path and os.path.exists(video_path):
return video_path
video_paths = st.session_state.get("video_origin_paths", [])
if isinstance(video_paths, list):
for path in video_paths:
if isinstance(path, str) and path and os.path.exists(path):
return path
return ""
def _save_subtitle_mask_preview_video(uploaded_file):
if uploaded_file is None:
return ""
signature = f"{uploaded_file.name}:{uploaded_file.size}"
existing_signature = st.session_state.get("subtitle_mask_preview_upload_signature")
existing_path = st.session_state.get("subtitle_mask_preview_video_path", "")
if signature == existing_signature and existing_path and os.path.exists(existing_path):
return existing_path
target_dir = utils.temp_dir("subtitle_mask_preview")
safe_name = os.path.basename(uploaded_file.name).strip() or "preview.mp4"
digest = hashlib.md5(signature.encode("utf-8")).hexdigest()[:10]
preview_path = os.path.join(target_dir, f"{digest}_{safe_name}")
with open(preview_path, "wb") as f:
f.write(uploaded_file.getbuffer())
st.session_state["subtitle_mask_preview_upload_signature"] = signature
st.session_state["subtitle_mask_preview_video_path"] = preview_path
return preview_path
def _video_mtime(video_path):
try:
return os.path.getmtime(video_path)
except OSError:
return 0
@st.cache_data(show_spinner=False)
def _probe_subtitle_mask_preview_video(video_path, mtime):
from moviepy import VideoFileClip
clip = VideoFileClip(video_path)
try:
return {
"duration": float(clip.duration or 0),
"width": int(clip.w),
"height": int(clip.h),
}
finally:
clip.close()
@st.cache_data(show_spinner=False)
def _extract_subtitle_mask_preview_frame(video_path, timestamp, mtime):
import numpy as np
from moviepy import VideoFileClip
clip = VideoFileClip(video_path)
try:
safe_time = min(max(float(timestamp or 0), 0.0), max(float(clip.duration or 0), 0.0))
frame = np.asarray(clip.get_frame(safe_time))
if frame.dtype != np.uint8:
frame = np.clip(frame, 0, 255).astype(np.uint8)
return frame
finally:
clip.close()
def _build_subtitle_mask_preview_options():
options = {"subtitle_mask_enabled": True}
for orientation in ("landscape", "portrait"):
for field in ("x_percent", "y_percent", "width_percent", "height_percent", "blur_radius", "opacity_percent"):
options[_subtitle_mask_key(orientation, field)] = _get_subtitle_mask_value(orientation, field)
return options
def _draw_subtitle_mask_preview(frame):
from PIL import Image, ImageDraw
from app.services.generate_video import _resolve_subtitle_mask_region
image = Image.fromarray(frame).convert("RGBA")
region = _resolve_subtitle_mask_region(image.width, image.height, _build_subtitle_mask_preview_options())
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
rect = (
region["x"],
region["y"],
region["x"] + region["width"],
region["y"] + region["height"],
)
draw.rounded_rectangle(
rect,
radius=region["corner_radius"],
fill=(0, 0, 0, 96),
outline=(255, 75, 85, 235),
width=max(2, round(min(image.width, image.height) * 0.004)),
)
image.alpha_composite(overlay)
return image.convert("RGB"), region
def _resize_subtitle_mask_preview_image(image, max_width=520, max_height=360):
image = image.copy()
image.thumbnail((max_width, max_height))
return image
def _render_subtitle_mask_preview(tr):
st.subheader(tr("Subtitle Mask Preview"))
uploaded_path = st.session_state.get("subtitle_mask_preview_video_path", "")
if uploaded_path and os.path.exists(uploaded_path):
preview_cols = st.columns([0.68, 0.32], vertical_alignment="center")
with preview_cols[0]:
st.caption(
tr("Using Subtitle Mask Preview Video").format(
file=os.path.basename(uploaded_path)
)
)
with preview_cols[1]:
if st.button(
tr("Change Subtitle Mask Preview Video"),
key="change_subtitle_mask_preview_video",
use_container_width=True,
):
st.session_state.pop("subtitle_mask_preview_video_path", None)
st.session_state.pop("subtitle_mask_preview_upload_signature", None)
st.rerun(scope="fragment")
else:
uploaded_file = st.file_uploader(
tr("Upload Subtitle Mask Preview Video"),
type=VIDEO_PREVIEW_UPLOAD_TYPES,
key="subtitle_mask_preview_video_uploader",
help=tr("Upload Subtitle Mask Preview Video Help"),
)
uploaded_path = _save_subtitle_mask_preview_video(uploaded_file)
if uploaded_path:
st.rerun(scope="fragment")
preview_video_path = uploaded_path or _get_current_preview_video_path()
if not preview_video_path:
st.info(tr("Subtitle Mask Preview Empty"))
return
try:
mtime = _video_mtime(preview_video_path)
video_info = _probe_subtitle_mask_preview_video(preview_video_path, mtime)
duration = max(0.0, video_info["duration"])
if duration <= 0:
st.warning(tr("Subtitle Mask Preview Failed"))
return
selected_time = st.slider(
tr("Subtitle Mask Preview Timeline"),
min_value=0.0,
max_value=duration,
value=min(float(st.session_state.get("subtitle_mask_preview_time", 0.0)), duration),
step=0.1,
format="%.1f",
key="subtitle_mask_preview_time",
help=tr("Subtitle Mask Preview Timeline Help"),
)
frame = _extract_subtitle_mask_preview_frame(preview_video_path, selected_time, mtime)
preview_image, region = _draw_subtitle_mask_preview(frame)
preview_image = _resize_subtitle_mask_preview_image(preview_image, max_width=420, max_height=280)
st.image(
preview_image,
caption=tr("Subtitle Mask Preview Frame Caption").format(
time=_format_preview_time(selected_time),
orientation=tr("Portrait") if region["orientation"] == "portrait" else tr("Landscape"),
),
)
except Exception:
st.warning(tr("Subtitle Mask Preview Failed"))
def _render_subtitle_mask_region_controls(tr, orientation):
x_percent = st.slider(
tr("Subtitle Mask Left"),
min_value=0,
max_value=99,
value=int(_get_subtitle_mask_value(orientation, "x_percent")),
help=tr("Subtitle Mask Left Help"),
key=f"{orientation}_subtitle_mask_x_percent",
)
_set_subtitle_mask_value(orientation, "x_percent", x_percent)
y_percent = st.slider(
tr("Subtitle Mask Top"),
min_value=0,
max_value=99,
value=int(_get_subtitle_mask_value(orientation, "y_percent")),
help=tr("Subtitle Mask Top Help"),
key=f"{orientation}_subtitle_mask_y_percent",
)
_set_subtitle_mask_value(orientation, "y_percent", y_percent)
max_width = max(2, 100 - x_percent)
width_widget_key = f"{orientation}_subtitle_mask_width_percent"
if st.session_state.get(width_widget_key, 2) < 2:
st.session_state[width_widget_key] = 2
if st.session_state.get(width_widget_key, 0) > max_width:
st.session_state[width_widget_key] = max_width
width_percent = st.slider(
tr("Subtitle Mask Width"),
min_value=2,
max_value=max_width,
value=min(int(_get_subtitle_mask_value(orientation, "width_percent")), max_width),
help=tr("Subtitle Mask Width Help"),
key=width_widget_key,
)
_set_subtitle_mask_value(orientation, "width_percent", width_percent)
max_height = max(2, 100 - y_percent)
height_widget_key = f"{orientation}_subtitle_mask_height_percent"
if st.session_state.get(height_widget_key, 2) < 2:
st.session_state[height_widget_key] = 2
if st.session_state.get(height_widget_key, 0) > max_height:
st.session_state[height_widget_key] = max_height
height_percent = st.slider(
tr("Subtitle Mask Height"),
min_value=2,
max_value=max_height,
value=min(int(_get_subtitle_mask_value(orientation, "height_percent")), max_height),
help=tr("Subtitle Mask Height Help"),
key=height_widget_key,
)
_set_subtitle_mask_value(orientation, "height_percent", height_percent)
blur_radius = st.slider(
tr("Subtitle Mask Blur Radius"),
min_value=0,
max_value=200,
value=int(_get_subtitle_mask_value(orientation, "blur_radius")),
help=tr("Subtitle Mask Blur Radius Help"),
key=f"{orientation}_subtitle_mask_blur_radius",
)
_set_subtitle_mask_value(orientation, "blur_radius", blur_radius)
opacity_percent = st.slider(
tr("Subtitle Mask Opacity"),
min_value=0,
max_value=100,
value=int(_get_subtitle_mask_value(orientation, "opacity_percent")),
help=tr("Subtitle Mask Opacity Help"),
key=f"{orientation}_subtitle_mask_opacity_percent",
)
_set_subtitle_mask_value(orientation, "opacity_percent", opacity_percent)
def _render_subtitle_mask_dialog(tr):
@st.dialog(tr("Subtitle Mask Settings"), width="large")
def subtitle_mask_dialog():
preview_col, settings_col = st.columns([1, 1], vertical_alignment="top")
with settings_col:
st.caption(tr("Subtitle Mask Settings Caption"))
st.caption(tr("Subtitle Mask Preview Caption"))
landscape_tab, portrait_tab = st.tabs([
tr("Landscape Subtitle Mask"),
tr("Portrait Subtitle Mask"),
])
with landscape_tab:
_render_subtitle_mask_region_controls(tr, "landscape")
with portrait_tab:
_render_subtitle_mask_region_controls(tr, "portrait")
with preview_col:
_render_subtitle_mask_preview(tr)
if st.button(tr("Save Subtitle Mask Settings"), type="primary", use_container_width=True):
config.save_config()
st.rerun()
subtitle_mask_dialog()
def render_subtitle_mask_settings(tr):
"""渲染原字幕遮罩设置。"""
mask_enabled = st.checkbox(
tr("Enable Subtitle Mask"),
value=bool(config.ui.get("subtitle_mask_enabled", False)),
help=tr("Enable Subtitle Mask Help"),
key="subtitle_mask_enabled_checkbox",
)
st.session_state['subtitle_mask_enabled'] = mask_enabled
config.ui["subtitle_mask_enabled"] = mask_enabled
if not mask_enabled:
return
button_col, summary_col = st.columns([0.35, 0.65], vertical_alignment="center")
with button_col:
if st.button(tr("Set Subtitle Mask"), key="set_subtitle_mask", use_container_width=True):
_render_subtitle_mask_dialog(tr)
with summary_col:
st.caption(
tr("Subtitle Mask Summary").format(
landscape_x=_get_subtitle_mask_value("landscape", "x_percent"),
landscape_y=_get_subtitle_mask_value("landscape", "y_percent"),
landscape_width=_get_subtitle_mask_value("landscape", "width_percent"),
landscape_height=_get_subtitle_mask_value("landscape", "height_percent"),
portrait_x=_get_subtitle_mask_value("portrait", "x_percent"),
portrait_y=_get_subtitle_mask_value("portrait", "y_percent"),
portrait_width=_get_subtitle_mask_value("portrait", "width_percent"),
portrait_height=_get_subtitle_mask_value("portrait", "height_percent"),
)
)
def _get_saved_auto_transcribe_backend():
saved_backend = str(config.fun_asr.get("backend", "")).strip().lower()
if saved_backend not in {"local", "bailian"}:
saved_backend = (
"bailian"
if config.fun_asr.get("api_key") and not config.fun_asr.get("api_url")
else "local"
)
return saved_backend
def render_auto_transcription_settings(tr):
"""渲染最终视频自动转录设置。"""
from app.services import fun_asr_subtitle
auto_transcribe_enabled = st.checkbox(
tr("Enable Auto Transcription"),
value=bool(config.fun_asr.get("auto_transcribe_enabled", False)),
help=tr("Enable Auto Transcription Help"),
key="subtitle_auto_transcribe_enabled_checkbox",
)
st.session_state['subtitle_auto_transcribe_enabled'] = auto_transcribe_enabled
config.fun_asr["auto_transcribe_enabled"] = auto_transcribe_enabled
backend = _get_saved_auto_transcribe_backend()
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))
api_key = config.fun_asr.get("api_key", "")
if not auto_transcribe_enabled:
st.session_state['subtitle_auto_transcribe_backend'] = backend
st.session_state['subtitle_auto_transcribe_api_url'] = api_url
st.session_state['subtitle_auto_transcribe_hotword'] = hotword
st.session_state['subtitle_auto_transcribe_enable_spk'] = enable_spk
st.session_state['subtitle_auto_transcribe_api_key'] = api_key
return
backend_options = {
tr("Local FunASR-Pack API"): "local",
tr("Ali Bailian Online Fun-ASR"): "bailian",
}
backend_values = list(backend_options.values())
backend_labels = list(backend_options.keys())
backend_label = st.radio(
tr("Subtitle Processing Method"),
options=backend_labels,
index=backend_values.index(backend),
horizontal=True,
key="subtitle_auto_transcribe_backend_radio",
)
backend = backend_options[backend_label]
if backend == "local":
st.caption(tr("Auto Transcription Local Caption"))
api_url = st.text_input(
tr("Local FunASR-Pack API URL"),
value=api_url,
help=tr("Local FunASR-Pack API URL Help"),
key="subtitle_auto_transcribe_api_url_input",
)
hotword = st.text_input(
tr("Fun-ASR Hotword"),
value=hotword,
help=tr("Fun-ASR Hotword Help"),
key="subtitle_auto_transcribe_hotword_input",
)
enable_spk = st.checkbox(
tr("Enable speaker diarization"),
value=enable_spk,
help=tr("Enable speaker diarization Help"),
key="subtitle_auto_transcribe_enable_spk_checkbox",
)
else:
st.caption(tr("Auto Transcription Online 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)"
)
api_key = st.text_input(
tr("Ali Bailian API Key"),
value=api_key,
type="password",
help=tr("Ali Bailian API Key Help"),
key="subtitle_auto_transcribe_api_key_input",
)
config.fun_asr["backend"] = backend
config.fun_asr["api_url"] = str(api_url).strip()
config.fun_asr["api_key"] = str(api_key).strip()
config.fun_asr["hotword"] = str(hotword).strip()
config.fun_asr["enable_spk"] = bool(enable_spk)
config.fun_asr["model"] = "fun-asr"
st.session_state['subtitle_auto_transcribe_backend'] = backend
st.session_state['subtitle_auto_transcribe_api_url'] = str(api_url).strip()
st.session_state['subtitle_auto_transcribe_api_key'] = str(api_key).strip()
st.session_state['subtitle_auto_transcribe_hotword'] = str(hotword).strip()
st.session_state['subtitle_auto_transcribe_enable_spk'] = bool(enable_spk)
def render_font_settings(tr):
@ -154,6 +614,40 @@ def get_subtitle_params():
font_name = st.session_state.get('font_name') or "SimHei"
return {
'subtitle_enabled': st.session_state.get('subtitle_enabled', True),
'subtitle_mask_enabled': st.session_state.get('subtitle_mask_enabled', False),
'subtitle_mask_landscape_x_percent': _get_subtitle_mask_value("landscape", "x_percent"),
'subtitle_mask_landscape_y_percent': _get_subtitle_mask_value("landscape", "y_percent"),
'subtitle_mask_landscape_width_percent': _get_subtitle_mask_value("landscape", "width_percent"),
'subtitle_mask_landscape_height_percent': _get_subtitle_mask_value("landscape", "height_percent"),
'subtitle_mask_landscape_blur_radius': _get_subtitle_mask_value("landscape", "blur_radius"),
'subtitle_mask_landscape_opacity_percent': _get_subtitle_mask_value("landscape", "opacity_percent"),
'subtitle_mask_portrait_x_percent': _get_subtitle_mask_value("portrait", "x_percent"),
'subtitle_mask_portrait_y_percent': _get_subtitle_mask_value("portrait", "y_percent"),
'subtitle_mask_portrait_width_percent': _get_subtitle_mask_value("portrait", "width_percent"),
'subtitle_mask_portrait_height_percent': _get_subtitle_mask_value("portrait", "height_percent"),
'subtitle_mask_portrait_blur_radius': _get_subtitle_mask_value("portrait", "blur_radius"),
'subtitle_mask_portrait_opacity_percent': _get_subtitle_mask_value("portrait", "opacity_percent"),
'subtitle_auto_transcribe_enabled': st.session_state.get('subtitle_auto_transcribe_enabled', False),
'subtitle_auto_transcribe_backend': st.session_state.get(
'subtitle_auto_transcribe_backend',
_get_saved_auto_transcribe_backend()
),
'subtitle_auto_transcribe_api_url': st.session_state.get(
'subtitle_auto_transcribe_api_url',
config.fun_asr.get("api_url", "")
),
'subtitle_auto_transcribe_api_key': st.session_state.get(
'subtitle_auto_transcribe_api_key',
config.fun_asr.get("api_key", "")
),
'subtitle_auto_transcribe_hotword': st.session_state.get(
'subtitle_auto_transcribe_hotword',
config.fun_asr.get("hotword", "")
),
'subtitle_auto_transcribe_enable_spk': st.session_state.get(
'subtitle_auto_transcribe_enable_spk',
bool(config.fun_asr.get("enable_spk", False))
),
'font_name': font_name,
'font_size': st.session_state.get('font_size', 60),
'text_fore_color': st.session_state.get('text_fore_color', '#FFFFFF'),

View File

@ -51,9 +51,52 @@
"Random Background Music": "Random Background Music",
"Custom Background Music": "Custom Background Music",
"Custom Background Music File": "Please enter the file path of the custom background music",
"Background Music Source": "Background Music Source",
"Background Music Source Help": "Choose background music from the resource directory, upload a new file, or disable background music.",
"Upload Background Music": "Upload Background Music",
"Background Music Path Help": "Choose the background music used for video synthesis.",
"No Background Music Resources Found": "No background music resources found. Please upload a background music file.",
"Preview Background Music Help": "Play the selected background music.",
"Upload Background Music File": "Upload Background Music File",
"Upload Background Music Help": "Upload an audio file to use as background music.",
"Background Music uploaded": "✅ Background music uploaded: {path}",
"Background Music Volume": "Background Music Volume (0.2 represents 20%, background sound should not be too loud)",
"Subtitle Settings": "**Subtitle Settings**",
"Enable Subtitles": "Enable Subtitles (If unchecked, the following settings will not take effect)",
"Enable Subtitle Mask": "Enable Subtitle Mask",
"Enable Subtitle Mask Help": "Before burning in new subtitles, cover the original subtitle area with a soft blurred mask.",
"Set Subtitle Mask": "Set Subtitle Mask",
"Subtitle Mask Summary": "Landscape {landscape_x}%/{landscape_y}% · {landscape_width}%×{landscape_height}%; portrait {portrait_x}%/{portrait_y}% · {portrait_width}%×{portrait_height}%",
"Subtitle Mask Settings": "Subtitle Mask Settings",
"Subtitle Mask Settings Caption": "Save landscape and portrait mask regions as frame percentages. The mask is applied before new subtitles are burned in.",
"Landscape Subtitle Mask": "Landscape Mask",
"Portrait Subtitle Mask": "Portrait Mask",
"Save Subtitle Mask Settings": "Save Subtitle Mask Settings",
"Subtitle Mask Left": "Left Position",
"Subtitle Mask Left Help": "Mask distance from the left edge as a frame percentage.",
"Subtitle Mask Top": "Top Position",
"Subtitle Mask Top Help": "Mask distance from the top edge as a frame percentage.",
"Subtitle Mask Width": "Mask Width",
"Subtitle Mask Width Help": "Width of the covered mask region as a frame percentage.",
"Subtitle Mask Height": "Mask Height",
"Subtitle Mask Height Help": "Height of the covered mask region as a frame percentage.",
"Subtitle Mask Blur Radius": "Blur Radius",
"Subtitle Mask Blur Radius Help": "Blur strength for the mask background and edge.",
"Subtitle Mask Opacity": "Mask Strength",
"Subtitle Mask Opacity Help": "Mask blend strength. Higher values cover source subtitles more strongly.",
"Subtitle Mask Preview": "Source Subtitle Mask Preview",
"Subtitle Mask Preview Caption": "Upload a source video for preview, or use the currently selected source video. Uploaded files here are only used for mask preview.",
"Upload Subtitle Mask Preview Video": "Upload Preview Source Video",
"Upload Subtitle Mask Preview Video Help": "Only used for previewing the mask in this dialog. It will not replace the source video used for generation.",
"Using Subtitle Mask Preview Video": "Preview video: {file}",
"Change Subtitle Mask Preview Video": "Change Video",
"Subtitle Mask Preview Empty": "Upload a preview video, or select a source video above first.",
"Subtitle Mask Preview Timeline": "Preview Timeline (seconds)",
"Subtitle Mask Preview Timeline Help": "Drag to a frame where the source subtitles appear, then fine-tune the mask region.",
"Subtitle Mask Preview Frame Caption": "{time} · {orientation} · red outline shows the current mask region",
"Subtitle Mask Preview Failed": "Unable to read this video preview. Please try another video file.",
"Enable Auto Transcription": "Enable Auto Transcription",
"Enable Auto Transcription Help": "After the final video is merged, transcribe the whole video into subtitles and burn them into the output.",
"Font": "Subtitle Font",
"Position": "Subtitle Position",
"Top": "Top",
@ -219,7 +262,6 @@
"短剧名称": "Short Drama Name",
"生成短剧解说脚本": "Generate Short Drama Narration Script",
"请输入视频脚本": "Please enter the video script",
"Subtitle TTS support notice": "💡 Note: currently only the **edge-tts** engine supports automatic subtitle generation. Other TTS engines are not supported yet.",
"TTS engine does not support precise subtitles": "⚠️ {engine} does not support precise subtitle generation",
"Manual subtitle editing recommendation": "💡 We recommend adding subtitles manually in a professional editor such as CapCut or Premiere Pro.",
"Disabled subtitles help": "This TTS engine does not support subtitle generation. Please use another TTS engine.",
@ -329,6 +371,8 @@
"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.",
"Auto Transcription Local Caption": "After the final video is merged, it will be converted to SRT subtitles through the locally running FunASR-Pack API.",
"Auto Transcription Online Caption": "After the final video is merged, it 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",

View File

@ -41,9 +41,52 @@
"Random Background Music": "随机背景音乐",
"Custom Background Music": "自定义背景音乐",
"Custom Background Music File": "请输入自定义背景音乐的文件路径",
"Background Music Source": "背景音乐来源",
"Background Music Source Help": "选择资源目录中的背景音乐、上传新的背景音乐,或关闭背景音乐",
"Upload Background Music": "上传背景音乐",
"Background Music Path Help": "选择用于视频合成的背景音乐",
"No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件",
"Preview Background Music Help": "播放当前背景音乐",
"Upload Background Music File": "上传背景音乐文件",
"Upload Background Music Help": "上传一个音频文件作为背景音乐",
"Background Music uploaded": "✅ 背景音乐已上传: {path}",
"Background Music Volume": "背景音乐音量0.2表示20%,背景声音不宜过高)",
"Subtitle Settings": "**字幕设置**",
"Enable Subtitles": "启用字幕(若取消勾选,下面的设置都将不生效)",
"Enable Subtitle Mask": "启用字幕遮罩",
"Enable Subtitle Mask Help": "开启后会在烧录新字幕前,先用模糊遮罩覆盖原视频自带字幕区域",
"Set Subtitle Mask": "设置字幕遮罩",
"Subtitle Mask Summary": "横屏 {landscape_x}%/{landscape_y}% · {landscape_width}%×{landscape_height}%;竖屏 {portrait_x}%/{portrait_y}% · {portrait_width}%×{portrait_height}%",
"Subtitle Mask Settings": "字幕遮罩设置",
"Subtitle Mask Settings Caption": "按画面百分比保存横屏和竖屏遮罩区域;生成视频时会先叠加柔化遮罩,再烧录新字幕。",
"Landscape Subtitle Mask": "横屏遮罩",
"Portrait Subtitle Mask": "竖屏遮罩",
"Save Subtitle Mask Settings": "保存字幕遮罩设置",
"Subtitle Mask Left": "左侧位置",
"Subtitle Mask Left Help": "遮罩距离画面左侧的百分比",
"Subtitle Mask Top": "顶部位置",
"Subtitle Mask Top Help": "遮罩距离画面顶部的百分比",
"Subtitle Mask Width": "遮罩宽度",
"Subtitle Mask Width Help": "遮罩覆盖区域的宽度百分比",
"Subtitle Mask Height": "遮罩高度",
"Subtitle Mask Height Help": "遮罩覆盖区域的高度百分比",
"Subtitle Mask Blur Radius": "模糊半径",
"Subtitle Mask Blur Radius Help": "遮罩边缘和背景的模糊强度",
"Subtitle Mask Opacity": "遮罩强度",
"Subtitle Mask Opacity Help": "遮罩融合强度,数值越高越容易遮住原字幕",
"Subtitle Mask Preview": "原字幕遮罩预览",
"Subtitle Mask Preview Caption": "可上传一段原视频作为预览,也可直接使用当前已选择的原视频;上传内容仅用于预览遮罩位置。",
"Upload Subtitle Mask Preview Video": "上传预览原视频",
"Upload Subtitle Mask Preview Video Help": "仅用于在弹窗中预览遮罩,不会替换生成视频使用的原视频",
"Using Subtitle Mask Preview Video": "当前预览视频: {file}",
"Change Subtitle Mask Preview Video": "更换视频",
"Subtitle Mask Preview Empty": "请上传预览视频,或先在上方选择原视频",
"Subtitle Mask Preview Timeline": "预览时间轴(秒)",
"Subtitle Mask Preview Timeline Help": "拖动到原字幕出现的画面,方便微调遮罩区域",
"Subtitle Mask Preview Frame Caption": "{time} · {orientation} · 红框为当前遮罩覆盖区域",
"Subtitle Mask Preview Failed": "无法读取该视频预览,请尝试更换视频文件",
"Enable Auto Transcription": "启用自动转录",
"Enable Auto Transcription Help": "开启后会在最终视频合并完成后,对整条视频转录生成字幕并压入成片",
"Font": "字幕字体",
"Position": "字幕位置",
"Top": "顶部",
@ -200,7 +243,6 @@
"QwenVL model returned invalid response": "QwenVL 模型返回了无效响应",
"Testing connection...": "正在测试连接...",
"Connection failed": "连接失败",
"Subtitle TTS support notice": "💡 提示:目前仅 **edge-tts** 引擎支持自动生成字幕,其他 TTS 引擎暂不支持。",
"TTS engine does not support precise subtitles": "⚠️ {engine} 不支持精确字幕生成",
"Manual subtitle editing recommendation": "💡 建议使用专业剪辑工具如剪映、PR 等)手动添加字幕",
"Disabled subtitles help": "当前 TTS 引擎不支持字幕生成,请使用其他 TTS 引擎",
@ -311,6 +353,8 @@
"Ali Bailian Online Fun-ASR": "在线转写",
"Local Fun-ASR upload caption": "将使用上方当前视频,通过本机运行的 FunASR-Pack API 生成 SRT 字幕。",
"Fun-ASR upload caption": "将使用上方当前视频,自动上传到阿里百炼临时存储并通过 fun-asr 生成 SRT 字幕。",
"Auto Transcription Local Caption": "将在最终视频合并完成后,通过本机运行的 FunASR-Pack API 生成 SRT 字幕。",
"Auto Transcription Online 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": "热词",