diff --git a/app/models/schema.py b/app/models/schema.py index a41b1e1..45d3658 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -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" # 文本前景色 diff --git a/app/services/generate_video.py b/app/services/generate_video.py index 2eb633f..f5b808a 100644 --- a/app/services/generate_video.py +++ b/app/services/generate_video.py @@ -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): diff --git a/app/services/task.py b/app/services/task.py index d1da33e..0b6b138 100644 --- a/app/services/task.py +++ b/app/services/task.py @@ -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 diff --git a/config.example.toml b/config.example.toml index 1b2730d..89217eb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 + ########################################## # 代理和网络配置 ########################################## diff --git a/webui/components/audio_settings.py b/webui/components/audio_settings.py index 121cadb..43b48fd 100644 --- a/webui/components/audio_settings.py +++ b/webui/components/audio_settings.py @@ -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), diff --git a/webui/components/subtitle_settings.py b/webui/components/subtitle_settings.py index 41fac95..ac3793b 100644 --- a/webui/components/subtitle_settings.py +++ b/webui/components/subtitle_settings.py @@ -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'), diff --git a/webui/i18n/en.json b/webui/i18n/en.json index f912355..5c7b5b1 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -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", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 75c9721..29945e0 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -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": "热词",