#!/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("") html_lines.append("") html_lines.append("") html_lines.append(" ") html_lines.append(" 字幕预览检查图") html_lines.append(" ") html_lines.append("") html_lines.append("") html_lines.append("

字幕预览检查图

") if video_path: html_lines.append(f"

视频:{os.path.basename(video_path)}

") html_lines.append(f"

共 {len(frame_paths)} 帧

") html_lines.append("
") 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("
") html_lines.append(f" ") html_lines.append(f"
{timestamp}
") html_lines.append("
") html_lines.append("
") html_lines.append("") html_lines.append("") # 写入文件 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()