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

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

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

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

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

354 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

#!/usr/bin/env python3
"""
Subtitle 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()