diff --git a/video-ai-system/assets/subtitle-styles/subtitle-style.reference-drama.json b/video-ai-system/assets/subtitle-styles/subtitle-style.reference-drama.json new file mode 100644 index 0000000..6c5ae5f --- /dev/null +++ b/video-ai-system/assets/subtitle-styles/subtitle-style.reference-drama.json @@ -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: ,,,") + 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() diff --git a/video-ai-system/engines/subtitle-pipeline/qc-tools/subtitle-safe-area-qc.py b/video-ai-system/engines/subtitle-pipeline/qc-tools/subtitle-safe-area-qc.py new file mode 100644 index 0000000..d79e74b --- /dev/null +++ b/video-ai-system/engines/subtitle-pipeline/qc-tools/subtitle-safe-area-qc.py @@ -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() diff --git a/video-ai-system/engines/subtitle-pipeline/reference-analysis/ass-subtitle-renderer.py b/video-ai-system/engines/subtitle-pipeline/reference-analysis/ass-subtitle-renderer.py new file mode 100644 index 0000000..25d0f29 --- /dev/null +++ b/video-ai-system/engines/subtitle-pipeline/reference-analysis/ass-subtitle-renderer.py @@ -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() diff --git a/video-ai-system/engines/subtitle-pipeline/reference-analysis/reference-subtitle-analyzer.py b/video-ai-system/engines/subtitle-pipeline/reference-analysis/reference-subtitle-analyzer.py new file mode 100644 index 0000000..68052ec --- /dev/null +++ b/video-ai-system/engines/subtitle-pipeline/reference-analysis/reference-subtitle-analyzer.py @@ -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() diff --git a/video-ai-system/engines/subtitle-pipeline/srt-tools/script-to-srt-with-timing.py b/video-ai-system/engines/subtitle-pipeline/srt-tools/script-to-srt-with-timing.py new file mode 100644 index 0000000..8af5074 --- /dev/null +++ b/video-ai-system/engines/subtitle-pipeline/srt-tools/script-to-srt-with-timing.py @@ -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()