# -*- coding: utf-8 -*-
"""AUTOMOVIE — ride video auto-narration app.

Run:  python main.py   ->  http://127.0.0.1:8765
"""
import json
import re
import subprocess
import sys
import threading
import traceback
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path

# The embeddable Python distribution (used by the portable dist/ bundle) puts
# a python3XX._pth file next to python.exe, which switches the interpreter to
# an "isolated" sys.path built ONLY from that file — the usual "add the
# launched script's own directory" behaviour is suppressed. Do it explicitly
# so `import core` resolves under any interpreter (embeddable, system, venv).
sys.path.insert(0, str(Path(__file__).resolve().parent))

import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
                               StreamingResponse)
from fastapi.staticfiles import StaticFiles

from core import (ai_cli, attachments as atts, dashboard, db, fitio, pulse,
                  scenes, scriptgen, ttsio)
from core import render as renderer
from core.ffbin import (JobCancelled, ffmpeg, load_config, nvenc_ok,
                        probe_video, run, save_config)

APP_DIR = Path(__file__).resolve().parent
PROJECTS = APP_DIR / "projects"
PROJECTS.mkdir(exist_ok=True)

app = FastAPI(title="AUTOMOVIE")
JOBS: dict[str, dict] = {}

db.init({"scene": scenes.SCENE_PROMPT, "script": scriptgen.AI_PROMPT})


@app.exception_handler(Exception)
async def unhandled_exception(request: Request, exc: Exception):
    """Default Starlette behaviour returns a bare "Internal Server Error"
    text body on an unhandled exception, which the frontend's `r.json()`
    then fails to parse — showing a cryptic "Unexpected token" instead of
    the actual problem. Return the real error as JSON instead."""
    return JSONResponse(
        status_code=500,
        content={"detail": f"{type(exc).__name__}: {exc}",
                "traceback": traceback.format_exc()[-2000:]})


# ------------------------------------------------------------- helpers
def pdir(pid: str) -> Path:
    d = PROJECTS / pid
    if not d.exists():
        raise HTTPException(404, f"project {pid} not found")
    return d


def pmeta(pid: str) -> dict:
    return json.loads((pdir(pid) / "project.json").read_text(encoding="utf-8"))


def psave(pid: str, meta: dict):
    (pdir(pid) / "project.json").write_text(
        json.dumps(meta, ensure_ascii=False, indent=1), encoding="utf-8")


def _clips(meta: dict) -> list:
    """Unified clip view: a multi-clip project stores meta['clips'];
    a legacy single-video project is presented as one clip so shared code
    paths don't need to branch."""
    if meta.get("clips"):
        return meta["clips"]
    return [{"path": meta["video_path"], "offset": 0.0,
             "duration": meta["video"]["duration"],
             "sync": meta.get("sync")}]


def _locate(meta: dict, t: float):
    """Map an OUTPUT-timeline second to (clip_path, local_second)."""
    for c in _clips(meta):
        if t < c["offset"] + c["duration"] or c is _clips(meta)[-1]:
            return c["path"], max(0.0, t - c["offset"])
    c = _clips(meta)[-1]
    return c["path"], max(0.0, t - c["offset"])


def _rebuild_window(pid: str, meta: dict):
    """Composite FIT window across all synced clips, on the output timeline:
    each clip contributes records within [t0, t0+duration], re-based to
    vt = clip_offset + (T - t0). Real-time gaps between clips simply don't
    exist on the output timeline — downstream consumers (highlights, pulse,
    dashboard, narration) all speak vt and keep working unchanged."""
    fit_data = json.loads((pdir(pid) / "fit.json").read_text(encoding="utf-8"))
    win = []
    for c in _clips(meta):
        sync = c.get("sync")
        if not sync:
            continue
        part = fitio.slice_window(fit_data, sync["video_t0"], c["duration"])
        for r in part:
            # strictly [0, duration): a boundary second belongs to exactly
            # ONE clip — overlapping margins here produced duplicate vt
            # entries where the later clip's record shadowed the earlier's
            if 0 <= r["vt"] < c["duration"]:
                r2 = dict(r)
                r2["vt"] = round(r["vt"] + c["offset"], 1)
                win.append(r2)
    win.sort(key=lambda r: r["vt"])
    (pdir(pid) / "window.json").write_text(json.dumps(win, ensure_ascii=False),
                                           encoding="utf-8")
    meta["window_stats"] = fitio.window_stats(win)
    return win


def _save_script_txt(d: Path, script: list):
    """Human-readable copy of the narration script, alongside the JSON in
    project.json — this is one of the things the user asked to keep on disk."""
    lines = [f"{ln['start']:>7.1f}s  {ln['spoken']}" +
            (f"\n         字幕: {ln['sub']}" if ln.get("sub") else "")
            for ln in script]
    (d / "旁白腳本.txt").write_text("\n".join(lines), encoding="utf-8")


def start_job(kind: str, fn) -> str:
    jid = uuid.uuid4().hex[:8]
    stop = threading.Event()
    JOBS[jid] = {"kind": kind, "status": "running", "log": [],
                 "pct": 0.0, "stage": "", "_stop": stop}

    def log(msg):
        JOBS[jid]["log"].append(str(msg))

    def progress(pct=None, stage=None):
        if pct is not None:
            JOBS[jid]["pct"] = round(max(0.0, min(100.0, float(pct))), 1)
        if stage is not None:
            JOBS[jid]["stage"] = str(stage)

    def wrapper():
        try:
            fn(log, progress, stop.is_set)
            JOBS[jid]["pct"] = 100.0
            JOBS[jid]["status"] = "done"
        except JobCancelled as e:
            JOBS[jid]["status"] = "cancelled"
            JOBS[jid]["stage"] = "已中止"
            log(f"⏹ {e}")
        except Exception:
            JOBS[jid]["status"] = "error"
            log(traceback.format_exc()[-1500:])

    threading.Thread(target=wrapper, daemon=True).start()
    return jid


@app.post("/api/job/{jid}/cancel")
def cancel_job(jid: str):
    j = JOBS.get(jid)
    if not j:
        raise HTTPException(404, "找不到這個工作")
    if j["status"] != "running":
        return {"ok": True, "status": j["status"]}
    j["_stop"].set()
    j["stage"] = "中止中…"
    return {"ok": True, "status": "cancelling"}


# ------------------------------------------------------------- endpoints
@app.get("/", response_class=HTMLResponse)
def index():
    return (APP_DIR / "static" / "index.html").read_text(encoding="utf-8")


def _pick_file(title: str, filetypes: list):
    """Native Windows file dialog. Runs entirely on this machine (same box
    as the browser), so it only ever returns a path — no file bytes cross
    the wire. Requires tkinter, which ships with a normal Python install but
    NOT with the embeddable distribution used by the portable dist/ bundle;
    callers should fall back to manual path entry if this errors."""
    import tkinter as tk
    from tkinter import filedialog
    root = tk.Tk()
    root.withdraw()
    root.attributes("-topmost", True)
    try:
        path = filedialog.askopenfilename(title=title, filetypes=filetypes)
    finally:
        root.destroy()
    return path or None


@app.get("/api/browse")
def browse(kind: str = "video"):
    """Opens a native file-picker dialog and returns the chosen path."""
    if kind == "fit":
        title = "選擇 FIT 檔"
        filetypes = [("FIT 檔", "*.fit"), ("所有檔案", "*.*")]
    elif kind == "image":
        title = "選擇圖檔"
        filetypes = [("圖檔", "*.jpg *.jpeg *.png *.webp *.bmp"), ("所有檔案", "*.*")]
    elif kind == "file":
        title = "選擇文件檔"
        filetypes = [("文件", "*.txt *.md *.csv *.docx"), ("所有檔案", "*.*")]
    else:
        title = "選擇影片"
        filetypes = [("影片檔", "*.mp4 *.mov *.mkv *.avi *.m4v"), ("所有檔案", "*.*")]
    try:
        path = _pick_file(title, filetypes)
    except ImportError:
        raise HTTPException(501, "此環境沒有內建選檔視窗（tkinter 未安裝），請直接貼路徑")
    return {"path": path}


@app.get("/api/config")
def get_config():
    cfg = load_config()
    return {"ai_cmd": cfg.get("ai_cmd", ""),
            "ai_cmds": ai_cli.templates(),
            "ffmpeg_path": cfg.get("ffmpeg_path", ""),
            "ffmpeg_in_use": ffmpeg(),
            "nvenc": nvenc_ok()}


@app.put("/api/config")
def put_config(body: dict):
    cfg = load_config()
    if "ai_cmds" in body:
        chain = [str(t).strip() for t in (body["ai_cmds"] or [])
                 if str(t).strip()]
        cfg["ai_cmds"] = chain
        # keep the legacy single key pointing at the head of the chain so a
        # downgrade (or anything still reading ai_cmd) stays consistent
        cfg["ai_cmd"] = chain[0] if chain else ""
    elif "ai_cmd" in body:                 # legacy single-template save
        cfg["ai_cmd"] = body["ai_cmd"]
        cfg["ai_cmds"] = [body["ai_cmd"]] if str(body["ai_cmd"]).strip() else []
    if "ffmpeg_path" in body:
        cfg["ffmpeg_path"] = body["ffmpeg_path"]
    save_config(cfg)
    return {"ok": True, "ai_cmds": ai_cli.templates()}


@app.get("/api/ai/detect")
def ai_detect():
    return ai_cli.detect_clis()


TEST_IMAGE = APP_DIR / "assets" / "test_frame.jpg"


@app.post("/api/ai/test")
def ai_test(body: dict):
    # the configured templates need {image} to make sense (they're for scene
    # description), so the "測試" button always has a real image to hand it —
    # otherwise every template would fail this check for a reason that has
    # nothing to do with whether the template actually works.
    image = body.get("image") or (str(TEST_IMAGE) if TEST_IMAGE.exists() else None)
    prompt = body.get("prompt") or "這是一張自行車行車紀錄畫面，用一句話描述騎乘情境"
    try:
        reply = ai_cli.ask(prompt, image=image, timeout=60)
        return {"ok": True, "reply": reply[:500]}
    except Exception as e:  # noqa: BLE001
        return {"ok": False, "error": str(e)[-500:]}


@app.get("/api/projects")
def list_projects():
    out = []
    for d in sorted(PROJECTS.iterdir()):
        pj = d / "project.json"
        if pj.exists():
            m = json.loads(pj.read_text(encoding="utf-8"))
            out.append({"id": d.name, "name": m.get("name"),
                        "created": m.get("created")})
    return out


@app.post("/api/projects")
def create_project(body: dict):
    """Single-machine tool: video/FIT stay where they are on disk, only the
    absolute path is recorded. The project folder holds generated artifacts
    only (screenshots, prompts used, narration script, TTS audio, output) —
    never a copy of the multi-GB source video."""
    raw_paths = body.get("video_paths") or [body.get("video_path", "")]
    vpaths = [Path(str(p)).expanduser() for p in raw_paths if str(p).strip()]
    fpath = Path(body.get("fit_path", "")).expanduser()
    if not vpaths:
        raise HTTPException(400, "請至少提供一個影片路徑")
    for v in vpaths:
        if not v.is_file():
            raise HTTPException(400, f"找不到影片檔: {v}")
    if not fpath.is_file():
        raise HTTPException(400, f"找不到 FIT 檔: {fpath}")

    infos = [probe_video(str(v)) for v in vpaths]
    if len(vpaths) > 1:
        # concat without re-scaling needs identical geometry/fps — same
        # camera, same session normally guarantees this; reject early with a
        # clear message instead of failing deep inside the render
        base = (infos[0]["width"], infos[0]["height"], infos[0]["fps"])
        for v, i in zip(vpaths[1:], infos[1:]):
            if (i["width"], i["height"], i["fps"]) != base:
                raise HTTPException(400,
                    f"片段規格不一致：{vpaths[0].name} 是 "
                    f"{base[0]}x{base[1]}@{base[2]}，但 {v.name} 是 "
                    f"{i['width']}x{i['height']}@{i['fps']}。"
                    f"多段合併需要相同解析度與幀率（同一台相機同一次設定）。")

    pid = datetime.now().strftime("%Y%m%d_%H%M%S")
    d = PROJECTS / pid
    d.mkdir(parents=True)

    fit_data = fitio.parse_fit(str(fpath))
    (d / "fit.json").write_text(json.dumps(fit_data, ensure_ascii=False),
                                encoding="utf-8")
    total = round(sum(i["duration"] for i in infos), 3)
    meta = {"name": vpaths[0].name
            + (f" (+{len(vpaths)-1}段)" if len(vpaths) > 1 else ""),
            "created": datetime.now().isoformat(),
            "fit_path": str(fpath),
            "video": {**infos[0], "duration": total},
            "fit_records": len(fit_data["records"]),
            "fit_start": fit_data["records"][0]["t"] if fit_data["records"] else None,
            "fit_end": fit_data["records"][-1]["t"] if fit_data["records"] else None,
            "sync": None, "script": [], "frames": [], "interval": 10,
            "context": ""}
    if len(vpaths) > 1:
        off = 0.0
        clips = []
        for v, i in zip(vpaths, infos):
            clips.append({"path": str(v), "duration": i["duration"],
                          "offset": round(off, 3), "sync": None,
                          "name": v.name})
            off += i["duration"]
        meta["clips"] = clips
    else:
        meta["video_path"] = str(vpaths[0])
    psave(pid, meta)
    return {"id": pid, **meta}


@app.get("/api/p/{pid}")
def get_project(pid: str):
    return {"id": pid, **pmeta(pid)}


@app.get("/api/p/{pid}/frame")
def get_frame(pid: str, t: float = 0, clip: int = -1):
    """On-demand single frame (used by the sync step). Reads straight from
    the source path on disk, no local copy involved.

    clip >= 0: `t` is LOCAL seconds within that clip (per-clip sync UI).
    clip == -1: `t` is output-timeline seconds, mapped through the clips."""
    d = pdir(pid)
    meta = pmeta(pid)
    if clip >= 0:
        cl = _clips(meta)
        if clip >= len(cl):
            raise HTTPException(404, "clip index out of range")
        video_path, local_t = cl[clip]["path"], max(0.0, t)
    else:
        video_path, local_t = _locate(meta, t)
    cache = d / "framecache"
    cache.mkdir(exist_ok=True)
    dst = cache / f"t_{clip}_{local_t:.1f}.jpg"
    if not dst.exists():
        run([ffmpeg(), "-y", "-v", "error", "-ss", str(local_t),
             "-i", video_path, "-frames:v", "1",
             "-vf", "scale=960:-1", str(dst)])
    return FileResponse(dst)


@app.get("/api/p/{pid}/frames/{name:path}")
def get_scan_frame(pid: str, name: str):
    # name carries the per-interval subdirectory, e.g. "i10/f_0003.jpg"
    root = (pdir(pid) / "frames").resolve()
    p = (root / name).resolve()
    if root not in p.parents or not p.is_file():
        raise HTTPException(404)
    return FileResponse(p)


def _apply_sync(pid: str, video_t: float, clock: str, clip: int = -1) -> dict:
    """One (video second ↔ burned-in overlay clock) pair fixes a clip's
    whole timeline (verified linear on real footage). For multi-clip
    projects each clip is anchored independently; the composite window is
    rebuilt from every clip synced so far."""
    meta = pmeta(pid)
    fit_data = json.loads((pdir(pid) / "fit.json").read_text(encoding="utf-8"))
    if not fit_data["records"]:
        raise HTTPException(400, "FIT has no records")
    tz = timezone(timedelta(hours=fit_data.get("tz_hours", 8)))
    fit_day = datetime.fromisoformat(fit_data["records"][0]["t"]).date()

    clock = str(clock or "").strip()
    parts = clock.split(":")
    if len(parts) != 3 or not all(p.isdigit() for p in parts):
        raise HTTPException(400,
            f"畫面時間格式不對，要填成「時:分:秒」例如 08:57:11（目前收到: \"{clock}\"）")
    hh, mm, ss = [int(x) for x in parts]
    if not (0 <= hh <= 23 and 0 <= mm <= 59 and 0 <= ss <= 59):
        raise HTTPException(400, f"畫面時間數值不合理: {clock}")
    clock_dt = datetime.combine(fit_day, datetime.min.time(), tz).replace(
        hour=hh, minute=mm, second=ss)
    t0 = clock_dt - timedelta(seconds=float(video_t))
    entry = {"video_t0": t0.isoformat(), "entered_at": video_t, "clock": clock}

    if meta.get("clips"):
        k = clip if clip >= 0 else 0
        if k >= len(meta["clips"]):
            raise HTTPException(404, "clip index out of range")
        meta["clips"][k]["sync"] = entry
        # legacy-shaped meta["sync"] flips on only when EVERY clip is
        # anchored — it's the gate the analyze/render endpoints check
        synced = [c for c in meta["clips"] if c.get("sync")]
        if len(synced) == len(meta["clips"]):
            meta["sync"] = {"multi": True,
                            "video_t0": meta["clips"][0]["sync"]["video_t0"],
                            "clips_synced": len(synced)}
        else:
            meta["sync"] = None
        _rebuild_window(pid, meta)
        psave(pid, meta)
        return {"ok": True, "clock": clock, "clip": k,
                "clips_synced": len(synced), "clips_total": len(meta["clips"]),
                "all_synced": len(synced) == len(meta["clips"]),
                "stats": meta["window_stats"]}

    meta["sync"] = entry
    _rebuild_window(pid, meta)
    psave(pid, meta)
    return {"ok": True, "clock": clock, "all_synced": True,
            "stats": meta["window_stats"]}


@app.post("/api/p/{pid}/sync")
def set_sync(pid: str, body: dict):
    """body: {video_t, clock, clip?} — video_t 為該片段內的秒數。"""
    return _apply_sync(pid, float(body["video_t"]), body.get("clock"),
                       int(body.get("clip", -1)))


_DJI_NAME_RE = re.compile(r"(20\d{6})(\d{6})")   # DJI_20260801073816_0017_D


@app.post("/api/p/{pid}/metasync")
def meta_sync(pid: str, body: dict):
    """body: {clip?} for one clip, or {all: true} for every clip at once.

    A camera that auto-splits a ride into N files gives every part its own
    recording timestamp, so there is nothing for the user to read off the
    screen N times — one click can anchor the whole project.
    """
    meta = pmeta(pid)
    cl = _clips(meta)
    if body.get("all") and len(cl) > 1:
        ok, errors = [], []
        for k in range(len(cl)):
            try:
                ok.append(_metasync_clip(pid, k))
            except HTTPException as e:
                errors.append(f"片段{k + 1}: {e.detail}")
        if not ok:
            raise HTTPException(422, "每一段都無法從檔案時間對時 —— "
                                     + " ｜ ".join(errors))
        return {**ok[-1], "batch": True, "done": len(ok), "errors": errors}
    k = int(body.get("clip", -1))
    return _metasync_clip(pid, k if k >= 0 else 0)


def _metasync_clip(pid: str, k: int) -> dict:
    """Sync ONE clip from the file's own recording timestamp — no AI, no
    burned-in clock needed. Raw camera files carry the recording start in
    two places (verified on a real Osmo Action 5 Pro file):

      1. container metadata `creation_time` (UTC)
      2. the DJI filename pattern DJI_YYYYMMDDHHMMSS_xxxx

    The UTC tag is trusted first; but some devices write LOCAL time into a
    field labelled UTC, so both interpretations are checked against the FIT
    ride's actual time range and the one that lands inside wins.
    """
    meta = pmeta(pid)
    cl = _clips(meta)
    if k >= len(cl):
        raise HTTPException(404, "clip index out of range")
    path = Path(cl[k]["path"])

    fit_data = json.loads((pdir(pid) / "fit.json").read_text(encoding="utf-8"))
    if not fit_data["records"]:
        raise HTTPException(400, "FIT has no records")
    tz = timezone(timedelta(hours=fit_data.get("tz_hours", 8)))
    fit_a = datetime.fromisoformat(fit_data["records"][0]["t"])
    fit_b = datetime.fromisoformat(fit_data["records"][-1]["t"])

    candidates = []   # (label, local datetime)
    r = subprocess.run([ffmpeg().replace("ffmpeg.exe", "ffprobe.exe"),
                        "-v", "error", "-show_entries",
                        "format_tags=creation_time",
                        "-of", "default=nw=1:nk=1", str(path)],
                       capture_output=True, text=True, encoding="utf-8",
                       errors="replace")
    raw = (r.stdout or "").strip()
    if raw:
        try:
            ct = datetime.fromisoformat(raw.replace("Z", "+00:00"))
            candidates.append(("metadata(UTC)", ct.astimezone(tz)))
            # same wall-clock digits interpreted as already-local
            candidates.append(("metadata(視為本地)",
                               ct.replace(tzinfo=tz)))
        except ValueError:
            pass
    m = _DJI_NAME_RE.search(path.stem)
    if m:
        try:
            candidates.append(("檔名", datetime.strptime(
                m.group(1) + m.group(2), "%Y%m%d%H%M%S").replace(tzinfo=tz)))
        except ValueError:
            pass
    if not candidates:
        raise HTTPException(422,
            "這個檔案沒有可用的錄影時間（metadata 被剪輯軟體清掉、"
            "檔名也不含時間戳）。請改用 AI 判讀畫面時鐘或手動對時。")

    margin = timedelta(minutes=10)
    valid = [(lbl, dt) for lbl, dt in candidates
             if fit_a - margin <= dt <= fit_b + margin]
    if not valid:
        tried = "、".join(f"{l}={dt.strftime('%m/%d %H:%M:%S')}"
                          for l, dt in candidates)
        raise HTTPException(422,
            f"檔案時間對不上這份 FIT 的騎乘時段"
            f"（{fit_a.strftime('%H:%M')}~{fit_b.strftime('%H:%M')}）。"
            f"讀到的候選: {tried}。影片跟 FIT 是同一趟嗎？")
    label, t0 = valid[0]
    return {**_apply_sync(pid, 0.0, t0.strftime("%H:%M:%S"), k),
            "source": label, "t0": t0.isoformat()}


_VOICE_CACHE: list = []


@app.get("/api/voices")
def list_voices():
    """Every Chinese edge-tts voice, not just the three that used to be
    hardcoded — 14 are available across TW / CN / HK, including a couple
    aimed at sports commentary."""
    global _VOICE_CACHE
    if not _VOICE_CACHE:
        try:
            _VOICE_CACHE = ttsio.list_chinese_voices()
        except Exception:  # noqa: BLE001 — offline: fall back to the known set
            _VOICE_CACHE = [{"short": v, "locale": "zh-TW", "gender": "",
                             "label": v} for v in ttsio.VOICES]
    return {"voices": _VOICE_CACHE}


@app.post("/api/p/{pid}/tts/preview")
def tts_preview(pid: str, body: dict):
    """Synthesise ONE line so it can be auditioned while editing."""
    text = str(body.get("text", "")).strip()
    if not text:
        raise HTTPException(400, "沒有文字可以唸")
    voice = body.get("voice") or ttsio.VOICES[0]
    d = pdir(pid) / "tts"
    d.mkdir(parents=True, exist_ok=True)
    try:
        path = ttsio.synth_line(text, str(d), voice)
    except Exception as e:  # noqa: BLE001
        raise HTTPException(502, f"語音合成失敗: {str(e)[-300:]}")
    return FileResponse(path, media_type="audio/mpeg",
                        headers={"Cache-Control": "no-store"})


@app.get("/api/p/{pid}/telemetry")
def telemetry_at(pid: str, t: float = 0):
    """Per-second FIT readout nearest to output-timeline second `t` — lets
    the script-editing UI show actual speed/HR/power/etc next to a narration
    line, so a mismatched timestamp (line describing t=80 but landing near
    very different numbers) is obvious without generating a full dashboard
    preview render."""
    d = pdir(pid)
    wpath = d / "window.json"
    if not wpath.is_file():
        raise HTTPException(400, "尚未對時")
    win = json.loads(wpath.read_text(encoding="utf-8"))
    if not win:
        return {"found": False}
    nearest = min(win, key=lambda r: abs(r["vt"] - t))
    if abs(nearest["vt"] - t) > 5:
        return {"found": False}
    grads = dashboard._gradient_series(win)
    return {"found": True, "vt": nearest["vt"],
            "t": nearest.get("t"),          # real clock, for the dash editor
            "spd_kmh": nearest.get("spd_kmh"), "hr": nearest.get("hr"),
            "pwr": nearest.get("pwr"), "cad": nearest.get("cad"),
            "alt_m": nearest.get("alt_m"),
            "grad": grads.get(int(round(nearest["vt"])))}


CLOCK_PROMPT = (
    "這是行車紀錄器的畫面，角落有燒錄的日期與時鐘浮水印（例如 2026/08/01 "
    "08:57:11）。請只回覆畫面上時鐘顯示的時間，格式 HH:MM:SS，"
    "不要回覆日期，不要任何其他文字。"
)


@app.post("/api/p/{pid}/autosync")
def auto_sync(pid: str, body: dict):
    """Reads the burned-in clock off a frame with the user's AI CLI so they
    don't have to squint at the screenshot and type it themselves."""
    if not ai_cli.ai_available():
        raise HTTPException(400, "自動對時需要先在設定區設定 AI CLI")
    video_t = float(body.get("video_t", 10))
    clip = int(body.get("clip", -1))
    meta = pmeta(pid)
    cl = _clips(meta)
    k = clip if clip >= 0 else 0
    if k >= len(cl):
        raise HTTPException(404, "clip index out of range")
    d = pdir(pid)
    cache = d / "framecache"
    cache.mkdir(exist_ok=True)
    # higher resolution than the preview frame: the clock text is small
    frame = cache / f"sync_c{k}_{video_t:.1f}.jpg"
    if not frame.exists():
        run([ffmpeg(), "-y", "-v", "error", "-ss", str(video_t),
             "-i", cl[k]["path"], "-frames:v", "1",
             "-vf", "scale=1600:-1", str(frame)])
    try:
        reply = ai_cli.ask(CLOCK_PROMPT, image=str(frame), timeout=90)
    except Exception as e:  # noqa: BLE001
        raise HTTPException(502, f"AI 判讀失敗: {str(e)[-300:]}")
    m = re.search(r"(\d{1,2}):(\d{2}):(\d{2})", reply)
    if not m:
        raise HTTPException(422,
            f"AI 沒回出時間格式，回覆是: {reply[:120]}。請改用手動輸入。")
    return _apply_sync(pid, video_t, m.group(0), clip)


def _save_atts_factory(pid):
    def save_atts(a):
        m = pmeta(pid)
        m["attachments"] = a
        psave(pid, m)
    return save_atts


@app.post("/api/p/{pid}/analyze")
def analyze(pid: str, body: dict):
    """Stage 3 — frames + per-frame scene descriptions ONLY.

    Deliberately does NOT generate narration: the user gets to review and
    correct the scene descriptions first (they're the raw material the
    narration is written from), then trigger generation separately.
    """
    meta = pmeta(pid)
    if not meta.get("sync"):
        raise HTTPException(400, "sync first")
    interval = int(body.get("interval", 10))
    use_ai = bool(body.get("use_ai", False)) and ai_cli.ai_available()
    batch = max(1, min(10, int(body.get("batch", 1))))   # 每批連拍張數
    auto = bool(body.get("auto", False))   # FIT 波動自動找高光時段

    meta["interval"] = interval
    meta["batch"] = batch
    meta["auto"] = auto
    psave(pid, meta)
    d = pdir(pid)

    def work(log, progress, cancelled):
        prompts_dir = d / "prompts"
        progress(2, "抽取畫面")
        if auto:
            win0 = json.loads((d / "window.json").read_text(encoding="utf-8"))
            dur = meta["video"]["duration"]
            centers = fitio.detect_highlight_bursts(win0, dur)
            log(f"FIT 波動偵測到 {len(centers)} 個高光時段"
                f"（每段連拍 {batch} 張、每秒一張）")
            log("  時段: " + ", ".join(f"{c}s" for c in centers))
            items, seen = [], set()
            for c in centers:
                start = max(0, min(int(dur) - batch, c - batch // 2))
                for t in range(start, start + batch):
                    if t in seen:
                        continue
                    seen.add(t)
                    path, local = _locate(meta, t)
                    items.append({"t": t, "path": path, "local": local})
            frames = scenes.extract_frames_at(items, str(d / "frames"), log=log)
        elif meta.get("clips"):
            log(f"抽取畫面（每 {interval} 秒）…")
            frames = scenes.extract_frames_multi(
                meta["clips"], str(d / "frames"), interval, log=log)
        else:
            log(f"抽取畫面（每 {interval} 秒）…")
            frames = scenes.extract_frames(
                meta["video_path"], str(d / "frames"), interval,
                duration=meta["video"]["duration"], log=log)
        log(f"  {len(frames)} 張")

        # Scene descriptions are cached by TIMESTAMP, independent of the
        # interval that produced them. Cache values are either a plain string
        # (legacy / single-frame) or {"s": text, "b": true} for burst-derived
        # descriptions — the burst flag must survive the cache round-trip,
        # because it controls whether overtaking claims get stripped later.
        m0 = pmeta(pid)
        cache = dict(m0.get("scene_cache", {}))
        for f in m0.get("frames", []):        # seed from pre-cache projects
            if f.get("scene"):
                cache.setdefault(str(f["t"]), f["scene"])

        reused = 0
        for f in frames:
            hit = cache.get(str(f["t"]))
            if hit:
                if isinstance(hit, dict):
                    f["scene"] = hit.get("s")
                    if hit.get("b"):
                        f["burst"] = True
                else:
                    f["scene"] = hit
                reused += 1
        if reused:
            log(f"  沿用 {reused} 筆既有說明（不重花 AI 額度）")

        def save_frames(fr):
            m = pmeta(pid)
            m["frames"] = fr
            c = dict(m.get("scene_cache", {}))
            for x in fr:
                if x.get("scene"):
                    c[str(x["t"])] = ({"s": x["scene"], "b": True}
                                      if x.get("burst") else x["scene"])
            m["scene_cache"] = c
            psave(pid, m)

        save_frames(frames)
        if use_ai and batch > 1:
            n_groups = -(-len(frames) // batch)
            log(f"AI 連拍分析…（每 {batch} 張一批，共 {n_groups} 批）")
            # the counter below reports batches ACTUALLY being analysed, so
            # the starting label must not use the grand total as denominator
            # — a resumed run showed "0/13" while only 2 batches remained
            progress(5, "連拍分析 準備中…")
            scenes.describe_batches(
                frames, str(d / "frames"), batch, log,
                prompts_dir=str(prompts_dir), on_each=save_frames,
                should_cancel=cancelled,
                on_progress=lambda n, tot: progress(
                    5 + n / max(1, tot) * 94,
                    f"連拍分析 {n}/{tot} 批"
                    + (f"（另有 {n_groups - tot} 批沿用）"
                       if tot < n_groups else "")))
        elif use_ai:
            todo = len([f for f in frames if not f.get("scene")])
            log(f"AI 場景辨識…（需辨識 {todo} 張）")
            done = [0]

            def after_each(fr):
                save_frames(fr)
                done[0] += 1
                if todo:
                    progress(5 + done[0] / todo * 94,
                             f"AI 辨識 {done[0]}/{todo} 張")

            progress(5, f"AI 辨識 0/{todo} 張")
            _cp = db.get_prompt(meta["scene_prompt_id"])                 if meta.get("scene_prompt_id") else None
            log(f"  截圖說明範本：{_cp['name'] if _cp else '內建預設'}")
            scenes.describe_frames(frames, str(d / "frames"), log,
                                   prompts_dir=str(prompts_dir),
                                   on_each=after_each,
                                   prompt=db.get_content(
                                       meta.get("scene_prompt_id"), "scene",
                                       scenes.SCENE_PROMPT),
                                   should_cancel=cancelled)
        else:
            log("（未啟用 AI，只抽圖不辨識）")
        progress(100, "完成")
        log("完成，可展開下方清單檢查/修改截圖說明")

    return {"job": start_job("analyze", work)}


@app.post("/api/p/{pid}/frames/clear")
def clear_scenes(pid: str):
    """Wipe scene descriptions + the timestamp cache so the next analyze run
    re-describes from scratch (e.g. to redo single-frame results in burst
    mode). Extracted screenshots on disk are untouched — extraction is pure
    local CPU and the frames are still valid, so re-running analyze will
    skip extraction and only redo the AI part."""
    meta = pmeta(pid)
    n = 0
    for f in meta.get("frames", []):
        if f.get("scene"):
            n += 1
        f["scene"] = None
        f.pop("burst", None)
    meta["scene_cache"] = {}
    psave(pid, meta)
    return {"ok": True, "cleared": n}


@app.put("/api/p/{pid}/frames")
def put_frames(pid: str, body: dict):
    """Save user-corrected scene descriptions. Matched by frame timestamp so
    a stale/partial payload can't reorder or drop frames."""
    meta = pmeta(pid)
    edits = {float(f["t"]): str(f.get("scene", "")).strip() or None
             for f in body.get("frames", []) if "t" in f}
    cache = dict(meta.get("scene_cache", {}))
    n = 0
    for f in meta.get("frames", []):
        if float(f["t"]) in edits:
            f["scene"] = edits[float(f["t"])]
            # keep the timestamp-keyed cache in step, so a hand-corrected
            # description survives a later change of screenshot interval.
            # A burst-derived frame keeps its burst flag through the edit —
            # human-corrected burst text is MORE trustworthy, and losing the
            # flag would subject it to the single-still overtaking strip.
            if f["scene"]:
                cache[str(f["t"])] = ({"s": f["scene"], "b": True}
                                      if f.get("burst") else f["scene"])
            else:
                cache.pop(str(f["t"]), None)
            n += 1
    meta["scene_cache"] = cache
    psave(pid, meta)
    return {"ok": True, "updated": n}


@app.post("/api/p/{pid}/script/generate")
def generate_script(pid: str, body: dict):
    """Stage 4 — write the narration from everything gathered so far
    (scene descriptions incl. any manual corrections, ride data, attachments,
    free-text background), at the requested narration density."""
    meta = pmeta(pid)
    if not meta.get("sync"):
        raise HTTPException(400, "sync first")
    context = str(body.get("context", ""))
    density = max(5.0, min(120.0, float(body.get("density", scriptgen.MAX_GAP))))
    use_ai = bool(body.get("use_ai", True)) and ai_cli.ai_available()

    meta["context"] = context
    meta["density"] = density
    psave(pid, meta)
    d = pdir(pid)

    def work(log, progress, cancelled):
        m0 = pmeta(pid)
        frames = m0.get("frames", [])
        described = len([f for f in frames if f.get("scene")])
        progress(5, "整理資料")
        log(f"使用 {described}/{len(frames)} 張截圖說明")

        att_list = m0.get("attachments", [])
        if att_list:
            progress(15, "擷取附件")
            log("擷取附件參考資料…")
            atts.extract_all(att_list, log, on_each=_save_atts_factory(pid))
        att_text = atts.as_prompt_text(att_list)

        win = json.loads((d / "window.json").read_text(encoding="utf-8"))
        highlights = fitio.detect_highlights(win)
        table = fitio.per_10s_table(win)
        log(f"亮點: {len(highlights)} 個，旁白最大空白 {density:.0f} 秒")
        dur = meta["video"]["duration"]
        if use_ai:
            progress(35, "AI 生成旁白中（這一步較慢，請稍候）")
            # name the template in the log: picking one in the library and
            # picking one FOR THIS PROJECT are different actions, and when
            # they diverge the narration silently follows the built-in rules
            # (e.g. still saying 「他」 after a template set a rider's name)
            _sp = db.get_prompt(m0["script_prompt_id"])                 if m0.get("script_prompt_id") else None
            log(f"AI 生成旁白…（旁白範本：{_sp['name'] if _sp else '內建預設'}）")
            try:
                script = scriptgen.generate_ai(dur, table, highlights, frames,
                                               m0.get("interval", 10), context,
                                               log, prompts_dir=str(d / "prompts"),
                                               attachments_text=att_text,
                                               density=density,
                                               template=db.get_content(
                                                   m0.get("script_prompt_id"),
                                                   "script", scriptgen.AI_PROMPT))
            except Exception as e:  # noqa: BLE001
                log(f"AI 腳本失敗（{e}），改用規則法")
                script = scriptgen.generate_rules(dur, meta["window_stats"],
                                                  highlights, log, table=table,
                                                  density=density)
        else:
            script = scriptgen.generate_rules(dur, meta["window_stats"],
                                              highlights, log, table=table,
                                              density=density)
        m = pmeta(pid)
        m["highlights"] = highlights
        m["script"] = script
        psave(pid, m)
        _save_script_txt(d, script)
        progress(100, "完成")
        log(f"完成，產生 {len(script)} 句，請在下方確認/修改")

    return {"job": start_job("script", work)}


@app.get("/api/dash/presets")
def list_dash_presets():
    out = []
    for r in db.list_dash():
        try:
            out.append({**r, "layout": json.loads(r["layout"])})
        except ValueError:            # a corrupt row shouldn't hide the rest
            continue
    return {"presets": out, "default": dashboard.DEFAULT_LAYOUT}


@app.post("/api/dash/presets")
def save_dash_preset(body: dict):
    name = str(body.get("name", "")).strip() or "未命名配置"
    layout = body.get("layout")
    if not isinstance(layout, dict) or not layout:
        raise HTTPException(400, "layout 不能空白")
    unknown = set(layout) - set(dashboard.DEFAULT_LAYOUT)
    if unknown:
        raise HTTPException(400, f"不認得的項目: {', '.join(sorted(unknown))}")
    pid = body.get("id")
    return {"id": db.save_dash(name, json.dumps(layout, ensure_ascii=False),
                               int(pid) if pid else None)}


@app.delete("/api/dash/presets/{pid}")
def delete_dash_preset(pid: int):
    db.delete_dash(pid)
    return {"ok": True}


@app.get("/api/prompts")
def list_prompts(kind: str | None = None):
    return {"prompts": db.list_prompts(kind),
            "placeholders": list(scriptgen.PLACEHOLDERS)}


def _check_script(kind: str, content: str):
    if kind == "script":
        err = scriptgen.validate_template(content)
        if err:
            raise HTTPException(400, err)


@app.post("/api/prompts")
def create_prompt(body: dict):
    kind = body.get("kind")
    if kind not in ("scene", "script"):
        raise HTTPException(400, "kind 必須是 scene 或 script")
    content = str(body.get("content", "")).strip()
    name = str(body.get("name", "")).strip() or "未命名範本"
    if not content:
        raise HTTPException(400, "範本內容不能空白")
    _check_script(kind, content)
    return {"id": db.create_prompt(kind, name, content)}


@app.put("/api/prompts/{tid}")
def update_prompt(tid: int, body: dict):
    row = db.get_prompt(tid)
    if not row:
        raise HTTPException(404, "找不到這組範本")
    content = str(body.get("content", "")).strip()
    if not content:
        raise HTTPException(400, "範本內容不能空白")
    _check_script(row["kind"], content)
    try:
        db.update_prompt(tid, str(body.get("name", row["name"])).strip(), content)
    except PermissionError as e:
        raise HTTPException(403, str(e))
    return {"ok": True}


@app.delete("/api/prompts/{tid}")
def delete_prompt(tid: int):
    try:
        db.delete_prompt(tid)
    except PermissionError as e:
        raise HTTPException(403, str(e))
    except KeyError as e:
        raise HTTPException(404, str(e))
    return {"ok": True}


@app.put("/api/p/{pid}/prompts")
def put_project_prompts(pid: str, body: dict):
    """Which library templates this project uses."""
    meta = pmeta(pid)
    for key in ("scene_prompt_id", "script_prompt_id"):
        if key in body:
            v = body[key]
            meta[key] = int(v) if v else None
    psave(pid, meta)
    return {"ok": True}


@app.put("/api/p/{pid}/context")
def put_context(pid: str, body: dict):
    """Saves the free-text background the moment the user finishes typing,
    instead of only when an analyze run happens to be kicked off."""
    meta = pmeta(pid)
    meta["context"] = str(body.get("context", ""))
    psave(pid, meta)
    return {"ok": True}


@app.put("/api/p/{pid}/attachments")
def put_attachments(pid: str, body: dict):
    """body: {attachments: [{type, value}]} — summaries already extracted
    for unchanged entries are kept (they cost AI/network calls)."""
    meta = pmeta(pid)
    old = {(a.get("type"), a.get("value")): a
           for a in meta.get("attachments", [])}
    clean = []
    for a in body.get("attachments", []):
        kind = a.get("type")
        value = str(a.get("value", "")).strip()
        if kind not in ("image", "url", "file") or not value:
            continue
        prev = old.get((kind, value))
        clean.append(prev if prev else {"type": kind, "value": value,
                                        "summary": None})
    meta["attachments"] = clean
    psave(pid, meta)
    return {"ok": True, "count": len(clean)}


@app.post("/api/p/{pid}/attachments/{idx}/test")
def test_attachment(pid: str, idx: int):
    """Extract one attachment right now, so a bad URL/path surfaces while the
    user is still looking at it — not twenty minutes into a render."""
    meta = pmeta(pid)
    lst = meta.get("attachments", [])
    if idx < 0 or idx >= len(lst):
        raise HTTPException(404, "找不到這個附件")
    att = lst[idx]
    try:
        summary = atts.extract(att)
    except Exception as e:  # noqa: BLE001
        att["summary"] = None
        att["error"] = str(e)[:300]
        psave(pid, meta)
        return {"ok": False, "error": str(e)[:300]}
    att["summary"] = summary
    att.pop("error", None)
    psave(pid, meta)
    return {"ok": True, "chars": len(summary), "preview": summary[:300]}


@app.get("/api/p/{pid}/script")
def get_script(pid: str):
    meta = pmeta(pid)
    script = meta.get("script", [])
    flags = scriptgen.check_grounding(script, meta.get("frames", []))
    return [{**ln, "unsupported": f} for ln, f in zip(script, flags)]


@app.put("/api/p/{pid}/script")
def put_script(pid: str, body: dict):
    """body: {lines: [{start, spoken, sub}]}

    Takes an object, not a bare array: a bare `list` annotation makes FastAPI
    look for an embedded field literally named "body", so every save was
    rejected with 422 — and because the caller had no error handling, the
    failure was completely silent.
    """
    meta = pmeta(pid)
    clean = []
    for ln in body.get("lines", []):
        spoken = str(ln.get("spoken", "")).strip()
        if not spoken:
            continue
        clean.append({"start": float(ln["start"]), "spoken": spoken,
                      "sub": (str(ln["sub"]).strip() or None)
                      if ln.get("sub") else None})
    clean.sort(key=lambda x: x["start"])
    meta["script"] = clean
    psave(pid, meta)
    _save_script_txt(pdir(pid), clean)
    return {"ok": True, "lines": len(clean)}


@app.post("/api/p/{pid}/render")
def do_render(pid: str, body: dict):
    """body: {resolution, encoder, music, music_gain, voice, outro, dashboard}"""
    meta = pmeta(pid)
    if not meta.get("script"):
        raise HTTPException(400, "no script")
    d = pdir(pid)
    resolution = body.get("resolution", "1080p")
    encoder = body.get("encoder", "auto")
    music = bool(body.get("music", True))
    music_gain = float(body.get("music_gain", 0.9))
    voice = body.get("voice", ttsio.VOICES[0])
    outro = float(body.get("outro", 4))
    lead = max(0.0, min(3.0, float(body.get("lead", meta.get("lead",
                                                             ttsio.LEAD)))))
    meta["lead"] = lead
    dash_cfg = body.get("dashboard")
    if dash_cfg is not None:
        meta["dashboard"] = dash_cfg
        psave(pid, meta)
    else:
        dash_cfg = meta.get("dashboard")
    dur = meta["video"]["duration"]

    def work(log, progress, cancelled):
        win = json.loads((d / "window.json").read_text(encoding="utf-8"))
        progress(2, "合成旁白語音")
        log("TTS 合成…")
        lines = ttsio.synthesize(
            meta["script"], str(d / "tts"), voice, log=log,
            on_progress=lambda n, tot: progress(
                2 + n / max(1, tot) * 8, f"合成旁白語音 {n}/{tot} 句"),
            should_cancel=cancelled, lead=lead)
        pulse_path = None
        if music:
            progress(10, "生成節奏背景音")
            log("生成節奏底（心率+功率驅動）…")
            pulse_path = pulse.generate(win, dur, str(d / "build" / "pulse.wav"),
                                        log=log)
        progress(14, "建立字幕")
        log("建立字幕…")
        (d / "build").mkdir(exist_ok=True)
        ass = d / "build" / "subs.ass"
        dash_ev, dash_st = [], []
        if dash_cfg and dash_cfg.get("enabled"):
            W, H = meta["video"]["width"], meta["video"]["height"]
            dash_ev = dashboard.build_events(
                win, dash_cfg, W, H, dur,
                video_t0_iso=(meta.get("sync") or {}).get("video_t0"))
            dash_st = dashboard.styles(H)
            log(f"  儀表盤: {len(dash_ev)} 事件")
        n_ev = renderer.build_ass(lines, win, meta["video"]["width"],
                                  meta["video"]["height"], dur, outro,
                                  str(ass), extra_events=dash_ev,
                                  extra_styles=dash_st)
        log(f"  {n_ev} 事件")
        log("轉檔中…（這步最久，進度以實際編碼進度計算）")
        out = d / f"output_{resolution}.mp4"
        # encoding dominates the wall time; map ffmpeg's own reported
        # progress onto the remaining 15..99% of the bar
        src = ([c["path"] for c in meta["clips"]] if meta.get("clips")
               else meta["video_path"])
        try:
            renderer.render(src, lines, str(ass), str(out),
                            dur, outro, resolution, encoder,
                            pulse_path, music_gain, log=log,
                            on_progress=lambda p: progress(
                                15 + p * 0.84, f"轉檔中 {p:.0f}%"),
                            should_cancel=cancelled)
        except JobCancelled:
            # a half-written mp4 is unplayable and would masquerade as a
            # finished render in the project meta — drop it
            out.unlink(missing_ok=True)
            raise
        m = pmeta(pid)
        m["output"] = out.name
        psave(pid, m)
        progress(100, "完成")
        log("轉檔完成 ✔")

    return {"job": start_job("render", work)}


@app.post("/api/p/{pid}/dashboard/preview")
def dashboard_preview(pid: str, body: dict):
    """Render ONE frame with the dashboard overlaid, so layout/size can be
    judged without transcoding the whole video. Also persists the config."""
    meta = pmeta(pid)
    d = pdir(pid)
    cfg = body.get("dashboard") or {}
    meta["dashboard"] = cfg
    psave(pid, meta)
    t = float(body.get("t", meta["video"]["duration"] / 2))
    t = max(0.0, min(meta["video"]["duration"] - 0.5, t))

    win = json.loads((d / "window.json").read_text(encoding="utf-8"))
    W, H = meta["video"]["width"], meta["video"]["height"]
    ev = dashboard.build_events(
        win, cfg, W, H, meta["video"]["duration"],
        video_t0_iso=(meta.get("sync") or {}).get("video_t0"))
    st = dashboard.styles(H)
    header = ("[Script Info]\nScriptType: v4.00+\n"
              f"PlayResX: {W}\nPlayResY: {H}\n\n[V4+ Styles]\n"
              "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
              "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
              "ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
              "Alignment, MarginL, MarginR, MarginV, Encoding\n"
              + "\n".join(st) +
              "\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, "
              "MarginR, MarginV, Effect, Text\n")
    (d / "build").mkdir(exist_ok=True)
    ass = d / "build" / "dash_preview.ass"
    ass.write_text(header + "\n".join(ev) + "\n", encoding="utf-8-sig")

    out = d / "build" / "dash_preview.png"
    ass_esc = str(ass).replace("\\", "/").replace(":", r"\:")
    src_path, local_t = _locate(meta, t)
    # the dashboard ASS speaks OUTPUT time; shift subtitle timing so the
    # events at output-time t land on this frame's local timestamp
    run([ffmpeg(), "-y", "-v", "error", "-ss", str(local_t),
         "-copyts", "-i", src_path,
         "-ss", str(local_t), "-frames:v", "1",
         "-vf", f"setpts=PTS+{max(0.0, t - local_t)}/TB,"
                f"subtitles=filename='{ass_esc}',scale=1280:-2",
         str(out)])
    return FileResponse(out, media_type="image/png",
                        headers={"Cache-Control": "no-store"})


@app.get("/api/p/{pid}/output")
def get_output(pid: str, request: Request):
    """Serves the finished video for in-page playback. Supports HTTP Range
    so the <video> element can seek without pulling the whole file."""
    meta = pmeta(pid)
    if not meta.get("output"):
        raise HTTPException(404, "not rendered yet")
    path = pdir(pid) / meta["output"]
    size = path.stat().st_size
    rng = request.headers.get("range")
    if not rng:
        return FileResponse(path, media_type="video/mp4")

    m = re.match(r"bytes=(\d*)-(\d*)", rng)
    start = int(m.group(1)) if m and m.group(1) else 0
    end = int(m.group(2)) if m and m.group(2) else size - 1
    end = min(end, size - 1)
    length = end - start + 1

    def stream():
        with path.open("rb") as f:
            f.seek(start)
            left = length
            while left > 0:
                chunk = f.read(min(1024 * 512, left))
                if not chunk:
                    break
                left -= len(chunk)
                yield chunk

    return StreamingResponse(stream(), status_code=206, media_type="video/mp4",
                             headers={"Content-Range": f"bytes {start}-{end}/{size}",
                                      "Accept-Ranges": "bytes",
                                      "Content-Length": str(length)})


@app.post("/api/p/{pid}/reveal")
def reveal_output(pid: str):
    """Open Explorer with the finished file selected — this is a local
    single-machine tool, so pointing at the real file on disk beats
    round-tripping a 200 MB download through the browser."""
    meta = pmeta(pid)
    if not meta.get("output"):
        raise HTTPException(404, "尚未產生影片")
    path = (pdir(pid) / meta["output"]).resolve()
    if not path.is_file():
        raise HTTPException(404, f"找不到檔案: {path}")
    subprocess.Popen(["explorer.exe", "/select,", str(path)])
    return {"ok": True, "path": str(path)}


@app.get("/api/job/{jid}")
def job_status(jid: str):
    if jid not in JOBS:
        raise HTTPException(404)
    # strip the threading.Event — it isn't JSON-serialisable
    return {k: v for k, v in JOBS[jid].items() if not k.startswith("_")}


app.mount("/static", StaticFiles(directory=APP_DIR / "static"), name="static")

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8765)

