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, }, } SUBTITLE_POSITION_DEFAULTS = { "landscape": { "y_percent": 85, }, "portrait": { "y_percent": 82, }, } VIDEO_PREVIEW_UPLOAD_TYPES = ["mp4", "mov", "avi", "flv", "mkv", "mpeg4"] SUBTITLE_SAFE_AREA_PREVIEW_IMAGES = { "portrait": "subtitle_safe_area_portrait.png", "landscape": "subtitle_safe_area_landscape.png", } SUBTITLE_PREVIEW_FALLBACK_SIZES = { "portrait": (1080, 1920), "landscape": (1920, 1080), } def render_subtitle_panel(tr): """渲染字幕设置面板""" with st.container(border=True): st.write(tr("Subtitle Settings")) tts_engine = config.ui.get('tts_engine', '') is_disabled_subtitle = is_disabled_subtitle_settings(tts_engine) if is_disabled_subtitle: st.warning(tr("TTS engine does not support precise subtitles").format(engine=tts_engine)) enable_subtitles = st.checkbox(tr("Enable Subtitles"), value=True) st.session_state['subtitle_enabled'] = enable_subtitles 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: 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 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 _subtitle_position_key(orientation, field): return f"subtitle_position_{orientation}_{field}" def _get_orientation_subtitle_position_value(orientation, field): key = _subtitle_position_key(orientation, field) return config.ui.get(key, SUBTITLE_POSITION_DEFAULTS[orientation][field]) def _set_orientation_subtitle_position_value(orientation, field, value): key = _subtitle_position_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) options[_subtitle_position_key(orientation, "y_percent")] = _get_orientation_subtitle_position_value( orientation, "y_percent", ) 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)), ) subtitle_y_percent = _get_orientation_subtitle_position_value(region["orientation"], "y_percent") subtitle_y = round((image.height - 1) * subtitle_y_percent / 100) line_width = max(2, round(min(image.width, image.height) * 0.004)) draw.line( (0, subtitle_y, image.width, subtitle_y), fill=(59, 130, 246, 220), width=line_width, ) 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_position_controls(tr, orientation): label = tr("Portrait Subtitle Position") if orientation == "portrait" else tr("Landscape Subtitle Position") y_percent = st.slider( label, min_value=0, max_value=99, value=int(_get_orientation_subtitle_position_value(orientation, "y_percent")), help=tr("Subtitle Burn Position Help"), key=f"{orientation}_subtitle_burn_y_percent", ) _set_orientation_subtitle_position_value(orientation, "y_percent", y_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_mask_tab, portrait_mask_tab = st.tabs([ tr("Landscape Subtitle Mask"), tr("Portrait Subtitle Mask"), ]) with landscape_mask_tab: _render_subtitle_mask_region_controls(tr, "landscape") with portrait_mask_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", "firered", "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) firered_api_url = config.fun_asr.get("firered_api_url", fun_asr_subtitle.LOCAL_FIRERED_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_firered_api_url'] = firered_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("Local FireRedASR API"): "firered", tr("Ali Bailian Online Fun-ASR"): "bailian", } backend_values = list(backend_options.values()) backend_labels = list(backend_options.keys()) backend_label = st.selectbox( tr("Subtitle Processing Method"), options=backend_labels, index=backend_values.index(backend), key="subtitle_auto_transcribe_backend_select", ) 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", ) elif backend == "firered": st.caption(tr("Auto Transcription FireRed Caption")) firered_api_url = st.text_input( tr("Local FireRedASR API URL"), value=firered_api_url, help=tr("Local FireRedASR API URL Help"), key="subtitle_auto_transcribe_firered_api_url_input", ) 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["firered_api_url"] = str(firered_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_firered_api_url'] = str(firered_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): """渲染字体设置""" # 获取字体列表 font_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "resource", "fonts") font_names = get_fonts_cache(font_dir) # 获取保存的字体设置 saved_font_name = config.ui.get("font_name", "") saved_font_name_index = 0 if saved_font_name in font_names: saved_font_name_index = font_names.index(saved_font_name) # 字体选择 font_name = st.selectbox( tr("Font"), options=font_names, index=saved_font_name_index ) config.ui["font_name"] = font_name st.session_state['font_name'] = font_name # 字体大小 和 字幕大小 font_cols = st.columns([0.3, 0.7]) with font_cols[0]: saved_text_fore_color = config.ui.get("text_fore_color", "#FFFFFF") text_fore_color = st.color_picker( tr("Font Color"), saved_text_fore_color ) config.ui["text_fore_color"] = text_fore_color st.session_state['text_fore_color'] = text_fore_color with font_cols[1]: saved_font_size = config.ui.get("font_size", 60) saved_font_size = max(20, min(160, int(saved_font_size))) font_size = st.slider( tr("Font Size"), min_value=20, max_value=160, value=saved_font_size ) config.ui["font_size"] = font_size st.session_state['font_size'] = font_size def is_disabled_subtitle_settings(tts_engine:str)->bool: """是否禁用字幕设置""" return tts_engine=="soulvoice" or tts_engine=="qwen3_tts" or tts_engine==config.OMNIVOICE_ENGINE def render_position_settings(tr): """渲染位置设置""" subtitle_positions = [ (tr("Top"), "top"), (tr("Center"), "center"), (tr("Bottom"), "bottom"), (tr("Custom"), "custom"), ] selected_index = st.selectbox( tr("Position"), index=2, options=range(len(subtitle_positions)), format_func=lambda x: subtitle_positions[x][0], ) subtitle_position = subtitle_positions[selected_index][1] st.session_state['subtitle_position'] = subtitle_position # 自定义位置处理 if subtitle_position == "custom": custom_position = st.text_input( tr("Custom Position (% from top)"), value="70.0" ) try: custom_position_value = float(custom_position) if custom_position_value < 0 or custom_position_value > 100: st.error(tr("Please enter a value between 0 and 100")) else: st.session_state['custom_position'] = custom_position_value except ValueError: st.error(tr("Please enter a valid number")) def _resolve_subtitle_preview_font_path(font_name): if not font_name: return "" font_name = str(font_name) candidates = [] if os.path.isabs(font_name): candidates.append(font_name) candidates.extend( [ os.path.join(utils.font_dir(), font_name), os.path.join(utils.font_dir(), os.path.basename(font_name)), ] ) for candidate in candidates: if candidate and os.path.exists(candidate): return candidate return "" def _load_subtitle_preview_font(font_name, font_size): from PIL import ImageFont font_path = _resolve_subtitle_preview_font_path(font_name) font_size = max(12, int(round(font_size or 60))) if font_path: try: return ImageFont.truetype(font_path, font_size) except OSError: pass return ImageFont.load_default(size=font_size) def _resolve_subtitle_preview_color(color, fallback): from PIL import ImageColor try: value = ImageColor.getrgb(str(color or fallback).strip()) except ValueError: value = ImageColor.getrgb(fallback) if len(value) == 4: return value return (*value, 255) def _get_subtitle_preview_y(image_height, text_height, orientation): subtitle_position = st.session_state.get("subtitle_position", "bottom") if subtitle_position == "top": return max(12, round(image_height * 0.05)) if subtitle_position == "center": return max(0, round((image_height - text_height) / 2)) y_percent = _get_orientation_subtitle_position_value(orientation, "y_percent") if y_percent is None and subtitle_position == "custom": y_percent = st.session_state.get("custom_position", 70.0) try: y_percent = max(0.0, min(100.0, float(y_percent))) except (TypeError, ValueError): y_percent = 85.0 return max(0, round((image_height - text_height) * y_percent / 100)) def _get_subtitle_preview_background(orientation): from PIL import Image image_path = os.path.join( utils.resource_dir("safe_areas"), SUBTITLE_SAFE_AREA_PREVIEW_IMAGES.get(orientation, SUBTITLE_SAFE_AREA_PREVIEW_IMAGES["portrait"]), ) if os.path.exists(image_path): return Image.open(image_path).convert("RGBA") width, height = SUBTITLE_PREVIEW_FALLBACK_SIZES.get(orientation, SUBTITLE_PREVIEW_FALLBACK_SIZES["portrait"]) return Image.new("RGBA", (width, height), (19, 24, 35, 255)) def _render_subtitle_preview_image(tr): from PIL import ImageDraw font_name = st.session_state.get("font_name") or config.ui.get("font_name", "") font_size = int(st.session_state.get("font_size", config.ui.get("font_size", 60)) or 60) text_color = st.session_state.get("text_fore_color", config.ui.get("text_fore_color", "#FFFFFF")) stroke_color = st.session_state.get("stroke_color", config.ui.get("stroke_color", "#000000")) stroke_width = float(st.session_state.get("stroke_width", config.ui.get("stroke_width", 1.5)) or 0) orientation = st.pills( tr("Subtitle Preview Orientation"), options=["portrait", "landscape"], default=st.session_state.get("subtitle_preview_orientation", "portrait"), format_func=lambda value: tr("Portrait Safe Area") if value == "portrait" else tr("Landscape Safe Area"), key="subtitle_preview_orientation", width="stretch", ) or "portrait" _render_subtitle_position_controls(tr, orientation) preview_text = tr("Subtitle Preview Sample Text") image = _get_subtitle_preview_background(orientation) draw = ImageDraw.Draw(image) font = _load_subtitle_preview_font(font_name, font_size) stroke_width_px = max(0, int(round(stroke_width))) bbox = draw.textbbox((0, 0), preview_text, font=font, stroke_width=stroke_width_px) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] x = max(20, round((image.width - text_width) / 2)) y = _get_subtitle_preview_y(image.height, text_height, orientation) margin = max(10, stroke_width_px * 2 + 6) y = max(margin, min(y, image.height - text_height - margin)) draw.text( (x - bbox[0], y - bbox[1]), preview_text, font=font, fill=_resolve_subtitle_preview_color(text_color, "#FFFFFF"), stroke_width=stroke_width_px, stroke_fill=_resolve_subtitle_preview_color(stroke_color, "#000000"), ) st.image(image.convert("RGB"), width="stretch", output_format="PNG") def render_style_settings(tr): """渲染样式设置""" stroke_cols = st.columns([0.3, 0.7]) with stroke_cols[0]: stroke_color = st.color_picker( tr("Stroke Color"), value="#000000" ) st.session_state['stroke_color'] = stroke_color with stroke_cols[1]: stroke_width = st.slider( tr("Stroke Width"), min_value=0.0, max_value=10.0, value=1.0, step=0.01 ) st.session_state['stroke_width'] = stroke_width with st.expander(tr("Subtitle Preview"), expanded=False): _render_subtitle_preview_image(tr) 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_position_landscape_y_percent': _get_orientation_subtitle_position_value("landscape", "y_percent"), 'subtitle_position_portrait_y_percent': _get_orientation_subtitle_position_value("portrait", "y_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_firered_api_url': st.session_state.get( 'subtitle_auto_transcribe_firered_api_url', config.fun_asr.get("firered_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'), 'subtitle_position': st.session_state.get('subtitle_position', 'bottom'), 'custom_position': st.session_state.get('custom_position', 70.0), 'stroke_color': st.session_state.get('stroke_color', '#000000'), 'stroke_width': st.session_state.get('stroke_width', 1.5), }