diff --git a/app/services/generate_video.py b/app/services/generate_video.py index 1fe41fd..f227b93 100644 --- a/app/services/generate_video.py +++ b/app/services/generate_video.py @@ -671,6 +671,8 @@ def _resolve_font_path(subtitle_font: str) -> Optional[str]: for candidate in [ os.path.join(utils.font_dir(), "SourceHanSansCN-Regular.otf"), + os.path.join(utils.font_dir(), "SourceHanSerifSC-SemiBold.otf"), + os.path.join(utils.font_dir(), "LXGWWenKaiScreen.ttf"), os.path.join(utils.font_dir(), "SimHei.ttf"), "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", diff --git a/app/utils/utils.py b/app/utils/utils.py index 19dd46f..4ee1291 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -616,6 +616,19 @@ def init_resources(): font_dir = os.path.join(root_dir(), "resource", "fonts") os.makedirs(font_dir, exist_ok=True) + existing_fonts = [ + filename + for filename in os.listdir(font_dir) + if filename.lower().endswith((".ttf", ".ttc", ".otf")) + and os.path.isfile(os.path.join(font_dir, filename)) + and os.path.getsize(os.path.join(font_dir, filename)) > 0 + ] + if existing_fonts: + if not getattr(init_resources, "_logged_existing_fonts_skip", False): + logger.info(f"已检测到内置字体文件,跳过字体下载: {', '.join(sorted(existing_fonts))}") + init_resources._logged_existing_fonts_skip = True + return + # 检查字体文件 font_files = [ ("SourceHanSansCN-Regular.otf", diff --git a/project_version b/project_version index c18d72b..53a48a1 100644 --- a/project_version +++ b/project_version @@ -1 +1 @@ -0.8.1 \ No newline at end of file +0.8.2 \ No newline at end of file diff --git a/resource/safe_areas/subtitle_safe_area_landscape.png b/resource/safe_areas/subtitle_safe_area_landscape.png new file mode 100644 index 0000000..3daa804 Binary files /dev/null and b/resource/safe_areas/subtitle_safe_area_landscape.png differ diff --git a/resource/safe_areas/subtitle_safe_area_portrait.png b/resource/safe_areas/subtitle_safe_area_portrait.png new file mode 100644 index 0000000..05ae948 Binary files /dev/null and b/resource/safe_areas/subtitle_safe_area_portrait.png differ diff --git a/webui.py b/webui.py index 7897fbb..571bc64 100644 --- a/webui.py +++ b/webui.py @@ -437,7 +437,7 @@ def get_jianying_export_params(draft_name=None) -> VideoClipParams: n_threads=config.app.get('n_threads', 4), video_aspect=VideoAspect.landscape, subtitle_enabled=st.session_state.get('subtitle_enabled', False), - font_name=st.session_state.get('font_name', 'Microsoft YaHei'), + font_name=st.session_state.get('font_name', 'SourceHanSansCN-Regular.otf'), font_size=st.session_state.get('font_size', 24), text_fore_color=st.session_state.get('text_fore_color', '#FFFFFF'), subtitle_position=st.session_state.get('subtitle_position', 'bottom'), diff --git a/webui/components/subtitle_settings.py b/webui/components/subtitle_settings.py index 2c0355d..a0dd270 100644 --- a/webui/components/subtitle_settings.py +++ b/webui/components/subtitle_settings.py @@ -37,6 +37,16 @@ SUBTITLE_POSITION_DEFAULTS = { 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): """渲染字幕设置面板""" @@ -378,8 +388,9 @@ def _render_subtitle_mask_region_controls(tr, orientation): def _render_subtitle_position_controls(tr, orientation): + label = tr("Portrait Subtitle Position") if orientation == "portrait" else tr("Landscape Subtitle Position") y_percent = st.slider( - tr("Subtitle Burn Position"), + label, min_value=0, max_value=99, value=int(_get_orientation_subtitle_position_value(orientation, "y_percent")), @@ -397,20 +408,14 @@ def _render_subtitle_mask_dialog(tr): with settings_col: st.caption(tr("Subtitle Mask Settings Caption")) st.caption(tr("Subtitle Mask Preview Caption")) - landscape_mask_tab, portrait_mask_tab, landscape_position_tab, portrait_position_tab = st.tabs([ + landscape_mask_tab, portrait_mask_tab = st.tabs([ tr("Landscape Subtitle Mask"), tr("Portrait Subtitle Mask"), - tr("Landscape Subtitle Position"), - tr("Portrait Subtitle Position"), ]) with landscape_mask_tab: _render_subtitle_mask_region_controls(tr, "landscape") with portrait_mask_tab: _render_subtitle_mask_region_controls(tr, "portrait") - with landscape_position_tab: - _render_subtitle_position_controls(tr, "landscape") - with portrait_position_tab: - _render_subtitle_position_controls(tr, "portrait") with preview_col: _render_subtitle_mask_preview(tr) @@ -604,10 +609,11 @@ def render_font_settings(tr): 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=100, + max_value=160, value=saved_font_size ) config.ui["font_size"] = font_size @@ -653,6 +659,136 @@ def render_position_settings(tr): 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]) @@ -674,6 +810,9 @@ def render_style_settings(tr): ) st.session_state['stroke_width'] = stroke_width + with st.expander(tr("Subtitle Preview"), expanded=False): + _render_subtitle_preview_image(tr) + def get_subtitle_params(): """获取字幕参数""" diff --git a/webui/i18n/en.json b/webui/i18n/en.json index 1ce0df5..5966da0 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -89,7 +89,7 @@ "Subtitle Mask Opacity": "Mask Strength", "Subtitle Mask Opacity Help": "Mask blend strength. Higher values cover source subtitles more strongly.", "Subtitle Burn Position": "Subtitle Position", - "Subtitle Burn Position Help": "New subtitle distance from the top edge as a frame percentage. The blue line in preview shows this position.", + "Subtitle Burn Position Help": "New subtitle distance from the top edge as a frame percentage. The safe-area preview updates in real time.", "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", @@ -113,6 +113,10 @@ "Font Color": "Subtitle Color", "Stroke Color": "Stroke Color", "Stroke Width": "Stroke Width", + "Subtitle Preview Orientation": "Preview Ratio", + "Portrait Safe Area": "Portrait Safe Area", + "Landscape Safe Area": "Landscape Safe Area", + "Subtitle Preview Sample Text": "Preview", "Generate Video": "Generate Video", "Video Script and Subject Cannot Both Be Empty": "Video Subject and Video Script cannot both be empty", "Generating Video": "Generating video, please wait...", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 321af09..ee20def 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -77,7 +77,7 @@ "Subtitle Mask Opacity": "遮罩强度", "Subtitle Mask Opacity Help": "遮罩融合强度,数值越高越容易遮住原字幕", "Subtitle Burn Position": "字幕位置", - "Subtitle Burn Position Help": "新字幕距离画面顶部的百分比;预览中的蓝线表示当前字幕位置", + "Subtitle Burn Position Help": "新字幕距离画面顶部的百分比,可在下方安全区预览中实时查看位置", "Subtitle Mask Preview": "原字幕遮罩预览", "Subtitle Mask Preview Caption": "可上传一段原视频作为预览,也可直接使用当前已选择的原视频;上传内容仅用于预览遮罩位置。", "Upload Subtitle Mask Preview Video": "上传预览原视频", @@ -101,6 +101,10 @@ "Font Color": "字幕颜色", "Stroke Color": "描边颜色", "Stroke Width": "描边粗细", + "Subtitle Preview Orientation": "预览比例", + "Portrait Safe Area": "竖屏安全区", + "Landscape Safe Area": "横屏安全区", + "Subtitle Preview Sample Text": "字幕预览效果", "Generate Video": "生成视频", "Video Script and Subject Cannot Both Be Empty": "视频主题 和 视频文案,不能同时为空", "Generating Video": "正在生成视频,请稍候...",