# -*- coding: utf-8 -*-
"""
视频双路拆分 - P1 核心模块
=================================================
功能:
  A 路:视频帧 → 空白帧过滤 → 哈希变化检测 → OCR → B 稿 txt
  B 路:视频 → 16kHz/单声道/16bit WAV

不引入 ffmpeg-python 等 wrapper,只用 subprocess 调系统 ffmpeg。
"""

import csv
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Dict, List, Tuple, Optional

import numpy as np
from PIL import Image
import imagehash


# ========================================================================
# 凭证(从环境变量读取,供 OCR 调用 DeepSeek Vision)
# ========================================================================

DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()


# ========================================================================
# 字幕裁切参数
# ========================================================================

# 字幕裁切区域:全宽 × 下方 20%
# 适用于《军事科技》栏目特殊视频(黑底白字字幕,位置固定)
# 全年 52 期一致,如换栏目需重新评估此参数
SUBTITLE_CROP = "iw:ih*0.2:0:ih*0.8"


# ========================================================================
# 帧过滤参数
# ========================================================================

# 空白帧检测(黑底白字场景专用)
# 亮度阈值:0-255,>=240 视为接近纯白的"白像素"
# 从 200 提到 240(2026-06-12):过滤视频压缩产生的灰白噪点(亮度 200-230),
# 同时保留真实字幕白字(亮度接近 255)
BLANK_FRAME_BRIGHTNESS_THRESHOLD = 240
# 白色像素占比阈值:< 0.5% 则判定为空白帧(留气口黑画面)
BLANK_FRAME_WHITE_PIXEL_RATIO = 0.005

# 哈希算法参数
# 可选: "phash"(感知哈希,默认) / "dhash"(差异哈希,对边缘更敏感)
HASH_ALGORITHM = "dhash"
# pHash 阈值:海明距离 > 此值才算有变化
PHASH_THRESHOLD = 2
# dHash 阈值:差异位 > 此值才算有变化
DHASH_THRESHOLD = 5
# IoU 保底阈值:二值化帧间 IoU > 此值视为同字幕,跳过
IOU_THRESHOLD = 0.95


# ========================================================================
# FFmpeg 封装
# ========================================================================


def check_ffmpeg():
    """检查 ffmpeg 是否在 PATH 中"""
    result = shutil.which("ffmpeg")
    if result is None:
        raise RuntimeError(
            "ffmpeg 未找到,请先安装 ffmpeg 并加入 PATH。"
            "下载地址: https://ffmpeg.org/download.html"
        )
    return result


def extract_frames(
    video_path: Path,
    output_dir: Path,
    fps: int = 1,
    crop: str = SUBTITLE_CROP,
    dry_run: bool = False,
) -> List[Tuple[int, int, Path]]:
    """
    按固定 fps 抽帧,并裁切字幕区域

    参数:
        video_path: 输入视频路径
        output_dir: 输出目录
        fps: 抽帧帧率,默认 1(每秒一帧)
        crop: ffmpeg crop 表达式,默认 SUBTITLE_CROP
        dry_run: True 则输出裁切后的小图;False 则输出完整帧

    返回: [(frame_index, timestamp_ms, image_path), ...]
    """
    check_ffmpeg()

    frames_dir = output_dir / "frames"
    frames_dir.mkdir(parents=True, exist_ok=True)

    # 构建 ffmpeg 滤镜链
    if dry_run:
        # dry-run:裁切字幕区域输出小图
        filter_chain = f"fps={fps},crop={crop}"
    else:
        # 正式:完整帧
        filter_chain = f"fps={fps}"

    # ffmpeg 抽帧
    frame_pattern = str(frames_dir / "frame_%04d.png")
    cmd = [
        "ffmpeg",
        "-i", str(video_path),
        "-vf", filter_chain,
        "-q:v", "2",  # JPEG 质量
        frame_pattern,
        "-y",  # 覆盖
    ]

    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg 抽帧失败: {result.stderr}")

    # 收集抽出的帧
    # frame_index 直接从文件名提取(1-based),与 ffmpeg %04d 编号一致
    # 避免 enumerate 从 0 计数导致下游清理/自检的 off-by-one 索引错位
    frames = []
    for f in sorted(frames_dir.glob("frame_*.png")):
        frame_index = int(f.stem.split("_")[1])    # 1-based,与文件名编号一致
        timestamp_ms = (frame_index - 1) * 1000      # frame_0001 → 0ms, frame_0002 → 1000ms
        frames.append((frame_index, timestamp_ms, f))

    return frames


def extract_audio(
    video_path: Path,
    output_path: Path,
) -> Path:
    """
    用 ffmpeg 提取音频,转为 16kHz/单声道/16bit WAV
    """
    check_ffmpeg()

    cmd = [
        "ffmpeg",
        "-i", str(video_path),
        "-ac", "1",           # 单声道
        "-ar", "16000",       # 16kHz
        "-sample_fmt", "s16", # 16bit
        str(output_path),
        "-y",
    ]

    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg 音频提取失败: {result.stderr}")

    return output_path


# ========================================================================
# 空白帧检测
# ========================================================================


def is_blank_frame(image_path: Path, debug: bool = False) -> Tuple[bool, float, int]:
    """
    判断是否为空白帧(留气口黑画面)

    双条件检测(两条件必须同时满足才算"有字幕"):
    - 条件 A: max_brightness >= 240 (有接近纯白的像素)
    - 条件 B: white_pixel_ratio >= 0.005 (白像素占比 >= 0.5%)

    任一条件不满足 → is_blank=True

    适用于:黑底白字场景(军事科技栏目)

    返回: (is_blank, white_pixel_ratio, max_brightness)
    """
    img = Image.open(image_path).convert("L")
    arr = np.array(img)
    max_brightness = int(np.max(arr))
    # 阈值固定用 240,与条件 A 一致
    white_pixel_ratio = float(np.mean(arr > BLANK_FRAME_BRIGHTNESS_THRESHOLD))

    # 双条件:同时满足才算有字幕
    has_subtitle = (max_brightness >= BLANK_FRAME_BRIGHTNESS_THRESHOLD) and (white_pixel_ratio >= BLANK_FRAME_WHITE_PIXEL_RATIO)
    is_blank = not has_subtitle

    if debug:
        frame_idx = image_path.stem
        print(f"[debug] {frame_idx}, max_brightness={max_brightness}, white_pixel_ratio={white_pixel_ratio:.6f}, is_blank={is_blank}")

    return is_blank, white_pixel_ratio, max_brightness


# ========================================================================
# 哈希变化检测
# ========================================================================


def compute_hash(image_path: Path, algorithm: str = "dhash") -> str:
    """
    计算图片的哈希值

    算法:
    - dhash(差异哈希):对边缘更敏感,适合字幕小图场景
    - phash(感知哈希):对内容结构敏感
    """
    img = Image.open(image_path)
    if algorithm == "dhash":
        h = imagehash.dhash(img)
    else:
        h = imagehash.phash(img)
    return str(h)


def hamming_distance(s1: str, s2: str) -> int:
    """计算两个 hex 哈希字符串的海明距离"""
    if len(s1) != len(s2):
        return max(len(s1), len(s2))
    return sum(c1 != c2 for c1, c2 in zip(s1, s2))


def compute_binary_matrix(image_path: Path, threshold: int = 200) -> np.ndarray:
    """
    将图片转为二值化矩阵(亮度 > threshold → 1,否则 → 0)
    用于 IoU 对比

    返回: np.ndarray (dtype=uint8, 值为 0 或 1)
    """
    img = Image.open(image_path).convert("L")
    arr = np.array(img)
    return (arr > threshold).astype(np.uint8)


def compute_iou(matrix1: np.ndarray, matrix2: np.ndarray) -> float:
    """
    计算两个二值化矩阵的 IoU(交并比)
    matrix1, matrix2: np.ndarray (dtype=uint8, 值为 0 或 1)
    """
    intersection = int(np.sum((matrix1 == 1) & (matrix2 == 1)))
    union = int(np.sum((matrix1 == 1) | (matrix2 == 1)))
    return intersection / union if union > 0 else 1.0


def find_keyframes(
    frames: List[Tuple[int, int, Path]],
    hash_algorithm: str = "dhash",
    phash_threshold: int = PHASH_THRESHOLD,
    dhash_threshold: int = DHASH_THRESHOLD,
    iou_threshold: float = IOU_THRESHOLD,
    debug_csv: Optional[Path] = None,
) -> Tuple[List[Dict], Dict]:
    """
    基于多级检测找出字幕变化的关键帧

    过滤级别(从轻到严):
    1. IoU 保底:二值化矩阵 IoU > iou_threshold → 跳过
    2. 哈希检测:海明距离 > threshold → 新关键帧

    算法:
    - 第一帧总是关键帧
    - 后续帧:先走 IoU 保底,再走哈希检测

    返回: (keyframes, frame_analysis)
    frame_analysis: {frame_index: {white_ratio, is_blank, hash_value, iou_to_prev, hash_dist_to_prev, decision}}
    """
    if not frames:
        return [], {}

    keyframes = []
    frame_analysis: Dict[int, Dict] = {}
    last_keyframe_data: Optional[Dict] = None

    # CSV 写入器
    csv_file = None
    csv_writer = None
    if debug_csv:
        csv_file = open(debug_csv, "w", newline="", encoding="utf-8")
        csv_writer = csv.writer(csv_file)
        csv_writer.writerow([
            "frame_index", "timestamp_ms", "white_pixel_ratio", "is_blank",
            "max_brightness", "hash_value", "iou_to_prev", "hash_dist_to_prev",
            "decision", "keyframe_index"
        ])

    threshold = phash_threshold if hash_algorithm == "phash" else dhash_threshold

    for frame_index, timestamp_ms, image_path in frames:
        is_blank, white_ratio, max_brightness = is_blank_frame(image_path, debug=False)
        hash_value = compute_hash(image_path, hash_algorithm)

        decision = "kept"
        keyframe_index = -1

        if last_keyframe_data is None:
            # 第一帧总是关键帧
            is_keyframe = True
            iou_to_prev = None
            hash_dist_to_prev = None
        else:
            # IoU 保底检测
            last_matrix = last_keyframe_data["binary_matrix"]
            current_matrix = compute_binary_matrix(image_path)
            iou_to_prev = compute_iou(last_matrix, current_matrix)
            hash_dist_to_prev = hamming_distance(last_keyframe_data["hash_value"], hash_value)

            # 决策逻辑:IoU > threshold → 跳过;否则哈希 > threshold → 新关键帧
            if iou_to_prev > iou_threshold:
                # IoU 认为相同,跳过
                is_keyframe = False
                decision = "duplicate(iou)"
            elif hash_dist_to_prev > threshold:
                # 哈希认为有变化,新关键帧
                is_keyframe = True
                decision = "keyframe(hash)"
            else:
                # 哈希认为相同
                is_keyframe = False
                decision = "duplicate(hash)"

        if is_blank:
            decision = "blank"
            is_keyframe = False

        frame_analysis[frame_index] = {
            "white_ratio": white_ratio,
            "is_blank": is_blank,
            "hash_value": hash_value,
            "iou_to_prev": iou_to_prev,
            "hash_dist_to_prev": hash_dist_to_prev,
            "decision": decision,
            "timestamp_ms": timestamp_ms,
        }

        if is_keyframe:
            binary_matrix = compute_binary_matrix(image_path)
            kf_entry = {
                "frame_index": frame_index,
                "timestamp_ms": timestamp_ms,
                "frame_image_path": str(image_path),
                "hash_value": hash_value,
                "ocr_text": "",
                "binary_matrix": binary_matrix,
            }
            keyframes.append(kf_entry)
            last_keyframe_data = kf_entry
            keyframe_index = len(keyframes) - 1

        # CSV 写入
        if csv_writer:
            csv_writer.writerow([
                frame_index,
                timestamp_ms,
                f"{white_ratio:.6f}",
                is_blank,
                max_brightness,
                hash_value,
                f"{iou_to_prev:.6f}" if iou_to_prev is not None else "",
                hash_dist_to_prev if hash_dist_to_prev is not None else "",
                decision,
                keyframe_index,
            ])

    if csv_file:
        csv_file.close()

    return keyframes, frame_analysis


# ========================================================================
# OCR 接口(P2 实现,目前返回占位)
# ========================================================================


def ocr_frame(image_path: Path) -> str:
    """
    识别帧内文字,返回纯文本

    P1: 返回占位文本
    P2: 调用 DeepSeek Vision API
    """
    if not DEEPSEEK_API_KEY:
        return f"[OCR待填充 frame={image_path.name}]"
    return f"[OCR待填充 frame={image_path.name}]"


def ocr_keyframes(keyframes: List[Dict]) -> List[Dict]:
    """对关键帧列表逐一调用 OCR"""
    result = []
    for kf in keyframes:
        image_path = Path(kf["frame_image_path"])
        ocr_text = ocr_frame(image_path)
        kf_copy = {k: v for k, v in kf.items() if k != "binary_matrix"}
        kf_copy["ocr_text"] = ocr_text
        result.append(kf_copy)
    return result


# ========================================================================
# B 稿格式化
# ========================================================================


def format_timestamp(ms: int) -> str:
    """毫秒转 [Nm Ns] 格式"""
    total_sec = ms // 1000
    return f"{total_sec // 60}m{total_sec % 60}s"


def build_b_manuscript(keyframes: List[Dict]) -> List[str]:
    """
    将关键帧 OCR 结果合并为 B 稿
    合并相邻同文本的关键帧
    """
    lines = []
    last_text = None

    for kf in keyframes:
        text = kf.get("ocr_text", "").strip()
        if not text:
            continue

        if text.startswith("[OCR待填充"):
            text = ""

        if text and text != last_text:
            ts = format_timestamp(kf["timestamp_ms"])
            lines.append(f"[{ts}] {text}")
            last_text = text

    return lines


def write_b_manuscript(lines: List[str], output_path: Path) -> Path:
    """写入 B 稿 txt"""
    output_path.parent.mkdir(parents=True, exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))
    return output_path


# ========================================================================
# 主流程
# ========================================================================


def split_video(
    video_path: Path,
    output_dir: Path,
    episode_id: str,
    hash_algorithm: str = HASH_ALGORITHM,
    phash_threshold: int = PHASH_THRESHOLD,
    dhash_threshold: int = DHASH_THRESHOLD,
    iou_threshold: float = IOU_THRESHOLD,
    fps: int = 1,
    dry_run: bool = False,
) -> Dict[str, any]:
    """
    视频双路拆分主流程

    参数:
        video_path: 输入视频路径
        output_dir: 输出目录(work/ 路径)
        episode_id: 节目 ID
        hash_algorithm: 哈希算法,"dhash"(默认) 或 "phash"
        phash_threshold: pHash 海明距离阈值,默认 2
        dhash_threshold: dHash 海明距离阈值,默认 5
        iou_threshold: IoU 保底阈值,默认 0.95
        fps: 抽帧帧率,默认 1(每秒一帧)
        dry_run: True 则不调 OCR,只输出裁切帧和 keyframes.json

    返回:
        {
            "b_manuscript_path": Path,
            "audio_path": Path,
            "keyframes_path": Path,
            "keyframe_count": int,
            "dry_run": bool,
            "filter_stats": dict,
        }
    """
    video_path = Path(video_path)
    output_dir = Path(output_dir)

    if not video_path.exists():
        raise FileNotFoundError(f"视频文件不存在: {video_path}")

    # 创建输出目录
    output_dir.mkdir(parents=True, exist_ok=True)
    frames_dir = output_dir / "frames"

    # dry-run 前自动清空上次产物,避免干扰
    if dry_run:
        if frames_dir.exists():
            shutil.rmtree(frames_dir)
        frames_dir.mkdir(parents=True, exist_ok=True)
        keyframes_json_path = output_dir / "keyframes.json"
        if keyframes_json_path.exists():
            keyframes_json_path.unlink()

    print(f"[video_split] 开始处理: {video_path.name}")
    print(f"[video_split] fps={fps}, hash={hash_algorithm}, phash_th={phash_threshold}, dhash_th={dhash_threshold}, iou_th={iou_threshold}, dry_run={dry_run}")

    # ---- A 路:抽帧 + 空白帧过滤 + 哈希检测 + OCR ----
    print(f"[video_split] A路:抽帧({'裁切模式' if dry_run else '完整帧模式'})...")
    frames = extract_frames(video_path, output_dir, fps=fps, crop=SUBTITLE_CROP, dry_run=dry_run)
    total_extracted = len(frames)
    print(f"[video_split] 抽帧完成,共 {total_extracted} 帧")

    # 诊断 CSV
    debug_csv_path = output_dir / "frame_analysis_debug.csv"

    # 空白帧过滤
    print("[video_split] 空白帧过滤...")
    non_blank_frames = []
    blank_count = 0
    # 统计各 decision 类型
    decision_stats = {"blank": 0, "duplicate(iou)": 0, "duplicate(hash)": 0, "keyframe(hash)": 0, "kept": 0}

    for frame_index, timestamp_ms, image_path in frames:
        is_blank, white_ratio, max_brightness = is_blank_frame(image_path, debug=False)
        if is_blank:
            blank_count += 1
            decision_stats["blank"] += 1
        else:
            non_blank_frames.append((frame_index, timestamp_ms, image_path))

    print(f"[stats] 原始抽帧: {total_extracted} 张")
    print(f"[stats] 空白帧过滤后: {len(non_blank_frames)} 张 (筛掉 {blank_count} 张纯黑)")

    # 哈希 + IoU 去重
    print(f"[video_split] 哈希变化检测(算法={hash_algorithm}, IoU保底阈值={iou_threshold})...")
    keyframes, frame_analysis = find_keyframes(
        non_blank_frames,
        hash_algorithm=hash_algorithm,
        phash_threshold=phash_threshold,
        dhash_threshold=dhash_threshold,
        iou_threshold=iou_threshold,
        debug_csv=debug_csv_path,
    )

    # 统计各 decision（frame_analysis 已包含所有非空白帧的 decision）
    for frame_idx, data in frame_analysis.items():
        decision_stats[data["decision"]] = decision_stats.get(data["decision"], 0)

    # 从 frame_analysis 统计中取 duplicate 相关计数（blank_count 已在上方单独统计）
    duplicate_iou = decision_stats.get("duplicate(iou)", 0)
    duplicate_hash = decision_stats.get("duplicate(hash)", 0)
    total_duplicate = blank_count + duplicate_iou + duplicate_hash
    print(f"[stats] pHash/dHash 去重后(IoU保底): {len(keyframes)} 张 (筛掉 {duplicate_iou} 张IoU相同 + {duplicate_hash} 张哈希相同)")
    print(f"[stats] 最终关键帧: {len(keyframes)} 张")
    print(f"[video_split] 检测到 {len(keyframes)} 个关键帧")
    print(f"[video_split] 诊断CSV写入: {debug_csv_path}")

    # ---- 删除非关键帧,只保留最终关键帧 PNG ----
    # 建立应保留的帧索引集合
    keyframe_indices = set(kf["frame_index"] for kf in keyframes)
    all_frame_files = sorted(frames_dir.glob("frame_*.png"))
    deleted_count = 0
    for frame_file in all_frame_files:
        # 从文件名 frame_0001.png 提取帧索引
        frame_idx = int(frame_file.stem.split("_")[1])
        if frame_idx not in keyframe_indices:
            frame_file.unlink()
            deleted_count += 1

    print(f"[video_split] 清理非关键帧完成,删除 {deleted_count} 张,保留 {len(keyframes)} 张关键帧")

    # 确认 frames/ 目录文件数与关键帧数一致
    actual_frame_files = len(list(frames_dir.glob("frame_*.png")))
    print(f"[debug] frames/ 目录实际文件数: {actual_frame_files} (预期: {len(keyframes)})")
    if actual_frame_files != len(keyframes):
        print(f"[WARNING] frames/ 文件数({actual_frame_files}) 与关键帧数({len(keyframes)})不一致!")

    if dry_run:
        print("[video_split] dry-run:跳过 OCR")
        keyframes_clean = [
            {k: v for k, v in kf.items() if k != "binary_matrix"}
            for kf in keyframes
        ]
    else:
        print("[video_split] OCR 关键帧...")
        keyframes_clean = ocr_keyframes(keyframes)
        print(f"[video_split] OCR 完成")

    # ---- B 路:音频提取 ----
    print("[video_split] B路:提取音频...")
    audio_path = output_dir / "audio_16k.wav"
    extract_audio(video_path, audio_path)
    print(f"[video_split] 音频提取完成: {audio_path}")

    # ---- 输出产物 ----
    filter_stats = {
        "total_extracted_frames": total_extracted,
        "blank_frames_removed": blank_count,
        "duplicate_frames_removed": duplicate_iou + duplicate_hash,
        "duplicate_iou_removed": duplicate_iou,
        "duplicate_hash_removed": duplicate_hash,
        "final_keyframes": len(keyframes),
        "hash_algorithm": hash_algorithm,
        "phash_threshold": phash_threshold,
        "dhash_threshold": dhash_threshold,
        "iou_threshold": iou_threshold,
    }

    if dry_run:
        keyframes_data = {
            "video_path": str(video_path),
            "fps_sampled": fps,
            "hash_algorithm": hash_algorithm,
            "phash_threshold": phash_threshold,
            "dhash_threshold": dhash_threshold,
            "iou_threshold": iou_threshold,
            "dry_run": True,
            "filter_stats": filter_stats,
            "crop_params": {
                "width_ratio": 1.0,
                "height_ratio": 0.2,
                "x_offset": 0.0,
                "y_offset": 0.8,
                "hardcoded": True,
                "applies_to": "军事科技 全栏目特殊视频",
                "ffmpeg_crop": SUBTITLE_CROP,
            },
            "keyframes": keyframes_clean,
        }
        keyframes_path = output_dir / "keyframes.json"
        with open(keyframes_path, "w", encoding="utf-8") as f:
            json.dump(keyframes_data, f, ensure_ascii=False, indent=2)
        print(f"[video_split] dry-run keyframes.json 写入: {keyframes_path}")
        print("[video_split] 请检查 frames/ 目录下的关键帧图片,确认裁切框位置正确")

        # ---- 自检:随机抽 3 张最终关键帧 PNG,验证像素数据与 CSV 一致 ----
        keyframe_files = sorted(frames_dir.glob("frame_*.png"))
        import random
        sample_frames = random.sample(keyframe_files, min(3, len(keyframe_files)))
        for frame_file in sample_frames:
            is_blank_check, white_ratio_check, max_brightness_check = is_blank_frame(frame_file, debug=False)
            frame_idx_str = frame_file.stem  # e.g. "frame_0001"
            # CSV 里 frame_index = 1 对应 frame_0001
            frame_idx_in_csv = int(frame_idx_str.split("_")[1])
            if frame_idx_in_csv in frame_analysis:
                csv_max_bright = None
                csv_white_ratio = None
                # 从 CSV 原始行找对应 frame_index 的数据
                with open(debug_csv_path, "r", encoding="utf-8") as csvf:
                    reader = csv.DictReader(csvf)
                    for row in reader:
                        if int(row["frame_index"]) == frame_idx_in_csv:
                            csv_max_bright = int(row["max_brightness"])
                            csv_white_ratio = float(row["white_pixel_ratio"])
                            break
                if csv_max_bright is not None:
                    if abs(csv_max_bright - max_brightness_check) > 1 or abs(csv_white_ratio - white_ratio_check) > 0.0001:
                        print(f"[WARNING] 自检失败: {frame_file.name} 像素数据与 CSV 不一致! CSV: max_brightness={csv_max_bright}, white_ratio={csv_white_ratio:.6f}; 实际重读: max_brightness={max_brightness_check}, white_ratio={white_ratio_check:.6f}")
                        raise RuntimeError(f"自检失败: {frame_file.name} 像素数据与 CSV 记录不一致,已中止")
                    else:
                        print(f"[自检] {frame_file.name}: max_brightness={max_brightness_check}, white_ratio={white_ratio_check:.6f} ✓")

        return {
            "keyframes_path": str(keyframes_path),
            "audio_path": str(audio_path),
            "keyframe_count": len(keyframes),
            "dry_run": True,
            "b_manuscript_path": None,
            "filter_stats": filter_stats,
        }
    else:
        b_lines = build_b_manuscript(keyframes_clean)
        b_manuscript_path = output_dir / "b_manuscript.txt"
        write_b_manuscript(b_lines, b_manuscript_path)
        print(f"[video_split] B稿写入: {b_manuscript_path} ({len(b_lines)} 行)")

        keyframes_data = {
            "video_path": str(video_path),
            "fps_sampled": fps,
            "hash_algorithm": hash_algorithm,
            "phash_threshold": phash_threshold,
            "dhash_threshold": dhash_threshold,
            "iou_threshold": iou_threshold,
            "dry_run": False,
            "filter_stats": filter_stats,
            "crop_params": {
                "width_ratio": 1.0,
                "height_ratio": 0.2,
                "x_offset": 0.0,
                "y_offset": 0.8,
                "hardcoded": True,
                "applies_to": "军事科技 全栏目特殊视频",
                "ffmpeg_crop": SUBTITLE_CROP,
            },
            "keyframes": keyframes_clean,
        }
        keyframes_path = output_dir / "keyframes.json"
        with open(keyframes_path, "w", encoding="utf-8") as f:
            json.dump(keyframes_data, f, ensure_ascii=False, indent=2)
        print(f"[video_split] 关键帧索引写入: {keyframes_path}")

        return {
            "b_manuscript_path": str(b_manuscript_path),
            "audio_path": str(audio_path),
            "keyframes_path": str(keyframes_path),
            "keyframe_count": len(keyframes),
            "dry_run": False,
            "filter_stats": filter_stats,
        }