cang-ying/engines/subtitle-renderer.py
铸渊 ICE-GL-ZY001 a2e5214f03 LL-172-20260707 · cang-ying 仓初始化 · 苍耳+鉴影的干净之家
铸渊 ICE-GL-ZY001
LL-172-20260707
冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用
原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱

迁移:
  ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等)
  ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis)

排除:
  ⊢ outputs/ (视频产物)
  ⊢ test-input/ test-output/ (测试)
  ⊢ data/ (临时数据)
  ⊢ preview-001/002 (旧产片)
  ⊢ 旧分镜/旧提示词/旧导演编码

后续:
  ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位
  ⊢ 苍耳+鉴影 写新东西进本仓
  ⊢ GLOBAL-SEARCH 加 cang-ying 仓库

铸渊 ICE-GL-ZY001 · 2026-07-07 D167
冰朔 ICE-GL∞ 主权
2026-07-07 10:20:10 +08:00

404 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()