404 lines
13 KiB
Python
404 lines
13 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Subtitle Renderer · 字幕渲染引擎
|
|||
|
|
=============================
|
|||
|
|
将 SRT 字幕文件渲染为 PNG 序列,再通过 FFmpeg 合成到视频中。
|
|||
|
|
|
|||
|
|
依赖:
|
|||
|
|
pip install Pillow pysrt
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
# 基本用法(SRT → PNG 序列)
|
|||
|
|
python subtitle-renderer.py --srt input.srt --output-dir ./subtitles-png/
|
|||
|
|
|
|||
|
|
# 指定视频尺寸(PNG 宽度匹配视频)
|
|||
|
|
python subtitle-renderer.py --srt input.srt --output-dir ./subtitles-png/ --width 1080 --height 1920
|
|||
|
|
|
|||
|
|
# 渲染后直接合成到视频
|
|||
|
|
python subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4
|
|||
|
|
|
|||
|
|
# 自定义字幕样式
|
|||
|
|
python subtitle-renderer.py --srt input.srt --font-size 48 --font-color white --style clean-white --position bottom
|
|||
|
|
|
|||
|
|
# 作为模块导入
|
|||
|
|
from subtitle_renderer import render_subtitles
|
|||
|
|
render_subtitles("input.srt", "./subtitles-png/")
|
|||
|
|
|
|||
|
|
字幕样式配置:
|
|||
|
|
--font-size : 字体大小(默认 36)
|
|||
|
|
--font-color : 字体颜色(默认 white)
|
|||
|
|
--style : 字幕样式(reference-drama / short-drama-bold / clean-white / black-box,默认 reference-drama)
|
|||
|
|
--position : 位置(top / middle / bottom,默认 bottom)
|
|||
|
|
--margin-bottom : 底部边距(默认 100px)
|
|||
|
|
|
|||
|
|
路径:
|
|||
|
|
video-ai-system/engines/subtitle-renderer.py
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import pysrt
|
|||
|
|
except ImportError:
|
|||
|
|
print("[ERROR] 缺少依赖:pysrt")
|
|||
|
|
print("请先安装:pip install pysrt")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from PIL import Image, ImageDraw, ImageFont
|
|||
|
|
except ImportError:
|
|||
|
|
print("[ERROR] 缺少依赖:Pillow")
|
|||
|
|
print("请先安装:pip install Pillow")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 默认字幕样式
|
|||
|
|
DEFAULT_STYLE = {
|
|||
|
|
"font_size": 38,
|
|||
|
|
"font_color": "white",
|
|||
|
|
"bg_enabled": False,
|
|||
|
|
"position": "bottom", # top / middle / bottom
|
|||
|
|
"margin_bottom": 28,
|
|||
|
|
"margin_horizontal": 60,
|
|||
|
|
"stroke_color": "black",
|
|||
|
|
"stroke_width": 1,
|
|||
|
|
"bold_weight": 2,
|
|||
|
|
"video_width": 1080,
|
|||
|
|
"video_height": 1920,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
SUBTITLE_STYLE_PRESETS = {
|
|||
|
|
"reference-drama": {
|
|||
|
|
"font_color": (255, 255, 255, 255),
|
|||
|
|
"bg_enabled": False,
|
|||
|
|
"stroke_width": 2,
|
|||
|
|
"stroke_color": (0, 0, 0, 235),
|
|||
|
|
"bold_weight": 1,
|
|||
|
|
"line_box_ratio": 1.45,
|
|||
|
|
},
|
|||
|
|
"short-drama-bold": {
|
|||
|
|
"font_color": (255, 255, 255, 255),
|
|||
|
|
"bg_enabled": False,
|
|||
|
|
"stroke_width": 1,
|
|||
|
|
"stroke_color": (18, 18, 18, 220),
|
|||
|
|
"bold_weight": 2,
|
|||
|
|
"line_box_ratio": 1.55,
|
|||
|
|
},
|
|||
|
|
"clean-white": {
|
|||
|
|
"font_color": (255, 255, 255, 255),
|
|||
|
|
"bg_enabled": False,
|
|||
|
|
"stroke_width": 0,
|
|||
|
|
"bold_weight": 1,
|
|||
|
|
"line_box_ratio": 1.55,
|
|||
|
|
},
|
|||
|
|
"black-box": {
|
|||
|
|
"font_color": (255, 255, 255, 255),
|
|||
|
|
"bg_enabled": True,
|
|||
|
|
"bg_fill": (0, 0, 0, 160),
|
|||
|
|
"stroke_width": 0,
|
|||
|
|
"bold_weight": 1,
|
|||
|
|
"line_box_ratio": 1.55,
|
|||
|
|
},
|
|||
|
|
"outlined-white": {
|
|||
|
|
"font_color": (255, 255, 255, 255),
|
|||
|
|
"bg_enabled": False,
|
|||
|
|
"stroke_width": 2,
|
|||
|
|
"stroke_color": (0, 0, 0, 255),
|
|||
|
|
"bold_weight": 1,
|
|||
|
|
"line_box_ratio": 1.55,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
CHINESE_FONT_CANDIDATES = [
|
|||
|
|
"/System/Library/Fonts/PingFang.ttc",
|
|||
|
|
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|||
|
|
"/System/Library/Fonts/STHeiti Medium.ttc",
|
|||
|
|
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
|||
|
|
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
|
|||
|
|
"/Library/Fonts/Arial Unicode.ttf",
|
|||
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_font(font_size: int):
|
|||
|
|
for font_path in CHINESE_FONT_CANDIDATES:
|
|||
|
|
if not os.path.isfile(font_path):
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
return ImageFont.truetype(font_path, font_size)
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
return ImageFont.load_default()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_subtitle_png(
|
|||
|
|
text: str,
|
|||
|
|
output_path: str,
|
|||
|
|
width: int = 1080,
|
|||
|
|
height: int = 200,
|
|||
|
|
style: dict = None
|
|||
|
|
) -> bool:
|
|||
|
|
"""
|
|||
|
|
渲染单条字幕为 PNG(带背景)
|
|||
|
|
|
|||
|
|
:param text: 字幕文本
|
|||
|
|
:param output_path: 输出 PNG 路径
|
|||
|
|
:param width: PNG 宽度(匹配视频宽度)
|
|||
|
|
:param height: PNG 高度
|
|||
|
|
:param style: 字幕样式字典
|
|||
|
|
:return: 是否成功
|
|||
|
|
"""
|
|||
|
|
if style is None:
|
|||
|
|
style = DEFAULT_STYLE
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 创建透明背景 PNG
|
|||
|
|
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
|||
|
|
draw = ImageDraw.Draw(img)
|
|||
|
|
|
|||
|
|
# 字体(使用系统字体)
|
|||
|
|
font_size = style.get("font_size", 36)
|
|||
|
|
font = load_font(font_size)
|
|||
|
|
|
|||
|
|
# 计算文本尺寸
|
|||
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|||
|
|
text_width = bbox[2] - bbox[0]
|
|||
|
|
text_height = bbox[3] - bbox[1]
|
|||
|
|
|
|||
|
|
# 居中位置
|
|||
|
|
x = (width - text_width) // 2
|
|||
|
|
y = (height - text_height) // 2
|
|||
|
|
|
|||
|
|
# 可选背景。默认纯白字幕不画背景。
|
|||
|
|
if style.get("bg_enabled", False):
|
|||
|
|
bg_padding = 20
|
|||
|
|
bg_x1 = x - bg_padding
|
|||
|
|
bg_y1 = y - bg_padding
|
|||
|
|
bg_x2 = x + text_width + bg_padding
|
|||
|
|
bg_y2 = y + text_height + bg_padding
|
|||
|
|
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=style.get("bg_fill", (0, 0, 0, 160)))
|
|||
|
|
|
|||
|
|
# 可选描边。短剧默认使用极细暗边,不使用投影。
|
|||
|
|
stroke_width = style.get("stroke_width", 2)
|
|||
|
|
stroke_color = style.get("stroke_color", "black")
|
|||
|
|
if stroke_width > 0:
|
|||
|
|
for offset in range(-stroke_width, stroke_width + 1):
|
|||
|
|
draw.text((x + offset, y), text, font=font, fill=stroke_color)
|
|||
|
|
draw.text((x, y + offset), text, font=font, fill=stroke_color)
|
|||
|
|
|
|||
|
|
# 绘制主文本。bold_weight 用多次微偏移模拟加粗,避免依赖某台机器是否有粗体中文字体。
|
|||
|
|
font_color = style.get("font_color", "white")
|
|||
|
|
bold_weight = max(1, int(style.get("bold_weight", 1)))
|
|||
|
|
offsets = [(0, 0)]
|
|||
|
|
if bold_weight >= 2:
|
|||
|
|
offsets += [(-1, 0), (1, 0), (0, -1), (0, 1)]
|
|||
|
|
if bold_weight >= 3:
|
|||
|
|
offsets += [(-1, -1), (1, -1), (-1, 1), (1, 1)]
|
|||
|
|
for dx, dy in offsets:
|
|||
|
|
draw.text((x + dx, y + dy), text, font=font, fill=font_color)
|
|||
|
|
|
|||
|
|
# 保存
|
|||
|
|
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
|||
|
|
img.save(output_path, "PNG")
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[ERROR] 渲染字幕失败:{e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_subtitles(
|
|||
|
|
srt_path: str,
|
|||
|
|
output_dir: str,
|
|||
|
|
style: dict = None
|
|||
|
|
) -> dict:
|
|||
|
|
"""
|
|||
|
|
渲染 SRT 字幕为 PNG 序列
|
|||
|
|
|
|||
|
|
:param srt_path: SRT 文件路径
|
|||
|
|
:param output_dir: 输出目录
|
|||
|
|
:param style: 字幕样式字典
|
|||
|
|
:return: {idx: {"png": png_path, "start": start_time, "end": end_time, "text": text}}
|
|||
|
|
"""
|
|||
|
|
if not os.path.isfile(srt_path):
|
|||
|
|
print(f"[ERROR] SRT 文件不存在:{srt_path}")
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
os.makedirs(output_dir, exist_ok=True)
|
|||
|
|
|
|||
|
|
# 加载 SRT
|
|||
|
|
subs = pysrt.open(srt_path, encoding="utf-8")
|
|||
|
|
print(f"[INFO] 找到 {len(subs)} 条字幕,开始渲染 PNG 序列...")
|
|||
|
|
|
|||
|
|
results = {}
|
|||
|
|
for sub in subs:
|
|||
|
|
idx = str(sub.index).zfill(4)
|
|||
|
|
text = sub.text.strip()
|
|||
|
|
start_time = sub.start.ordinal # 毫秒
|
|||
|
|
end_time = sub.end.ordinal
|
|||
|
|
|
|||
|
|
# 渲染 PNG
|
|||
|
|
png_path = os.path.join(output_dir, f"{idx}.png")
|
|||
|
|
video_width = style.get("video_width", 1080) if style else 1080
|
|||
|
|
font_size = style.get("font_size", 36) if style else 36
|
|||
|
|
line_box_ratio = style.get("line_box_ratio", 1.55) if style else 1.55
|
|||
|
|
png_height = int(font_size * line_box_ratio)
|
|||
|
|
|
|||
|
|
ok = render_subtitle_png(text, png_path, width=video_width, height=png_height, style=style)
|
|||
|
|
if ok:
|
|||
|
|
results[idx] = {
|
|||
|
|
"png": png_path,
|
|||
|
|
"start": start_time,
|
|||
|
|
"end": end_time,
|
|||
|
|
"text": text
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
print(f"[OK] 字幕 PNG 序列渲染完成:{len(results)}/{len(subs)} 成功")
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
|
|||
|
|
def burn_subtitles_to_video(
|
|||
|
|
video_path: str,
|
|||
|
|
subtitles: dict,
|
|||
|
|
output_path: str,
|
|||
|
|
video_width: int = 1080,
|
|||
|
|
video_height: int = 1920
|
|||
|
|
) -> bool:
|
|||
|
|
"""
|
|||
|
|
将 PNG 字幕序列合成到视频中(使用 FFmpeg overlay 滤镜)
|
|||
|
|
|
|||
|
|
:param video_path: 输入视频路径
|
|||
|
|
:param subtitles: render_subtitles 返回的字典
|
|||
|
|
:param output_path: 输出视频路径
|
|||
|
|
:param video_width: 视频宽度
|
|||
|
|
:param video_height: 视频高度
|
|||
|
|
:return: 是否成功
|
|||
|
|
"""
|
|||
|
|
if not subtitles:
|
|||
|
|
print("[ERROR] 没有字幕数据")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
if not os.path.isfile(video_path):
|
|||
|
|
print(f"[ERROR] 视频文件不存在:{video_path}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
|||
|
|
|
|||
|
|
ordered_subs = [subtitles[k] for k in sorted(subtitles.keys())]
|
|||
|
|
cmd = ["ffmpeg", "-y", "-i", video_path]
|
|||
|
|
for sub in ordered_subs:
|
|||
|
|
cmd.extend(["-i", sub["png"]])
|
|||
|
|
|
|||
|
|
margin_bottom = DEFAULT_STYLE["margin_bottom"]
|
|||
|
|
chains = []
|
|||
|
|
previous = "[0:v]"
|
|||
|
|
|
|||
|
|
for i, sub in enumerate(ordered_subs, start=1):
|
|||
|
|
start_sec = sub["start"] / 1000.0
|
|||
|
|
end_sec = sub["end"] / 1000.0
|
|||
|
|
scaled = f"[s{i}]"
|
|||
|
|
out = f"[v{i}]"
|
|||
|
|
chains.append(f"[{i}:v]scale={video_width}:-1{scaled}")
|
|||
|
|
chains.append(
|
|||
|
|
f"{previous}{scaled}"
|
|||
|
|
f"overlay=x=0:y=H-h-{margin_bottom}:"
|
|||
|
|
f"enable='between(t,{start_sec:.3f},{end_sec:.3f})'{out}"
|
|||
|
|
)
|
|||
|
|
previous = out
|
|||
|
|
|
|||
|
|
filter_complex = ";".join(chains)
|
|||
|
|
cmd.extend([
|
|||
|
|
"-filter_complex", filter_complex,
|
|||
|
|
"-map", previous,
|
|||
|
|
"-map", "0:a?",
|
|||
|
|
"-c:v", "libx264",
|
|||
|
|
"-pix_fmt", "yuv420p",
|
|||
|
|
"-c:a", "copy",
|
|||
|
|
"-shortest",
|
|||
|
|
output_path,
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
print("[INFO] 开始合成字幕到视频...")
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
print("[ERROR] 未找到 ffmpeg")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
print("[ERROR] FFmpeg 合成失败")
|
|||
|
|
print(result.stderr[-2000:])
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
if os.path.isfile(output_path) and os.path.getsize(output_path) > 0:
|
|||
|
|
print(f"[OK] 字幕已合成到视频:{output_path}")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
print(f"[ERROR] 输出视频未生成:{output_path}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(
|
|||
|
|
description="Subtitle Renderer · 字幕渲染引擎",
|
|||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|||
|
|
epilog="""
|
|||
|
|
示例:
|
|||
|
|
python subtitle-renderer.py --srt input.srt --output-dir ./subtitles-png/
|
|||
|
|
python subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4
|
|||
|
|
python subtitle-renderer.py --srt input.srt --font-size 48 --position bottom
|
|||
|
|
"""
|
|||
|
|
)
|
|||
|
|
parser.add_argument("--srt", required=True, help="SRT 字幕文件路径")
|
|||
|
|
parser.add_argument("--output-dir", help="PNG 序列输出目录")
|
|||
|
|
parser.add_argument("--video", help="输入视频路径(可选,用于直接合成)")
|
|||
|
|
parser.add_argument("--output", help="输出视频路径(配合 --video 使用)")
|
|||
|
|
parser.add_argument("--font-size", type=int, default=36, help="字体大小")
|
|||
|
|
parser.add_argument("--font-color", default="white", help="字体颜色")
|
|||
|
|
parser.add_argument("--style", default="reference-drama", choices=sorted(SUBTITLE_STYLE_PRESETS.keys()), help="字幕样式")
|
|||
|
|
parser.add_argument("--margin-bottom", type=int, default=None, help="字幕距离画面底部的像素边距")
|
|||
|
|
parser.add_argument("--position", default="bottom", choices=["top", "middle", "bottom"], help="字幕位置")
|
|||
|
|
parser.add_argument("--video-width", type=int, default=1080, help="视频宽度")
|
|||
|
|
parser.add_argument("--video-height", type=int, default=1920, help="视频高度")
|
|||
|
|
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
# 构建样式字典
|
|||
|
|
style = {
|
|||
|
|
**SUBTITLE_STYLE_PRESETS[args.style],
|
|||
|
|
"font_size": args.font_size,
|
|||
|
|
"font_color": args.font_color,
|
|||
|
|
"position": args.position,
|
|||
|
|
"video_width": args.video_width,
|
|||
|
|
"video_height": args.video_height,
|
|||
|
|
}
|
|||
|
|
if args.margin_bottom is not None:
|
|||
|
|
style["margin_bottom"] = args.margin_bottom
|
|||
|
|
|
|||
|
|
# 渲染 PNG 序列
|
|||
|
|
output_dir = args.output_dir or "./subtitles-png/"
|
|||
|
|
subtitles = render_subtitles(args.srt, output_dir, style)
|
|||
|
|
|
|||
|
|
if not subtitles:
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# 如果指定了视频,则合成
|
|||
|
|
if args.video and args.output:
|
|||
|
|
ok = burn_subtitles_to_video(args.video, subtitles, args.output, args.video_width, args.video_height)
|
|||
|
|
if not ok:
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
print(f"\n[OK] 字幕 PNG 序列已生成:{output_dir}")
|
|||
|
|
print(f"[INFO] 共 {len(subtitles)} 条字幕")
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|