mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-24 06:58:19 +00:00
feat(subtitle, asr, bgm): 添加字幕遮罩、自动转录功能,优化背景音乐设置
- 新增字幕遮罩功能,可在烧录新字幕前遮盖原视频自带的字幕区域,支持横屏/竖屏自定义配置与预览调试 - 新增自动字幕转录功能,支持本地FunASR和阿里百炼在线转写,在最终视频合并完成后自动生成并压入成片字幕 - 重构背景音乐设置面板,新增从资源目录选择BGM、上传本地BGM文件的功能,新增BGM试听预览,优化交互流程 - 更新配置示例文件、数据Schema与多语言翻译文件,完善前后端参数传递逻辑
This commit is contained in:
parent
5b2487e879
commit
33c17c2636
@ -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" # 文本前景色
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -105,6 +105,7 @@
|
||||
[fun_asr]
|
||||
# Fun-ASR 字幕转录配置
|
||||
# backend = "local" 使用本地 FunASR-Pack API;backend = "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
|
||||
|
||||
##########################################
|
||||
# 代理和网络配置
|
||||
##########################################
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "热词",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user