# -*- coding: utf-8 -*-
"""
SuperMovie Subtitles — テロップ整列 正規スクリプト（確定仕様の実装・supermovie-0609で実証済み）

モデル（Claude/Codex）の仕事は2つの入力ファイルを作ることだけ。
整列・同期・キャップ・スタイル回転・検証 V1〜V6 はすべてこのスクリプトが行う。

使い方:
  python3 align_telop.py <PROJECT_DIR>             # telopData.ts / titleData.ts を書き出し
  python3 align_telop.py <PROJECT_DIR> --dry-run   # 検証レポートのみ（書き込みなし）
  python3 align_telop.py <PROJECT_DIR> --out-dir D # 出力先を変える（テスト用）

入力ファイル（モデルが用意する）:
  <PROJECT>/テロップ確定.txt
      1行 = 1テロップ。改行ナレッジ.md（まさき式・節ベース・最大18字）準拠。
      ★transcript_fixed.json の本文を「一字一句変えずに」行分割しただけのもの。
        文の省略・圧縮・言い換えは禁止（整列が文字単位の照合で行われるため必ず失敗する）。
        句読点・！？・空白は照合時に無視されるので、付けても付けなくてもよい。
      ★対談動画（supermovie2）の場合:
        - 行頭に話者プレフィックス `M: `（male）/ `F: `（female）を付ける（内容で確定した話者）
        - `#` で始まる行はターンコメント（# --- turn3 しょう --- 等）として無視される
        - 話者交代では必ず行を分ける（1行=1話者）。プレフィックスが words の声紋ラベルと
          食い違う場合は警告を出すが、プレフィックス（内容確定）を優先する
  <PROJECT>/スタイル割当.json
      {
        "tone": "教育的",                  // 教育的/プロフェッショナル/カジュアル/エンタメ
        "emphasis":   [2, 11, 14],         // 1-origin のテロップ行番号
        "warning":    [13, 32],
        "success":    [34, 45],
        "list_slide": [16, 17, 18],        // リスト・見出し（slideFromLeftで統一・連続例外）
        "titles": [                        // 章タイトル（最初の1件は word_start_ms: null = frame 0）
          { "text": "スキルを育てる運用", "word_start_ms": null },
          { "text": "①スキルは育てるもの", "word_start_ms": 45080 }
        ],
        "hallucination_ranges": []         // 任意: [[startIdx, endIdxExclusive]] 自動検出への追加/上書き
      }

自動で行うこと:
  - 幻聴重複ワードの自動除去（整列駆動: 確定.txt と一致しないワードのうち、
    圧縮タイムスタンプ（語の尺≦60ms）または低conf（<0.5）のものだけスキップ可。
    実発話と不一致なら理由を表示して停止＝確定.txt の書き間違いを誤魔化さない）
  - cutData.ts の keepSegmentsMs を読み取り playback タイムラインへリマップ
  - start = round(word.start) + SYNC_DELAY(+2f) / end = min(次start, 発話終わり+HOLD_TAIL 0.4s)
  - 最終テロップは end = TOTAL_FRAMES（カットが末尾無音≦0.5sを保証しているため安全）
  - スタイル別アニメプール回転・3連続禁止・冒頭アニメ無し・normal=none
  - 検証 V1〜V6 + 無音キャップ検査（違反があれば exit 1 で理由を表示）
"""
import difflib
import json
import math
import re
import sys
from collections import Counter
from pathlib import Path

# ---- 確定パラメータ（まさきFBで確定。変更しない） ----
SYNC_DELAY = 2        # start = round + 2f（PRE=0でもまだ早い→+2fでピッタリ）
SYNC_POST_BUFFER = 2
HOLD_TAIL = 12        # 発話終わり+0.4秒で消す（無音キャップ）
MAX_CHARS = 18        # 1行最大18字（盤面で折り返さない上限）
SILENT_CAP_S = 0.55   # 無音上テロップ検査: endFrame - 発話終わり ≦ 0.55s
MIN_SHOW_FRAMES = 1   # endFrame > startFrame の最低保証

# アニメプール（slideFromLeft はリスト・見出し専用にして3連続衝突を避ける）
POOLS_CALM = {  # 教育的・プロフェッショナル: charByChar / slideLeftFadeBlur 除外
    "emphasis": ["slideIn", "fadeFromRight", "fadeBlurFromBottom"],
    "warning": ["fadeBlurFromBottom", "slideIn", "fadeFromRight"],
    "success": ["fadeFromLeft", "fadeOnly", "fadeFromRight"],
}
POOLS_LIVELY = {  # カジュアル・エンタメ
    "emphasis": ["slideIn", "fadeFromRight", "fadeBlurFromBottom", "charByChar"],
    "warning": ["fadeBlurFromBottom", "slideIn", "slideLeftFadeBlur", "fadeFromRight"],
    "success": ["fadeFromLeft", "fadeOnly", "fadeFromRight"],
}


def is_suspicious(w):
    """幻聴の疑いがあるワードか（圧縮タイムスタンプ or 低conf）。"""
    return (w["end"] - w["start"]) <= 60 or w.get("confidence", 1.0) < 0.5


def parse_keep_segments_frames(cutdata_path, to_frame):
    """cutData.ts から keep 区間を「元動画フレーム」のペアで抽出。3形式に対応:
    1) keepSegmentsMs: { startMs: A, endMs: B }       (正本・cut_rms.py出力)
    2) originalStart: toFrame(A), originalEnd: toFrame(B)  (旧形式)
    3) originalStart: A, originalEnd: B               (生フレーム旧形式)
    無ければ None（カットなし）。"""
    if not cutdata_path.exists():
        return None
    txt = cutdata_path.read_text()
    pairs = re.findall(r"\{\s*startMs:\s*(\d+),\s*endMs:\s*(\d+)\s*\}", txt)
    if pairs:
        return [(to_frame(int(a)), to_frame(int(b))) for a, b in pairs]
    pairs = re.findall(r"originalStart:\s*toFrame\((\d+)\),\s*originalEnd:\s*toFrame\((\d+)\)", txt)
    if pairs:
        return [(to_frame(int(a)), to_frame(int(b))) for a, b in pairs]
    pairs = re.findall(r"originalStart:\s*(\d+),\s*originalEnd:\s*(\d+)", txt)
    if pairs:
        return [(int(a), int(b)) for a, b in pairs]
    return None


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

    vc = (proj / "src" / "videoConfig.ts").read_text()
    FPS = int(re.search(r"export const FPS = (\d+)", vc).group(1))
    TOTAL = int(re.search(r"export const DURATION_FRAMES = (\d+)", vc).group(1))

    d = json.load(open(proj / "transcript_fixed.json"))
    words_all = d["words"]
    cfg = json.load(open(proj / "スタイル割当.json"))

    # ---- 手動指定の幻聴除去（任意。基本は整列駆動の自動スキップに任せる） ----
    bad = set()
    for a, b in cfg.get("hallucination_ranges", []):
        bad |= set(range(a, b))
    if bad:
        print(f"手動指定の幻聴除去: {len(bad)}語")
    words = [w for i, w in enumerate(words_all) if i not in bad]

    # ---- cutData リマップ ----
    to_frame = lambda ms: round(ms / 1000 * FPS)
    keep_f = parse_keep_segments_frames(proj / "src" / "cutData.ts", to_frame)
    if keep_f:
        cut_segs = []
        pb = 0
        for oS, oE in keep_f:
            cut_segs.append((oS, oE, pb, pb + (oE - oS)))
            pb += oE - oS
        if pb != TOTAL:
            print(f"★CUT_DURATION不一致: cutData={pb}f vs videoConfig.DURATION_FRAMES={TOTAL}f")
            sys.exit(1)

        def map_o2p(f):
            for oS, oE, pS, pE in cut_segs:
                if oS <= f <= oE:
                    return pS + (f - oS)
            for oS, oE, pS, pE in reversed(cut_segs):
                if f > oE:
                    return pE
            return 0
    else:
        print("cutData なし（恒等マッピング）")
        map_o2p = lambda f: min(f, TOTAL)

    # ---- テロップ確定.txt（話者プレフィックス M:/F: と #コメント対応） ----
    lines = []          # テロップ本文
    line_speakers = []  # 明示話者（'male'/'female'/None）
    for raw in open(proj / "テロップ確定.txt"):
        s = raw.strip()
        if not s or s.startswith("#"):
            continue
        m = re.match(r"^([MF])[:：]\s*(.+)$", s)
        if m:
            line_speakers.append("male" if m.group(1) == "M" else "female")
            lines.append(m.group(2).strip())
        else:
            line_speakers.append(None)
            lines.append(s)
    print(f"テロップ行数: {len(lines)}")
    over = [(i + 1, l) for i, l in enumerate(lines) if len(l) > MAX_CHARS]
    if over:
        print(f"★{MAX_CHARS}字超の行（テロップ確定.txt を修正してから再実行）:")
        for n, l in over:
            print(f"  行{n}: {l} ({len(l)}字)")
        sys.exit(1)

    # ---- 逐次文字マッチ整列（V1: 全ワードカバーの構造的保証） ----
    # 不一致時は「幻聴スキップ」を試みる: ストリーム側を最大 MAX_SKIP 文字飛ばして
    # 照合が再開できる位置を探す。ただしスキップ対象ワードが全て is_suspicious
    # （圧縮タイムスタンプ or 低conf）の場合だけ許可する。
    # → 幻聴は確定.txt（正解テキスト）駆動で自動除去され、実発話との不一致は止まる。
    norm = lambda t: re.sub(r"[、。!！?？・\s]", "", t)
    stream = []
    for wi, w in enumerate(words):
        for ch in norm(w["text"]):
            stream.append((ch, wi))

    MAX_SKIP = 120
    LOOKAHEAD = 8
    FILLER_RE = re.compile(r"^(まあ+|あの+|えっと+|えー+|うー*ん|うん+|はい+|なんか|その|こう|ね|よ|あ+|お+)+$")

    def try_skip(pos, rest_target):
        """pos から最小スキップで rest_target[:LOOKAHEAD] が一致する位置を探す。"""
        k = min(LOOKAHEAD, len(rest_target))
        for skip in range(1, min(MAX_SKIP, len(stream) - pos - k + 1)):
            if all(stream[pos + skip + j][0] == rest_target[j] for j in range(k)):
                skipped_wis = {wi for _, wi in stream[pos : pos + skip]}
                if all(is_suspicious(words[wi]) for wi in skipped_wis):
                    return skip, skipped_wis
                return None  # 実発話をスキップしようとした → 確定.txt 側の誤り
        return None

    def align_strict():
        """厳密照合（確定.txt = 本文一字一句一致）。成功時 (aligned, 失敗理由None)。"""
        pos = 0
        aligned = []
        halluc_wis = set()
        logs = []
        for li, line in enumerate(lines):
            target = norm(line)
            fw = lw = None
            ti = 0
            while ti < len(target):
                ch = target[ti]
                if pos >= len(stream):
                    return None, f"行{li + 1}「{line}」: 音声側の文字が尽きた（言い換え/追加がある）"
                sch, wi = stream[pos]
                if sch != ch:
                    res = try_skip(pos, target[ti:])
                    if res:
                        skip, skipped = res
                        txt = "".join(c for c, _ in stream[pos : pos + skip])
                        logs.append(f"幻聴スキップ: 行{li + 1} 付近で {skip}文字 (word idx {min(skipped)}-{max(skipped)}) 「{txt[:30]}」")
                        halluc_wis |= skipped
                        pos += skip
                        continue
                    ctx = "".join(c for c, _ in stream[pos : pos + 25])
                    return None, f"行{li + 1}「{line}」不一致: 期待'{ch}' 実際'{sch}' / 音声側の続き'{ctx}'"
                if fw is None:
                    fw = wi
                lw = wi
                pos += 1
                ti += 1
            aligned.append((line, fw, lw))
        if pos != len(stream):
            rest_wis = {wi for _, wi in stream[pos:]}
            if all(is_suspicious(words[wi]) for wi in rest_wis):
                txt = "".join(c for c, _ in stream[pos : pos + 30])
                logs.append(f"幻聴スキップ（末尾）: {len(stream) - pos}文字 「{txt}」")
            else:
                rest = "".join(c for c, _ in stream[pos : pos + 60])
                return None, f"未カバーの発話あり: 残り{len(stream) - pos}文字 '{rest}'"
        for l in logs:
            print(l)
        return aligned, None

    def align_difflib():
        """difflib照合（対談モード: 表記修正・フィラー除去を許容）。"""
        clean_chars = []  # (ch, line_index)
        for li, line in enumerate(lines):
            for ch in norm(line):
                clean_chars.append((ch, li))
        clean_text = "".join(c for c, _ in clean_chars)
        raw_text = "".join(c for c, _ in stream)
        sm = difflib.SequenceMatcher(None, clean_text, raw_text, autojunk=False)
        c2r = {}
        raw_matched = [False] * len(raw_text)
        for a, b, n in sm.get_matching_blocks():
            for k in range(n):
                c2r[a + k] = b + k
                raw_matched[b + k] = True
        # 各行の fw/lw
        aligned = []
        for li, line in enumerate(lines):
            idxs = [c2r[ci] for ci, (ch, l) in enumerate(clean_chars) if l == li and ci in c2r]
            tlen = len(norm(line))
            if not idxs or len(idxs) < max(2, tlen * 0.5):
                print(f"★行{li + 1}「{line}」が音声と{len(idxs)}/{tlen}文字しか一致しない")
                print("  → この行は本文に無い言い換えの可能性。表記は transcript-fix で直すか本文に合わせること")
                sys.exit(1)
            fw, lw = stream[min(idxs)][1], stream[max(idxs)][1]
            aligned.append((line, fw, lw))
        # fw 非重複・順次補正（前テロップの発話完了前に次が始まらない）
        for i in range(1, len(aligned)):
            line, fw, lw = aligned[i]
            pl, pfw, plw = aligned[i - 1]
            if fw <= plw:
                aligned[i] = (line, min(plw + 1, lw), lw)
        # カバレッジレポート: テロップが付かない発話（真の省略だけを問題にする）
        # 表記書き換え（ショウ→しょう等）の不一致は、そのワードがテロップの
        # [fw,lw] 範囲に入っていれば「カバー済み」とみなす。
        covered_wis = set()
        for _, fw, lw in aligned:
            covered_wis |= set(range(fw, lw + 1))
        spans = []
        s = None
        for k, m in enumerate(raw_matched):
            if not m and s is None:
                s = k
            elif m and s is not None:
                spans.append((s, k)); s = None
        if s is not None:
            spans.append((s, len(raw_text)))
        bad = []
        for a, b in spans:
            if b - a < 3:
                continue
            seg_txt = raw_text[a:b]
            wis = {stream[k][1] for k in range(a, b)}
            if wis <= covered_wis:
                continue  # 表記書き換え（テロップは該当ワード範囲をカバー済み）
            if all(is_suspicious(words[w]) for w in wis):
                print(f"幻聴扱い: 「{seg_txt[:30]}」({b - a}文字・圧縮タイムスタンプ)")
            elif FILLER_RE.match(seg_txt):
                print(f"フィラー省略: 「{seg_txt[:20]}」")
            else:
                bad.append(seg_txt)
        if bad:
            print(f"テロップ未カバーの発話 {len(bad)}箇所（フィラー以外・テロップ範囲外）。")
            print("  対談では短い相槌・冗長句の省略は許容。意図した省略か確認すること:")
            for t in bad[:10]:
                print(f"  - 「{t[:40]}」")
            fatal = [t for t in bad if len(t) >= 15]
            if fatal:
                print(f"  ★15文字以上の丸ごと欠落 {len(fatal)}箇所 → 確定.txt に該当行を追加してから再実行:")
                for t in fatal[:5]:
                    print(f"    - 「{t[:50]}」")
                sys.exit(1)
        return aligned

    aligned, why = align_strict()
    if aligned:
        print(f"整列OK（厳密モード・全ワードカバー V1=0）")
    else:
        print(f"厳密照合不成立（{why[:80]}）→ difflibモードに切替（対談・表記修正許容）")
        aligned = align_difflib()
        print(f"整列OK（difflibモード）")

    # ---- フレーム計算 ----
    ps = lambda w: map_o2p(round(w["start"] / 1000 * FPS)) + SYNC_DELAY
    pe = lambda w: map_o2p(math.ceil(w["end"] / 1000 * FPS))
    telops = []
    for line, fw, lw in aligned:
        telops.append({"text": line, "start": ps(words[fw]), "speechEnd": pe(words[lw]), "fw": fw, "lw": lw})
    for i, t in enumerate(telops):
        if i + 1 < len(telops):
            nxt = telops[i + 1]
            # ★意味優先トリミング: 確定.txt にあえて載せなかった冗長句（言い直し・即時の繰り返し）が
            #   このテロップと次テロップの間に残っている場合（次の fw が自分の lw+1 より先＝発話の隙間）、
            #   そこは無音ではなく「冗長な発話」なので、直前テロップを次の発話頭まで保持する。
            #   → 冗長な2枚目テロップを出さずに直前テロップの意味で読ませ続け、声だけ区間も作らない。
            #   ※ フルカバー（通常）動画では次 fw は必ず lw+1 なので発動せず、従来挙動を完全維持。
            if nxt["fw"] > t["lw"] + 1:
                t["end"] = nxt["start"]
                t["holdsDropped"] = True
            else:
                t["end"] = min(nxt["start"], t["speechEnd"] + HOLD_TAIL)
        else:
            # 最終テロップ: Whisperは末尾ワードのタイムスタンプを圧縮しがち（数百msに十数文字）。
            # カットが末尾無音≦0.5秒を保証しているため、動画末尾まで表示してもキャップ内。
            t["end"] = TOTAL
            t["speechEndReliable"] = False
        t["end"] = max(t["end"], t["speechEnd"])  # V3: 発話完了前に消えない
        if i + 1 < len(telops):
            t["end"] = min(t["end"], telops[i + 1]["start"])  # 重複禁止
        if t["end"] <= t["start"]:
            t["end"] = t["start"] + MIN_SHOW_FRAMES

    # ---- 話者付与（対談: words に speaker がある場合のみ） ----
    has_speaker = any("speaker" in w for w in words)
    if has_speaker:
        for i, t in enumerate(telops):
            seg = [words[k].get("speaker") for k in range(t["fw"], t["lw"] + 1)]
            cnt = Counter(s for s in seg if s)
            voice = cnt.most_common(1)[0][0] if cnt else None
            explicit = line_speakers[i]
            t["speaker"] = explicit or voice or "male"
            if explicit and voice and explicit != voice:
                print(f"  話者注意: 行{i + 1}「{t['text'][:12]}」プレフィックス={explicit} / 声紋多数={voice}"
                      f"（内容確定のプレフィックスを採用）")
            if not explicit and cnt and len(cnt) > 1:
                minority = 1 - cnt.most_common(1)[0][1] / sum(cnt.values())
                if minority >= 0.4:
                    print(f"  ★話者混在: 行{i + 1}「{t['text'][:12]}」{dict(cnt)} → 話者交代で行を分けるか M:/F: で確定すること")
        sp_cnt = Counter(t["speaker"] for t in telops)
        print(f"話者付与: {dict(sp_cnt)}（プレフィックス指定 {sum(1 for s in line_speakers if s)}行 / 声紋自動 {sum(1 for s in line_speakers if not s)}行）")

    # ---- スタイル/アニメ割当 ----
    tone = cfg.get("tone", "教育的")
    POOLS = POOLS_CALM if tone in ("教育的", "プロフェッショナル") else POOLS_LIVELY
    EMP = set(cfg.get("emphasis", [])) | set(cfg.get("list_slide", []))
    WAR = set(cfg.get("warning", []))
    SUC = set(cfg.get("success", []))
    LST = set(cfg.get("list_slide", []))
    style_count = {"emphasis": 0, "warning": 0, "success": 0}
    last_anims = []
    for i, t in enumerate(telops):
        n = i + 1
        st = "warning" if n in WAR else "success" if n in SUC else "emphasis" if n in EMP else "normal"
        t["style"] = st
        if st == "normal":
            t["anim"] = "none"
            continue
        if n == 1:  # ★冒頭はアニメ無し（クリーンに始める）
            t["anim"] = "none"
            continue
        if n in LST:  # リスト・見出しは slideFromLeft 統一（連続禁止の例外）
            t["anim"] = "slideFromLeft"
            style_count[st] += 1
            last_anims.append(t["anim"])
            continue
        pool = POOLS[st]
        k = style_count[st]
        anim = pool[k % len(pool)]
        if len(last_anims) >= 2 and last_anims[-1] == anim and last_anims[-2] == anim:
            for off in range(1, len(pool)):
                cand = pool[(k + off) % len(pool)]
                if cand != anim:
                    anim = cand
                    break
        t["anim"] = anim
        style_count[st] += 1
        last_anims.append(anim)

    # ---- タイトル ----
    def word_near_ms(ms):
        best = min(words, key=lambda w: abs(w["start"] - ms))
        if abs(best["start"] - ms) > 200:
            print(f"★タイトル word_start_ms={ms} に近いワードがありません（最近傍 {best['start']}ms「{best['text']}」）")
            sys.exit(1)
        return best

    titles = []
    tdefs = cfg.get("titles", [])
    for idx, td in enumerate(tdefs):
        start = 0 if td.get("word_start_ms") is None else ps(word_near_ms(td["word_start_ms"]))
        titles.append({"id": idx + 1, "text": td["text"], "start": start})
    for idx, ti in enumerate(titles):
        ti["end"] = titles[idx + 1]["start"] if idx + 1 < len(titles) else TOTAL

    # ---- 検証 V2〜V6 ----
    errors = []
    for i, t in enumerate(telops):
        n = i + 1
        if not (0 <= t["start"] < t["end"] <= TOTAL):
            errors.append(f"行{n} フレーム異常 {t['start']}-{t['end']}")
        if t.get("speechEndReliable", True) and not t.get("holdsDropped") and t["end"] - t["speechEnd"] > SILENT_CAP_S * FPS + 1e-9:
            errors.append(f"行{n} 無音上テロップ {(t['end'] - t['speechEnd']) / FPS:.2f}s")
        if i + 1 < len(telops) and t["end"] > telops[i + 1]["start"]:
            errors.append(f"行{n} 次と重複")
        if t["style"] == "normal" and t["anim"] != "none":
            errors.append(f"行{n} normalにアニメ")
    anim_seq = [(i + 1, t["anim"]) for i, t in enumerate(telops) if t["anim"] != "none"]
    for j in range(2, len(anim_seq)):
        a, b, c = anim_seq[j - 2], anim_seq[j - 1], anim_seq[j]
        if a[1] == b[1] == c[1] and not all(x[0] in LST for x in (a, b, c)):
            errors.append(f"アニメ3連続: 行{a[0]},{b[0]},{c[0]} = {c[1]}")
    ac = Counter(a for _, a in anim_seq)
    for a, cnt in ac.items():
        if cnt > len(anim_seq) * 0.5:
            errors.append(f"アニメ偏り: {a} {cnt}/{len(anim_seq)}")
    # V6 均一性（尺3分割）
    thirds = [[], [], []]
    for t in telops:
        thirds[min(2, t["start"] * 3 // TOTAL)].append(t)
    mins = TOTAL / 3 / FPS / 60
    dens = [(len(th), len(th) / mins, sum(len(x["text"]) for x in th) / max(1, len(th))) for th in thirds]
    print("V6 尺3分割 (個数, 個/分, 平均字数):", [(n, round(pm, 1), round(ch, 1)) for n, pm, ch in dens])
    for k in (1, 2):
        if dens[k][1] < dens[0][1] * 0.8:
            errors.append(f"後半1/3の密度低下: {dens[k][1]:.1f}/分 vs 前半 {dens[0][1]:.1f}/分 → 該当区間の改行を前半と同じ粒度でやり直す")
        if dens[k][2] > dens[0][2] * 1.2:
            errors.append(f"後半1/3の平均字数増: {dens[k][2]:.1f} vs 前半 {dens[0][2]:.1f}")

    if errors:
        print("★検証エラー（出力しません）:")
        for e in errors[:30]:
            print(" -", e)
        sys.exit(1)
    print("検証OK (V1-V6 + 無音キャップ)")

    # ---- 統計 ----
    sc = Counter(t["style"] for t in telops)
    anc = Counter(t["anim"] for t in telops)
    disp = sum(t["end"] - t["start"] for t in telops)
    print(f"テロップ {len(telops)}個 / スタイル {dict(sc)} / アニメ {dict(anc)}")
    print(f"平均 {sum(len(t['text']) for t in telops) / len(telops):.1f}字 / "
          f"平均表示 {disp / len(telops) / FPS:.2f}s / 密度 {disp / TOTAL * 100:.1f}%")
    caps = [t["end"] - t["speechEnd"] for t in telops if t.get("speechEndReliable", True)]
    print(f"無音キャップ最大: {max(caps)}f = {max(caps) / FPS:.2f}s (≦{SILENT_CAP_S}s)")

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

    # ---- 書き出し ----
    esc = lambda s: s.replace("\\", "\\\\").replace("'", "\\'")
    L = []
    L.append("import type { TelopSegment } from './telopTypes';")
    L.append("import { FPS as CONFIG_FPS, DURATION_FRAMES } from '../videoConfig';")
    L.append("")
    L.append("// ===== テロップデータ =====")
    L.append("// /supermovie-subtitles scripts/align_telop.py が自動生成")
    L.append("// （まさき式・節ベース改行 / 持続表示+無音キャップ0.4s / start=+2f同期）")
    L.append("")
    L.append("export const FPS = CONFIG_FPS;")
    L.append("export const TOTAL_FRAMES = DURATION_FRAMES;")
    L.append("")
    L.append("export const telopData: TelopSegment[] = [")
    for i, t in enumerate(telops):
        sp = f"speaker: '{t['speaker']}', " if t.get("speaker") else ""
        L.append(
            f"  {{ id: {i + 1}, startFrame: {t['start']}, endFrame: {t['end']}, "
            f"text: '{esc(t['text'])}', {sp}style: '{t['style']}', animation: '{t['anim']}' }},"
        )
    L.append("];")
    L.append("")
    tel_dir = out_dir / "src" / "テロップテンプレート"
    tel_dir.mkdir(parents=True, exist_ok=True)
    (tel_dir / "telopData.ts").write_text("\n".join(L))

    L = []
    L.append("import type { TitleSegment } from './Title';")
    L.append("")
    L.append("// ==== タイトル（セグメント見出し）データ ====")
    L.append("// /supermovie-subtitles scripts/align_telop.py が自動生成（冒頭はTitle.tsx側でアニメ無し）")
    L.append("export const titleData: TitleSegment[] = [")
    for ti in titles:
        L.append(
            f"  {{ id: {ti['id']}, startFrame: {ti['start']}, endFrame: {ti['end']}, text: '{esc(ti['text'])}' }},"
        )
    L.append("];")
    L.append("")
    ttl_dir = out_dir / "src" / "Title"
    ttl_dir.mkdir(parents=True, exist_ok=True)
    (ttl_dir / "titleData.ts").write_text("\n".join(L))
    print(f"書き出し完了: {tel_dir / 'telopData.ts'} / {ttl_dir / 'titleData.ts'}")
    print("タイトル:", [(t["id"], t["text"], t["start"], t["end"]) for t in titles])


if __name__ == "__main__":
    main()
