"""
wivexx_captions.py — video caption engine  (Stage 1)
═══════════════════════════════════════════════════════════════════════════════
Auto-caption a video: transcribe with WORD-level timing, render styled ASS
subtitles (libass), and burn them into the video with the bundled ffmpeg.

Caption types
  • "sentence"  — the whole line on screen for its segment.
  • "word"      — one word at a time (each word timed to when it's spoken).
  • "highlight" — the whole sentence, with the active word lit up as it's spoken
                  (ASS karaoke \\k — words flip from the base colour to the highlight
                  colour in time with the audio). This is the popular "Submagic" look.

Multi-speaker + a fully editable timeline come in Stage 2 (the editor UI); the
segment list already carries a `speaker` field and the ASS builder is style-driven,
so the editor just edits segments/styles and re-renders.

    transcribe_segments(video, language=None) -> {ok, segments:[{start,end,text,words,speaker}], language}
    build_ass(segments, opts, w, h) -> str            # the .ass document
    burn(video, ass_path, out=None) -> {ok, path}
    make_captions(video, opts=None, out=None, translate_fn=None) -> {ok, path, segments}
"""
from __future__ import annotations

import os
import subprocess
import tempfile
from typing import Callable, Optional

import wivexx_media as mm

_logger = None


def _log(level, msg, *a):
    try:
        if _logger:
            getattr(_logger, level)(msg, *a)
    except Exception:
        pass


# ── transcription with word timing (reuses the shared faster-whisper model) ───
def transcribe_segments(video: str, language: Optional[str] = None,
                        progress: Optional[Callable[[float], None]] = None,
                        cancel: Optional[Callable[[], bool]] = None) -> dict:
    """`cancel` is polled per segment; when it returns True we stop consuming the
    (lazy) faster-whisper generator — so no further audio is decoded — and return
    {ok:False, cancelled:True}. That gives a real, prompt cancel for long clips."""
    try:
        import wivexx_transcribe as wt
        # captions are interactive (the editor waits on this) → use the fast
        # multilingual model (turbo) rather than the heavyweight large-v3.
        model = wt.get_fast_model()
        kwargs = dict(beam_size=5, vad_filter=True, word_timestamps=True,
                      vad_parameters={"min_silence_duration_ms": 400})
        if language:
            kwargs["language"] = language
        else:
            kwargs["multilingual"] = True      # re-detect per segment so a video that
            #                                    switches language (ES→EN→PT) transcribes
            #                                    each part in its OWN language, not one.
        out, dur = [], 0.0
        with wt._model_lock:
            if cancel and cancel():
                return {"ok": False, "cancelled": True, "segments": []}
            segments, info = model.transcribe(video, **kwargs)
            dur = float(getattr(info, "duration", 0.0) or 0.0)
            for s in segments:
                if cancel and cancel():                  # stop decoding the rest
                    return {"ok": False, "cancelled": True, "segments": out}
                words = []
                for w in (getattr(s, "words", None) or []):
                    ww = (w.word or "")
                    if ww.strip():
                        words.append({"start": float(w.start), "end": float(w.end), "word": ww})
                out.append({"start": float(s.start), "end": float(s.end),
                            "text": (s.text or "").strip(), "words": words, "speaker": 0})
                if progress and dur > 0:
                    try:
                        progress(min(1.0, float(s.end) / dur))
                    except Exception:
                        pass
        return {"ok": True, "segments": out, "language": getattr(info, "language", language),
                "duration": dur}
    except Exception as e:
        _log("warning", "captions: transcribe failed: %s", e)
        return {"ok": False, "error": str(e), "segments": []}


# ── ASS rendering ─────────────────────────────────────────────────────────────
def _ts(t: float) -> str:
    t = max(0.0, float(t))
    cs = int(round((t - int(t)) * 100))
    if cs >= 100:
        cs = 99
    s = int(t) % 60
    m = (int(t) // 60) % 60
    h = int(t) // 3600
    return f"{h}:{m:02d}:{s:02d}.{cs:02d}"


def _col(hexs: str, alpha: int = 0) -> str:
    """#RRGGBB → ASS &HAABBGGRR."""
    c = mm._parse_hex(hexs) or (255, 255, 255)
    return "&H%02X%02X%02X%02X" % (alpha & 0xFF, c[2], c[1], c[0])


def _esc(t: str) -> str:
    """Make caption text safe inside an ASS event (strip override braces, etc.)."""
    return (t or "").replace("\\", "/").replace("{", "(").replace("}", ")").replace("\n", "\\N").strip()


def build_ass(segments: list, opts: dict, w: int = 1920, h: int = 1080) -> str:
    opts = opts or {}
    typ = (opts.get("type") or "highlight").lower()
    font = opts.get("font") or "Arial"
    size = int(opts.get("size") or max(24, int(h * 0.055)))
    ow = float(opts.get("outline_width", 3))
    shadow = float(opts.get("shadow", 0))
    bold = -1 if opts.get("bold", True) else 0
    marginV = int(opts.get("marginV") or max(40, int(h * 0.07)))
    # horizontal margin proportional to WIDTH (≈6% a side) so the wrap width is
    # resolution-independent AND matches the live HTML overlay (which the editor sizes
    # to the same fraction) — a fixed px margin made small frames wrap far too early.
    marginH = int(opts.get("marginH") or max(40, int(w * 0.06)))
    upper = bool(opts.get("uppercase"))          # ALL CAPS
    box = bool(opts.get("box"))                   # solid box behind text (TikTok look)
    box_color = opts.get("box_color") or "#000000"
    spacing = float(opts.get("letter_spacing", 0) or 0)     # ASS Spacing (px between glyphs)
    italic = -1 if opts.get("italic") else 0
    underline = -1 if opts.get("underline") else 0
    scale_x = int(opts.get("scale_x", 100) or 100)
    scale_y = int(opts.get("scale_y", 100) or 100)
    # animation preset (Phase 2): one unified set, back-compatible with the old
    # fade/pop toggles. See _anim_prefix below for what each preset emits.
    anim = (opts.get("anim") or "").lower()
    if not anim:
        anim = "pop" if opts.get("pop") else (opts.get("animation", "fade") or "none")
    # free placement: a normalized {x,y} (0..1) drag position overrides the bottom/
    # center/top alignment — emits \an5\pos so a caption can sit anywhere on the frame.
    pos = opts.get("pos") if isinstance(opts.get("pos"), dict) else None
    pos_tag = ""
    px = py = 0
    if pos and pos.get("x") is not None and pos.get("y") is not None:
        px = int(round(float(pos["x"]) * w))
        py = int(round(float(pos["y"]) * h))
        pos_tag = "\\an5\\pos(%d,%d)" % (px, py)
    # caption anchor (centre x, baseline y by position) — used by the slide \move.
    position = (opts.get("position") or "bottom")
    if pos_tag:
        anchor_x, anchor_y, anchor_an = px, py, 5
    elif position == "top":
        anchor_x, anchor_y, anchor_an = w // 2, marginV, 8
    elif position == "center":
        anchor_x, anchor_y, anchor_an = w // 2, h // 2, 5
    else:
        anchor_x, anchor_y, anchor_an = w // 2, h - marginV, 2

    # one ASS style per speaker (Wvx0, Wvx1, …) — each can override colour/position.
    speakers = opts.get("speakers") or [{}]
    style_lines = []
    for i, sp in enumerate(speakers):
        primary = sp.get("primary") or opts.get("primary") or "#FFFFFF"
        highlight = sp.get("highlight") or opts.get("highlight") or "#C1F73A"
        outline = sp.get("outline") or opts.get("outline") or "#101010"
        align = {"bottom": 2, "center": 5, "top": 8}.get(sp.get("position") or opts.get("position") or "bottom", 2)
        if typ == "highlight":                       # karaoke: base → highlight as spoken
            prim, sec = _col(highlight), _col(primary)
        else:
            prim, sec = _col(primary), _col(primary)
        # a solid box behind the text uses ASS BorderStyle 3, where the "outline"
        # colour becomes the box fill; otherwise a normal text outline.
        border_style = 3 if box else 1
        out_col = _col(box_color) if box else _col(outline)
        ow_use = max(ow, 8) if box else ow           # box needs padding around the text
        style_lines.append(
            f"Style: Wvx{i},{font},{size},{prim},{sec},{out_col},&H64000000,"
            f"{bold},{italic},{underline},0,{scale_x},{scale_y},{spacing:g},0,"
            f"{border_style},{ow_use},{shadow},{align},{marginH},{marginH},{marginV},1")

    head = [
        "[Script Info]", "ScriptType: v4.00+", "WrapStyle: 0",
        f"PlayResX: {w}", f"PlayResY: {h}", "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_lines, "",
        "[Events]",
        "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
    ]
    ev = []

    def _case(s):
        return (s or "").upper() if upper else (s or "")

    def _ease_accel(name):
        # libass \t accel: <1 decelerates (ease-out), >1 accelerates (ease-in), 1 linear.
        return {"linear": 1.0, "ease-in": 1.8, "ease-out": 0.55, "ease": 0.7}.get(
            (name or "linear").lower(), 1.0)

    def _anim_prefix():
        # the GLOBAL entrance / emphasis animation (one preset), per caption event.
        tags = []
        use_move = anim in ("slide-up", "slide-down", "slide-left", "slide-right")
        if pos_tag and not use_move:
            tags.append(pos_tag)
        if anim == "pop":
            tags.append("\\fscx%d\\fscy%d\\t(0,130,\\fscx%d\\fscy%d)" % (
                scale_x * 72 // 100, scale_y * 72 // 100, scale_x, scale_y))
        elif anim == "bounce":
            tags.append("\\fscx%d\\fscy%d\\t(0,120,\\fscx%d\\fscy%d)\\t(120,260,\\fscx%d\\fscy%d)" % (
                scale_x * 35 // 100, scale_y * 35 // 100,
                scale_x * 116 // 100, scale_y * 116 // 100, scale_x, scale_y))
        elif anim == "zoom":
            tags.append("\\fscx%d\\fscy%d\\t(0,280,1.5,\\fscx%d\\fscy%d)" % (
                scale_x * 10 // 100, scale_y * 10 // 100, scale_x, scale_y))
        elif anim == "pulse":
            tags.append("\\t(0,300,\\fscx%d\\fscy%d)\\t(300,600,\\fscx%d\\fscy%d)" % (
                scale_x * 108 // 100, scale_y * 108 // 100, scale_x, scale_y))
        elif use_move:
            dx = dy = 0.0
            if anim == "slide-up":
                dy = h * 0.09
            elif anim == "slide-down":
                dy = -h * 0.09
            elif anim == "slide-left":
                dx = w * 0.14
            elif anim == "slide-right":
                dx = -w * 0.14
            tags.append("\\an%d\\move(%d,%d,%d,%d,0,300)" % (
                anchor_an, int(anchor_x + dx), int(anchor_y + dy), int(anchor_x), int(anchor_y)))
        if anim in ("fade", "pop", "bounce", "zoom",
                    "slide-up", "slide-down", "slide-left", "slide-right"):
            tags.append("\\fad(120,90)")
        return tags

    _ap = _anim_prefix()
    pre_global = ("{" + "".join(_ap) + "}") if _ap else ""

    def _kf_pre(kfs, seg_dur):
        # a per-caption KEYFRAME animation: scale / rotation / opacity over the
        # caption's life, mapped to chained \t(...) with easing (the libass accel curve).
        ks = sorted(kfs, key=lambda k: float(k.get("t", 0)))

        def _frame(k):
            a = max(0, min(255, int((1.0 - float(k.get("op", 1))) * 255)))
            return "\\fscx%d\\fscy%d\\frz%g\\alpha&H%02X&" % (
                int(k.get("sx", 100)), int(k.get("sy", 100)), float(k.get("rot", 0)), a)

        dur_ms = max(1, int(float(seg_dur) * 1000))
        out = _frame(ks[0])
        for a, b in zip(ks, ks[1:]):
            t1 = max(0, min(dur_ms, int(float(a.get("t", 0)) * dur_ms)))
            t2 = max(t1, min(dur_ms, int(float(b.get("t", 1)) * dur_ms)))
            accel = _ease_accel(b.get("ease"))
            inner = _frame(b)
            out += ("\\t(%d,%d,%g,%s)" % (t1, t2, accel, inner)) if accel != 1.0 \
                else ("\\t(%d,%d,%s)" % (t1, t2, inner))
        return "{" + (pos_tag + out if pos_tag else out) + "}"

    ns = len(speakers)
    for seg in (segments or []):
        sidx = int(seg.get("speaker", 0) or 0)
        if sidx < 0 or sidx >= ns:
            sidx = 0
        st = f"Wvx{sidx}"
        words = seg.get("words") or []
        kfs = seg.get("kf")
        # keyframes drive scale/rot/opacity per-caption; not for one-word mode (each
        # word is its own short event, so a per-segment keyframe curve wouldn't line up).
        pre = (_kf_pre(kfs, float(seg.get("end", 0)) - float(seg.get("start", 0)))
               if (kfs and len(kfs) >= 2 and typ != "word") else pre_global)
        if typ == "word" and words:
            for wd in words:
                txt = _esc(_case(wd["word"]))
                if txt:
                    ev.append(f"Dialogue: 0,{_ts(wd['start'])},{_ts(wd['end'])},{st},,0,0,0,,{pre}{txt}")
        elif typ == "highlight" and words:
            parts = []
            for wd in words:
                cs = max(1, int(round((float(wd["end"]) - float(wd["start"])) * 100)))
                parts.append("{\\k%d}%s " % (cs, _esc(_case(wd["word"]))))
            line = "".join(parts).strip()
            if line:
                ev.append(f"Dialogue: 0,{_ts(seg['start'])},{_ts(seg['end'])},{st},,0,0,0,,{pre}{line}")
        else:
            txt = _esc(_case(seg.get("text", "")))
            if txt:
                ev.append(f"Dialogue: 0,{_ts(seg['start'])},{_ts(seg['end'])},{st},,0,0,0,,{pre}{txt}")
    return "\n".join(head + ev) + "\n"


def synth_words(text: str, start: float, end: float) -> list:
    """Synthesise word-level timings for a TRANSLATED line by spreading the segment's
    [start,end] across its words (weighted by length). Lets a translated caption keep the
    karaoke word-by-word highlight even though the translator only returns whole lines."""
    toks = (text or "").split()
    if not toks:
        return []
    total = max(0.05, float(end) - float(start))
    weights = [max(1, len(t)) for t in toks]
    sw = float(sum(weights))
    out, t = [], float(start)
    for tok, wt in zip(toks, weights):
        d = total * wt / sw
        out.append({"word": tok, "start": round(t, 2), "end": round(t + d, 2)})
        t += d
    return out


def build_ass_tracks(segments: list, tracks: list, w: int = 1080, h: int = 1920) -> str:
    """Render MULTIPLE simultaneous caption tracks (e.g. English primary + Spanish below),
    each in its OWN language, screen position and style. Each track:
      {lang, pos:{x,y}(0..1), size, primary, highlight, outline, type:'highlight'|'word'|
       'line', font, bold, uppercase, anim, outline_width, box, box_color, letter_spacing,
       shadow}.
    Every segment must carry seg['langs'][lang] = {text, words[]} (words optional for
    'line' type). All tracks share the timeline, so they overlay at once."""
    head = ["[Script Info]", "ScriptType: v4.00+", "WrapStyle: 0",
            "PlayResX: %d" % w, "PlayResY: %d" % h, "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_lines = []
    for ti, tk in enumerate(tracks):
        font = tk.get("font") or "Arial"
        size = int(tk.get("size") or max(24, int(h * 0.056)))
        bold = -1 if tk.get("bold", True) else 0
        typ = (tk.get("type") or "highlight").lower()
        primary, highlight = tk.get("primary") or "#FFFFFF", tk.get("highlight") or "#C1F73A"
        prim, sec = (_col(highlight), _col(primary)) if typ == "highlight" else (_col(primary), _col(primary))
        box = bool(tk.get("box"))
        ow = float(tk.get("outline_width", 4))
        out_col = _col(tk.get("box_color", "#000000")) if box else _col(tk.get("outline") or "#101010")
        ow_use = max(ow, 8) if box else ow
        marginH = int(tk.get("marginH") or max(40, int(w * 0.06)))
        style_lines.append(
            "Style: WvxT%d,%s,%d,%s,%s,%s,&H64000000,%d,%d,0,0,100,100,%g,0,%d,%g,%g,5,%d,%d,%d,1" % (
                ti, font, size, prim, sec, out_col, bold, (-1 if tk.get("italic") else 0),
                float(tk.get("letter_spacing", 0) or 0), 3 if box else 1, ow_use,
                float(tk.get("shadow", 0)), marginH, marginH, max(20, int(h * 0.04))))
    head += style_lines + ["", "[Events]",
                           "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"]
    ev = []
    for ti, tk in enumerate(tracks):
        lang = tk.get("lang")
        typ = (tk.get("type") or "highlight").lower()
        upper = bool(tk.get("uppercase"))
        anim = (tk.get("anim") or "pop").lower()
        pos = tk.get("pos") or {"x": 0.5, "y": 0.70 + ti * 0.09}
        px, py = int(round(float(pos.get("x", 0.5)) * w)), int(round(float(pos.get("y", 0.70)) * h))
        animtag = "\\fscx72\\fscy72\\t(0,130,\\fscx100\\fscy100)" if anim == "pop" else ""
        fad = "\\fad(120,90)" if anim in ("pop", "fade", "bounce", "zoom") else ""
        pre = "{\\an5\\pos(%d,%d)%s%s}" % (px, py, animtag, fad)
        st = "WvxT%d" % ti

        def _case(s):
            return (s or "").upper() if upper else (s or "")

        maxw = max(1, int(tk.get("max_words", 4) or 4))    # roll captions in short phrases
        for seg in (segments or []):
            L = (seg.get("langs") or {}).get(lang)
            if not L:
                continue
            words = L.get("words") or []
            if typ in ("highlight", "word") and words:
                for i in range(0, len(words), maxw):          # split the line into short chunks
                    chunk = words[i:i + maxw]
                    cs, ce = float(chunk[0]["start"]), float(chunk[-1]["end"])
                    if typ == "word":
                        for wd in chunk:
                            txt = _esc(_case(wd["word"]))
                            if txt:
                                ev.append("Dialogue: 0,%s,%s,%s,,0,0,0,,%s%s" % (_ts(wd["start"]), _ts(wd["end"]), st, pre, txt))
                    else:
                        parts = ["{\\k%d}%s " % (max(1, int(round((float(wd["end"]) - float(wd["start"])) * 100))),
                                                 _esc(_case(wd["word"]))) for wd in chunk]
                        line = "".join(parts).strip()
                        if line:
                            ev.append("Dialogue: 0,%s,%s,%s,,0,0,0,,%s%s" % (_ts(cs), _ts(ce), st, pre, line))
            else:
                txt = _esc(_case(L.get("text", "")))
                if txt:
                    ev.append("Dialogue: 0,%s,%s,%s,,0,0,0,,%s%s" % (_ts(seg["start"]), _ts(seg["end"]), st, pre, txt))
    return "\n".join(head + ev) + "\n"


def render_tracks(video: str, segments: list, tracks: list, out: Optional[str] = None) -> dict:
    """Burn MULTI-LANGUAGE caption tracks (see build_ass_tracks) onto the video."""
    info = mm.media_info(video)
    if not info.get("ok") or not info.get("has_video"):
        return {"ok": False, "error": "That isn't a video."}
    ass = build_ass_tracks(segments or [], tracks or [], int(info.get("w") or 1920), int(info.get("h") or 1080))
    ass_path = os.path.join(tempfile.gettempdir(), "wvx_captions_multi.ass")
    with open(ass_path, "w", encoding="utf-8") as fh:
        fh.write(ass)
    r = burn(video, ass_path, out)
    if r.get("ok"):
        r["tracks"], r["segments"] = len(tracks or []), len(segments or [])
    return r


def burn(video: str, ass_path: str, out: Optional[str] = None) -> dict:
    """Burn an .ass subtitle file into the video (libass) and re-encode H.264.
    Runs ffmpeg with cwd = the subtitle's folder and references it by bare filename,
    so the Windows drive colon never has to be escaped inside the filter graph."""
    exe = mm.ffmpeg_exe()
    if not exe:
        return {"ok": False, "error": "ffmpeg isn't installed."}
    out = out or (os.path.splitext(video)[0] + "_captioned.mp4")
    ass_dir = os.path.dirname(ass_path) or "."
    ass_name = os.path.basename(ass_path)
    # Deliver in the universal social-media format: limited-range (tv) yuv420p tagged bt709.
    # Phone clips are often full-range (pc/yuvj420p), which some players render washed-out;
    # scale=out_range=tv converts using the input's tagged range, then we tag bt709.
    args = [exe, "-hide_banner", "-loglevel", "error", "-y", "-i", video,
            "-vf", "ass=" + ass_name + ",scale=w=iw:h=ih:out_range=tv,format=yuv420p",
            "-c:v", "libx264", "-crf", "18", "-preset", "medium", "-pix_fmt", "yuv420p",
            "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709",
            "-c:a", "copy", "-movflags", "+faststart", out]
    try:
        p = subprocess.run(args, capture_output=True, text=True, cwd=ass_dir,
                           creationflags=mm._CREATE_NO_WINDOW, timeout=3600)
        if p.returncode != 0:
            return {"ok": False, "error": (p.stderr or "").strip()[-400:] or "ffmpeg failed"}
        return {"ok": True, "path": out, "kb": mm._kb(out)}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def make_captions(video: str, opts: Optional[dict] = None, out: Optional[str] = None,
                  translate_fn: Optional[Callable] = None,
                  progress: Optional[Callable[[float], None]] = None) -> dict:
    """Full pipeline: transcribe → (optional translate) → ASS → burn."""
    opts = opts or {}
    info = mm.media_info(video)
    if not info.get("ok") or not info.get("has_video"):
        return {"ok": False, "error": "That isn't a video."}
    tr = transcribe_segments(video, language=opts.get("source_lang"), progress=progress)
    if not tr.get("ok") or not tr.get("segments"):
        return {"ok": False, "error": tr.get("error", "No speech found in the video.")}
    segs = tr["segments"]
    tgt = opts.get("translate_to")
    if tgt and translate_fn and tgt != tr.get("language"):
        try:
            segs = translate_fn(segs, tgt) or segs       # caller translates (Claude); may drop word timing
        except Exception as e:
            _log("warning", "captions: translate failed: %s", e)
    ass = build_ass(segs, opts, int(info.get("w") or 1920), int(info.get("h") or 1080))
    ass_path = os.path.join(tempfile.gettempdir(), "wvx_captions.ass")
    with open(ass_path, "w", encoding="utf-8") as fh:
        fh.write(ass)
    r = burn(video, ass_path, out)
    if r.get("ok"):
        r["segments"] = len(segs)
        r["language"] = tgt or tr.get("language")
    return r


# ── Stage 2 (editor): render edited segments + a live preview frame ──────────
def render(video: str, segments: list, opts: Optional[dict] = None,
           out: Optional[str] = None) -> dict:
    """Burn EDITED segments (no transcription) — the editor's export."""
    info = mm.media_info(video)
    if not info.get("ok") or not info.get("has_video"):
        return {"ok": False, "error": "That isn't a video."}
    ass = build_ass(segments or [], opts or {}, int(info.get("w") or 1920), int(info.get("h") or 1080))
    ass_path = os.path.join(tempfile.gettempdir(), "wvx_captions.ass")
    with open(ass_path, "w", encoding="utf-8") as fh:
        fh.write(ass)
    r = burn(video, ass_path, out)
    if r.get("ok"):
        r["segments"] = len(segments or [])
    return r


def preview_frame(video: str, segments: list, opts: Optional[dict] = None,
                  t: float = 1.0) -> dict:
    """Render ONE frame at time `t` with the captions burned in → base64 JPEG
    (for the live preview in the editor). Scaled down to 640px wide."""
    exe = mm.ffmpeg_exe()
    if not exe:
        return {"ok": False, "error": "ffmpeg isn't installed."}
    info = mm.media_info(video)
    if not info.get("ok") or not info.get("has_video"):
        return {"ok": False, "error": "That isn't a video."}
    tmp = tempfile.gettempdir()
    ass = build_ass(segments or [], opts or {}, int(info.get("w") or 1920), int(info.get("h") or 1080))
    ass_name = "wvx_preview.ass"
    out_name = "wvx_preview.jpg"
    with open(os.path.join(tmp, ass_name), "w", encoding="utf-8") as fh:
        fh.write(ass)
    out_path = os.path.join(tmp, out_name)
    try:
        if os.path.exists(out_path):
            os.remove(out_path)
    except Exception:
        pass
    # -ss before -i (fast) + -copyts so the burned subtitle matches time t
    args = [exe, "-hide_banner", "-loglevel", "error", "-y", "-ss", str(max(0.0, float(t))),
            "-copyts", "-i", video, "-vf", "ass=" + ass_name + ",scale=640:-2",
            "-frames:v", "1", "-q:v", "4", "-an", out_name]
    try:
        p = subprocess.run(args, capture_output=True, text=True, cwd=tmp,
                           creationflags=mm._CREATE_NO_WINDOW, timeout=90)
        if p.returncode != 0 or not os.path.exists(out_path):
            return {"ok": False, "error": (p.stderr or "").strip()[-300:] or "preview failed"}
        import base64
        with open(out_path, "rb") as fh:
            b64 = base64.standard_b64encode(fh.read()).decode()
        return {"ok": True, "thumb": "data:image/jpeg;base64," + b64, "w": info.get("w"), "h": info.get("h")}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def _proxy_out_path(video: str, max_side: int = 854) -> str:
    import hashlib
    try:
        st = os.stat(video)
        key = hashlib.md5(("%s|%d|%d|%d" % (os.path.abspath(video), int(st.st_mtime),
                                            st.st_size, max_side)).encode()).hexdigest()[:16]
    except Exception:
        key = hashlib.md5(video.encode()).hexdigest()[:16]
    return os.path.join(tempfile.gettempdir(), "wvx_capproxy_%s.mp4" % key)


def proxy_cache_path(video: str, max_side: int = 854):
    """The proxy's path IF it's already built (cached) — else None. No transcode."""
    p = _proxy_out_path(video, max_side)
    return p if (os.path.isfile(p) and os.path.getsize(p) > 1024) else None


def make_preview_proxy(video: str, max_side: int = 854,
                       progress: Optional[Callable[[float], None]] = None) -> dict:
    """Transcode `video` to a small, ALWAYS-web-playable preview (H.264 8-bit 4:2:0,
    display-rotated, +faststart, AAC) so the editor's <video> can play it. Needed for
    high-end camera files (10-bit 4:2:2, HEVC, 4K-at-300Mbps) that WebView2/Chromium
    can't decode. Cached in temp by source path+mtime+size, so it only builds once."""
    exe = mm.ffmpeg_exe()
    if not exe:
        return {"ok": False, "error": "ffmpeg isn't installed."}
    if not os.path.isfile(video):
        return {"ok": False, "error": "video not found."}
    out = _proxy_out_path(video, max_side)
    if os.path.isfile(out) and os.path.getsize(out) > 1024:
        return {"ok": True, "path": out, "cached": True}
    # fit inside max_side×max_side (keeps aspect, even dims); autorotate is on by
    # default so the proxy comes out display-oriented (a 90°-flagged clip → portrait).
    vf = ("scale=%d:%d:force_original_aspect_ratio=decrease:force_divisible_by=2:flags=lanczos,"
          "format=yuv420p" % (max_side, max_side))
    args = [exe, "-hide_banner", "-loglevel", "error", "-y", "-i", video,
            "-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
            "-profile:v", "high", "-pix_fmt", "yuv420p",
            "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-movflags", "+faststart", out]
    try:
        p = subprocess.run(args, capture_output=True, text=True,
                           creationflags=mm._CREATE_NO_WINDOW, timeout=900)
        if p.returncode != 0 or not os.path.isfile(out):
            return {"ok": False, "error": (p.stderr or "").strip()[-300:] or "proxy failed"}
        return {"ok": True, "path": out, "kb": mm._kb(out)}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def start(logger=None) -> bool:
    global _logger
    _logger = logger
    return mm.has_ffmpeg()
