# -*- coding: utf-8 -*-
"""AUTOMOVIE — 里嶺大橋 2026/08/01

Builds a broadcast-style cut from:
  * 0801.mp4          4K30 helmet cam, already carries a burned-in telemetry overlay
  * ride .fit         full 97.8 km ride, sliced to the clip window
  * segment leaderboard screenshot (transcribed below)

Sync is exact, not guessed: the camera burns 'YYYY/MM/DD HH:MM:SS' into the
bottom-right corner. OCR of 12 sample frames all resolve to the same origin,
so   real_time = 08:57:01 + video_seconds   with zero drift.

Pipeline:  tts -> measure -> build .ass -> render
Run `python make_movie.py --help` for individual stages.
"""
import argparse
import asyncio
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path

sys.stdout.reconfigure(encoding="utf-8")

# ---------------------------------------------------------------- paths / config
ROOT = Path(r"C:\ai\AUTOMOVIE")
BUILD = ROOT / "build"
TTSDIR = BUILD / "tts"
PULSE = BUILD / "pulse.wav"   # HR/power-driven rhythm bed, see gen_pulse.py
SRC = ROOT / "0801.mp4"
ASS = BUILD / "subs.ass"
OUT_4K = ROOT / "里嶺大橋_成品_4K.mp4"
OUT_HD = ROOT / "里嶺大橋_成品_1080p.mp4"
FONTS = ROOT / "fonts"

FFDIR = Path(
    r"C:\Users\segaa\AppData\Local\Microsoft\WinGet\Packages"
    r"\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.1.2-full_build\bin"
)
FFMPEG, FFPROBE = str(FFDIR / "ffmpeg.exe"), str(FFDIR / "ffprobe.exe")

# override for one-off CPU/GPU comparisons: AUTOMOVIE_FFMPEG=<path to ffmpeg.exe>
_override = os.environ.get("AUTOMOVIE_FFMPEG")
if _override:
    FFMPEG = _override
    FFPROBE = str(Path(_override).with_name("ffprobe.exe"))

W, H = 3840, 2160
SRC_DUR = 220.203
OUTRO = 24.0                 # frozen-frame results card appended after the clip
                              # (extended from 16.0 for the longer closing narration)
TOTAL = SRC_DUR + OUTRO

SEG_IN, SEG_OUT = 55.0, 214.0   # Strava segment, located by matching 159 s / 50.2 km/h
TITLE_CARD_END = 7.0
KOM_S = 155                     # 2:35 by 庭嘉 SPACIOUS
VOICE = "zh-TW-YunJheNeural"

# ---------------------------------------------------------------- narration
# (start_seconds, spoken, subtitle). Subtitle falls back to the spoken text;
# it only differs where a clock value reads badly out loud ("2:39" -> 2分39秒).
# Superlatives are scoped to this clip on purpose: across the full 97.8 km ride
# the peaks were 62.4 km/h, 185 bpm and 599 W, all outside this window.
LINES = [
    (0.6,   "8月1號早上，里嶺大橋。", None),
    (9.0,   "時速45，心率165。奈特已經在拚了。", None),
    (33.0,  "踏頻拉到120，他自己先加速了。", None),
    (43.5,  "瓦數來到572，前面開始有人要被甩下車了。",
            "572W！前面開始有人被甩下車了"),
    (55.0,  "里嶺大橋，這一波節奏，被火車頭抓得死死的。", None),
    (65.0,  "心率178，正式踏進紅色區間。目前集團有點混亂，路線被卡死，不知道該怎麼走位。", None),
    (95.0,  "躲進一個小集團，稍微喘口氣，把力氣省下來。", None),
    (101.0, "瓦數來到452瓦，集團咬得很死，目前還沒有機會衝出去。",
            "452W　集團咬得死死的　還沒機會衝出去"),
    (111.5, "時速突破50公里了！", None),
    (126.0, "這次火車頭敲的超大力，已經連續17秒平均瓦數來到517，死命跟住不要被甩開。",
            "火車頭狂拉！17 秒均功率 517W　死咬不放"),
    (137.0, "心率184，今天這條路段的最高點。", "心率 184　今日本路段最高"),
    (147.0, "路線選擇錯誤，稍微被擋了一下。時速已經來到57，前面還有兩個。這波有機會追回來嗎？",
            "路線被擋了一下！57 km/h　前面還有兩個　追得回來嗎？"),
    (165.0, "速度開始掉了，但心率不肯掉。紅區已經連續超過100秒。硬直輸出死撐到底吧。", None),
    (194.0, "最後500公尺，奈特已經沒有多餘的力氣了，但他沒有放手。", None),
    (212.5, "通過終點！2分39秒！", "通過終點！2:39！"),
    (218.0, "strava路段，里嶺大橋東至西，均速50.2公里，平均功率301瓦，平均心率180，"
            "時間兩分三十九秒，這是今天在橋上留下的數字。",
            "Strava 路段：里嶺大橋東至西　均速 50.2　功率 301W　心率 180　2:39"),
    (233.5, "紅色心率區，撐了超過100秒，沒有一秒鬆懈。", None),
    (238.0, "第3名。這是奈特的里嶺大橋。", None),
]

# big centre-screen callouts: (start, dur, text, colour)
POPS = [
    (43.8, 2.2, "572 W　前方掉隊", "&H00A5FF&"),
    (101.3, 2.4, "452 W　集團咬得死死的", "&H00A5FF&"),
    (111.8, 1.8, "50 km/h", "&H00FFFF&"),
    (126.5, 2.4, "517W 均功率　死咬 17 秒", "&H0000FF&"),
    (137.3, 2.0, "184 bpm　今日本路段最高", "&H0000FF&"),
    (147.3, 2.4, "57.6 km/h　今日本路段最速", "&H00FFFF&"),
    (213.0, 3.2, "2:39　第 3 名", "&H00FFFF&"),
]

LEADERBOARD = [
    ("1", "庭嘉 SPACIOUS", "2:35", "51.5", "322 W", "168"),
    ("2", "Fu shiu Cheung", "2:36", "51.2", "—", "—"),
    ("3", "TW 奈特 TW", "2:39", "50.2", "301 W", "180"),
    ("3", "雲明", "2:39", "50.2", "376 W", "—"),
    ("5", "Judy Lan", "2:40", "49.9", "197 W", "—"),
    ("5", "昱叡 江", "2:40", "49.9", "243 W", "171"),
    ("7", "Han Hsuan Mei", "2:42", "49.3", "244 W", "189"),
]


# ---------------------------------------------------------------- helpers
def ts(sec: float) -> str:
    sec = max(0.0, sec)
    h, sec = divmod(sec, 3600)
    m, s = divmod(sec, 60)
    return f"{int(h)}:{int(m):02d}:{s:05.2f}"


def wrap(text: str, width: int = 20) -> str:
    """Wrap CJK text into at most two ASS lines, preferring punctuation breaks."""
    if len(text) <= width:
        return text
    cut = -1
    for i, ch in enumerate(text):
        if ch in "，。！？、" and i <= width:
            cut = i + 1
    if cut < 0:
        cut = width
    a, b = text[:cut], text[cut:]
    return f"{a.rstrip('，。')}\\N{b}" if b else a


def rect(x, y, w, h):
    return f"{{\\p1}}m {x} {y} l {x+w} {y} l {x+w} {y+h} l {x} {y+h}{{\\p0}}"


def run(cmd, **kw):
    r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
                       errors="replace", **kw)
    if r.returncode != 0:
        print("FAILED:", " ".join(str(c) for c in cmd[:6]), "...")
        print((r.stderr or "")[-3000:])
        raise SystemExit(r.returncode)
    return r


_ENC = None


def pick_encoder() -> str:
    """h264_nvenc if the installed driver actually supports this ffmpeg's API.

    ffmpeg 8.1.2 wants nvenc API 13.1 (driver 610+); the GTX 1080 here reports
    13.0, so it silently fails at encoder-open time. Probe rather than assume.
    """
    global _ENC
    if _ENC:
        return _ENC
    probe = subprocess.run(
        [FFMPEG, "-v", "error", "-f", "lavfi",
         "-i", "color=size=640x360:duration=0.1:rate=10",
         "-c:v", "h264_nvenc", "-f", "null", "-"],
        capture_output=True, text=True)
    _ENC = "h264_nvenc" if probe.returncode == 0 else "libx264"
    print(f"  encoder: {_ENC}")
    return _ENC


def probe_dur(path) -> float:
    r = run([FFPROBE, "-v", "error", "-show_entries", "format=duration",
             "-of", "default=nw=1:nk=1", str(path)])
    return float(r.stdout.strip())


# ---------------------------------------------------------------- stage 1: TTS
def _voice_path(i: int, spoken: str) -> Path:
    """Cache key includes the text, so editing a line re-synthesises it."""
    h = hashlib.md5(spoken.encode("utf-8")).hexdigest()[:8]
    return TTSDIR / f"n{i:02d}_{h}.mp3"


async def _tts():
    import edge_tts
    TTSDIR.mkdir(parents=True, exist_ok=True)
    for i, (start, spoken, _sub) in enumerate(LINES):
        dst = _voice_path(i, spoken)
        if dst.exists():
            continue
        await edge_tts.Communicate(spoken, VOICE, rate="+18%", pitch="+3Hz").save(str(dst))
        print(f"  tts {i:02d} @ {start:6.1f}s  {spoken[:24]}")


def stage_tts():
    print("== TTS ==")
    asyncio.run(_tts())
    out = []
    for i, (start, spoken, sub) in enumerate(LINES):
        p = _voice_path(i, spoken)
        out.append({"i": i, "start": start, "text": spoken,
                    "sub": sub if sub else spoken,
                    "file": str(p), "dur": probe_dur(p)})
    (BUILD / "narration.json").write_text(
        json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
    last = max(o["start"] + o["dur"] for o in out)
    print(f"  {len(out)} lines, narration ends at {last:.1f}s (video is {TOTAL:.0f}s)")
    for o in out:
        nxt = next((x["start"] for x in out if x["start"] > o["start"]), 1e9)
        if o["start"] + o["dur"] > nxt + 0.15:
            print(f"  ! line {o['i']} overruns the next by "
                  f"{o['start']+o['dur']-nxt:.1f}s")
    return out


# ---------------------------------------------------------------- stage 2: ASS
def stage_ass(narr):
    print("== ASS ==")
    win = json.loads((ROOT / "window_data.json").read_text(encoding="utf-8"))["records"]
    by_t = {}
    for r in win:
        by_t.setdefault(int(round(r["vt"])), r)

    ev = []

    def add(start, end, style, text, layer=0):
        ev.append(f"Dialogue: {layer},{ts(start)},{ts(end)},{style},,0,0,0,,{text}")

    # ---- narration subtitles (bottom centre, above the stock overlay) ----
    for o in narr:
        a, b = o["start"], o["start"] + o["dur"] + 0.35
        add(a, b, "Narr",
            f"{{\\pos(1920,1580)\\an2\\fad(180,220)}}{wrap(o.get('sub') or o['text'])}")

    # ---- opening hook over the leaderboard title card ----
    add(0.9, TITLE_CARD_END, "Hook",
        f"{{\\pos(1920,300)\\an8\\fad(300,300)}}距離冠軍　只差 4 秒")

    # (a running segment timer lived here; removed — it read as an unexplained
    #  number on screen and the finish time is called out at the end anyway)

    # ---- heart-rate zone column, right edge ----
    # sits between the stock G-meter (ends y~315) and speedometer (starts y~1410)
    BX, BY, BW, BH = 3676, 520, 68, 740
    HMIN, HMAX = 140, 186
    add(TITLE_CARD_END, SRC_DUR, "Bar",
        f"{{\\pos(0,0)\\an7\\c&H303030&\\alpha&H90&}}{rect(BX, BY, BW, BH)}")
    add(TITLE_CARD_END, SRC_DUR, "BarLbl",
        f"{{\\pos({BX+BW//2},{BY+BH+18})\\an8\\c&HC0C0C0&}}HR")
    for t in range(int(TITLE_CARD_END), int(SRC_DUR)):
        r = by_t.get(t)
        if not r or r.get("hr") is None:
            continue
        hr = r["hr"]
        frac = min(1.0, max(0.0, (hr - HMIN) / (HMAX - HMIN)))
        fh = int(BH * frac)
        if hr >= 178:
            col, zone = "&H0000FF&", "Z5"
        elif hr >= 170:
            col, zone = "&H0060FF&", "Z4"
        elif hr >= 160:
            col, zone = "&H00C0FF&", "Z3"
        else:
            col, zone = "&H40D040&", "Z2"
        add(t, t + 1, "Bar",
            f"{{\\pos(0,0)\\an7\\c{col}\\alpha&H30&}}"
            f"{rect(BX, BY + BH - fh, BW, fh)}")
        add(t, t + 1, "BarLbl",
            f"{{\\pos({BX+BW//2},{BY-16})\\an2\\c{col}}}{zone}\\N{hr:.0f}")

    # ---- power burst: pulsing frame around the picture ----
    bursts, run_ = [], []
    for t in range(int(SRC_DUR)):
        r = by_t.get(t)
        p = r.get("pwr") if r else None
        if p is not None and p >= 450:
            run_.append(t)
        else:
            if len(run_) >= 2:
                bursts.append((run_[0], run_[-1] + 1))
            run_ = []
    if len(run_) >= 2:
        bursts.append((run_[0], run_[-1] + 1))
    TH = 18
    PULSE = 1.4
    edges = (rect(0, 0, W, TH), rect(0, H - TH, W, TH),
             rect(0, 0, TH, H), rect(W - TH, 0, TH, H))
    for a, b in bursts:
        # repeat a short pulse for the whole burst rather than holding a static
        # border, which reads as a rendering artefact on the longer 12 s efforts
        t0 = a
        while t0 < b:
            t1 = min(t0 + PULSE, b)
            ms = int((t1 - t0) * 1000)
            for edge in edges:
                add(t0, t1, "Flash",
                    f"{{\\pos(0,0)\\an7\\c&H0060FF&\\alpha&HFF&"
                    f"\\t(0,{ms//2},\\alpha&H50&)\\t({ms//2},{ms},\\alpha&HFF&)}}{edge}",
                    layer=1)
            t0 = t1
    print(f"  power bursts: {len(bursts)}  -> {[f'{a}-{b}s' for a, b in bursts]}")

    # ---- centre callouts ----
    for start, dur, text, col in POPS:
        add(start, start + dur, "Pop",
            f"{{\\pos(1920,900)\\an5\\c{col}\\fad(120,260)"
            f"\\fscx55\\fscy55\\t(0,170,\\fscx112\\fscy112)"
            f"\\t(170,300,\\fscx100\\fscy100)}}{text}", layer=2)

    # ---- outro: dim the frozen frame, then the result card ----
    o0 = SRC_DUR
    add(o0, TOTAL, "Bar",
        f"{{\\pos(0,0)\\an7\\c&H000000&\\alpha&H28&\\fad(500,0)}}{rect(0,0,W,H)}",
        layer=3)
    add(o0 + 0.4, TOTAL, "CardTitle",
        "{\\pos(1920,250)\\an8\\fad(400,0)}里嶺大橋　·　2026.08.01")
    add(o0 + 0.6, TOTAL, "CardSub",
        "{\\pos(1920,470)\\an8\\fad(400,0)}2:39　—　第 3 名")

    y = 700
    hdr = "{\\pos(560,%d)\\an7\\fad(400,0)}名次      選手                          成績     均速      功率     心率" % y
    add(o0 + 0.9, TOTAL, "CardHdr", hdr, layer=4)
    add(o0 + 0.9, TOTAL, "Bar",
        f"{{\\pos(0,0)\\an7\\c&HFFFFFF&\\alpha&H60&\\fad(400,0)}}{rect(560, y+92, 2760, 4)}",
        layer=4)
    for k, (pos, name, tm, kmh, pw, hr) in enumerate(LEADERBOARD):
        yy = y + 150 + k * 118
        me = name.startswith("TW 奈特")
        style = "CardMe" if me else "CardRow"
        if me:
            add(o0 + 1.0 + k * 0.09, TOTAL, "Bar",
                f"{{\\pos(0,0)\\an7\\c&H00FFFF&\\alpha&HB0&\\fad(400,0)}}"
                f"{rect(540, yy-14, 2800, 108)}", layer=4)
        add(o0 + 1.0 + k * 0.09, TOTAL, style,
            f"{{\\pos(600,{yy})\\an7\\fad(400,0)}}{pos}", layer=5)
        add(o0 + 1.0 + k * 0.09, TOTAL, style,
            f"{{\\pos(800,{yy})\\an7\\fad(400,0)}}{name}", layer=5)
        add(o0 + 1.0 + k * 0.09, TOTAL, style,
            f"{{\\pos(2280,{yy})\\an7\\fad(400,0)}}{tm}", layer=5)
        add(o0 + 1.0 + k * 0.09, TOTAL, style,
            f"{{\\pos(2600,{yy})\\an7\\fad(400,0)}}{kmh}", layer=5)
        add(o0 + 1.0 + k * 0.09, TOTAL, style,
            f"{{\\pos(2920,{yy})\\an7\\fad(400,0)}}{pw}", layer=5)
        add(o0 + 1.0 + k * 0.09, TOTAL, style,
            f"{{\\pos(3220,{yy})\\an7\\fad(400,0)}}{hr}", layer=5)

    add(o0 + 2.2, TOTAL, "CardFoot",
        "{\\pos(1920,1830)\\an8\\fad(500,0)}"
        "紅區心率 178+ 連續 104 秒　·　最高 184 bpm　·　最速 57.6 km/h　·　爆發 572 W")

    header = f"""[Script Info]
Title: 里嶺大橋 20260801
ScriptType: v4.00+
PlayResX: {W}
PlayResY: {H}
WrapStyle: 2
ScaledBorderAndShadow: yes
YCbCr Matrix: TV.709

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Narr,Microsoft JhengHei,104,&H00FFFFFF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,1,0,1,6,4,2,80,80,60,1
Style: Hook,Microsoft JhengHei,150,&H0000E5FF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,6,0,1,7,4,8,60,60,60,1
Style: Pop,Microsoft JhengHei,190,&H00FFFFFF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,4,0,1,9,5,5,60,60,60,1
Style: Timer,Microsoft JhengHei,132,&H0000FFFF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,2,0,1,6,3,8,40,40,40,1
Style: TimerLbl,Microsoft JhengHei,56,&H00E0E0E0,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,4,0,1,4,2,8,40,40,40,1
Style: Bar,Microsoft JhengHei,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1
Style: BarLbl,Microsoft JhengHei,46,&H00FFFFFF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,1,0,1,4,2,8,0,0,0,1
Style: Flash,Microsoft JhengHei,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1
Style: CardTitle,Microsoft JhengHei,120,&H00FFFFFF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,8,0,1,5,3,8,60,60,60,1
Style: CardSub,Microsoft JhengHei,150,&H0000E5FF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,4,0,1,6,3,8,60,60,60,1
Style: CardHdr,Microsoft JhengHei,56,&H00B0B0B0,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,2,0,1,3,2,7,0,0,0,1
Style: CardRow,Microsoft JhengHei,72,&H00FFFFFF,&H000000FF,&H00101010,&H80000000,0,0,0,0,100,100,1,0,1,4,2,7,0,0,0,1
Style: CardMe,Microsoft JhengHei,76,&H00000000,&H000000FF,&H0000FFFF,&H80000000,-1,0,0,0,100,100,1,0,1,3,0,7,0,0,0,1
Style: CardFoot,Microsoft JhengHei,58,&H00D0D0D0,&H000000FF,&H00101010,&H80000000,0,0,0,0,100,100,3,0,1,4,2,8,60,60,60,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
    BUILD.mkdir(parents=True, exist_ok=True)
    ASS.write_text(header + "\n".join(ev) + "\n", encoding="utf-8-sig")
    print(f"  {len(ev)} events -> {ASS}")


# ---------------------------------------------------------------- stage 3: render
def stage_render(narr, hd=False):
    label = "1080p" if hd else "4K"
    dst = OUT_HD if hd else OUT_4K
    print(f"== RENDER {label} ==")

    cmd = [FFMPEG, "-y", "-hide_banner", "-v", "warning", "-stats", "-i", str(SRC)]
    # each narration file is fed in THREE TIMES (independent decode per copy):
    # one chain drives the ambient-duck trigger, one is the audible copy
    # actually mixed in, one drives the pulse-bed-duck trigger. Sharing a
    # single decoded/split stream between a sidechaincompress trigger and a
    # later amix input was verified (empirically, via isolated filter-graph
    # tests) to corrupt the mix on this ffmpeg build — "silent" regions came
    # out louder than the ducked bed alone, voice vs. no-voice stopped being
    # distinguishable. Duplicating the tiny TTS clips is cheap; do that instead.
    for _ in range(3):
        for o in narr:
            cmd += ["-i", o["file"]]
    nA = len(narr)
    pulse_idx = 1 + 3 * nA
    cmd += ["-i", str(PULSE)]

    ass_path = str(ASS).replace("\\", "/").replace(":", r"\:")
    fonts_path = str(FONTS).replace("\\", "/").replace(":", r"\:")

    vf = (f"[0:v]tpad=stop_mode=clone:stop_duration={OUTRO},"
          f"subtitles=filename='{ass_path}':fontsdir='{fonts_path}'")
    if hd:
        vf += ",scale=1920:1080:flags=lanczos"
    vf += "[v]"

    parts = [vf]

    def narr_chain(offset, suffix):
        mixin = []
        for k, o in enumerate(narr, start=1):
            idx = offset + k
            parts.append(f"[{idx}:a]adelay={int(o['start']*1000)}|{int(o['start']*1000)},"
                         f"volume=2.3[n{suffix}{k}]")
            mixin.append(f"[n{suffix}{k}]")
        parts.append("".join(mixin) + f"amix=inputs={len(narr)}:normalize=0:"
                     f"dropout_transition=0[nar{suffix}]")
        parts.append(f"[nar{suffix}]apad=whole_dur={TOTAL}[narp{suffix}]")

    narr_chain(0, "A")      # drives the ambient-duck trigger only
    narr_chain(nA, "B")     # actually mixed into the output
    narr_chain(2 * nA, "C")  # drives the pulse-bed-duck trigger only

    # ambient bed sits well under the voice by default, not just while ducked
    parts.append(f"[0:a]apad=whole_dur={TOTAL},volume=0.45[amb]")
    parts.append("[amb][narpA]sidechaincompress=threshold=0.025:ratio=14:"
                 "attack=20:release=400:makeup=1[duck]")

    # HR/power-driven pulse bed: present but subtle, recedes further while
    # Nate's talking so it never competes with the narration for clarity
    parts.append(f"[{pulse_idx}:a]volume=0.55,apad=whole_dur={TOTAL}[pulseRaw]")
    parts.append("[pulseRaw][narpC]sidechaincompress=threshold=0.03:ratio=8:"
                 "attack=25:release=450:makeup=1[pulseDuck]")

    parts.append("[duck][narpB][pulseDuck]amix=inputs=3:normalize=0[mixed]")
    parts.append(f"[mixed]alimiter=limit=0.95,atrim=0:{TOTAL},asetpts=N/SR/TB[a]")

    cmd += ["-filter_complex", ";".join(parts), "-map", "[v]", "-map", "[a]"]
    enc = pick_encoder()
    q = "21" if not hd else "22"
    if enc == "h264_nvenc":
        cmd += ["-c:v", "h264_nvenc", "-preset", "p5", "-rc", "vbr",
                "-cq", q, "-b:v", "0"]
    else:
        cmd += ["-c:v", "libx264", "-preset", "faster", "-crf", q,
                "-threads", "0"]
    cmd += ["-pix_fmt", "yuv420p", "-movflags", "+faststart",
            "-c:a", "aac", "-b:a", "192k", "-t", str(TOTAL), str(dst)]

    (BUILD / f"cmd_{label}.txt").write_text(
        "\n".join(str(c) for c in cmd), encoding="utf-8")
    run(cmd)
    mb = dst.stat().st_size / 1024 / 1024
    print(f"  -> {dst}  ({mb:.0f} MB, {probe_dur(dst):.1f}s)")


# ---------------------------------------------------------------- stage: preview
def stage_preview(times):
    print("== PREVIEW FRAMES ==")
    pv = BUILD / "preview"
    pv.mkdir(parents=True, exist_ok=True)
    ass_path = str(ASS).replace("\\", "/").replace(":", r"\:")
    fonts_path = str(FONTS).replace("\\", "/").replace(":", r"\:")
    for t in times:
        # png: the cloned outro frames trip mjpeg's colour-range check
        dst = pv / f"p_{int(t):04d}.png"
        vf = (f"tpad=stop_mode=clone:stop_duration={OUTRO},"
              f"subtitles=filename='{ass_path}':fontsdir='{fonts_path}',"
              f"scale=1600:-1")
        # outro frames live past the source EOF, so the *input* seek has to stay
        # inside the file and let tpad generate the rest
        seek = min(max(0.0, t - 2), SRC_DUR - 4)
        run([FFMPEG, "-y", "-v", "error", "-copyts", "-ss", str(seek),
             "-i", str(SRC), "-ss", str(t), "-frames:v", "1", "-vf", vf, str(dst)])
        if not dst.exists():
            raise SystemExit(f"preview t={t} produced no frame")
        print(f"  t={t:6.1f}s -> {dst.name}")


# ---------------------------------------------------------------- main
if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--stage", default="all",
                    choices=["all", "tts", "ass", "preview", "render", "hd"])
    ap.add_argument("--at", default="20,60,130,150,214,226",
                    help="preview timestamps, comma separated")
    a = ap.parse_args()

    BUILD.mkdir(parents=True, exist_ok=True)
    nf = BUILD / "narration.json"

    if a.stage in ("all", "tts") or not nf.exists():
        narr = stage_tts()
    else:
        narr = json.loads(nf.read_text(encoding="utf-8"))

    if a.stage in ("all", "ass", "preview", "render", "hd"):
        stage_ass(narr)
    if a.stage == "preview":
        stage_preview([float(x) for x in a.at.split(",")])
    if a.stage in ("all", "render"):
        stage_render(narr, hd=False)
    if a.stage in ("all", "hd"):
        stage_render(narr, hd=True)
    print("done.")
