D144 · 字幕工业化流水线8大工具开发完成
- ✅ reference-subtitle-analyzer.py 从样片截图里量字幕:字高、底部距离、描边宽度、字幕框位置、字号比例 - ✅ subtitle-style.reference-drama.json 把样片字幕标准固化成配置,不再靠肉眼调 - ✅ ass-subtitle-renderer.py 用ASS/libass正式渲染字幕,支持字体、粗体、描边、边距、对齐 不再用Pillow PNG凑合 - ✅ subtitle-font-manager.py 固定项目字幕字体,不能依赖系统默认字体 - ✅ subtitle-safe-area-qc.py 自动检查字幕有没有遮脸、遮身体表演、贴边、看不清 - ✅ subtitle-preview-contact-sheet.py 每次烧字幕后自动抽帧出检查图 - ✅ subtitle-style-ab-test.py 同一底片一次生成多个字幕风格版本,对比选,不盲调 - ✅ script-to-srt-with-timing.py 用剧本台词和配音时长生成正式SRT,不手写时间轴 冰朔 TCS-0002∞ 见证 · 国作登字-2026-A-00037559 ⊢ 铸渊 ICE-GL-ZY001 · D144 · 2026-06-24
This commit is contained in:
parent
89f7b26c96
commit
f0d5879d68
@ -0,0 +1,119 @@
|
||||
{
|
||||
"_comment": "参考短剧字幕样式配置 · subtitle-style.reference-drama.json",
|
||||
"_description": "从样片截图量化得到的字幕标准,用于ASS字幕渲染",
|
||||
"_version": "1.0",
|
||||
"_date": "2026-06-24",
|
||||
"_source": "渔乡守真心 样片分析",
|
||||
|
||||
"video": {
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"aspect_ratio": "9:16",
|
||||
"scan_type": "progressive"
|
||||
},
|
||||
|
||||
"subtitle_box": {
|
||||
"x": 100,
|
||||
"y": 1750,
|
||||
"width": 880,
|
||||
"height": 120,
|
||||
"_unit": "px",
|
||||
"_note": "字幕框位置(左下角x,y + 宽度,高度)"
|
||||
},
|
||||
|
||||
"font": {
|
||||
"family": "PingFang SC",
|
||||
"size": 38,
|
||||
"size_ratio": 0.0198,
|
||||
"_note_size_ratio": "字号/视频高度 = 38/1920 = 0.0198",
|
||||
"color": "&HFFFFFF&",
|
||||
"color_hex": "#FFFFFF",
|
||||
"bold": true,
|
||||
"italic": false,
|
||||
"underline": false,
|
||||
"strikeout": false,
|
||||
"_note_font": "使用PingFang SC粗体,白色"
|
||||
},
|
||||
|
||||
"stroke": {
|
||||
"enabled": true,
|
||||
"width": 2,
|
||||
"width_px": 2,
|
||||
"color": "&H000000&",
|
||||
"color_hex": "#000000",
|
||||
"opacity": 0.92,
|
||||
"_note": "黑色描边,宽度2px,用于白色字体轮廓"
|
||||
},
|
||||
|
||||
"shadow": {
|
||||
"enabled": false,
|
||||
"depth": 0,
|
||||
"color": "&H000000&",
|
||||
"opacity": 0
|
||||
},
|
||||
|
||||
"position": {
|
||||
"alignment": 2,
|
||||
"_alignment_values": {
|
||||
"1": "左下",
|
||||
"2": "中下",
|
||||
"3": "右下",
|
||||
"4": "左中",
|
||||
"5": "中中",
|
||||
"6": "右中",
|
||||
"7": "左上",
|
||||
"8": "中上",
|
||||
"9": "右上"
|
||||
},
|
||||
"margin_left": 100,
|
||||
"margin_right": 100,
|
||||
"margin_vertical": 50,
|
||||
"_note": "对齐方式=2(中下),水平边距100px,底部边距50px"
|
||||
},
|
||||
|
||||
"spacing": {
|
||||
"line_spacing": 1.45,
|
||||
"letter_spacing": 0,
|
||||
"_note": "行间距1.45倍,字间距默认"
|
||||
},
|
||||
|
||||
"background": {
|
||||
"enabled": false,
|
||||
"color": "&H000000&",
|
||||
"opacity": 0.6,
|
||||
"padding": 20,
|
||||
"_note": "参考短剧不使用背景框,纯字幕+描边"
|
||||
},
|
||||
|
||||
"timing": {
|
||||
"fade_in_ms": 150,
|
||||
"fade_out_ms": 150,
|
||||
"_note": "字幕淡入淡出150ms"
|
||||
},
|
||||
|
||||
"qc_thresholds": {
|
||||
"min_bottom_distance_ratio": 0.02,
|
||||
"max_bottom_distance_ratio": 0.05,
|
||||
"min_font_size_ratio": 0.015,
|
||||
"max_font_size_ratio": 0.025,
|
||||
"max_stroke_width_px": 3,
|
||||
"min_contrast_ratio": 4.5,
|
||||
"_note": "QC自动检查阈值"
|
||||
},
|
||||
|
||||
"ass_template": {
|
||||
"_comment": "ASS字幕格式模板,用于FFmpeg libass渲染",
|
||||
"_example": "Dialogue: 0,0:00:01.00,0:00:03.50,Default,,0,0,0,,{\\pos(540,1850)\\fs38\\b1\\3c&H000000&\\4a&H40&\\fsp0}苏白:付费才能修仙?",
|
||||
"format": "Dialogue: <Layer>,<Start>,<End>,<Style>,<Name>,<MarginL>,<MarginR>,<MarginV>,<Effect>,<Text>",
|
||||
"default_style": "Default",
|
||||
"common_tags": {
|
||||
"font_size": "\\fs38",
|
||||
"bold": "\\b1",
|
||||
"stroke_color": "\\3c&H000000&",
|
||||
"stroke_opacity": "\\4a&H40&",
|
||||
"position": "\\pos(540,1850)",
|
||||
"fade_in": "\\fad(150,0)",
|
||||
"fade_out": "\\fad(0,150)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,557 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Subtitle Style A/B Test · 字幕样式 A/B 测试器
|
||||
===========================================
|
||||
同一底片一次生成多个字幕风格版本,对比选,不要一版一版盲调。
|
||||
|
||||
功能:
|
||||
1. 读取多个字幕样式配置
|
||||
2. 用同一个底片和 SRT,生成多个字幕版本
|
||||
3. 生成对比预览图(contact sheet)
|
||||
4. 生成 HTML 对比页面
|
||||
|
||||
依赖:
|
||||
pip install pysrt
|
||||
|
||||
用法:
|
||||
# 单视频 A/B 测试
|
||||
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --styles style1.json style2.json --output-dir ./ab-test/
|
||||
|
||||
# 批量 A/B 测试
|
||||
python subtitle-style-ab-test.py --batch ./videos/ --srt-dir ./srt/ --styles style1.json style2.json --output-dir ./ab-tests/
|
||||
|
||||
# 使用预设样式
|
||||
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --preset-styles reference-drama short-drama-bold --output-dir ./ab-test/
|
||||
|
||||
输出目录结构:
|
||||
./ab-test/
|
||||
├── style1/
|
||||
│ ├── subtitles.ass
|
||||
│ ├── output.mp4
|
||||
│ └── preview.png
|
||||
├── style2/
|
||||
│ ├── subtitles.ass
|
||||
│ ├── output.mp4
|
||||
│ └── preview.png
|
||||
├── comparison.png # 对比预览图
|
||||
└── comparison.html # 对比 HTML 页面
|
||||
|
||||
路径:
|
||||
video-ai-system/engines/subtitle-pipeline/ab-test-tools/subtitle-style-ab-test.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pysrt
|
||||
except ImportError:
|
||||
print("[ERROR] 缺少依赖:pysrt")
|
||||
print("请先安装:pip install pysrt")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# 预设样式列表
|
||||
PRESET_STYLES = {
|
||||
"reference-drama": {
|
||||
"font_family": "PingFang SC",
|
||||
"font_size": 38,
|
||||
"font_color": "&HFFFFFF&",
|
||||
"bold": True,
|
||||
"stroke_enabled": True,
|
||||
"stroke_width": 2,
|
||||
"stroke_color": "&H000000&",
|
||||
"alignment": 2,
|
||||
"margin_left": 100,
|
||||
"margin_right": 100,
|
||||
"margin_vertical": 50,
|
||||
},
|
||||
"short-drama-bold": {
|
||||
"font_family": "PingFang SC",
|
||||
"font_size": 42,
|
||||
"font_color": "&HFFFFFF&",
|
||||
"bold": True,
|
||||
"stroke_enabled": True,
|
||||
"stroke_width": 1,
|
||||
"stroke_color": "&H121212&",
|
||||
"alignment": 2,
|
||||
"margin_left": 80,
|
||||
"margin_right": 80,
|
||||
"margin_vertical": 60,
|
||||
},
|
||||
"clean-white": {
|
||||
"font_family": "PingFang SC",
|
||||
"font_size": 36,
|
||||
"font_color": "&HFFFFFF&",
|
||||
"bold": False,
|
||||
"stroke_enabled": False,
|
||||
"stroke_width": 0,
|
||||
"stroke_color": "&H000000&",
|
||||
"alignment": 2,
|
||||
"margin_left": 100,
|
||||
"margin_right": 100,
|
||||
"margin_vertical": 50,
|
||||
},
|
||||
"black-box": {
|
||||
"font_family": "PingFang SC",
|
||||
"font_size": 36,
|
||||
"font_color": "&HFFFFFF&",
|
||||
"bold": False,
|
||||
"stroke_enabled": False,
|
||||
"stroke_width": 0,
|
||||
"stroke_color": "&H000000&",
|
||||
"background_enabled": True,
|
||||
"background_color": "&H000000&",
|
||||
"background_opacity": 0.6,
|
||||
"alignment": 2,
|
||||
"margin_left": 100,
|
||||
"margin_right": 100,
|
||||
"margin_vertical": 50,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_style_config(style_path: str) -> dict:
|
||||
"""
|
||||
加载样式配置
|
||||
|
||||
:param style_path: 样式文件路径或预设样式名称
|
||||
:return: 样式字典
|
||||
"""
|
||||
# 检查是否是预设样式
|
||||
if style_path in PRESET_STYLES:
|
||||
print(f"[INFO] 使用预设样式:{style_path}")
|
||||
return PRESET_STYLES[style_path]
|
||||
|
||||
# 加载自定义样式文件
|
||||
if not os.path.isfile(style_path):
|
||||
print(f"[ERROR] 样式文件不存在:{style_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(style_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
print(f"[INFO] 已加载样式配置:{style_path}")
|
||||
return config
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 加载样式配置失败:{e}")
|
||||
return None
|
||||
|
||||
|
||||
def generate_ass_file_for_style(srt_path: str, output_path: str, style: dict) -> bool:
|
||||
"""
|
||||
为指定样式生成 ASS 字幕文件
|
||||
|
||||
:param srt_path: SRT 文件路径
|
||||
:param output_path: 输出 ASS 文件路径
|
||||
:param style: 样式字典
|
||||
:return: 是否成功
|
||||
"""
|
||||
if not os.path.isfile(srt_path):
|
||||
print(f"[ERROR] SRT 文件不存在:{srt_path}")
|
||||
return False
|
||||
|
||||
# 加载 SRT
|
||||
try:
|
||||
subs = pysrt.open(srt_path, encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 加载 SRT 失败:{e}")
|
||||
return False
|
||||
|
||||
print(f"[INFO] 为样式生成 ASS 文件:{os.path.basename(output_path)}")
|
||||
|
||||
# 构建 ASS 文件内容(简化版,只生成 Dialogues)
|
||||
ass_lines = []
|
||||
|
||||
# 1. [Script Info] 节
|
||||
ass_lines.append("[Script Info]")
|
||||
ass_lines.append("; Script generated by Subtitle Style A/B Test")
|
||||
ass_lines.append("PlayResX: 1080")
|
||||
ass_lines.append("PlayResY: 1920")
|
||||
ass_lines.append("")
|
||||
|
||||
# 2. [V4+ Styles] 节
|
||||
ass_lines.append("[V4+ Styles]")
|
||||
ass_lines.append(
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, "
|
||||
"Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
|
||||
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
|
||||
)
|
||||
|
||||
# 构建样式行
|
||||
font_name = style.get("font_family", "PingFang SC")
|
||||
font_size = style.get("font_size", 38)
|
||||
primary_colour = style.get("font_color", "&HFFFFFF&")
|
||||
outline_colour = style.get("stroke_color", "&H000000&")
|
||||
|
||||
if style.get("background_enabled", False):
|
||||
bg_opacity = int(style.get("background_opacity", 0.6) * 255)
|
||||
back_colour = f"&H{bg_opacity:02X}000000&"
|
||||
else:
|
||||
back_colour = "&H00000000&"
|
||||
|
||||
bold = -1 if style.get("bold", False) else 0
|
||||
outline_width = style.get("stroke_width", 2) if style.get("stroke_enabled", True) else 0
|
||||
|
||||
alignment = style.get("alignment", 2)
|
||||
margin_l = style.get("margin_left", 100)
|
||||
margin_r = style.get("margin_right", 100)
|
||||
margin_v = style.get("margin_vertical", 50)
|
||||
|
||||
style_line = (
|
||||
f"Style: Default,{font_name},{font_size},{primary_colour},&H000000FF&,{outline_colour},{back_colour},"
|
||||
f"{bold},0,0,0,100,100,0,0,"
|
||||
f"1,{outline_width},0,{alignment},{margin_l},{margin_r},{margin_v},1"
|
||||
)
|
||||
ass_lines.append(style_line)
|
||||
ass_lines.append("")
|
||||
|
||||
# 3. [Events] 节
|
||||
ass_lines.append("[Events]")
|
||||
ass_lines.append("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text")
|
||||
|
||||
for sub in subs:
|
||||
start_time = srt_time_to_ass(sub.start)
|
||||
end_time = srt_time_to_ass(sub.end)
|
||||
text = sub.text.strip()
|
||||
|
||||
# 转义特殊字符
|
||||
text = text.replace("\\", "\\\\")
|
||||
text = text.replace("{", "\\{")
|
||||
text = text.replace("}", "\\}")
|
||||
text = text.replace("\n", "\\N")
|
||||
|
||||
dialogue_line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{text}"
|
||||
ass_lines.append(dialogue_line)
|
||||
|
||||
# 写入 ASS 文件
|
||||
try:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(ass_lines))
|
||||
|
||||
print(f"[OK] ASS 字幕文件已生成:{output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 写入 ASS 文件失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def srt_time_to_ass(srt_time) -> str:
|
||||
"""
|
||||
将 pysrt 时间对象转换为 ASS 时间戳格式
|
||||
|
||||
:param srt_time: pysrt 时间对象
|
||||
:return: ASS 时间戳字符串(H:MM:SS.cc)
|
||||
"""
|
||||
hours = srt_time.hours
|
||||
minutes = srt_time.minutes
|
||||
seconds = srt_time.seconds
|
||||
milliseconds = srt_time.milliseconds
|
||||
|
||||
centiseconds = milliseconds // 10
|
||||
return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
|
||||
|
||||
|
||||
def burn_subtitles_with_ffmpeg(video_path: str, ass_path: str, output_path: str) -> bool:
|
||||
"""
|
||||
用 FFmpeg + libass 烧录字幕到视频
|
||||
|
||||
:param video_path: 输入视频路径
|
||||
:param ass_path: ASS 字幕文件路径
|
||||
:param output_path: 输出视频路径
|
||||
:return: 是否成功
|
||||
"""
|
||||
if not os.path.isfile(video_path):
|
||||
print(f"[ERROR] 视频文件不存在:{video_path}")
|
||||
return False
|
||||
|
||||
if not os.path.isfile(ass_path):
|
||||
print(f"[ERROR] ASS 字幕文件不存在:{ass_path}")
|
||||
return False
|
||||
|
||||
# FFmpeg 命令
|
||||
ass_path_escaped = ass_path.replace(":", "\\:").replace("'", "'\\''")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", video_path,
|
||||
"-vf", f"subtitles='{ass_path_escaped}'",
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "copy",
|
||||
"-shortest",
|
||||
output_path
|
||||
]
|
||||
|
||||
print(f"[INFO] 烧录字幕:{os.path.basename(output_path)}")
|
||||
|
||||
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
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def extract_sample_frames(video_path: str, output_dir: str, num_frames: int = 4) -> list:
|
||||
"""
|
||||
从视频中抽取样本帧(用于预览)
|
||||
|
||||
:param video_path: 视频文件路径
|
||||
:param output_dir: 输出目录
|
||||
:param num_frames: 抽取帧数
|
||||
:return: 帧文件路径列表
|
||||
"""
|
||||
import cv2
|
||||
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
print(f"[ERROR] 无法打开视频:{video_path}")
|
||||
return []
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
# 计算采样间隔
|
||||
interval = total_frames // (num_frames + 1)
|
||||
|
||||
frame_paths = []
|
||||
for i in range(1, num_frames + 1):
|
||||
frame_num = i * interval
|
||||
|
||||
# 跳转到指定帧
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
continue
|
||||
|
||||
# 保存帧
|
||||
frame_path = os.path.join(output_dir, f"sample_{i:02d}.png")
|
||||
cv2.imwrite(frame_path, frame)
|
||||
|
||||
frame_paths.append(frame_path)
|
||||
|
||||
cap.release()
|
||||
|
||||
print(f"[INFO] 已抽取 {len(frame_paths)} 个样本帧")
|
||||
return frame_paths
|
||||
|
||||
|
||||
def generate_comparison_preview(style_dirs: list, output_path: str):
|
||||
"""
|
||||
生成对比预览图
|
||||
|
||||
:param style_dirs: 样式目录列表
|
||||
:param output_path: 输出图片路径
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("[ERROR] 缺少依赖:Pillow")
|
||||
print("请先安装:pip install Pillow")
|
||||
return False
|
||||
|
||||
# 收集所有样本帧
|
||||
all_frames = []
|
||||
style_names = []
|
||||
|
||||
for style_dir in style_dirs:
|
||||
style_name = os.path.basename(style_dir)
|
||||
style_names.append(style_name)
|
||||
|
||||
# 查找样本帧
|
||||
frame_files = [f for f in os.listdir(style_dir) if f.startswith("sample_") and f.endswith(".png")]
|
||||
frame_files.sort()
|
||||
|
||||
for frame_file in frame_files:
|
||||
frame_path = os.path.join(style_dir, frame_file)
|
||||
all_frames.append((style_name, frame_path))
|
||||
|
||||
if not all_frames:
|
||||
print("[WARN] 未找到样本帧,跳过对比预览图生成")
|
||||
return False
|
||||
|
||||
# 读取第一帧,获取尺寸
|
||||
first_frame = Image.open(all_frames[0][1])
|
||||
frame_width, frame_height = first_frame.size
|
||||
|
||||
# 计算拼接图尺寸
|
||||
num_styles = len(style_dirs)
|
||||
num_samples = len(all_frames) // num_styles
|
||||
|
||||
# 横向拼接:每个样式一行,每行包含多个样本帧
|
||||
sheet_width = num_samples * (frame_width + 10) + 10
|
||||
sheet_height = num_styles * (frame_height + 30) + 10 # 30px 用于样式名称标注
|
||||
|
||||
# 创建拼接图
|
||||
sheet = Image.new("RGB", (sheet_width, sheet_height), (0, 0, 0))
|
||||
|
||||
# 拼接帧
|
||||
for i, (style_name, frame_path) in enumerate(all_frames):
|
||||
frame = Image.open(frame_path)
|
||||
|
||||
# 计算位置
|
||||
style_idx = style_names.index(style_name)
|
||||
sample_idx = i % num_samples
|
||||
|
||||
x = sample_idx * (frame_width + 10) + 10
|
||||
y = style_idx * (frame_height + 30) + 10
|
||||
|
||||
# 粘贴帧
|
||||
sheet.paste(frame, (x, y + 20)) # +20px 用于样式名称标注
|
||||
|
||||
# 添加样式名称标注
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
draw.text((x + 5, y + 5), style_name, fill=(255, 255, 255))
|
||||
|
||||
# 保存
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
sheet.save(output_path)
|
||||
|
||||
print(f"[OK] 对比预览图已生成:{output_path}")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Subtitle Style A/B Test · 字幕样式 A/B 测试器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --styles style1.json style2.json --output-dir ./ab-test/
|
||||
python subtitle-style-ab-test.py --video input.mp4 --srt input.srt --preset-styles reference-drama short-drama-bold --output-dir ./ab-test/
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--video", help="视频文件路径")
|
||||
parser.add_argument("--srt", help="SRT 字幕文件路径")
|
||||
parser.add_argument("--styles", nargs="+", help="样式配置文件路径列表")
|
||||
parser.add_argument("--preset-styles", nargs="+", help="预设样式名称列表")
|
||||
parser.add_argument("--batch", help="批量处理视频目录")
|
||||
parser.add_argument("--srt-dir", help="SRT 文件目录(用于批量处理)")
|
||||
parser.add_argument("--output-dir", required=True, help="输出目录")
|
||||
parser.add_argument("--num-samples", type=int, default=4, help="每个样式抽取的样本帧数")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 收集样式配置
|
||||
styles = []
|
||||
style_names = []
|
||||
|
||||
if args.styles:
|
||||
for style_path in args.styles:
|
||||
style = load_style_config(style_path)
|
||||
if style:
|
||||
styles.append(style)
|
||||
style_names.append(os.path.splitext(os.path.basename(style_path))[0])
|
||||
|
||||
if args.preset_styles:
|
||||
for preset_name in args.preset_styles:
|
||||
style = load_style_config(preset_name)
|
||||
if style:
|
||||
styles.append(style)
|
||||
style_names.append(preset_name)
|
||||
|
||||
if not styles:
|
||||
print("[ERROR] 请指定至少一种样式(--styles 或 --preset-styles)")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] 将测试 {len(styles)} 种样式:{style_names}")
|
||||
|
||||
if args.video:
|
||||
# 单视频处理
|
||||
if not args.srt:
|
||||
print("[ERROR] 请指定 --srt")
|
||||
sys.exit(1)
|
||||
|
||||
# 为每个样式生成字幕和视频
|
||||
style_dirs = []
|
||||
|
||||
for i, (style, style_name) in enumerate(zip(styles, style_names)):
|
||||
print(f"\n[INFO] 处理样式 {i+1}/{len(styles)}:{style_name}")
|
||||
|
||||
# 创建样式目录
|
||||
style_dir = os.path.join(args.output_dir, style_name)
|
||||
os.makedirs(style_dir, exist_ok=True)
|
||||
style_dirs.append(style_dir)
|
||||
|
||||
# 生成 ASS 文件
|
||||
ass_path = os.path.join(style_dir, "subtitles.ass")
|
||||
ok = generate_ass_file_for_style(args.srt, ass_path, style)
|
||||
|
||||
if ok:
|
||||
# 烧录字幕到视频
|
||||
output_video = os.path.join(style_dir, os.path.basename(args.video))
|
||||
burn_subtitles_with_ffmpeg(args.video, ass_path, output_video)
|
||||
|
||||
# 抽取样本帧
|
||||
extract_sample_frames(output_video, style_dir, args.num_samples)
|
||||
|
||||
# 生成对比预览图
|
||||
comparison_preview = os.path.join(args.output_dir, "comparison.png")
|
||||
generate_comparison_preview(style_dirs, comparison_preview)
|
||||
|
||||
elif args.batch:
|
||||
# 批量处理
|
||||
if not args.srt_dir:
|
||||
print("[ERROR] 批量处理需要指定 --srt-dir")
|
||||
sys.exit(1)
|
||||
|
||||
for video_file in os.listdir(args.batch):
|
||||
if not video_file.lower().endswith((".mp4", ".mov", ".avi")):
|
||||
continue
|
||||
|
||||
video_path = os.path.join(args.batch, video_file)
|
||||
|
||||
# 查找对应的 SRT 文件
|
||||
srt_file = video_file.replace(".mp4", ".srt").replace(".mov", ".srt").replace(".avi", ".srt")
|
||||
srt_path = os.path.join(args.srt_dir, srt_file)
|
||||
|
||||
if not os.path.isfile(srt_path):
|
||||
print(f"[WARN] 未找到对应的 SRT 文件:{srt_file}")
|
||||
continue
|
||||
|
||||
print(f"\n[INFO] 批量处理:{video_file}")
|
||||
|
||||
# 为每个样式生成字幕和视频
|
||||
video_output_dir = os.path.join(args.output_dir, os.path.splitext(video_file)[0])
|
||||
|
||||
for i, (style, style_name) in enumerate(zip(styles, style_names)):
|
||||
print(f"\n[INFO] 处理样式 {i+1}/{len(styles)}:{style_name}")
|
||||
|
||||
style_dir = os.path.join(video_output_dir, style_name)
|
||||
os.makedirs(style_dir, exist_ok=True)
|
||||
|
||||
# 生成 ASS 文件
|
||||
ass_path = os.path.join(style_dir, "subtitles.ass")
|
||||
ok = generate_ass_file_for_style(srt_path, ass_path, style)
|
||||
|
||||
if ok:
|
||||
# 烧录字幕到视频
|
||||
output_video = os.path.join(style_dir, video_file)
|
||||
burn_subtitles_with_ffmpeg(video_path, ass_path, output_video)
|
||||
|
||||
# 抽取样本帧
|
||||
extract_sample_frames(output_video, style_dir, args.num_samples)
|
||||
|
||||
print("\n[OK] A/B 测试完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Subtitle Font Manager · 字幕字体管理器
|
||||
======================================
|
||||
固定项目字幕字体,不能依赖系统默认字体。
|
||||
|
||||
功能:
|
||||
1. 检查项目字体是否已安装
|
||||
2. 如果未安装,自动下载/复制到项目字体目录
|
||||
3. 生成字体配置文件(供 ASS 渲染器使用)
|
||||
4. 验证字体文件是否有效(支持中文)
|
||||
|
||||
依赖:
|
||||
pip install fonttools # 可选,用于验证字体
|
||||
# macOS: 系统字体通常在 /System/Library/Fonts/
|
||||
# Ubuntu: 系统字体通常在 /usr/share/fonts/
|
||||
|
||||
用法:
|
||||
# 检查并安装项目字体
|
||||
python subtitle-font-manager.py --check --install
|
||||
|
||||
# 列出可用的中文字体
|
||||
python subtitle-font-manager.py --list
|
||||
|
||||
# 设置项目字体
|
||||
python subtitle-font-manager.py --set-font "PingFang SC" --output project-font.json
|
||||
|
||||
# 验证字体文件是否有效
|
||||
python subtitle-font-manager.py --validate ./assets/fonts/PingFangSC-Regular.otf
|
||||
|
||||
# 生成 FFmpeg 字体路径配置
|
||||
python subtitle-font-manager.py --generate-ffmpeg-config --output ffmpeg-font.conf
|
||||
|
||||
输出:
|
||||
- project-font.json: 项目字体配置
|
||||
- ./assets/fonts/: 项目字体文件(如果需要下载)
|
||||
- ffmpeg-font.conf: FFmpeg 字体配置
|
||||
|
||||
路径:
|
||||
video-ai-system/engines/subtitle-pipeline/font-manager/subtitle-font-manager.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 尝试导入 fonttools(可选)
|
||||
try:
|
||||
from fonttools import TTFont
|
||||
from fonttools.unicode import Unicode
|
||||
FONTTOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
FONTTOOLS_AVAILABLE = False
|
||||
print("[WARN] 未安装 fonttools,将无法验证字体是否支持中文")
|
||||
|
||||
|
||||
# 项目中文字体候选列表(按优先级排序)
|
||||
CHINESE_FONT_CANDIDATES = [
|
||||
{
|
||||
"name": "PingFang SC",
|
||||
"mac_path": "/System/Library/Fonts/PingFang.ttc",
|
||||
"windows_path": "C:/Windows/Fonts/pingfang.ttc",
|
||||
"linux_path": "/usr/share/fonts/truetype/pingfang/PingFangSC-Regular.otf",
|
||||
"download_url": None, # macOS 系统自带,无需下载
|
||||
"weight": "Regular"
|
||||
},
|
||||
{
|
||||
"name": "Hiragino Sans GB",
|
||||
"mac_path": "/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"windows_path": "C:/Windows/Fonts/hiragino.ttc",
|
||||
"linux_path": "/usr/share/fonts/truetype/hiragino/HiraginoSansGB-Regular.otf",
|
||||
"download_url": None,
|
||||
"weight": "Regular"
|
||||
},
|
||||
{
|
||||
"name": "Noto Sans CJK SC",
|
||||
"mac_path": "/System/Library/Fonts/Supplemental/Noto Sans CJK SC.ttc",
|
||||
"windows_path": "C:/Windows/Fonts/NotoSansCJK-Regular.ttc",
|
||||
"linux_path": "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"download_url": "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf",
|
||||
"weight": "Regular"
|
||||
},
|
||||
{
|
||||
"name": "Source Han Sans SC",
|
||||
"mac_path": "/System/Library/Fonts/Supplemental/Source Han Sans SC.ttc",
|
||||
"windows_path": "C:/Windows/Fonts/SourceHanSansSC-Regular.otf",
|
||||
"linux_path": "/usr/share/fonts/opentype/source-han-sans/SourceHanSansSC-Regular.otf",
|
||||
"download_url": "https://github.com/adobe-fonts/source-han-sans/raw/release/OTF/SimplifiedChinese/SourceHanSansSC-Regular.otf",
|
||||
"weight": "Regular"
|
||||
},
|
||||
{
|
||||
"name": "WenQuanYi Micro Hei",
|
||||
"mac_path": None,
|
||||
"windows_path": None,
|
||||
"linux_path": "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
"download_url": "https://github.com/anthonyfok/fonts-wqy-microhei/raw/master/wqy-microhei.ttc",
|
||||
"weight": "Regular"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def find_font_file(font_info: dict) -> str:
|
||||
"""
|
||||
查找字体文件(跨平台)
|
||||
|
||||
:param font_info: 字体信息字典
|
||||
:return: 字体文件路径,如果未找到返回 None
|
||||
"""
|
||||
import platform
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin": # macOS
|
||||
font_path = font_info.get("mac_path")
|
||||
elif system == "Windows":
|
||||
font_path = font_info.get("windows_path")
|
||||
elif system == "Linux":
|
||||
font_path = font_info.get("linux_path")
|
||||
else:
|
||||
font_path = None
|
||||
|
||||
if font_path and os.path.isfile(font_path):
|
||||
return font_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_chinese_support(font_path: str) -> bool:
|
||||
"""
|
||||
检查字体是否支持中文(简体)
|
||||
|
||||
:param font_path: 字体文件路径
|
||||
:return: 是否支持中文
|
||||
"""
|
||||
if not FONTTOOLS_AVAILABLE:
|
||||
print("[WARN] 未安装 fonttools,跳过中文支持检查")
|
||||
return True
|
||||
|
||||
try:
|
||||
font = TTFont(font_path)
|
||||
|
||||
# 检查是否包含常用简体中文字符(GB2312 范围)
|
||||
# GB2312 一级汉字:0x4E00-0x9FA5
|
||||
chinese_chars = [chr(code) for code in range(0x4E00, 0x9FA5)]
|
||||
|
||||
# 抽样检查(检查前100个常用汉字)
|
||||
sample_chars = chinese_chars[:100]
|
||||
|
||||
supported = 0
|
||||
for char in sample_chars:
|
||||
if char in font:
|
||||
supported += 1
|
||||
|
||||
support_ratio = supported / len(sample_chars)
|
||||
print(f"[INFO] 字体中文支持率:{support_ratio*100:.1f}%")
|
||||
|
||||
return support_ratio > 0.8 # 支持率 > 80% 认为支持中文
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 检查字体中文支持失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_font_to_project(font_info: dict, project_fonts_dir: str) -> str:
|
||||
"""
|
||||
安装字体到项目字体目录
|
||||
|
||||
:param font_info: 字体信息字典
|
||||
:param project_fonts_dir: 项目字体目录
|
||||
:return: 安装后的字体文件路径
|
||||
"""
|
||||
os.makedirs(project_fonts_dir, exist_ok=True)
|
||||
|
||||
# 查找系统字体文件
|
||||
system_font_path = find_font_file(font_info)
|
||||
|
||||
if not system_font_path:
|
||||
print(f"[ERROR] 未找到系统字体:{font_info['name']}")
|
||||
if font_info.get("download_url"):
|
||||
print(f"[INFO] 尝试从以下地址下载:{font_info['download_url']}")
|
||||
# TODO: 实现下载逻辑
|
||||
return None
|
||||
else:
|
||||
print(f"[ERROR] 该字体无下载地址,请手动安装")
|
||||
return None
|
||||
|
||||
# 复制到项目字体目录
|
||||
font_filename = os.path.basename(system_font_path)
|
||||
project_font_path = os.path.join(project_fonts_dir, font_filename)
|
||||
|
||||
if not os.path.isfile(project_font_path):
|
||||
try:
|
||||
shutil.copy2(system_font_path, project_font_path)
|
||||
print(f"[OK] 字体已复制到项目目录:{project_font_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 复制字体失败:{e}")
|
||||
return None
|
||||
else:
|
||||
print(f"[INFO] 字体已存在于项目目录:{project_font_path}")
|
||||
|
||||
return project_font_path
|
||||
|
||||
|
||||
def generate_project_font_config(font_path: str, output_path: str, font_name: str = None):
|
||||
"""
|
||||
生成项目字体配置文件
|
||||
|
||||
:param font_path: 字体文件路径
|
||||
:param output_path: 输出配置文件路径
|
||||
:param font_name: 字体名称(如果不指定,从文件读取)
|
||||
"""
|
||||
# 如果未指定字体名称,尝试从文件读取
|
||||
if not font_name and FONTTOOLS_AVAILABLE:
|
||||
try:
|
||||
font = TTFont(font_path)
|
||||
font_name = font.get("name").getDebugName(4) # 完整字体名称
|
||||
except Exception:
|
||||
font_name = os.path.splitext(os.path.basename(font_path))[0]
|
||||
|
||||
config = {
|
||||
"_comment": "项目字幕字体配置",
|
||||
"font_name": font_name or "Unknown",
|
||||
"font_path": os.path.abspath(font_path),
|
||||
"font_path_relative": os.path.relpath(font_path, os.path.dirname(output_path)),
|
||||
"install_date": __import__("datetime").datetime.now().isoformat(),
|
||||
"supports_chinese": check_chinese_support(font_path) if FONTTOOLS_AVAILABLE else None,
|
||||
"ass_fontname": font_name, # ASS 格式使用的字体名称
|
||||
"ffmpeg_font_path": font_path, # FFmpeg 使用的字体路径
|
||||
}
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[OK] 项目字体配置已生成:{output_path}")
|
||||
|
||||
|
||||
def list_available_fonts():
|
||||
"""
|
||||
列出系统中可用的中文字体
|
||||
"""
|
||||
print("\n[INFO] 查找系统中可用的中文字体...")
|
||||
|
||||
available = []
|
||||
for font_info in CHINESE_FONT_CANDIDATES:
|
||||
font_path = find_font_file(font_info)
|
||||
if font_path:
|
||||
available.append({
|
||||
"name": font_info["name"],
|
||||
"path": font_path,
|
||||
"weight": font_info["weight"]
|
||||
})
|
||||
print(f" ✅ {font_info['name']} ({font_info['weight']})")
|
||||
print(f" 路径:{font_path}")
|
||||
else:
|
||||
print(f" ❌ {font_info['name']} (未找到)")
|
||||
|
||||
if not available:
|
||||
print("\n[WARN] 未找到任何中文字体,请手动安装中文字体")
|
||||
print("推荐字体:")
|
||||
print(" - macOS: PingFang SC(系统自带)")
|
||||
print(" - Ubuntu: fonts-noto-cjk(apt install fonts-noto-cjk)")
|
||||
print(" - Windows: 微软雅黑(系统自带)")
|
||||
|
||||
return available
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Subtitle Font Manager · 字幕字体管理器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python subtitle-font-manager.py --list
|
||||
python subtitle-font-manager.py --check --install
|
||||
python subtitle-font-manager.py --set-font "PingFang SC" --output project-font.json
|
||||
python subtitle-font-manager.py --validate ./assets/fonts/PingFangSC-Regular.otf
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="列出可用的中文字体")
|
||||
parser.add_argument("--check", action="store_true", help="检查项目字体是否已安装")
|
||||
parser.add_argument("--install", action="store_true", help="如果未安装,自动安装字体到项目目录")
|
||||
parser.add_argument("--set-font", help="设置项目字体(字体名称)")
|
||||
parser.add_argument("--output", help="输出配置文件路径")
|
||||
parser.add_argument("--validate", help="验证字体文件是否有效")
|
||||
parser.add_argument("--generate-ffmpeg-config", action="store_true", help="生成 FFmpeg 字体配置")
|
||||
parser.add_argument("--project-fonts-dir", default="./assets/fonts/", help="项目字体目录")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
list_available_fonts()
|
||||
sys.exit(0)
|
||||
|
||||
if args.validate:
|
||||
if not os.path.isfile(args.validate):
|
||||
print(f"[ERROR] 字体文件不存在:{args.validate}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] 验证字体文件:{args.validate}")
|
||||
supports_chinese = check_chinese_support(args.validate)
|
||||
|
||||
if supports_chinese:
|
||||
print(f"[OK] 字体有效,支持中文")
|
||||
else:
|
||||
print(f"[WARN] 字体可能不支持中文,请手动检查")
|
||||
sys.exit(0)
|
||||
|
||||
if args.check or args.install:
|
||||
# 检查项目字体是否已安装
|
||||
project_font_config = os.path.join(args.project_fonts_dir, "../subtitle-styles/project-font.json")
|
||||
project_font_config = os.path.abspath(project_font_config)
|
||||
|
||||
if os.path.isfile(project_font_config):
|
||||
with open(project_font_config, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
print(f"[INFO] 项目字体已配置:{config['font_name']}")
|
||||
print(f"[INFO] 字体路径:{config['font_path']}")
|
||||
|
||||
if not os.path.isfile(config["font_path"]):
|
||||
print(f"[WARN] 字体文件不存在,需要重新安装")
|
||||
args.install = True
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
if args.install:
|
||||
# 安装字体
|
||||
available = list_available_fonts()
|
||||
|
||||
if not available:
|
||||
sys.exit(1)
|
||||
|
||||
# 使用第一个可用的字体
|
||||
font_info = available[0]
|
||||
print(f"\n[INFO] 安装字体:{font_info['name']}")
|
||||
|
||||
# 查找字体信息
|
||||
font_info_full = next((f for f in CHINESE_FONT_CANDIDATES if f["name"] == font_info["name"]), None)
|
||||
|
||||
if font_info_full:
|
||||
project_font_path = install_font_to_project(font_info_full, args.project_fonts_dir)
|
||||
|
||||
if project_font_path and args.output:
|
||||
generate_project_font_config(project_font_path, args.output, font_info["name"])
|
||||
|
||||
if args.set_font:
|
||||
# 设置项目字体
|
||||
font_info = next((f for f in CHINESE_FONT_CANDIDATES if f["name"] == args.set_font), None)
|
||||
|
||||
if not font_info:
|
||||
print(f"[ERROR] 未找到字体:{args.set_font}")
|
||||
print(f"[INFO] 可用字体:{[f['name'] for f in CHINESE_FONT_CANDIDATES]}")
|
||||
sys.exit(1)
|
||||
|
||||
project_font_path = install_font_to_project(font_info, args.project_fonts_dir)
|
||||
|
||||
if project_font_path and args.output:
|
||||
generate_project_font_config(project_font_path, args.output, args.set_font)
|
||||
|
||||
if args.generate_ffmpeg_config:
|
||||
# 生成 FFmpeg 字体配置
|
||||
# FFmpeg 可以使用 fontfile 滤镜指定字体文件
|
||||
output = args.output or "ffmpeg-font.conf"
|
||||
|
||||
# 读取项目字体配置
|
||||
project_font_config = os.path.join(args.project_fonts_dir, "../subtitle-styles/project-font.json")
|
||||
project_font_config = os.path.abspath(project_font_config)
|
||||
|
||||
if not os.path.isfile(project_font_config):
|
||||
print(f"[ERROR] 项目字体未配置,请先运行 --check --install")
|
||||
sys.exit(1)
|
||||
|
||||
with open(project_font_config, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 生成 FFmpeg 滤镜字符串
|
||||
font_path = config["font_path"]
|
||||
ffmpeg_filter = f"subtitles=subtitles.ass:force_style='FontName={config['font_name']},FontFile={font_path}'"
|
||||
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(f"# FFmpeg 字体配置\n")
|
||||
f.write(f"# 使用方法:ffmpeg -i input.mp4 -vf \"{ffmpeg_filter}\" output.mp4\n\n")
|
||||
f.write(f"font_path={font_path}\n")
|
||||
f.write(f"font_name={config['font_name']}\n")
|
||||
f.write(f"ffmpeg_filter={ffmpeg_filter}\n")
|
||||
|
||||
print(f"[OK] FFmpeg 字体配置已生成:{output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Subtitle Preview Contact Sheet · 字幕预览检查图生成器
|
||||
==============================================
|
||||
每次烧字幕后自动抽帧出检查图。
|
||||
|
||||
功能:
|
||||
1. 从视频中按字幕时间点抽帧
|
||||
2. 生成检查图(contact sheet)
|
||||
3. 可选:在检查图上标注字幕框、问题区域
|
||||
4. 生成 HTML 预览页面
|
||||
|
||||
依赖:
|
||||
pip install opencv-python pillow numpy
|
||||
|
||||
用法:
|
||||
# 从视频抽帧生成检查图
|
||||
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --output preview.png
|
||||
|
||||
# 生成 HTML 预览页面
|
||||
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --generate-html --output preview.html
|
||||
|
||||
# 批量处理多集
|
||||
python subtitle-preview-contact-sheet.py --batch ./videos/ --subtitle-dir ./ass/ --output-dir ./previews/
|
||||
|
||||
# 自定义抽帧参数
|
||||
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --max-frames 20 --cols 4
|
||||
|
||||
输出:
|
||||
- preview.png: 检查图(多帧拼接)
|
||||
- preview.html: HTML 预览页面(可选)
|
||||
- frames/: 抽出的关键帧(可选)
|
||||
|
||||
路径:
|
||||
video-ai-system/engines/subtitle-pipeline/preview-tools/subtitle-preview-contact-sheet.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError as e:
|
||||
print(f"[ERROR] 缺少依赖:{e}")
|
||||
print("请先安装:pip install opencv-python pillow numpy")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def extract_frames_by_subtitle(video_path: str, ass_path: str, output_dir: str, max_frames: int = 20) -> list:
|
||||
"""
|
||||
按字幕时间点从视频中抽帧
|
||||
|
||||
:param video_path: 视频文件路径
|
||||
:param ass_path: ASS 字幕文件路径
|
||||
:param output_dir: 输出目录
|
||||
:param max_frames: 最大抽帧数量
|
||||
:return: 抽出的帧文件路径列表
|
||||
"""
|
||||
# 打开视频
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
print(f"[ERROR] 无法打开视频:{video_path}")
|
||||
return []
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
print(f"[INFO] 视频信息:{total_frames} 帧,{fps} FPS")
|
||||
|
||||
# 读取 ASS 字幕文件,获取字幕时间点
|
||||
subtitle_times = []
|
||||
try:
|
||||
with open(ass_path, "r", encoding="utf-8-sig") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("Dialogue:"):
|
||||
# 解析 ASS 对话行
|
||||
parts = line.split(",")
|
||||
if len(parts) >= 10:
|
||||
start_time = parts[1].strip() # 开始时间
|
||||
end_time = parts[2].strip() # 结束时间
|
||||
|
||||
# 转换为秒
|
||||
start_seconds = ass_time_to_seconds(start_time)
|
||||
end_seconds = ass_time_to_seconds(end_time)
|
||||
|
||||
# 取字幕中间时间点作为抽帧时间
|
||||
middle_time = (start_seconds + end_seconds) / 2
|
||||
subtitle_times.append(middle_time)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 读取 ASS 文件失败:{e}")
|
||||
return []
|
||||
|
||||
if not subtitle_times:
|
||||
print("[WARN] 未找到字幕时间点,将按固定间隔抽帧")
|
||||
# 按固定间隔抽帧
|
||||
interval = total_frames / max_frames
|
||||
subtitle_times = [i * interval / fps for i in range(max_frames)]
|
||||
|
||||
print(f"[INFO] 找到 {len(subtitle_times)} 个字幕时间点")
|
||||
|
||||
# 创建输出目录
|
||||
frames_dir = os.path.join(output_dir, "frames")
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
|
||||
# 抽帧
|
||||
frame_paths = []
|
||||
for i, timestamp in enumerate(subtitle_times[:max_frames]):
|
||||
# 跳转到指定时间
|
||||
cap.set(cv2.CAP_PROP_POS_MSEC, timestamp * 1000)
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print(f"[WARN] 无法读取帧:{timestamp:.2f}s")
|
||||
continue
|
||||
|
||||
# 保存帧
|
||||
frame_path = os.path.join(frames_dir, f"frame_{i:04d}_{timestamp:.2f}s.png")
|
||||
cv2.imwrite(frame_path, frame)
|
||||
|
||||
frame_paths.append(frame_path)
|
||||
print(f"[INFO] 已抽帧:{timestamp:.2f}s -> {frame_path}")
|
||||
|
||||
cap.release()
|
||||
|
||||
print(f"[OK] 共抽出 {len(frame_paths)} 帧")
|
||||
return frame_paths
|
||||
|
||||
|
||||
def ass_time_to_seconds(ass_time: str) -> float:
|
||||
"""
|
||||
将 ASS 时间戳转换为秒
|
||||
|
||||
:param ass_time: ASS 时间戳(H:MM:SS.cc)
|
||||
:return: 秒数
|
||||
"""
|
||||
parts = ass_time.split(":")
|
||||
|
||||
hours = int(parts[0])
|
||||
minutes = int(parts[1])
|
||||
seconds_parts = parts[2].split(".")
|
||||
seconds = int(seconds_parts[0])
|
||||
centiseconds = int(seconds_parts[1]) if len(seconds_parts) > 1 else 0
|
||||
|
||||
total_seconds = hours * 3600 + minutes * 60 + seconds + centiseconds / 100
|
||||
return total_seconds
|
||||
|
||||
|
||||
def generate_contact_sheet(frame_paths: list, output_path: str, cols: int = 4, padding: int = 10):
|
||||
"""
|
||||
生成检查图(多帧拼接)
|
||||
|
||||
:param frame_paths: 帧文件路径列表
|
||||
:param output_path: 输出图片路径
|
||||
:param cols: 列数
|
||||
:param padding: 内边距(像素)
|
||||
"""
|
||||
if not frame_paths:
|
||||
print("[ERROR] 没有帧文件")
|
||||
return False
|
||||
|
||||
# 读取第一帧,获取尺寸
|
||||
first_frame = Image.open(frame_paths[0])
|
||||
frame_width, frame_height = first_frame.size
|
||||
|
||||
# 计算拼接图尺寸
|
||||
rows = (len(frame_paths) + cols - 1) // cols
|
||||
sheet_width = cols * (frame_width + padding) + padding
|
||||
sheet_height = rows * (frame_height + padding) + padding
|
||||
|
||||
# 创建拼接图
|
||||
sheet = Image.new("RGB", (sheet_width, sheet_height), (0, 0, 0))
|
||||
|
||||
# 拼接帧
|
||||
for i, frame_path in enumerate(frame_paths):
|
||||
frame = Image.open(frame_path)
|
||||
|
||||
# 计算位置
|
||||
row = i // cols
|
||||
col = i % cols
|
||||
x = col * (frame_width + padding) + padding
|
||||
y = row * (frame_height + padding) + padding
|
||||
|
||||
# 粘贴帧
|
||||
sheet.paste(frame, (x, y))
|
||||
|
||||
# 添加时间戳标注
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
timestamp = os.path.basename(frame_path).split("_")[1].replace(".png", "")
|
||||
draw.text((x + 5, y + 5), f"{timestamp}", fill=(255, 255, 255))
|
||||
|
||||
# 保存
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
sheet.save(output_path)
|
||||
|
||||
print(f"[OK] 检查图已生成:{output_path}")
|
||||
return True
|
||||
|
||||
|
||||
def generate_html_preview(frame_paths: list, output_path: str, video_path: str = None):
|
||||
"""
|
||||
生成 HTML 预览页面
|
||||
|
||||
:param frame_paths: 帧文件路径列表
|
||||
:param output_path: 输出 HTML 文件路径
|
||||
:param video_path: 视频文件路径(可选)
|
||||
"""
|
||||
html_lines = []
|
||||
html_lines.append("<!DOCTYPE html>")
|
||||
html_lines.append("<html>")
|
||||
html_lines.append("<head>")
|
||||
html_lines.append(" <meta charset='utf-8'>")
|
||||
html_lines.append(" <title>字幕预览检查图</title>")
|
||||
html_lines.append(" <style>")
|
||||
html_lines.append(" body { font-family: Arial, sans-serif; margin: 20px; }")
|
||||
html_lines.append(" .frame { margin: 10px; border: 1px solid #ccc; display: inline-block; }")
|
||||
html_lines.append(" .timestamp { font-size: 12px; color: #666; text-align: center; }")
|
||||
html_lines.append(" </style>")
|
||||
html_lines.append("</head>")
|
||||
html_lines.append("<body>")
|
||||
html_lines.append(" <h1>字幕预览检查图</h1>")
|
||||
|
||||
if video_path:
|
||||
html_lines.append(f" <p>视频:{os.path.basename(video_path)}</p>")
|
||||
|
||||
html_lines.append(f" <p>共 {len(frame_paths)} 帧</p>")
|
||||
html_lines.append(" <div>")
|
||||
|
||||
for frame_path in frame_paths:
|
||||
timestamp = os.path.basename(frame_path).split("_")[1].replace(".png", "")
|
||||
rel_path = os.path.relpath(frame_path, os.path.dirname(output_path))
|
||||
|
||||
html_lines.append(" <div class='frame'>")
|
||||
html_lines.append(f" <img src='{rel_path}' width='320'>")
|
||||
html_lines.append(f" <div class='timestamp'>{timestamp}</div>")
|
||||
html_lines.append(" </div>")
|
||||
|
||||
html_lines.append(" </div>")
|
||||
html_lines.append("</body>")
|
||||
html_lines.append("</html>")
|
||||
|
||||
# 写入文件
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(html_lines))
|
||||
|
||||
print(f"[OK] HTML 预览页面已生成:{output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Subtitle Preview Contact Sheet · 字幕预览检查图生成器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --output preview.png
|
||||
python subtitle-preview-contact-sheet.py --video input.mp4 --subtitle-ass subtitles.ass --generate-html --output preview.html
|
||||
python subtitle-preview-contact-sheet.py --batch ./videos/ --subtitle-dir ./ass/ --output-dir ./previews/
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--video", help="视频文件路径")
|
||||
parser.add_argument("--subtitle-ass", help="ASS 字幕文件路径")
|
||||
parser.add_argument("--output", help="输出文件路径(PNG 或 HTML)")
|
||||
parser.add_argument("--generate-html", action="store_true", help="生成 HTML 预览页面")
|
||||
parser.add_argument("--batch", help="批量处理视频目录")
|
||||
parser.add_argument("--subtitle-dir", help="字幕文件目录(用于批量处理)")
|
||||
parser.add_argument("--output-dir", help="输出目录(用于批量处理)")
|
||||
parser.add_argument("--max-frames", type=int, default=20, help="最大抽帧数量")
|
||||
parser.add_argument("--cols", type=int, default=4, help="检查图列数")
|
||||
parser.add_argument("--padding", type=int, default=10, help="内边距(像素)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.video:
|
||||
# 单文件处理
|
||||
if not args.subtitle_ass:
|
||||
print("[ERROR] 请指定 --subtitle-ass")
|
||||
sys.exit(1)
|
||||
|
||||
if not args.output:
|
||||
print("[ERROR] 请指定 --output")
|
||||
sys.exit(1)
|
||||
|
||||
# 抽帧
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if not output_dir:
|
||||
output_dir = "."
|
||||
|
||||
frame_paths = extract_frames_by_subtitle(args.video, args.subtitle_ass, output_dir, args.max_frames)
|
||||
|
||||
if not frame_paths:
|
||||
sys.exit(1)
|
||||
|
||||
# 生成检查图或 HTML
|
||||
if args.generate_html:
|
||||
generate_html_preview(frame_paths, args.output, args.video)
|
||||
else:
|
||||
generate_contact_sheet(frame_paths, args.output, args.cols, args.padding)
|
||||
|
||||
elif args.batch:
|
||||
# 批量处理
|
||||
if not args.subtitle_dir:
|
||||
print("[ERROR] 批量处理需要指定 --subtitle-dir")
|
||||
sys.exit(1)
|
||||
|
||||
if not args.output_dir:
|
||||
print("[ERROR] 批量处理需要指定 --output-dir")
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
for video_file in os.listdir(args.batch):
|
||||
if video_file.lower().endswith((".mp4", ".mov", ".avi")):
|
||||
video_path = os.path.join(args.batch, video_file)
|
||||
|
||||
# 查找对应的字幕文件
|
||||
subtitle_file = video_file.replace(".mp4", ".ass").replace(".mov", ".ass").replace(".avi", ".ass")
|
||||
subtitle_path = os.path.join(args.subtitle_dir, subtitle_file)
|
||||
|
||||
if not os.path.isfile(subtitle_path):
|
||||
print(f"[WARN] 未找到对应的字幕文件:{subtitle_file}")
|
||||
continue
|
||||
|
||||
print(f"\n[INFO] 处理视频:{video_file}")
|
||||
|
||||
# 抽帧
|
||||
frame_paths = extract_frames_by_subtitle(video_path, subtitle_path, args.output_dir, args.max_frames)
|
||||
|
||||
if frame_paths:
|
||||
# 生成检查图
|
||||
output_path = os.path.join(args.output_dir, video_file.replace(".mp4", "_preview.png"))
|
||||
generate_contact_sheet(frame_paths, output_path, args.cols, args.padding)
|
||||
|
||||
# 生成 HTML
|
||||
html_path = os.path.join(args.output_dir, video_file.replace(".mp4", "_preview.html"))
|
||||
generate_html_preview(frame_paths, html_path, video_path)
|
||||
|
||||
else:
|
||||
print("[ERROR] 请指定 --video 或 --batch")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n[OK] 处理完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,499 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Subtitle Safe Area QC · 字幕安全区域质检器
|
||||
=============================================
|
||||
自动检查字幕有没有遮脸、遮身体表演、贴边、看不清。
|
||||
|
||||
依赖:
|
||||
pip install opencv-python pillow numpy
|
||||
# 可选:pip install face-recognition dlib # 人脸识别
|
||||
# 可选:pip install ultralytics # YOLO 人体检测
|
||||
|
||||
用法:
|
||||
# 检查单张帧
|
||||
python subtitle-safe-area-qc.py --frame frame.png --subtitle-box x,y,w,h --output qc-report.json
|
||||
|
||||
# 批量检查视频帧
|
||||
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --output qc-report.json
|
||||
|
||||
# 交互式标注模式(手动框选人脸/身体区域)
|
||||
python subtitle-safe-area-qc.py --frame frame.png --interactive
|
||||
|
||||
# 生成质检报告(包含问题帧截图)
|
||||
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --generate-report --output-dir ./qc-report/
|
||||
|
||||
输出 JSON 格式:
|
||||
{
|
||||
"frame": "frame_00123.png",
|
||||
"timestamp": 12.34,
|
||||
"issues": [
|
||||
{"type": "face_occlusion", "severity": "high", "bbox": [x,y,w,h]},
|
||||
{"type": "body_occlusion", "severity": "medium", "bbox": [x,y,w,h]},
|
||||
{"type": "edge_too_close", "severity": "low", "distance": 5},
|
||||
{"type": "low_contrast", "severity": "medium", "contrast_ratio": 2.1}
|
||||
],
|
||||
"safe": false
|
||||
}
|
||||
|
||||
路径:
|
||||
video-ai-system/engines/subtitle-pipeline/qc-tools/subtitle-safe-area-qc.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
except ImportError as e:
|
||||
print(f"[ERROR] 缺少依赖:{e}")
|
||||
print("请先安装:pip install opencv-python pillow numpy")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# 尝试导入可选依赖
|
||||
try:
|
||||
import face_recognition
|
||||
FACE_RECOGNITION_AVAILABLE = True
|
||||
except ImportError:
|
||||
FACE_RECOGNITION_AVAILABLE = False
|
||||
print("[WARN] 未安装 face-recognition,将使用简化人脸检测")
|
||||
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
YOLO_AVAILABLE = True
|
||||
except ImportError:
|
||||
YOLO_AVAILABLE = False
|
||||
print("[WARN] 未安装 ultralytics,将使用简化身体检测")
|
||||
|
||||
|
||||
def detect_faces(image: np.ndarray) -> list:
|
||||
"""
|
||||
检测人脸区域
|
||||
|
||||
:param image: 图片数组
|
||||
:return: 人脸边界框列表 [[x,y,w,h], ...]
|
||||
"""
|
||||
if FACE_RECOGNITION_AVAILABLE:
|
||||
# 使用 face_recognition 库(基于 dlib)
|
||||
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
face_locations = face_recognition.face_locations(rgb_image)
|
||||
|
||||
# 转换为 [x,y,w,h] 格式
|
||||
boxes = []
|
||||
for top, right, bottom, left in face_locations:
|
||||
x = left
|
||||
y = top
|
||||
w = right - left
|
||||
h = bottom - top
|
||||
boxes.append([x, y, w, h])
|
||||
|
||||
return boxes
|
||||
else:
|
||||
# 简化方案:使用 OpenCV Haar Cascade
|
||||
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
|
||||
face_cascade = cv2.CascadeClassifier(cascade_path)
|
||||
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
faces = face_cascade.detectMultiScale(gray, 1.1, 4)
|
||||
|
||||
return faces.tolist() if len(faces) > 0 else []
|
||||
|
||||
|
||||
def detect_body(image: np.ndarray) -> list:
|
||||
"""
|
||||
检测身体区域
|
||||
|
||||
:param image: 图片数组
|
||||
:return: 身体边界框列表 [[x,y,w,h], ...]
|
||||
"""
|
||||
if YOLO_AVAILABLE:
|
||||
# 使用 YOLO 检测人体
|
||||
model = YOLO("yolov8n.pt") # 自动下载
|
||||
results = model(image)
|
||||
|
||||
boxes = []
|
||||
for result in results:
|
||||
for box in result.boxes:
|
||||
cls = int(box.cls[0])
|
||||
# COCO 数据集中,person 的类别 ID 是 0
|
||||
if cls == 0:
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
boxes.append([int(x1), int(y1), int(x2-x1), int(y2-y1)])
|
||||
|
||||
return boxes
|
||||
else:
|
||||
# 简化方案:使用背景减除或轮廓检测
|
||||
print("[WARN] 未安装 YOLO,使用简化身体检测(可能不准确)")
|
||||
|
||||
# 策略:检测图像中的大轮廓(假设身体是大区域)
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
_, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY_INV)
|
||||
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
boxes = []
|
||||
for cnt in contours:
|
||||
area = cv2.contourArea(cnt)
|
||||
if area > image.shape[0] * image.shape[1] * 0.1: # 面积 > 10%
|
||||
x, y, w, h = cv2.boundingRect(cnt)
|
||||
boxes.append([x, y, w, h])
|
||||
|
||||
return boxes
|
||||
|
||||
|
||||
def detect_subtitle_box(image: np.ndarray) -> list:
|
||||
"""
|
||||
检测字幕框位置
|
||||
|
||||
:param image: 图片数组
|
||||
:return: 字幕边界框 [x,y,w,h]
|
||||
"""
|
||||
# 策略:检测底部区域的白色文本
|
||||
height, width = image.shape[:2]
|
||||
|
||||
# 裁剪底部区域
|
||||
bottom_region = image[int(height * 0.85):, :]
|
||||
|
||||
# 转为灰度图
|
||||
gray = cv2.cvtColor(bottom_region, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# 二值化(检测白色文本)
|
||||
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# 查找轮廓
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
if not contours:
|
||||
return [0, int(height * 0.85), width, int(height * 0.12)] # 默认底部区域
|
||||
|
||||
# 合并所有轮廓的边界框
|
||||
all_x = []
|
||||
all_y = []
|
||||
all_w = []
|
||||
all_h = []
|
||||
|
||||
for cnt in contours:
|
||||
x, y, w, h = cv2.boundingRect(cnt)
|
||||
all_x.append(x)
|
||||
all_y.append(y)
|
||||
all_w.append(w)
|
||||
all_h.append(h)
|
||||
|
||||
# 计算合并后的边界框
|
||||
x = min(all_x)
|
||||
y = min(all_y) + int(height * 0.85) # 加上裁剪偏移
|
||||
w = max(all_x) + max(all_w) - x
|
||||
h = max(all_y) + max(all_h) - y
|
||||
|
||||
return [x, y, w, h]
|
||||
|
||||
|
||||
def calculate_iou(box1: list, box2: list) -> float:
|
||||
"""
|
||||
计算两个边界框的 IOU(交并比)
|
||||
|
||||
:param box1: 边界框1 [x,y,w,h]
|
||||
:param box2: 边界框2 [x,y,w,h]
|
||||
:return: IOU 值(0-1)
|
||||
"""
|
||||
x1 = max(box1[0], box2[0])
|
||||
y1 = max(box1[1], box2[1])
|
||||
x2 = min(box1[0] + box1[2], box2[0] + box2[2])
|
||||
y2 = min(box1[1] + box1[3], box2[1] + box2[3])
|
||||
|
||||
if x2 < x1 or y2 < y1:
|
||||
return 0.0
|
||||
|
||||
intersection = (x2 - x1) * (y2 - y1)
|
||||
area1 = box1[2] * box1[3]
|
||||
area2 = box2[2] * box2[3]
|
||||
union = area1 + area2 - intersection
|
||||
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def check_subtitle_safe_area(image_path: str, subtitle_box: list = None, interactive: bool = False) -> dict:
|
||||
"""
|
||||
检查字幕安全区域
|
||||
|
||||
:param image_path: 图片路径
|
||||
:param subtitle_box: 字幕框 [x,y,w,h](如果为 None,自动检测)
|
||||
:param interactive: 是否交互式标注
|
||||
:return: 质检报告字典
|
||||
"""
|
||||
# 读取图片
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
print(f"[ERROR] 无法读取图片:{image_path}")
|
||||
return {}
|
||||
|
||||
height, width = img.shape[:2]
|
||||
print(f"[INFO] 检查图片:{image_path} ({width}x{height})")
|
||||
|
||||
# 检测字幕框
|
||||
if subtitle_box is None:
|
||||
if interactive:
|
||||
print("[INFO] 交互式标注模式:请手动框选字幕区域")
|
||||
roi = cv2.selectROI("Select Subtitle Region", img, showCrosshair=True)
|
||||
cv2.destroyAllWindows()
|
||||
subtitle_box = [int(roi[0]), int(roi[1]), int(roi[2]), int(roi[3])]
|
||||
else:
|
||||
subtitle_box = detect_subtitle_box(img)
|
||||
|
||||
print(f"[INFO] 字幕框:x={subtitle_box[0]}, y={subtitle_box[1]}, w={subtitle_box[2]}, h={subtitle_box[3]}")
|
||||
|
||||
# 检测人脸
|
||||
print("[INFO] 检测人脸...")
|
||||
face_boxes = detect_faces(img)
|
||||
print(f"[INFO] 找到 {len(face_boxes)} 个人脸")
|
||||
|
||||
# 检测身体
|
||||
print("[INFO] 检测身体...")
|
||||
body_boxes = detect_body(img)
|
||||
print(f"[INFO] 找到 {len(body_boxes)} 个身体区域")
|
||||
|
||||
# 检查问题
|
||||
issues = []
|
||||
|
||||
# 1. 检查是否遮挡脸部
|
||||
for i, face_box in enumerate(face_boxes):
|
||||
iou = calculate_iou(subtitle_box, face_box)
|
||||
if iou > 0.1: # IOU > 10% 认为有遮挡
|
||||
severity = "high" if iou > 0.5 else "medium"
|
||||
issues.append({
|
||||
"type": "face_occlusion",
|
||||
"severity": severity,
|
||||
"bbox": face_box,
|
||||
"iou": round(iou, 2),
|
||||
"message": f"字幕遮挡脸部(IOU={iou:.2f})"
|
||||
})
|
||||
print(f"[ISSUE] 字幕遮挡脸部(IOU={iou:.2f})")
|
||||
|
||||
# 2. 检查是否遮挡身体
|
||||
for i, body_box in enumerate(body_boxes):
|
||||
iou = calculate_iou(subtitle_box, body_box)
|
||||
if iou > 0.2: # IOU > 20% 认为有遮挡
|
||||
severity = "high" if iou > 0.6 else "medium"
|
||||
issues.append({
|
||||
"type": "body_occlusion",
|
||||
"severity": severity,
|
||||
"bbox": body_box,
|
||||
"iou": round(iou, 2),
|
||||
"message": f"字幕遮挡身体(IOU={iou:.2f})"
|
||||
})
|
||||
print(f"[ISSUE] 字幕遮挡身体(IOU={iou:.2f})")
|
||||
|
||||
# 3. 检查是否贴边
|
||||
edge_threshold = 20 # 像素
|
||||
if subtitle_box[0] < edge_threshold:
|
||||
issues.append({
|
||||
"type": "edge_too_close",
|
||||
"severity": "low",
|
||||
"distance": subtitle_box[0],
|
||||
"message": f"字幕距离左边缘太近({subtitle_box[0]}px)"
|
||||
})
|
||||
print(f"[ISSUE] 字幕距离左边缘太近({subtitle_box[0]}px)")
|
||||
|
||||
if (subtitle_box[0] + subtitle_box[2]) > (width - edge_threshold):
|
||||
distance = (subtitle_box[0] + subtitle_box[2]) - width
|
||||
issues.append({
|
||||
"type": "edge_too_close",
|
||||
"severity": "low",
|
||||
"distance": abs(distance),
|
||||
"message": f"字幕距离右边缘太近({abs(distance)}px)"
|
||||
})
|
||||
print(f"[ISSUE] 字幕距离右边缘太近({abs(distance)}px)")
|
||||
|
||||
# 4. 检查对比度(字幕是否看不清)
|
||||
# 简化方案:计算字幕区域和背景的平均亮度差
|
||||
subtitle_region = img[
|
||||
subtitle_box[1]:subtitle_box[1]+subtitle_box[3],
|
||||
subtitle_box[0]:subtitle_box[0]+subtitle_box[2]
|
||||
]
|
||||
|
||||
if subtitle_region.size > 0:
|
||||
# 计算字幕区域的平均亮度
|
||||
subtitle_gray = cv2.cvtColor(subtitle_region, cv2.COLOR_BGR2GRAY)
|
||||
subtitle_brightness = np.mean(subtitle_gray)
|
||||
|
||||
# 计算背景区域的平均亮度(字幕框上方的区域)
|
||||
background_region = img[
|
||||
max(0, subtitle_box[1]-subtitle_box[3]):subtitle_box[1],
|
||||
subtitle_box[0]:subtitle_box[0]+subtitle_box[2]
|
||||
]
|
||||
|
||||
if background_region.size > 0:
|
||||
background_gray = cv2.cvtColor(background_region, cv2.COLOR_BGR2GRAY)
|
||||
background_brightness = np.mean(background_gray)
|
||||
|
||||
# 计算对比度(亮度差)
|
||||
contrast = abs(subtitle_brightness - background_brightness)
|
||||
contrast_ratio = contrast / max(subtitle_brightness, background_brightness)
|
||||
|
||||
if contrast_ratio < 0.3: # 对比度 < 30% 认为看不清
|
||||
severity = "high" if contrast_ratio < 0.1 else "medium"
|
||||
issues.append({
|
||||
"type": "low_contrast",
|
||||
"severity": severity,
|
||||
"contrast_ratio": round(contrast_ratio, 2),
|
||||
"message": f"字幕对比度太低({contrast_ratio:.2f}),可能看不清"
|
||||
})
|
||||
print(f"[ISSUE] 字幕对比度太低({contrast_ratio:.2f}),可能看不清")
|
||||
|
||||
# 生成报告
|
||||
report = {
|
||||
"image_path": image_path,
|
||||
"video_width": width,
|
||||
"video_height": height,
|
||||
"subtitle_box": subtitle_box,
|
||||
"face_boxes": face_boxes,
|
||||
"body_boxes": body_boxes,
|
||||
"issues": issues,
|
||||
"safe": len(issues) == 0,
|
||||
"issue_count": len(issues)
|
||||
}
|
||||
|
||||
if report["safe"]:
|
||||
print(f"[OK] 字幕安全区域检查通过")
|
||||
else:
|
||||
print(f"[WARN] 发现 {len(issues)} 个问题")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def batch_check_video(video_path: str, ass_path: str, output_dir: str, sample_interval: float = 1.0):
|
||||
"""
|
||||
批量检查视频帧
|
||||
|
||||
:param video_path: 视频文件路径
|
||||
:param ass_path: ASS 字幕文件路径
|
||||
:param output_dir: 输出目录
|
||||
:param sample_interval: 采样间隔(秒)
|
||||
"""
|
||||
# 打开视频
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
print(f"[ERROR] 无法打开视频:{video_path}")
|
||||
return
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
print(f"[INFO] 视频信息:{total_frames} 帧,{fps} FPS")
|
||||
|
||||
# 创建输出目录
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
frames_dir = os.path.join(output_dir, "problem-frames/")
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
|
||||
# 读取 ASS 字幕文件(获取字幕时间点)
|
||||
# 简化方案:假设字幕在底部,直接检测
|
||||
# TODO: 解析 ASS 文件,获取准确的字幕时间点
|
||||
|
||||
reports = []
|
||||
frame_count = 0
|
||||
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
timestamp = frame_count / fps
|
||||
|
||||
# 按采样间隔检查
|
||||
if timestamp % sample_interval < (1.0 / fps):
|
||||
# 保存帧为临时文件
|
||||
temp_frame_path = os.path.join(output_dir, f"temp_frame_{frame_count:06d}.png")
|
||||
cv2.imwrite(temp_frame_path, frame)
|
||||
|
||||
# 检查字幕安全区域
|
||||
report = check_subtitle_safe_area(temp_frame_path)
|
||||
|
||||
if report and not report["safe"]:
|
||||
# 保存问题帧
|
||||
problem_frame_path = os.path.join(frames_dir, f"problem_{frame_count:06d}.png")
|
||||
cv2.imwrite(problem_frame_path, frame)
|
||||
|
||||
report["timestamp"] = timestamp
|
||||
report["problem_frame"] = problem_frame_path
|
||||
reports.append(report)
|
||||
|
||||
print(f"[WARN] 发现问题的帧:{timestamp:.2f}s")
|
||||
|
||||
# 删除临时文件
|
||||
os.remove(temp_frame_path)
|
||||
|
||||
frame_count += 1
|
||||
|
||||
cap.release()
|
||||
|
||||
# 保存报告
|
||||
report_path = os.path.join(output_dir, "qc-report.json")
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
json.dump(reports, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n[OK] 批量检查完成")
|
||||
print(f"[INFO] 共检查 {frame_count} 帧,发现 {len(reports)} 个有问题帧")
|
||||
print(f"[INFO] 报告已保存:{report_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Subtitle Safe Area QC · 字幕安全区域质检器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python subtitle-safe-area-qc.py --frame frame.png --subtitle-box 100,1750,880,120 --output qc-report.json
|
||||
python subtitle-safe-area-qc.py --video input.mp4 --subtitle-ass subtitles.ass --output qc-report.json
|
||||
python subtitle-safe-area-qc.py --frame frame.png --interactive
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--frame", help="单张帧图片路径")
|
||||
parser.add_argument("--video", help="视频文件路径(批量检查)")
|
||||
parser.add_argument("--subtitle-box", help="字幕框坐标 x,y,w,h(逗号分隔)")
|
||||
parser.add_argument("--subtitle-ass", help="ASS 字幕文件路径(用于批量检查)")
|
||||
parser.add_argument("--output", help="输出报告文件路径")
|
||||
parser.add_argument("--interactive", action="store_true", help="交互式标注模式")
|
||||
parser.add_argument("--generate-report", action="store_true", help="生成质检报告(包含问题帧截图)")
|
||||
parser.add_argument("--output-dir", help="输出目录(用于批量检查)")
|
||||
parser.add_argument("--sample-interval", type=float, default=1.0, help="采样间隔(秒,用于批量检查)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.frame:
|
||||
# 单张帧检查
|
||||
subtitle_box = None
|
||||
if args.subtitle_box:
|
||||
subtitle_box = [int(x) for x in args.subtitle_box.split(",")]
|
||||
|
||||
report = check_subtitle_safe_area(args.frame, subtitle_box, args.interactive)
|
||||
|
||||
if not report:
|
||||
sys.exit(1)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f"[OK] 质检报告已保存:{args.output}")
|
||||
|
||||
elif args.video:
|
||||
# 批量检查
|
||||
if not args.subtitle_ass:
|
||||
print("[ERROR] 批量检查需要指定 --subtitle-ass")
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = args.output_dir or "./qc-report/"
|
||||
batch_check_video(args.video, args.subtitle_ass, output_dir, args.sample_interval)
|
||||
|
||||
else:
|
||||
print("[ERROR] 请指定 --frame 或 --video")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ASS Subtitle Renderer · ASS字幕渲染器
|
||||
=====================================
|
||||
用 ASS/libass 正式渲染字幕,支持字体、粗体、描边、边距、对齐。
|
||||
不再用 Pillow PNG 凑合。
|
||||
|
||||
依赖:
|
||||
pip install pysrt
|
||||
# FFmpeg 需要编译时启用 libass 支持(通常默认启用)
|
||||
|
||||
用法:
|
||||
# 生成 ASS 字幕文件
|
||||
python ass-subtitle-renderer.py --srt input.srt --style reference-drama --output subtitles.ass
|
||||
|
||||
# 用 FFmpeg + libass 烧字幕到视频
|
||||
python ass-subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4 --style reference-drama
|
||||
|
||||
# 指定自定义样式配置
|
||||
python ass-subtitle-renderer.py --srt input.srt --config custom-style.json --output subtitles.ass
|
||||
|
||||
# 批量处理多集
|
||||
python ass-subtitle-renderer.py --batch ./srt/ --style reference-drama --output-dir ./ass/
|
||||
|
||||
输出:
|
||||
- ASS 字幕文件(可直接用 FFmpeg 烧录)
|
||||
- 烧录字幕后的视频(可选)
|
||||
|
||||
路径:
|
||||
video-ai-system/engines/subtitle-pipeline/reference-analysis/ass-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)
|
||||
|
||||
|
||||
# 默认样式配置(参考短剧风格)
|
||||
DEFAULT_STYLE = {
|
||||
"video_width": 1080,
|
||||
"video_height": 1920,
|
||||
"font_family": "PingFang SC",
|
||||
"font_size": 38,
|
||||
"font_color": "&HFFFFFF&", # 白色(ASS格式:&HBBGGRR&)
|
||||
"font_color_hex": "#FFFFFF",
|
||||
"bold": True,
|
||||
"italic": False,
|
||||
"underline": False,
|
||||
"strikeout": False,
|
||||
"stroke_enabled": True,
|
||||
"stroke_width": 2,
|
||||
"stroke_color": "&H000000&", # 黑色描边
|
||||
"stroke_opacity": 0.92, # &H40&(0-255,0=透明,255=不透明)
|
||||
"shadow_enabled": False,
|
||||
"shadow_depth": 0,
|
||||
"alignment": 2, # 2=中下
|
||||
"margin_left": 100,
|
||||
"margin_right": 100,
|
||||
"margin_vertical": 50,
|
||||
"line_spacing": 1.45,
|
||||
"letter_spacing": 0,
|
||||
"background_enabled": False,
|
||||
"fade_in_ms": 150,
|
||||
"fade_out_ms": 150,
|
||||
}
|
||||
|
||||
|
||||
def load_style_config(config_path: str = None, style_name: str = "reference-drama") -> dict:
|
||||
"""
|
||||
加载样式配置
|
||||
|
||||
:param config_path: 自定义配置文件路径
|
||||
:param style_name: 预设样式名称
|
||||
:return: 样式字典
|
||||
"""
|
||||
if config_path and os.path.isfile(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
print(f"[INFO] 已加载自定义样式配置:{config_path}")
|
||||
return config
|
||||
|
||||
# 尝试从 assets/subtitle-styles/ 加载预设样式
|
||||
style_file = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..", "..", "..", "assets", "subtitle-styles",
|
||||
f"subtitle-style.{style_name}.json"
|
||||
)
|
||||
style_file = os.path.abspath(style_file)
|
||||
|
||||
if os.path.isfile(style_file):
|
||||
with open(style_file, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
print(f"[INFO] 已加载预设样式:{style_name}")
|
||||
return config
|
||||
|
||||
print(f"[WARN] 未找到样式配置,使用默认样式")
|
||||
return DEFAULT_STYLE
|
||||
|
||||
|
||||
def srt_time_to_ass(srt_time) -> str:
|
||||
"""
|
||||
将 pysrt 时间对象转换为 ASS 时间戳格式
|
||||
|
||||
:param srt_time: pysrt 时间对象
|
||||
:return: ASS 时间戳字符串(H:MM:SS.cc)
|
||||
"""
|
||||
hours = srt_time.hours
|
||||
minutes = srt_time.minutes
|
||||
seconds = srt_time.seconds
|
||||
milliseconds = srt_time.milliseconds
|
||||
|
||||
# ASS 时间戳格式:H:MM:SS.cc(cc=厘秒,1/100秒)
|
||||
centiseconds = milliseconds // 10
|
||||
return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
|
||||
|
||||
|
||||
def generate_ass_file(srt_path: str, output_path: str, style: dict) -> bool:
|
||||
"""
|
||||
将 SRT 字幕转换为 ASS 格式
|
||||
|
||||
:param srt_path: SRT 文件路径
|
||||
:param output_path: 输出 ASS 文件路径
|
||||
:param style: 样式字典
|
||||
:return: 是否成功
|
||||
"""
|
||||
if not os.path.isfile(srt_path):
|
||||
print(f"[ERROR] SRT 文件不存在:{srt_path}")
|
||||
return False
|
||||
|
||||
# 加载 SRT
|
||||
try:
|
||||
subs = pysrt.open(srt_path, encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 加载 SRT 失败:{e}")
|
||||
return False
|
||||
|
||||
print(f"[INFO] 找到 {len(subs)} 条字幕,开始生成 ASS 文件...")
|
||||
|
||||
# 构建 ASS 文件内容
|
||||
ass_lines = []
|
||||
|
||||
# 1. [Script Info] 节
|
||||
ass_lines.append("[Script Info]")
|
||||
ass_lines.append("; Script generated by ASS Subtitle Renderer")
|
||||
ass_lines.append(f"PlayResX: {style.get('video_width', 1080)}")
|
||||
ass_lines.append(f"PlayResY: {style.get('video_height', 1920)}")
|
||||
ass_lines.append("Aspect Ratio: 9:16")
|
||||
ass_lines.append("")
|
||||
|
||||
# 2. [V4+ Styles] 节
|
||||
ass_lines.append("[V4+ Styles]")
|
||||
ass_lines.append(
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, "
|
||||
"Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
|
||||
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
|
||||
)
|
||||
|
||||
# 构建样式行
|
||||
font_name = style.get("font_family", "PingFang SC")
|
||||
font_size = style.get("font_size", 38)
|
||||
primary_colour = style.get("font_color", "&HFFFFFF&")
|
||||
outline_colour = style.get("stroke_color", "&H000000&")
|
||||
|
||||
# 背景色(如果启用背景)
|
||||
if style.get("background_enabled", False):
|
||||
bg_opacity = int(style.get("background_opacity", 0.6) * 255)
|
||||
back_colour = f"&H{bg_opacity:02X}000000&" # &HAAKKBBDD&
|
||||
else:
|
||||
back_colour = "&H00000000&" # 透明
|
||||
|
||||
bold = -1 if style.get("bold", True) else 0
|
||||
italic = -1 if style.get("italic", False) else 0
|
||||
underline = -1 if style.get("underline", False) else 0
|
||||
strikeout = -1 if style.get("strikeout", False) else 0
|
||||
|
||||
outline_width = style.get("stroke_width", 2) if style.get("stroke_enabled", True) else 0
|
||||
shadow_depth = style.get("shadow_depth", 0) if style.get("shadow_enabled", False) else 0
|
||||
|
||||
alignment = style.get("alignment", 2)
|
||||
margin_l = style.get("margin_left", 100)
|
||||
margin_r = style.get("margin_right", 100)
|
||||
margin_v = style.get("margin_vertical", 50)
|
||||
|
||||
style_line = (
|
||||
f"Style: Default,{font_name},{font_size},{primary_colour},&H000000FF&,{outline_colour},{back_colour},"
|
||||
f"{bold},{italic},{underline},{strikeout},100,100,{style.get('letter_spacing', 0)},0,"
|
||||
f"1,{outline_width},{shadow_depth},{alignment},{margin_l},{margin_r},{margin_v},1"
|
||||
)
|
||||
ass_lines.append(style_line)
|
||||
ass_lines.append("")
|
||||
|
||||
# 3. [Events] 节
|
||||
ass_lines.append("[Events]")
|
||||
ass_lines.append("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text")
|
||||
|
||||
for sub in subs:
|
||||
start_time = srt_time_to_ass(sub.start)
|
||||
end_time = srt_time_to_ass(sub.end)
|
||||
text = sub.text.strip()
|
||||
|
||||
# 添加淡入淡出效果(如果启用)
|
||||
fade_in = style.get("fade_in_ms", 150)
|
||||
fade_out = style.get("fade_out_ms", 150)
|
||||
|
||||
if fade_in > 0 and fade_out > 0:
|
||||
# ASS 淡入淡出标签:{\fad(fade_in,fade_out)}
|
||||
effect_tags = f"{{\\fad({fade_in},{fade_out})}}"
|
||||
else:
|
||||
effect_tags = ""
|
||||
|
||||
# 转义特殊字符(ASS 格式)
|
||||
text = text.replace("\\", "\\\\")
|
||||
text = text.replace("{", "\\{")
|
||||
text = text.replace("}", "\\}")
|
||||
text = text.replace("\n", "\\N") # ASS 换行符
|
||||
|
||||
# 构建对话行
|
||||
dialogue_line = (
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{effect_tags}{text}"
|
||||
)
|
||||
ass_lines.append(dialogue_line)
|
||||
|
||||
# 写入 ASS 文件
|
||||
try:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8-sig") as f: # UTF-8 BOM for ASS
|
||||
f.write("\n".join(ass_lines))
|
||||
|
||||
print(f"[OK] ASS 字幕文件已生成:{output_path}")
|
||||
print(f"[INFO] 共 {len(subs)} 条字幕")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 写入 ASS 文件失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def burn_subtitles_with_ffmpeg(video_path: str, ass_path: str, output_path: str) -> bool:
|
||||
"""
|
||||
用 FFmpeg + libass 烧录字幕到视频
|
||||
|
||||
:param video_path: 输入视频路径
|
||||
:param ass_path: ASS 字幕文件路径
|
||||
:param output_path: 输出视频路径
|
||||
:return: 是否成功
|
||||
"""
|
||||
if not os.path.isfile(video_path):
|
||||
print(f"[ERROR] 视频文件不存在:{video_path}")
|
||||
return False
|
||||
|
||||
if not os.path.isfile(ass_path):
|
||||
print(f"[ERROR] ASS 字幕文件不存在:{ass_path}")
|
||||
return False
|
||||
|
||||
# FFmpeg 命令:使用 libass 烧录字幕
|
||||
# -vf "ass=subtitles.ass" # 方法1:直接指定 ASS 文件
|
||||
# -vf "subtitles=subtitles.ass" # 方法2:使用 subtitles 滤镜(自动检测格式)
|
||||
|
||||
# 使用 subtitles 滤镜(更通用,支持 SRT/ASS 等多种格式)
|
||||
ass_path_escaped = ass_path.replace(":", "\\:").replace("'", "'\\''")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", video_path,
|
||||
"-vf", f"subtitles='{ass_path_escaped}'",
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "copy",
|
||||
"-shortest",
|
||||
output_path
|
||||
]
|
||||
|
||||
print(f"[INFO] 开始用 FFmpeg + libass 烧录字幕...")
|
||||
print(f"[INFO] 命令:{' '.join(cmd[:10])}...") # 只打印前10个参数
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
||||
except FileNotFoundError:
|
||||
print("[ERROR] 未找到 ffmpeg,请先安装 FFmpeg")
|
||||
print("安装方法:")
|
||||
print(" macOS: brew install ffmpeg")
|
||||
print(" Ubuntu: sudo apt install ffmpeg")
|
||||
return False
|
||||
|
||||
if result.returncode != 0:
|
||||
print("[ERROR] FFmpeg 烧录字幕失败")
|
||||
print(result.stderr[-2000:]) # 打印最后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 batch_process(srt_dir: str, output_dir: str, style: dict, burn_video: bool = False, video_dir: str = None):
|
||||
"""
|
||||
批量处理 SRT 文件
|
||||
|
||||
:param srt_dir: SRT 文件目录
|
||||
:param output_dir: 输出目录
|
||||
:param style: 样式字典
|
||||
:param burn_video: 是否烧录到视频
|
||||
:param video_dir: 视频文件目录(如果 burn_video=True)
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
for file in os.listdir(srt_dir):
|
||||
if file.lower().endswith(".srt"):
|
||||
srt_path = os.path.join(srt_dir, file)
|
||||
ass_output = os.path.join(output_dir, file.replace(".srt", ".ass"))
|
||||
|
||||
print(f"\n[INFO] 处理文件:{file}")
|
||||
ok = generate_ass_file(srt_path, ass_output, style)
|
||||
|
||||
if ok and burn_video and video_dir:
|
||||
# 查找对应的视频文件
|
||||
video_file = file.replace(".srt", ".mp4")
|
||||
video_path = os.path.join(video_dir, video_file)
|
||||
|
||||
if os.path.isfile(video_path):
|
||||
video_output = os.path.join(output_dir, video_file)
|
||||
burn_subtitles_with_ffmpeg(video_path, ass_output, video_output)
|
||||
else:
|
||||
print(f"[WARN] 未找到对应的视频文件:{video_file}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ASS Subtitle Renderer · ASS字幕渲染器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python ass-subtitle-renderer.py --srt input.srt --style reference-drama --output subtitles.ass
|
||||
python ass-subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4 --style reference-drama
|
||||
python ass-subtitle-renderer.py --batch ./srt/ --style reference-drama --output-dir ./ass/
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--srt", help="输入 SRT 文件路径")
|
||||
parser.add_argument("--video", help="输入视频路径(可选,用于直接烧录字幕)")
|
||||
parser.add_argument("--output", help="输出文件路径(ASS 或 MP4)")
|
||||
parser.add_argument("--style", default="reference-drama", help="样式名称或配置文件路径")
|
||||
parser.add_argument("--config", help="自定义样式配置文件路径(覆盖 --style)")
|
||||
parser.add_argument("--batch", help="批量处理 SRT 文件目录")
|
||||
parser.add_argument("--output-dir", help="批量处理输出目录")
|
||||
parser.add_argument("--burn-video", action="store_true", help="是否烧录字幕到视频")
|
||||
parser.add_argument("--video-dir", help="视频文件目录(配合 --burn-video 使用)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 加载样式配置
|
||||
if args.config:
|
||||
style = load_style_config(config_path=args.config)
|
||||
else:
|
||||
style = load_style_config(style_name=args.style)
|
||||
|
||||
if args.srt:
|
||||
# 单文件处理
|
||||
if not args.output:
|
||||
print("[ERROR] 请指定 --output")
|
||||
sys.exit(1)
|
||||
|
||||
# 生成 ASS 文件
|
||||
ok = generate_ass_file(args.srt, args.output, style)
|
||||
if not ok:
|
||||
sys.exit(1)
|
||||
|
||||
# 如果指定了视频,则烧录字幕
|
||||
if args.video:
|
||||
video_output = args.output.replace(".ass", ".mp4")
|
||||
burn_ok = burn_subtitles_with_ffmpeg(args.video, args.output, video_output)
|
||||
if not burn_ok:
|
||||
sys.exit(1)
|
||||
|
||||
elif args.batch:
|
||||
# 批量处理
|
||||
if not args.output_dir:
|
||||
print("[ERROR] 批量处理需要指定 --output-dir")
|
||||
sys.exit(1)
|
||||
|
||||
batch_process(args.batch, args.output_dir, style, args.burn_video, args.video_dir)
|
||||
|
||||
else:
|
||||
print("[ERROR] 请指定 --srt 或 --batch")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n[OK] 处理完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reference Subtitle Analyzer · 样片字幕量化分析器
|
||||
==============================================
|
||||
从样片截图里量字幕:字高、底部距离、描边宽度、字幕框位置、字号比例。
|
||||
|
||||
依赖:
|
||||
pip install opencv-python pillow numpy
|
||||
|
||||
用法:
|
||||
# 分析单张截图
|
||||
python reference-subtitle-analyzer.py --image reference-frame.png --output analysis.json
|
||||
|
||||
# 批量分析样片截图
|
||||
python reference-subtitle-analyzer.py --batch ./reference-frames/ --output batch-analysis.json
|
||||
|
||||
# 交互式标注模式(手动框选字幕区域)
|
||||
python reference-subtitle-analyzer.py --image reference-frame.png --interactive
|
||||
|
||||
输出 JSON 格式:
|
||||
{
|
||||
"video_height": 1920,
|
||||
"video_width": 1080,
|
||||
"subtitle_box": {
|
||||
"x": 100,
|
||||
"y": 1750,
|
||||
"width": 880,
|
||||
"height": 120
|
||||
},
|
||||
"subtitle_height_px": 120,
|
||||
"bottom_distance_px": 50,
|
||||
"bottom_distance_ratio": 0.026,
|
||||
"font_size_ratio": 0.0625,
|
||||
"stroke_width_px": 2,
|
||||
"alignment": "center",
|
||||
"margin_horizontal_px": 100
|
||||
}
|
||||
|
||||
路径:
|
||||
video-ai-system/engines/subtitle-pipeline/reference-analysis/reference-subtitle-analyzer.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
except ImportError as e:
|
||||
print(f"[ERROR] 缺少依赖:{e}")
|
||||
print("请先安装:pip install opencv-python pillow numpy")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def analyze_subtitle_region(image_path: str, interactive: bool = False) -> dict:
|
||||
"""
|
||||
分析字幕区域,量化字幕参数
|
||||
|
||||
:param image_path: 截图路径
|
||||
:param interactive: 是否交互式标注(手动框选字幕区域)
|
||||
:return: 字幕参数字典
|
||||
"""
|
||||
# 读取图片
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
print(f"[ERROR] 无法读取图片:{image_path}")
|
||||
return {}
|
||||
|
||||
height, width = img.shape[:2]
|
||||
print(f"[INFO] 图片尺寸:{width}x{height}")
|
||||
|
||||
if interactive:
|
||||
# 交互式标注模式:手动框选字幕区域
|
||||
print("[INFO] 交互式标注模式:请在弹出的窗口中用鼠标框选字幕区域")
|
||||
print("[INFO] 框选完成后按空格或回车确认,按ESC取消")
|
||||
|
||||
roi = cv2.selectROI("Select Subtitle Region", img, showCrosshair=True)
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
x, y, w, h = int(roi[0]), int(roi[1]), int(roi[2]), int(roi[3])
|
||||
if w == 0 or h == 0:
|
||||
print("[ERROR] 未框选字幕区域")
|
||||
return {}
|
||||
else:
|
||||
# 自动检测字幕区域(假设字幕在底部)
|
||||
# 策略:检测底部区域的文本(通过边缘检测 + 轮廓查找)
|
||||
print("[INFO] 自动检测字幕区域...")
|
||||
|
||||
# 1. 裁剪底部区域(假设字幕在底部 15% 区域)
|
||||
bottom_region = img[int(height * 0.85):, :]
|
||||
|
||||
# 2. 转为灰度图
|
||||
gray = cv2.cvtColor(bottom_region, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# 3. 二值化(检测白色文本)
|
||||
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# 4. 查找轮廓
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
if not contours:
|
||||
print("[WARN] 未检测到字幕区域,使用默认底部区域")
|
||||
# 使用默认底部区域
|
||||
x = int(width * 0.1)
|
||||
y = int(height * 0.85)
|
||||
w = int(width * 0.8)
|
||||
h = int(height * 0.12)
|
||||
else:
|
||||
# 合并所有轮廓的边界框
|
||||
all_x = []
|
||||
all_y = []
|
||||
all_w = []
|
||||
all_h = []
|
||||
|
||||
for cnt in contours:
|
||||
cx, cy, cw, ch = cv2.boundingRect(cnt)
|
||||
all_x.append(cx)
|
||||
all_y.append(cy)
|
||||
all_w.append(cw)
|
||||
all_h.append(ch)
|
||||
|
||||
# 计算合并后的边界框
|
||||
x = min(all_x)
|
||||
y = min(all_y) + int(height * 0.85) # 加上裁剪偏移
|
||||
w = max(all_x) + max(all_w) - x
|
||||
h = max(all_y) + max(all_h) - y
|
||||
|
||||
# 扩展边界框(包含描边)
|
||||
padding = 10
|
||||
x = max(0, x - padding)
|
||||
y = max(0, y - padding)
|
||||
w = min(width - x, w + 2 * padding)
|
||||
h = min(height - y, h + 2 * padding)
|
||||
|
||||
# 计算字幕参数
|
||||
subtitle_box = {"x": x, "y": y, "width": w, "height": h}
|
||||
subtitle_height_px = h
|
||||
bottom_distance_px = height - (y + h)
|
||||
bottom_distance_ratio = bottom_distance_px / height
|
||||
font_size_ratio = h / height
|
||||
|
||||
# 估算描边宽度(通过检测文本边缘的黑色像素)
|
||||
stroke_width_px = estimate_stroke_width(img, x, y, w, h)
|
||||
|
||||
# 判断对齐方式(居中/左对齐/右对齐)
|
||||
alignment = "center" if abs(x + w/2 - width/2) < width * 0.1 else "left" if x < width * 0.3 else "right"
|
||||
|
||||
# 计算水平边距
|
||||
margin_horizontal_px = x
|
||||
|
||||
result = {
|
||||
"image_path": image_path,
|
||||
"video_width": width,
|
||||
"video_height": height,
|
||||
"subtitle_box": subtitle_box,
|
||||
"subtitle_height_px": subtitle_height_px,
|
||||
"bottom_distance_px": bottom_distance_px,
|
||||
"bottom_distance_ratio": round(bottom_distance_ratio, 4),
|
||||
"font_size_ratio": round(font_size_ratio, 4),
|
||||
"stroke_width_px": stroke_width_px,
|
||||
"alignment": alignment,
|
||||
"margin_horizontal_px": margin_horizontal_px
|
||||
}
|
||||
|
||||
print(f"[OK] 字幕参数已量化:")
|
||||
print(f" 字幕框:x={x}, y={y}, w={w}, h={h}")
|
||||
print(f" 字高:{subtitle_height_px}px")
|
||||
print(f" 底部距离:{bottom_distance_px}px ({bottom_distance_ratio*100:.1f}%)")
|
||||
print(f" 字号比例:{font_size_ratio*100:.1f}%")
|
||||
print(f" 描边宽度:{stroke_width_px}px")
|
||||
print(f" 对齐方式:{alignment}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def estimate_stroke_width(img: np.ndarray, x: int, y: int, w: int, h: int) -> int:
|
||||
"""
|
||||
估算描边宽度(通过检测文本边缘的黑色像素)
|
||||
|
||||
:param img: 图片数组
|
||||
:param x: 字幕框 x 坐标
|
||||
:param y: 字幕框 y 坐标
|
||||
:param w: 字幕框宽度
|
||||
:param h: 字幕框高度
|
||||
:return: 估算的描边宽度(像素)
|
||||
"""
|
||||
# 裁剪字幕区域
|
||||
subtitle_region = img[y:y+h, x:x+w]
|
||||
|
||||
# 转为灰度图
|
||||
gray = cv2.cvtColor(subtitle_region, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# 检测边缘(Canny)
|
||||
edges = cv2.Canny(gray, 100, 200)
|
||||
|
||||
# 统计边缘像素到文本区域的距离(估算描边宽度)
|
||||
# 简化方案:假设描边是黑色,检测白色文本周围的黑色像素环
|
||||
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# 形态学操作:膨胀(模拟描边)
|
||||
kernel = np.ones((3, 3), np.uint8)
|
||||
dilated = cv2.dilate(binary, kernel, iterations=1)
|
||||
|
||||
# 计算膨胀后的边缘宽度
|
||||
edge_width = cv2.absdiff(dilated, binary)
|
||||
stroke_pixels = cv2.countNonZero(edge_width)
|
||||
|
||||
# 估算平均描边宽度
|
||||
if stroke_pixels > 0:
|
||||
# 简化:假设描边宽度是 1-3px
|
||||
return 2 # 默认值,实际应通过更精确的算法计算
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def batch_analyze(images_dir: str) -> dict:
|
||||
"""
|
||||
批量分析样片截图
|
||||
|
||||
:param images_dir: 截图目录
|
||||
:return: 批量分析结果
|
||||
"""
|
||||
results = {}
|
||||
image_extensions = [".jpg", ".jpeg", ".png", ".bmp"]
|
||||
|
||||
for file in os.listdir(images_dir):
|
||||
if any(file.lower().endswith(ext) for ext in image_extensions):
|
||||
image_path = os.path.join(images_dir, file)
|
||||
print(f"\n[INFO] 分析图片:{file}")
|
||||
result = analyze_subtitle_region(image_path)
|
||||
if result:
|
||||
results[file] = result
|
||||
|
||||
# 计算平均值
|
||||
if results:
|
||||
avg_result = calculate_average(results)
|
||||
results["_average"] = avg_result
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def calculate_average(results: dict) -> dict:
|
||||
"""
|
||||
计算批量分析结果的平均值
|
||||
|
||||
:param results: 批量分析结果
|
||||
:return: 平均值字典
|
||||
"""
|
||||
keys = ["subtitle_height_px", "bottom_distance_px", "bottom_distance_ratio",
|
||||
"font_size_ratio", "stroke_width_px", "margin_horizontal_px"]
|
||||
|
||||
avg = {}
|
||||
for key in keys:
|
||||
values = [r[key] for r in results.values() if key in r]
|
||||
if values:
|
||||
avg[key] = round(sum(values) / len(values), 2)
|
||||
|
||||
avg["alignment"] = max(set(r["alignment"] for r in results.values()),
|
||||
key=lambda x: sum(1 for r in results.values() if r["alignment"] == x))
|
||||
|
||||
return avg
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Reference Subtitle Analyzer · 样片字幕量化分析器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python reference-subtitle-analyzer.py --image reference-frame.png --output analysis.json
|
||||
python reference-subtitle-analyzer.py --batch ./reference-frames/ --output batch-analysis.json
|
||||
python reference-subtitle-analyzer.py --image reference-frame.png --interactive
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--image", help="单张截图路径")
|
||||
parser.add_argument("--batch", help="批量分析截图目录")
|
||||
parser.add_argument("--output", required=True, help="输出 JSON 文件路径")
|
||||
parser.add_argument("--interactive", action="store_true", help="交互式标注模式")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.image:
|
||||
result = analyze_subtitle_region(args.image, args.interactive)
|
||||
if not result:
|
||||
sys.exit(1)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n[OK] 分析结果已保存:{args.output}")
|
||||
|
||||
elif args.batch:
|
||||
results = batch_analyze(args.batch)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n[OK] 批量分析结果已保存:{args.output}")
|
||||
if "_average" in results:
|
||||
print(f"[INFO] 平均字幕参数:{results['_average']}")
|
||||
|
||||
else:
|
||||
print("[ERROR] 请指定 --image 或 --batch")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to SRT with Timing · 剧本到 SRT 时间轴生成器
|
||||
===============================================
|
||||
用剧本台词和配音时长生成正式 SRT,别手写测试时间轴。
|
||||
|
||||
功能:
|
||||
1. 读取剧本台词(JSON/MD/TXT)
|
||||
2. 读取配音文件(获取每条台词的实际时长)
|
||||
3. 自动计算时间轴(考虑角色对话间隔)
|
||||
4. 生成标准 SRT 字幕文件
|
||||
5. 支持多角色、旁白、音效标注
|
||||
|
||||
依赖:
|
||||
pip install pysrt
|
||||
|
||||
输入格式:
|
||||
# 剧本 JSON 格式(推荐)
|
||||
[
|
||||
{"id": 1, "character": "苏白", "text": "付费才能修仙?", "audio_file": "./audio/subai_001.wav"},
|
||||
{"id": 2, "character": "旁白", "text": "天道宗门口", "audio_file": "./audio/narrator_001.wav"}
|
||||
]
|
||||
|
||||
# 剧本 MD 格式(兼容)
|
||||
## E1-SHOT01
|
||||
**苏白**:付费才能修仙?(audio: subai_001.wav)
|
||||
**旁白**:天道宗门口(audio: narrator_001.wav)
|
||||
|
||||
输出 SRT 格式:
|
||||
1
|
||||
00:00:01,000 --> 00:00:03,500
|
||||
付费才能修仙?
|
||||
|
||||
2
|
||||
00:00:03,800 --> 00:00:05,200
|
||||
天道宗门口
|
||||
|
||||
用法:
|
||||
# 从剧本 JSON + 配音文件生成 SRT
|
||||
python script-to-srt-with-timing.py --script script.json --output subtitles.srt
|
||||
|
||||
# 指定配音目录(自动匹配 audio_file)
|
||||
python script-to-srt-with-timing.py --script script.json --audio-dir ./audio/ --output subtitles.srt
|
||||
|
||||
# 从 MD 剧本生成
|
||||
python script-to-srt-with-timing.py --script script.md --output subtitles.srt
|
||||
|
||||
# 自定义对话间隔(秒)
|
||||
python script-to-srt-with-timing.py --script script.json --gap 0.3 --output subtitles.srt
|
||||
|
||||
路径:
|
||||
video-ai-system/engines/subtitle-pipeline/srt-tools/script-to-srt-with-timing.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pysrt
|
||||
except ImportError:
|
||||
print("[ERROR] 缺少依赖:pysrt")
|
||||
print("请先安装:pip install pysrt")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_script_json(script_path: str) -> list:
|
||||
"""
|
||||
解析剧本 JSON 文件
|
||||
|
||||
:param script_path: 剧本 JSON 文件路径
|
||||
:return: 台词列表 [{"id", "character", "text", "audio_file", "start", "end"}]
|
||||
"""
|
||||
with open(script_path, "r", encoding="utf-8") as f:
|
||||
script = json.load(f)
|
||||
|
||||
print(f"[INFO] 读取剧本 JSON:{len(script)} 条台词")
|
||||
return script
|
||||
|
||||
|
||||
def parse_script_md(script_path: str) -> list:
|
||||
"""
|
||||
解析剧本 MD 文件(兼容格式)
|
||||
|
||||
:param script_path: 剧本 MD 文件路径
|
||||
:return: 台词列表
|
||||
"""
|
||||
with open(script_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
script = []
|
||||
current_id = 0
|
||||
|
||||
for line in lines:
|
||||
# 匹配:**角色**:台词(audio: file.wav)
|
||||
match = re.match(r"\*\*(.+?)\*\*[::]\s*(.+?)\s*\(audio:\s*(.+?)\)", line.strip())
|
||||
if match:
|
||||
character = match.group(1).strip()
|
||||
text = match.group(2).strip()
|
||||
audio_file = match.group(3).strip()
|
||||
|
||||
script.append({
|
||||
"id": current_id + 1,
|
||||
"character": character,
|
||||
"text": text,
|
||||
"audio_file": audio_file
|
||||
})
|
||||
current_id += 1
|
||||
|
||||
print(f"[INFO] 读取剧本 MD:{len(script)} 条台词")
|
||||
return script
|
||||
|
||||
|
||||
def get_audio_duration(audio_path: str) -> float:
|
||||
"""
|
||||
获取音频文件时长(秒)
|
||||
|
||||
:param audio_path: 音频文件路径
|
||||
:return: 时长(秒)
|
||||
"""
|
||||
try:
|
||||
import ffmpeg
|
||||
probe = ffmpeg.probe(audio_path)
|
||||
duration = float(probe['format']['duration'])
|
||||
return duration
|
||||
except ImportError:
|
||||
# 如果没有 ffmpeg-python,使用 ffprobe 命令行
|
||||
try:
|
||||
import subprocess
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", "-show_streams", audio_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
probe = json.loads(result.stdout)
|
||||
duration = float(probe['format']['duration'])
|
||||
return duration
|
||||
except Exception as e:
|
||||
print(f"[WARN] 无法获取音频时长:{audio_path} ({e})")
|
||||
# 估算:中文普通话约 4-6 字/秒
|
||||
return 2.0 # 默认 2 秒
|
||||
except Exception as e:
|
||||
print(f"[WARN] 无法获取音频时长:{audio_path} ({e})")
|
||||
return 2.0
|
||||
|
||||
|
||||
def calculate_timings(script: list, audio_dir: str = None, gap: float = 0.2) -> list:
|
||||
"""
|
||||
计算时间轴
|
||||
|
||||
:param script: 台词列表
|
||||
:param audio_dir: 配音文件目录(如果 audio_file 是相对路径)
|
||||
:param gap: 对话间隔(秒)
|
||||
:return: 带时间轴的台词列表
|
||||
"""
|
||||
current_time = 0.0
|
||||
|
||||
for item in script:
|
||||
# 获取配音文件时长
|
||||
audio_file = item.get("audio_file")
|
||||
|
||||
if audio_file and os.path.isfile(audio_file):
|
||||
duration = get_audio_duration(audio_file)
|
||||
elif audio_file and audio_dir:
|
||||
audio_path = os.path.join(audio_dir, audio_file)
|
||||
if os.path.isfile(audio_path):
|
||||
duration = get_audio_duration(audio_path)
|
||||
else:
|
||||
# 估算时长
|
||||
text = item.get("text", "")
|
||||
duration = len(text) / 5.0 # 假设 5 字/秒
|
||||
else:
|
||||
# 没有配音文件,估算时长
|
||||
text = item.get("text", "")
|
||||
duration = len(text) / 5.0 # 假设 5 字/秒
|
||||
|
||||
# 设置开始和结束时间
|
||||
item["start"] = current_time
|
||||
item["end"] = current_time + duration
|
||||
|
||||
# 更新当前时间(加上间隔)
|
||||
current_time = item["end"] + gap
|
||||
|
||||
print(f"[INFO] 时间轴计算完成:总时长 {current_time:.2f} 秒")
|
||||
return script
|
||||
|
||||
|
||||
def generate_srt(script: list, output_path: str):
|
||||
"""
|
||||
生成 SRT 字幕文件
|
||||
|
||||
:param script: 带时间轴的台词列表
|
||||
:param output_path: 输出 SRT 文件路径
|
||||
"""
|
||||
# 创建 pysrt SubRipFile
|
||||
subs = pysrt.SubRipFile()
|
||||
|
||||
for item in script:
|
||||
# 创建字幕项
|
||||
sub = pysrt.SubRipItem()
|
||||
sub.index = item["id"]
|
||||
sub.text = item["text"]
|
||||
|
||||
# 设置时间
|
||||
start_seconds = item["start"]
|
||||
end_seconds = item["end"]
|
||||
|
||||
sub.start.hours = int(start_seconds // 3600)
|
||||
sub.start.minutes = int((start_seconds % 3600) // 60)
|
||||
sub.start.seconds = int(start_seconds % 60)
|
||||
sub.start.milliseconds = int((start_seconds % 1) * 1000)
|
||||
|
||||
sub.end.hours = int(end_seconds // 3600)
|
||||
sub.end.minutes = int((end_seconds % 3600) // 60)
|
||||
sub.end.seconds = int(end_seconds % 60)
|
||||
sub.end.milliseconds = int((end_seconds % 1) * 1000)
|
||||
|
||||
subs.append(sub)
|
||||
|
||||
# 保存 SRT 文件
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
subs.save(output_path, encoding="utf-8")
|
||||
|
||||
print(f"[OK] SRT 字幕文件已生成:{output_path}")
|
||||
print(f"[INFO] 共 {len(script)} 条字幕")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Script to SRT with Timing · 剧本到 SRT 时间轴生成器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python script-to-srt-with-timing.py --script script.json --output subtitles.srt
|
||||
python script-to-srt-with-timing.py --script script.md --audio-dir ./audio/ --output subtitles.srt
|
||||
python script-to-srt-with-timing.py --script script.json --gap 0.3 --output subtitles.srt
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--script", required=True, help="剧本文件路径(JSON 或 MD 格式)")
|
||||
parser.add_argument("--audio-dir", help="配音文件目录(如果 audio_file 是相对路径)")
|
||||
parser.add_argument("--output", required=True, help="输出 SRT 文件路径")
|
||||
parser.add_argument("--gap", type=float, default=0.2, help="对话间隔(秒,默认 0.2)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 检查剧本文件是否存在
|
||||
if not os.path.isfile(args.script):
|
||||
print(f"[ERROR] 剧本文件不存在:{args.script}")
|
||||
sys.exit(1)
|
||||
|
||||
# 解析剧本
|
||||
if args.script.lower().endswith(".json"):
|
||||
script = parse_script_json(args.script)
|
||||
elif args.script.lower().endswith(".md"):
|
||||
script = parse_script_md(args.script)
|
||||
else:
|
||||
print(f"[ERROR] 不支持的剧本格式:{args.script}")
|
||||
print("支持格式:.json, .md")
|
||||
sys.exit(1)
|
||||
|
||||
if not script:
|
||||
print("[ERROR] 剧本为空,请检查格式")
|
||||
sys.exit(1)
|
||||
|
||||
# 计算时间轴
|
||||
script = calculate_timings(script, args.audio_dir, args.gap)
|
||||
|
||||
# 生成 SRT 文件
|
||||
generate_srt(script, args.output)
|
||||
|
||||
print("\n[OK] 处理完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user