# -*- coding: utf-8 -*-
"""
SuperMovie Cut — RMS無音カット 正規スクリプト（確定仕様の実装・検証済み）

supermovie-0609 で実証した「間≦0.5秒 と 発話を絶対切らない の両立」アルゴリズム。
モデルがロジックを書き直す必要はない。このスクリプトをそのまま実行する。

使い方:
  python3 cut_rms.py <PROJECT_DIR>            # cutData.ts 上書き + videoConfig.ts 更新
  python3 cut_rms.py <PROJECT_DIR> --dry-run  # 書き込まず解析・検証レポートのみ

前提ファイル:
  <PROJECT>/transcript_audio.wav   (16kHz mono pcm16 — transcribe が生成)
  <PROJECT>/transcript_fixed.json  (words 配列 — transcript-fix が生成)
  <PROJECT>/src/videoConfig.ts     (FPS / DURATION_FRAMES)

アルゴリズム（確定仕様）:
  1. wav を 10ms フレームで RMS 計算
  2. noise_floor = RMS の10%点。threshold = max(noise_floor*2.5, max_rms*0.03)
  3. RMS>=threshold = 実音(loud)。250ms 以内の loud はマージ（語内の谷で割らない）
  4. ★ワード区間内は threshold/2 で判定（小声・息も「音」として保護）
     ※ RMSベースなので Whisper が拾わなかった声（言い直し等）も自動で保護される
  5. 保護区間 = 各 loud を ±PROT(230ms) 拡張してマージ → これが keep 区間
  6. 先頭は最初の実音の 200ms 前、末尾は最後の実音の 350ms 後まで残す
  → カット点に残る無音 = 230+230 ≈ 0.46秒(≦0.5秒)。0.46秒未満の間は保護が重なり削られない。

必須検証（すべて自動実行・違反があれば exit 1）:
  V-cut1: 全削除区間に RMS>=threshold のフレームが無い（=音声を1フレームも削っていない）
  V-cut2: 全ワードの実音部分±PROT が keep 内
  V-cut3: カット後の最大連続無音 <= 0.5秒
  V-cut4: CUT_DURATION_FRAMES の整合（playback 連続性）
"""
import json
import math
import re
import struct
import sys
import wave
from pathlib import Path

# ---- 確定パラメータ（まさきFBで確定。変更しない） ----
FRAME_MS = 10          # RMS解析の粒度
PROT_MS = 230          # 実音の前後に必ず残す余白（語尾・頭・息継ぎ保護）
LOUD_MERGE_MS = 250    # この間隔以内の実音はひと続きとみなす
INTRO_LEAD_MS = 200    # 最初の実音の前に残す
OUTRO_TAIL_MS = 350    # 最後の実音の後に残す
MAX_SILENCE_MS = 500   # カット後に許容する最大の間
# 注: 微小カット（数十ms）もそのまま実行してよい。CutPlayerは問題なく連結し、知覚されない。
# 微小カットをマージすると keep 内の連続無音が 0.5秒 を超えて V-cut3 に違反する（検証済み）。
NOISE_PCT = 0.10       # noise_floor = RMS下位10%点
THRESH_MULT = 2.5      # threshold = noise_floor * 2.5
THRESH_MAX_RATIO = 0.03  # threshold >= max_rms * 0.03


def read_rms(wav_path):
    """16kHz mono pcm16 wav → 10msごとのRMS配列"""
    wf = wave.open(str(wav_path), "rb")
    assert wf.getnchannels() == 1 and wf.getsampwidth() == 2, "16kHz mono pcm16 が必要"
    sr = wf.getframerate()
    spf = int(sr * FRAME_MS / 1000)  # samples per frame
    raw = wf.readframes(wf.getnframes())
    wf.close()
    n = len(raw) // 2
    samples = struct.unpack(f"<{n}h", raw[: n * 2])
    rms = []
    for i in range(0, n - spf + 1, spf):
        acc = 0
        for s in samples[i : i + spf]:
            acc += s * s
        rms.append(math.sqrt(acc / spf) / 32768.0)
    return rms


def merge_intervals(ivs, gap=0):
    if not ivs:
        return []
    ivs = sorted(ivs)
    out = [list(ivs[0])]
    for s, e in ivs[1:]:
        if s <= out[-1][1] + gap:
            out[-1][1] = max(out[-1][1], e)
        else:
            out.append([s, e])
    return [tuple(x) for x in out]


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)
    proj = Path(sys.argv[1])
    dry = "--dry-run" in sys.argv

    words = json.load(open(proj / "transcript_fixed.json"))["words"]
    vc = (proj / "src" / "videoConfig.ts").read_text()
    fps = int(re.search(r"export const FPS = (\d+)", vc).group(1))
    orig_frames = int(re.search(r"export const DURATION_FRAMES = (\d+)", vc).group(1))

    print(f"解析開始: FPS={fps} / 元尺={orig_frames}f / words={len(words)}")
    rms = read_rms(proj / "transcript_audio.wav")
    total_ms = len(rms) * FRAME_MS

    svals = sorted(rms)
    noise_floor = svals[int(len(svals) * NOISE_PCT)]
    threshold = max(noise_floor * THRESH_MULT, max(rms) * THRESH_MAX_RATIO)
    print(f"noise_floor={noise_floor:.5f} threshold={threshold:.5f}")

    # ワード区間（小声保護: 区間内は threshold/2）
    in_word = [False] * len(rms)
    for w in words:
        a = max(0, w["start"] // FRAME_MS)
        b = min(len(rms) - 1, w["end"] // FRAME_MS)
        for k in range(a, b + 1):
            in_word[k] = True

    # 実音フレーム → 実音区間(ms)
    loud_ms = []
    for i, v in enumerate(rms):
        th = threshold / 2 if in_word[i] else threshold
        if v >= th:
            loud_ms.append((i * FRAME_MS, (i + 1) * FRAME_MS))
    loud = merge_intervals(loud_ms, gap=LOUD_MERGE_MS)
    if not loud:
        print("★実音が検出できません（無音動画？）")
        sys.exit(1)

    # 保護=keep区間
    keep = merge_intervals([(max(0, s - PROT_MS), min(total_ms, e + PROT_MS)) for s, e in loud])
    intro = max(0, loud[0][0] - INTRO_LEAD_MS)
    outro = min(total_ms, loud[-1][1] + OUTRO_TAIL_MS)
    keep = [(max(s, intro), min(e, outro)) for s, e in keep if e > intro and s < outro]
    keep = [(s, e) for s, e in keep if e - s >= FRAME_MS * 3]  # 30ms未満の島は除外

    # ---- 検証 ----
    errors = []
    # V-cut1: 削除区間に threshold 超の音が無い
    removed = []
    prev = 0
    for s, e in keep:
        if s > prev:
            removed.append((prev, s))
        prev = e
    if prev < total_ms:
        removed.append((prev, total_ms))
    for s, e in removed:
        a, b = s // FRAME_MS, min(len(rms), e // FRAME_MS)
        bad = [i for i in range(a, b) if rms[i] >= (threshold / 2 if in_word[i] else threshold)]
        if bad:
            errors.append(f"V-cut1: 削除区間 {s}-{e}ms に実音 {len(bad)}フレーム")
    # V-cut2: 各ワードの実音±PROT が keep 内
    def in_keep(ms):
        return any(s <= ms <= e for s, e in keep)
    vio = 0
    for w in words:
        a = max(0, w["start"] // FRAME_MS)
        b = min(len(rms) - 1, w["end"] // FRAME_MS)
        loud_in_w = [i for i in range(a, b + 1) if rms[i] >= threshold / 2]
        if not loud_in_w:
            continue  # 幻聴ワード（無音上）は保護対象外でよい
        # 動画の先頭/末尾では intro/outro までしか keep が伸びないため、そこでクランプして判定
        lo = max(intro, loud_in_w[0] * FRAME_MS - PROT_MS)
        hi = min(outro - 1, (loud_in_w[-1] + 1) * FRAME_MS + PROT_MS)
        if not (in_keep(lo) and in_keep(hi)):
            vio += 1
            if vio <= 5:
                errors.append(f"V-cut2: ワード「{w['text']}」({w['start']}ms) の実音±{PROT_MS}ms が keep 外")
    # V-cut3: カット後の最大連続無音
    max_sil = 0
    for s, e in keep:
        a, b = s // FRAME_MS, min(len(rms), e // FRAME_MS)
        run = 0
        for i in range(a, b):
            th = threshold / 2 if in_word[i] else threshold
            if rms[i] < th:
                run += 1
                max_sil = max(max_sil, run)
            else:
                run = 0
    # keep境界をまたぐ無音: 境界で連結されるため tail+head も確認
    for j in range(len(keep) - 1):
        s1, e1 = keep[j]
        s2, e2 = keep[j + 1]
        tail = 0
        for i in range(min(len(rms), e1 // FRAME_MS) - 1, s1 // FRAME_MS - 1, -1):
            if rms[i] < (threshold / 2 if in_word[i] else threshold):
                tail += 1
            else:
                break
        head = 0
        for i in range(s2 // FRAME_MS, min(len(rms), e2 // FRAME_MS)):
            if rms[i] < (threshold / 2 if in_word[i] else threshold):
                head += 1
            else:
                break
        max_sil = max(max_sil, tail + head)
    if max_sil * FRAME_MS > MAX_SILENCE_MS:
        errors.append(f"V-cut3: 最大連続無音 {max_sil * FRAME_MS}ms > {MAX_SILENCE_MS}ms")

    # フレーム変換 + playback 連続性 (V-cut4)
    to_frame = lambda ms: round(ms / 1000 * fps)
    pb = 0
    segs = []
    for s, e in keep:
        oS, oE = to_frame(s), to_frame(e)
        if oE <= oS:
            continue
        segs.append((s, e, oS, oE, pb, pb + (oE - oS)))
        pb += oE - oS
    cut_frames = pb

    kept_ms = sum(e - s for s, e in keep)
    print(f"\nkeep {len(segs)}区間 / カット {len(removed)}箇所")
    print(f"尺: {total_ms/1000:.1f}s ({orig_frames}f) → {kept_ms/1000:.1f}s ({cut_frames}f)  "
          f"削減 {(total_ms-kept_ms)/1000:.1f}s ({(total_ms-kept_ms)/total_ms*100:.1f}%)")
    print(f"検証: V-cut1 削れた発話={'0 OK' if not any('V-cut1' in e for e in errors) else 'NG'} / "
          f"V-cut2 {'OK' if vio == 0 else f'{vio}件NG'} / "
          f"V-cut3 最大残無音 {max_sil * FRAME_MS}ms {'OK' if max_sil * FRAME_MS <= MAX_SILENCE_MS else 'NG'}")
    top = sorted(removed, key=lambda x: x[1] - x[0], reverse=True)[:5]
    print("カット上位5:", [(f"{s/1000:.1f}-{e/1000:.1f}s", f"{(e-s)/1000:.2f}s") for s, e in top])

    if errors:
        print("\n★検証エラー（出力しません）:")
        for e in errors[:20]:
            print(" -", e)
        sys.exit(1)

    if dry:
        print("\n--dry-run のため書き込みなし")
        return

    # ---- cutData.ts 書き出し（keepSegmentsMs 形式 = テンプレート互換） ----
    L = []
    L.append("// ==== カットデータ（残す区間 = keep segments） ====")
    L.append("// /supermovie-cut scripts/cut_rms.py が自動生成（RMS無音検出 + 実音±230ms保護・確定仕様）")
    L.append(f"// 元: {total_ms/1000:.2f}s ({orig_frames}f) → カット後: {kept_ms/1000:.2f}s ({cut_frames}f)、"
             f"keep {len(segs)}区間 / カット {len(removed)}箇所")
    L.append(f"// 検証済み: 削れた発話=0 / 残存最大連続無音 {max_sil * FRAME_MS}ms（≦0.5s）")
    L.append("// ⚠️ 全数値はフレーム値。ms値は必ず toFrame() でラップする。")
    L.append("")
    L.append("import { FPS as CONFIG_FPS } from './videoConfig';")
    L.append("")
    L.append("export interface CutSegment {")
    L.append("  id: number;")
    L.append("  originalStart: number; // 元動画フレーム")
    L.append("  originalEnd: number;   // 元動画フレーム")
    L.append("  playbackStart: number; // カット後の再生フレーム")
    L.append("  playbackEnd: number;   // カット後の再生フレーム")
    L.append("}")
    L.append("")
    L.append("export const FPS = CONFIG_FPS;")
    L.append("const toFrame = (ms: number) => Math.round((ms / 1000) * FPS);")
    L.append("")
    L.append("// 残す区間（元動画のms）。再生位置はtoFrame変換後に連続で自動算出する。")
    L.append("const keepSegmentsMs: { startMs: number; endMs: number }[] = [")
    for s, e, *_ in segs:
        L.append(f"  {{ startMs: {s}, endMs: {e} }},")
    L.append("];")
    L.append("")
    L.append("let pb = 0;")
    L.append("export const cutData: CutSegment[] = keepSegmentsMs.map((s, i) => {")
    L.append("  const oS = toFrame(s.startMs);")
    L.append("  const oE = toFrame(s.endMs);")
    L.append("  const seg: CutSegment = {")
    L.append("    id: i + 1,")
    L.append("    originalStart: oS,")
    L.append("    originalEnd: oE,")
    L.append("    playbackStart: pb,")
    L.append("    playbackEnd: pb + (oE - oS),")
    L.append("  };")
    L.append("  pb += oE - oS;")
    L.append("  return seg;")
    L.append("});")
    L.append("")
    L.append(f"export const ORIGINAL_DURATION_FRAMES = toFrame({round(total_ms)});")
    L.append(f"export const CUT_DURATION_FRAMES = pb; // = {cut_frames}（videoConfig.DURATION_FRAMES と一致させる）")
    L.append("")
    L.append("// ★ subtitles / se が使う: 元動画フレーム → カット後再生フレーム への変換")
    L.append("export function mapOriginalToPlayback(originalFrame: number): number {")
    L.append("  for (const seg of cutData) {")
    L.append("    if (originalFrame >= seg.originalStart && originalFrame <= seg.originalEnd) {")
    L.append("      return seg.playbackStart + (originalFrame - seg.originalStart);")
    L.append("    }")
    L.append("  }")
    L.append("  for (let i = cutData.length - 1; i >= 0; i--) {")
    L.append("    if (originalFrame > cutData[i].originalEnd) {")
    L.append("      return cutData[i].playbackEnd;")
    L.append("    }")
    L.append("  }")
    L.append("  return 0;")
    L.append("}")
    L.append("")
    L.append("export function isKept(originalFrame: number): boolean {")
    L.append("  return cutData.some(")
    L.append("    (seg) => originalFrame >= seg.originalStart && originalFrame <= seg.originalEnd,")
    L.append("  );")
    L.append("}")
    L.append("")
    (proj / "src" / "cutData.ts").write_text("\n".join(L))

    # videoConfig.ts の DURATION_FRAMES 更新
    vc_new = re.sub(
        r"export const DURATION_FRAMES = \d+;.*",
        f"export const DURATION_FRAMES = {cut_frames}; // カット後の尺（/supermovie-cut が更新。元: {orig_frames}）",
        vc,
        count=1,
    )
    (proj / "src" / "videoConfig.ts").write_text(vc_new)
    print(f"\n書き出し完了: src/cutData.ts ({len(segs)}区間) / videoConfig.ts DURATION_FRAMES={cut_frames}")


if __name__ == "__main__":
    main()
