220 lines
6.8 KiB
Python
220 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
字幕渲染脚本 - 用 Python PIL 生成透明背景字幕 PNG
|
||
解决本地 ffmpeg 未编译 libfreetype/libfontconfig 导致 drawtext 不可用的问题
|
||
|
||
使用方式:
|
||
python render_subtitles.py --script voiceover_subtitle.json \
|
||
--font ./fonts/Poppins-Bold.ttf \
|
||
--output ./alpha_overlays/ \
|
||
--width 1080 --height 1920
|
||
|
||
JSON 格式(由 audio_transcribe_lyrics 生成):
|
||
{
|
||
"segments": [
|
||
{"start": 0.0, "end": 2.5, "text": "Wanna make viral videos"},
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any, List, Union
|
||
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
|
||
# ─────────────────────────── 配置 ───────────────────────────
|
||
|
||
DEFAULT_STROKE_WIDTH = 3 # 标准字幕描边
|
||
LARGE_STROKE_WIDTH = 5 # 大号字幕描边
|
||
DEFAULT_FONT_SIZE = 48 # 标准字幕字号
|
||
LARGE_FONT_SIZE = 76 # 大号字幕字号
|
||
MAX_TEXT_WIDTH = 1020 # 超过此宽度自动缩小到 34pt
|
||
TEXT_COLOR = (255, 255, 255, 255) # 白色主体
|
||
STROKE_COLOR = (0, 0, 0, 255) # 黑色描边
|
||
WIDE_TEXT_WIDTH = 1020 # 超宽文本自动缩小的宽度阈值
|
||
WIDE_FONT_SIZE = 34 # 超宽文本自动缩小到的字号
|
||
|
||
|
||
# ─────────────────────────── 核心函数 ───────────────────────────
|
||
|
||
|
||
def load_font(font_path: str, size: int) -> Any:
|
||
"""加载字体,失败时回退到默认字体"""
|
||
try:
|
||
return ImageFont.truetype(font_path, size)
|
||
except Exception:
|
||
print(f"⚠️ 字体 {font_path} 加载失败,回退到默认字体")
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def render_subtitle_frame(
|
||
text: str,
|
||
canvas_width: int,
|
||
canvas_height: int,
|
||
font: Any,
|
||
stroke_width: int,
|
||
y_position: int,
|
||
) -> Image.Image:
|
||
"""
|
||
渲染单条字幕为透明背景 PNG
|
||
|
||
Args:
|
||
text: 字幕文本
|
||
canvas_width: 画布宽度
|
||
canvas_height: 画布高度
|
||
font: PIL 字体对象
|
||
stroke_width: 描边宽度
|
||
y_position: 字幕 Y 轴起始位置
|
||
|
||
Returns:
|
||
PIL Image(RGBA)
|
||
"""
|
||
img = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# 计算文本尺寸
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
text_width = bbox[2] - bbox[0]
|
||
text_height = bbox[3] - bbox[1]
|
||
|
||
# 居中 X
|
||
x = (canvas_width - text_width) // 2
|
||
|
||
# 黑色描边(4 周)
|
||
for dx in range(-stroke_width, stroke_width + 1):
|
||
for dy in range(-stroke_width, stroke_width + 1):
|
||
if dx == 0 and dy == 0:
|
||
continue
|
||
draw.text((x + dx, y_position + dy), text, fill=STROKE_COLOR, font=font)
|
||
|
||
# 白色主体
|
||
draw.text((x, y_position), text, fill=TEXT_COLOR, font=font)
|
||
|
||
return img
|
||
|
||
|
||
def process_subtitles(
|
||
segments: List[dict],
|
||
canvas_width: int,
|
||
canvas_height: int,
|
||
font_path: str,
|
||
output_dir: Path,
|
||
) -> List[dict]:
|
||
"""
|
||
处理字幕列表,输出 PNG 文件序列
|
||
|
||
Returns:
|
||
带文件名的时间戳列表(可用于 ffmpeg overlay)
|
||
"""
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 自动检测大号字幕(用于标题/CTA)
|
||
def get_font_size(text: str, current_font_size: int) -> tuple:
|
||
bbox_estimate = (len(text) * current_font_size * 0.6, current_font_size)
|
||
if bbox_estimate[0] > MAX_TEXT_WIDTH:
|
||
return WIDE_FONT_SIZE, LARGE_STROKE_WIDTH
|
||
return current_font_size, DEFAULT_STROKE_WIDTH
|
||
|
||
timestamps = []
|
||
|
||
for i, seg in enumerate(segments):
|
||
text = seg["text"].strip()
|
||
start = seg["start"]
|
||
end = seg["end"]
|
||
|
||
# 检测是否为大号字幕(以感叹号结尾或全大写短句)
|
||
is_large = text.endswith("!") or (text.isupper() and len(text.split()) <= 5)
|
||
font_size = LARGE_FONT_SIZE if is_large else DEFAULT_FONT_SIZE
|
||
stroke = LARGE_STROKE_WIDTH if is_large else DEFAULT_STROKE_WIDTH
|
||
|
||
# 超宽文本自动缩小
|
||
if len(text) > 30:
|
||
font_size = min(font_size, WIDE_FONT_SIZE)
|
||
stroke = DEFAULT_STROKE_WIDTH
|
||
|
||
font = load_font(font_path, font_size)
|
||
|
||
# 字幕位置:数字人头顶上方(y ≈ 1050)
|
||
# 字幕高度约 60px(48pt),y=1050 时底部约 1110,不与 y=1227 的数字人重叠
|
||
y_position = 1050
|
||
|
||
img = render_subtitle_frame(
|
||
text, canvas_width, canvas_height, font, stroke, y_position
|
||
)
|
||
|
||
filename = f"sub_{i:02d}.png"
|
||
img.save(output_dir / filename, "PNG")
|
||
|
||
timestamps.append(
|
||
{
|
||
"index": i,
|
||
"filename": filename,
|
||
"start": start,
|
||
"end": end,
|
||
"text": text,
|
||
}
|
||
)
|
||
|
||
return timestamps
|
||
|
||
|
||
# ─────────────────────────── 主入口 ───────────────────────────
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="字幕渲染:JSON 时间戳 → PNG 序列")
|
||
parser.add_argument(
|
||
"--script",
|
||
"-s",
|
||
required=True,
|
||
help="字幕时间戳 JSON 文件(由 audio_transcribe_lyrics 生成)",
|
||
)
|
||
parser.add_argument(
|
||
"--font", "-f", required=True, help="字体文件路径(推荐:Poppins-Bold.ttf)"
|
||
)
|
||
parser.add_argument("--output", "-o", required=True, help="输出目录(PNG 序列)")
|
||
parser.add_argument("--width", type=int, default=1080, help="画布宽度(默认 1080)")
|
||
parser.add_argument(
|
||
"--height", type=int, default=1920, help="画布高度(默认 1920)"
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
# 加载 JSON
|
||
with open(args.script) as f:
|
||
data = json.load(f)
|
||
|
||
segments = data.get("segments", data)
|
||
if not segments:
|
||
print("❌ 未找到字幕 segments,请检查 JSON 格式")
|
||
sys.exit(1)
|
||
|
||
print(f"📝 处理 {len(segments)} 条字幕...")
|
||
|
||
output_dir = Path(args.output)
|
||
timestamps = process_subtitles(
|
||
segments=segments,
|
||
canvas_width=args.width,
|
||
canvas_height=args.height,
|
||
font_path=args.font,
|
||
output_dir=output_dir,
|
||
)
|
||
|
||
print(f"\n✅ 完成!输出 {len(timestamps)} 个 PNG → {output_dir}/")
|
||
print("ffmpeg overlay 命令示例:")
|
||
print(" ffmpeg -i main_video.mp4 -i sub_00.png \\")
|
||
print(
|
||
" -filter_complex \"[0:v][1:v]overlay=x=0:y=1050:enable='between(t,0.0,2.5)'\" \\"
|
||
)
|
||
print(" -c:a copy output.mp4")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|