mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-06-17 04:42:05 +00:00
Merge pull request #251 from linyqh/develop
feat(subtitle, ui): 新增字幕安全区预览,优化字体与字幕配置
This commit is contained in:
commit
b9f07a6a10
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1 +1 @@
|
||||
0.8.1
|
||||
0.8.2
|
||||
BIN
resource/safe_areas/subtitle_safe_area_landscape.png
Normal file
BIN
resource/safe_areas/subtitle_safe_area_landscape.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
resource/safe_areas/subtitle_safe_area_portrait.png
Normal file
BIN
resource/safe_areas/subtitle_safe_area_portrait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
2
webui.py
2
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'),
|
||||
|
||||
@ -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():
|
||||
"""获取字幕参数"""
|
||||
|
||||
@ -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...",
|
||||
|
||||
@ -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": "正在生成视频,请稍候...",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user