NarratoAI/webui/components/subtitle_settings.py
viccy 99fcd45704 feat(subtitle, ui): 新增字幕安全区预览,优化字体与字幕配置
- 新增竖屏/横屏字幕安全区预览背景图,支持切换预览比例
- 将项目版本从0.8.1升级至0.8.2
- 扩展字体搜索候选列表,新增SourceHanSerifSC-SemiBold.otf和LXGWWenKaiScreen.ttf两款字体
- 修改默认字幕字体为SourceHanSansCN-Regular.otf,替换原Microsoft YaHei默认值
- 新增内置字体检测逻辑,检测到resource/fonts目录有有效字体时跳过下载
- 更新中英文多语言文案,优化字幕位置提示文本
- 重构字幕设置面板,合并位置控制到预览区域并精简标签页
- 调整字体大小滑块范围从20-100扩展至20-160,新增数值边界校验
2026-06-10 12:05:05 +08:00

870 lines
32 KiB
Python

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),
}