"""
wivexx_timeline.py — quick multi-clip "reel builder" (Phase 3 of the captions editor)
═══════════════════════════════════════════════════════════════════════════════
Stitch several clips (and/or still images) into ONE social-ready video — with
optional crossfade transitions, image/B-roll overlays, and a ducked music bed —
then the captions editor transcribes + captions the result. The lightweight
Premiere/CapCut-style timeline: import → trim → reorder/split → build.

Pipeline (separate ffmpeg passes for robustness):
  1. NORMALISE every (trimmed) clip/image to identical params (target WxH fit+pad,
     autorotated, fps, yuv420p, 44.1 kHz stereo AAC; silent track synthesised when a
     clip has no audio; an image → an N-second still).
  2. JOIN — concat demuxer (hard cut, stream-copy) OR an xfade/acrossfade chain
     (crossfade transition).
  3. OVERLAYS — composite image/B-roll overlays for their time windows (overlay filter).
  4. MUSIC — mix a (looped) music bed under the reel, optionally side-chain DUCKED.

    build_timeline(clips, out=None, w=1080, h=1920, fps=30, transition="none",
                   transition_dur=0.5, overlays=None, music=None, music_vol=0.5,
                   duck=True, progress=None) -> {ok, path, kb, clips, w, h, duration}
"""
from __future__ import annotations

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

import wivexx_media as mm

_logger = None

# xfade transitions we expose in the UI (a curated subset of libavfilter's set).
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "slideup", "slidedown",
               "slideleft", "slideright", "circleopen", "circleclose", "smoothleft", "radial"]

_RES = {"vertical": (1080, 1920), "square": (1080, 1080), "landscape": (1920, 1080)}
# ffprobe reports a still image as a 1-frame "video" stream, so detect images by
# extension (and the picker's `kind`) rather than has_video. (animated GIF → video.)
_IMG_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif", ".heic", ".heif")


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


def resolve_size(preset: str, w=None, h=None):
    if w and h:
        return int(w), int(h)
    return _RES.get((preset or "vertical").lower(), (1080, 1920))


def _enc():
    # INTERMEDIATE stages (join / overlays): high quality so the 3–5 re-encode generations a
    # full reel goes through don't compound into mush. crf 16 + fast ≈ visually lossless.
    return ["-c:v", "libx264", "-preset", "fast", "-crf", "16", "-pix_fmt", "yuv420p",
            "-c:a", "aac", "-ar", "44100", "-ac", "2", "-b:a", "192k", "-movflags", "+faststart"]


# ── EXPORT: final transcode to the user-chosen container / codec / quality ───────
# (the "add captions" flow keeps using the assembled H.264 reel directly; only an
#  explicit "export to file" pays for this extra pass.)
_EXPORT_CRF = {"high": 16, "balanced": 19, "small": 25}
# codec key -> (video args, audio args)
_EXPORT_CODECS = {
    "h264": (["-c:v", "libx264", "-pix_fmt", "yuv420p"], ["-c:a", "aac", "-b:a", "192k"]),
    "h265": (["-c:v", "libx265", "-pix_fmt", "yuv420p", "-tag:v", "hvc1"],
             ["-c:a", "aac", "-b:a", "192k"]),
    "vp9":  (["-c:v", "libvpx-vp9", "-pix_fmt", "yuv420p", "-b:v", "0"],
             ["-c:a", "libopus", "-b:a", "160k"]),
}
# UI format key -> (internal codec, label, file extension)
EXPORT_FORMATS = {
    "h264": ("h264", "MP4 · H.264 (most compatible)", ".mp4"),
    "h265": ("h265", "MP4 · H.265 / HEVC (smaller)", ".mp4"),
    "webm": ("vp9",  "WebM · VP9", ".webm"),
    "gif":  ("gif",  "Animated GIF", ".gif"),
}


def export_ext(fmt: str) -> str:
    """The file extension for an export format key (for the save dialog default)."""
    return EXPORT_FORMATS.get((fmt or "h264").lower(), EXPORT_FORMATS["h264"])[2]


def export_encode(src, out, fmt="h264", quality="balanced", fps=None, max_gif_w=480):
    """Transcode the assembled reel `src` → `out` in the chosen container/codec/quality.
    GIF is palette-optimised (palettegen/paletteuse). Returns {ok, path, kb, error}."""
    exe = mm.ffmpeg_exe()
    if not exe:
        return {"ok": False, "error": "ffmpeg isn't installed."}
    if not src or not os.path.isfile(src):
        return {"ok": False, "error": "nothing to export"}
    fmt = (fmt or "h264").lower()
    codec = EXPORT_FORMATS.get(fmt, EXPORT_FORMATS["h264"])[0]
    if codec == "gif":
        fr = int(fps or 12)
        vf = ("fps=%d,scale=%d:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];"
              "[b][p]paletteuse=dither=bayer:bayer_scale=3" % (fr, int(max_gif_w)))
        ok, err = _run(["-i", src, "-filter_complex", vf, "-loop", "0", out], exe)
        return {"ok": ok and os.path.isfile(out), "path": out,
                "error": None if ok else err, "kb": mm._kb(out) if os.path.isfile(out) else 0}
    vargs, aargs = _EXPORT_CODECS.get(codec, _EXPORT_CODECS["h264"])
    crf = _EXPORT_CRF.get((quality or "balanced").lower(), 22)
    args = ["-i", src] + vargs + ["-crf", str(crf), "-preset", "medium"]
    if fps:
        args += ["-r", str(int(fps))]
    # universal social-media delivery: limited-range yuv420p tagged bt709 (avoids the
    # washed-out look full-range/yuvj clips get on players that ignore the range tag)
    args += ["-vf", "scale=w=iw:h=ih:out_range=tv,format=yuv420p",
             "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
    args += aargs
    if str(out).lower().endswith((".mp4", ".mov", ".m4v")):
        args += ["-movflags", "+faststart"]
    args += [out]
    ok, err = _run(args, exe)
    return {"ok": ok and os.path.isfile(out), "path": out,
            "error": None if ok else err, "kb": mm._kb(out) if os.path.isfile(out) else 0}


def _run(args: list, exe: str) -> tuple:
    full = [exe, "-hide_banner", "-loglevel", "error", "-y"] + args
    try:
        p = subprocess.run(full, capture_output=True, text=True,
                           creationflags=mm._CREATE_NO_WINDOW, timeout=3600)
        return (p.returncode == 0), (p.stderr or "").strip()[-300:]
    except Exception as e:
        return False, str(e)


def _dur(path: str) -> float:
    try:
        return float(mm.media_info(path).get("duration") or 0)
    except Exception:
        return 0.0


def _atempo(speed):
    """Audio tempo chain (atempo only spans 0.5–2.0, so chain for wider ranges)."""
    if not speed or abs(speed - 1.0) < 1e-3:
        return ""
    s, parts = float(speed), []
    while s > 2.0:
        parts.append("atempo=2.0"); s /= 2.0
    while s < 0.5:
        parts.append("atempo=0.5"); s *= 2.0
    parts.append("atempo=%g" % s)
    return ",".join(parts)


def _slog3_709_points(n=20):
    """Control points mapping S-Log3 code values → Rec.709, for ffmpeg's `curves` filter.
    Uses Sony's published S-Log3 EOTF (code→scene-linear) then the Rec.709 OETF — so flat
    log footage gets its real contrast + highlight roll-off back (a proper log→709 grade)."""
    def s2l(x):                                   # S-Log3 (normalised 0..1) → scene linear
        v = x * 1023.0
        if v >= 171.2102946929:
            return (10 ** ((v - 420.0) / 261.5)) * 0.19 - 0.01
        return (v - 95.0) * 0.01125 / (171.2102946929 - 95.0)

    def l709(lin):                                # scene linear → Rec.709 (gamma-encoded)
        lin = max(0.0, lin)
        return lin * 4.5 if lin < 0.018 else 1.099 * (lin ** 0.45) - 0.099

    pts = []
    for i in range(n + 1):
        x = i / float(n)
        y = max(0.0, min(1.0, l709(s2l(x))))
        pts.append("%.4f/%.4f" % (x, y))
    return " ".join(pts)


def _log_filter(label):
    """The video filter that turns a flat S-Log/log frame into a graded Rec.709 one
    (natural look): the S-Log3→709 tone curve + a gentle saturation/contrast lift."""
    return ("[%s]curves=all='%s',eq=saturation=1.28:contrast=1.05:brightness=0.01[lg]"
            % (label, _slog3_709_points()))


def detect_log(path):
    """Heuristic 'is this flat/log footage?' — Sony XAVC carries no colour tags, so read the
    SIGNAL: log footage has lifted blacks, compressed highlights and low saturation. Returns
    {is_log, blacks, highs, sat, reason}. Cheap (one frame through signalstats)."""
    exe = mm.ffmpeg_exe()
    if not exe or not path or not os.path.isfile(path):
        return {"is_log": False}
    try:
        info = mm.media_info(path)
        pf = (info.get("pix_fmt") or "")
        maxv = 1023.0 if ("10" in pf or "p10" in pf or "422" in pf) else 255.0
        p = subprocess.run(
            [exe, "-hide_banner", "-v", "error", "-i", path,
             "-vf", "select=eq(n\\,30),signalstats,metadata=print:file=-", "-frames:v", "1", "-f", "null", "-"],
            capture_output=True, text=True, creationflags=mm._CREATE_NO_WINDOW, timeout=60)
        out = (p.stdout or "") + (p.stderr or "")
        vals = {}
        for key in ("YLOW", "YHIGH", "YMIN", "YMAX", "SATAVG"):
            m = re.search(r"signalstats\.%s=([\d.]+)" % key, out)
            if m:
                vals[key] = float(m.group(1))
        if "YLOW" not in vals or "YHIGH" not in vals:
            return {"is_log": False}
        blacks = vals["YLOW"] / maxv
        highs = vals["YHIGH"] / maxv
        sat = vals.get("SATAVG", 0) / maxv
        is_log = (blacks > 0.12 and highs < 0.72 and sat < 0.07)
        return {"is_log": bool(is_log), "blacks": round(blacks, 3),
                "highs": round(highs, 3), "sat": round(sat, 4)}
    except Exception as e:
        return {"is_log": False, "error": str(e)}


def _rot_flt(rotate):
    """A 90°-step rotate prefix (applied to the raw frame, before fit/scale)."""
    r = int(rotate or 0) % 360
    if r == 90:
        return "transpose=1,"               # 90° clockwise
    if r == 180:
        return "transpose=1,transpose=1,"
    if r == 270:
        return "transpose=2,"               # 90° counter-clockwise
    return ""


def _vfc(vlabel, w, h, fps, background, fit, speed, motion, out_dur, is_img, rotate=0, zoom=1.0):
    """Build the video filtergraph producing [v] — handles rotate, fit/fill, a blurred-fill
    background (vs black bars), speed, a Ken-Burns zoom on stills, and a static punch-in
    `zoom` (>1 scales up + centre-crops back to WxH — the auto-edit's energy cuts)."""
    rot = _rot_flt(rotate)
    zf = ""
    if zoom and zoom > 1.001:                          # punch-in: scale up, centre-crop to WxH
        zf = ",scale=%d:%d,crop=%d:%d" % (max(2, int(round(w * zoom)) // 2 * 2),
                                          max(2, int(round(h * zoom)) // 2 * 2), w, h)
    # IMAGE + Ken-Burns: cover-scale the still, then zoompan GENERATES all its frames.
    if is_img and motion in ("zoomin", "zoomout"):
        n = max(2, int((out_dur or 4) * fps))
        z = ("min(1.0001+0.13*on/%d,1.14)" % n) if motion == "zoomin" \
            else ("max(1.14-0.13*on/%d,1.0)" % n)
        return ("[%s]%sscale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d,setsar=1,"
                "zoompan=z='%s':d=%d:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=%dx%d:fps=%d,"
                "format=yuv420p[v]" % (vlabel, rot, w, h, w, h, z, n, w, h, fps))
    fitc = ("scale=%d:%d:force_original_aspect_ratio=increase:flags=lanczos,crop=%d:%d" % (w, h, w, h)) \
        if fit == "fill" else ("scale=%d:%d:force_original_aspect_ratio=decrease:flags=lanczos" % (w, h))
    spd = ("setpts=%g*PTS," % (1.0 / speed)) if (speed and abs(speed - 1.0) > 1e-3) else ""
    if background == "blur" and fit != "fill":
        return ("[%s]%s%ssplit=2[s0][s1];" % (vlabel, rot, spd) +
                "[s0]%s,setsar=1[fg];" % fitc +
                "[s1]scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d,gblur=sigma=22,setsar=1[bg];"
                % (w, h, w, h) +
                "[bg][fg]overlay=(W-w)/2:(H-h)/2%s,fps=%d,format=yuv420p[v]" % (zf, fps))
    pad = (",pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=black" % (w, h)) if fit != "fill" else ""
    return "[%s]%s%s%s%s%s,setsar=1,fps=%d,format=yuv420p[v]" % (vlabel, rot, spd, fitc, pad, zf, fps)


def _norm_clip(src, c, w, h, fps, idx, tmpdir, background="black", fit="fit"):
    """Normalise one (trimmed) clip/image to a uniform mp4, honouring per-clip speed /
    mute / motion and the global background / fit. Returns (path, err)."""
    exe = mm.ffmpeg_exe()
    out = os.path.join(tmpdir, "wvx_tl_%02d.mp4" % idx)
    info = mm.media_info(src)
    t0 = float(c.get("start") or 0)
    t1 = float(c["end"]) if c.get("end") not in (None, "", 0) else None
    speed = max(0.25, min(4.0, float(c.get("speed") or 1.0)))
    vol = max(0.0, min(4.0, float(c.get("volume") if c.get("volume") is not None else 1.0)))
    motion = (c.get("motion") or "none").lower()
    is_img = (c.get("kind") == "image") or (os.path.splitext(src)[1].lower() in _IMG_EXT)
    has_audio = bool(info.get("has_audio")) and not bool(c.get("mute")) and not is_img
    dur = (t1 - t0) if (t1 and t1 > (t0 or 0)) else None
    out_dur = (dur / speed) if (dur and not is_img) else (dur or 4.0)
    args = ["-hide_banner", "-loglevel", "error", "-y"]
    if is_img:
        d = dur or 4.0
        if motion in ("zoomin", "zoomout"):
            args += ["-i", src]          # single frame → zoompan generates the frames
        else:
            args += ["-loop", "1", "-t", str(d), "-i", src]
        v_in, a_in = "0:v", None
    else:
        if t0 and t0 > 0:
            args += ["-ss", str(t0)]
        if dur:
            args += ["-t", str(dur)]          # INPUT trim (before -i) so speed can then expand it
        args += ["-i", src]
        v_in, a_in = "0:v", ("0:a:0" if has_audio else None)
    if a_in is None:                                   # synthesise a silent track
        args += ["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"]
        a_in = "1:a"
    log_pre = ""
    if c.get("log") and not is_img:                    # S-Log/flat → Rec.709 grade before framing
        log_pre = _log_filter(v_in) + ";"
        v_in = "lg"
    vfc = _vfc(v_in, w, h, fps, background, fit, (speed if not is_img else 1.0), motion, out_dur,
               is_img, rotate=int(c.get("rotate") or 0), zoom=float(c.get("zoom") or 1.0))
    atempo = _atempo(speed) if has_audio else ""
    volflt = ("volume=%g," % vol) if (has_audio and abs(vol - 1.0) > 1e-3) else ""
    afc = "[%s]%s%saresample=44100[a]" % (a_in, (atempo + "," if atempo else ""), volflt)
    args += ["-filter_complex", log_pre + vfc + ";" + afc, "-map", "[v]", "-map", "[a]"]
    if is_img:
        args += ["-t", str(out_dur)]      # bound the looped/zoompan-generated image clip
    elif a_in == "1:a":
        args += ["-shortest"]             # silent video clip → bound to the video length
    args += ["-r", str(fps), "-c:v", "libx264", "-preset", "fast", "-crf", "17",
             "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100", "-ac", "2", "-b:a", "192k",
             "-video_track_timescale", "90000", "-movflags", "+faststart", out]
    try:
        p = subprocess.run([exe] + args, capture_output=True, text=True, cwd=tmpdir,
                           creationflags=mm._CREATE_NO_WINDOW, timeout=1800)
        if p.returncode != 0 or not os.path.isfile(out):
            return None, (p.stderr or "").strip()[-300:] or "normalise failed"
        return out, None
    except Exception as e:
        return None, str(e)


def _join_concat(norm, out, exe, tmpdir):
    listf = os.path.join(tmpdir, "list.txt")
    with open(listf, "w", encoding="utf-8") as fh:
        for n in norm:
            fh.write("file '%s'\n" % n.replace("\\", "/").replace("'", "'\\''"))
    base = ["-f", "concat", "-safe", "0", "-i", listf]
    ok, err = _run(base + ["-c", "copy", "-movflags", "+faststart", out], exe)
    if not ok or not os.path.isfile(out):
        ok, err = _run(base + _enc() + [out], exe)
    return ok, err


def _join_xfade(norm, transition, tdur, out, exe):
    """Crossfade the clips with a chained xfade (video) + acrossfade (audio)."""
    durs = [_dur(n) for n in norm]
    if min(durs) <= 0:
        return False, "could not read clip durations"
    tdur = max(0.1, min(float(tdur), min(durs) / 2.0))
    transition = transition if transition in TRANSITIONS else "fade"
    inputs = []
    for n in norm:
        inputs += ["-i", n]
    filt, vprev, aprev = "", "0:v", "0:a"
    voff = durs[0] - tdur
    for k in range(1, len(norm)):
        vout, aout = "v%d" % k, "a%d" % k
        filt += "[%s][%d:v]xfade=transition=%s:duration=%g:offset=%g[%s];" % (vprev, k, transition, tdur, voff, vout)
        filt += "[%s][%d:a]acrossfade=d=%g[%s];" % (aprev, k, tdur, aout)
        vprev, aprev = vout, aout
        voff += durs[k] - tdur
    args = inputs + ["-filter_complex", filt.rstrip(";"),
                     "-map", "[%s]" % vprev, "-map", "[%s]" % aprev] + _enc() + [out]
    return _run(args, exe)


def _join_positioned(norm, starts, timeline_dur, w, h, fps, out, exe, xfade=0.0):
    """POSITION-BASED join: composite each normalised clip onto a black canvas of length
    `timeline_dur` at its timeline start (gaps → black + silence; overlapping clips → the
    later one on top). This is what makes the timeline a real NLE — clips can be moved,
    have gaps, and the timeline can be longer than the content. When `xfade` > 0 a clip
    that starts while an earlier clip is still on screen DISSOLVES in over the overlap."""
    dur = max(0.1, float(timeline_dur or 0))
    inputs = ["-f", "lavfi", "-i", "color=c=black:s=%dx%d:r=%d:d=%g" % (w, h, fps, dur),
              "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"]
    for n in norm:
        inputs += ["-i", n]
    ds = [_dur(n) for n in norm]
    chains = ["[1:a]atrim=0:%g[abase]" % dur]
    vacc, amix = "[0:v]", ["[abase]"]
    for k, n in enumerate(norm):
        idx, st, d = k + 2, max(0.0, float(starts[k])), ds[k]
        ov = 0.0                                   # dissolve duration if this clip overlaps one beneath it
        if xfade > 0:
            cover_end = 0.0
            for j in range(k):                     # latest-ending earlier clip still on-screen at st
                sj = max(0.0, float(starts[j]))
                if sj <= st < sj + ds[j] - 0.02:
                    cover_end = max(cover_end, sj + ds[j])
            if cover_end > st:
                ov = min(cover_end - st, d, float(xfade))
        if ov > 0.05:                              # add an alpha channel + fade in over the overlap
            chains.append("[%d:v]setpts=PTS-STARTPTS+%g/TB,format=yuva420p,fade=t=in:st=%g:d=%g:alpha=1[v%d]"
                          % (idx, st, st, ov, k))
        else:
            chains.append("[%d:v]setpts=PTS-STARTPTS+%g/TB[v%d]" % (idx, st, k))
        chains.append("%s[v%d]overlay=eof_action=pass:enable='between(t,%g,%g)'[o%d]"
                      % (vacc, k, st, st + d, k))
        vacc = "[o%d]" % k
        ms = int(round(st * 1000))
        chains.append("[%d:a]adelay=%d|%d[a%d]" % (idx, ms, ms, k))
        amix.append("[a%d]" % k)
    chains.append("%samix=inputs=%d:duration=first:dropout_transition=0:normalize=0[a]"
                  % ("".join(amix), len(amix)))
    args = inputs + ["-filter_complex", ";".join(chains), "-map", vacc, "-map", "[a]",
                     "-t", "%g" % dur] + _enc() + [out]
    return _run(args, exe)


def _apply_overlays(base, overlays, w, h, out, exe):
    """Composite image/B-roll overlays onto the base — each scaled, positioned, with
    optional opacity + fade, visible for its [start, start+dur] window. Video overlays
    are trimmed and PTS-shifted so they play from their own start, in sync."""
    reel = _dur(base) or 60.0
    inputs = ["-i", base]
    filt, last = "", "[0:v]"
    for i, ov in enumerate(overlays):
        idx = i + 1
        is_img = (ov.get("kind", "image") != "video")
        ow = max(2, int(w * float(ov.get("scale", 0.4))))
        st = float(ov.get("start", 0) or 0)
        du = float(ov.get("dur", 3) or 3)
        en = st + du
        xf = float(ov.get("x", 0.5)); yf = float(ov.get("y", 0.25))
        op = max(0.0, min(1.0, float(ov.get("opacity", 1.0) if ov.get("opacity") is not None else 1.0)))
        fd = max(0.0, float(ov.get("fade", 0) or 0))
        if is_img:
            inputs += ["-loop", "1", "-t", str(reel), "-i", ov["path"]]
            chain = "scale=%d:-1,format=yuva420p" % ow
        else:
            vin = max(0.0, float(ov.get("in", 0) or 0))         # overlay source in-point
            inputs += ["-i", ov["path"]]
            chain = "trim=%g:%g,setpts=PTS-STARTPTS+%g/TB,scale=%d:-1,format=yuva420p" % (vin, vin + du, st, ow)
        if op < 1.0:
            chain += ",colorchannelmixer=aa=%g" % op
        if fd > 0:
            chain += (",fade=t=in:st=%g:d=%g:alpha=1,fade=t=out:st=%g:d=%g:alpha=1"
                      % (st, fd, max(st, en - fd), fd))
        filt += "[%d:v]%s[ov%d];" % (idx, chain, i)
        filt += ("%s[ov%d]overlay=x=(W*%g-w/2):y=(H*%g-h/2):enable='between(t,%g,%g)'[b%d];"
                 % (last, i, xf, yf, st, en, i))
        last = "[b%d]" % i
    args = inputs + ["-filter_complex", filt.rstrip(";"),
                     "-map", last, "-map", "0:a?"] + _enc() + [out]
    return _run(args, exe)


def _add_voiceover(base, vo, vol, out, exe):
    """Mix a voiceover/narration track OVER the reel (full level, NOT ducked) — plays
    from its own start, the reel keeps playing under it."""
    inputs = ["-i", base, "-i", vo]
    filt = ("[1:a]volume=%g[vo];[0:a][vo]amix=inputs=2:duration=first:dropout_transition=0,volume=1.6[a]"
            % vol)
    args = inputs + ["-filter_complex", filt, "-map", "0:v", "-map", "[a]", "-c:v", "copy",
                     "-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", out]
    return _run(args, exe)


def waveform(path, width=600, height=40, color="6AA0FF"):
    """Render a waveform image (base64 PNG) for an audio file — for the canvas audio lane."""
    exe = mm.ffmpeg_exe()
    if not exe or not path or not os.path.isfile(path):
        return {"ok": False, "error": "no audio"}
    out = os.path.join(tempfile.gettempdir(), "wvx_wave.png")
    try:
        if os.path.exists(out):
            os.remove(out)
    except Exception:
        pass
    fc = ("[0:a]aformat=channel_layouts=mono,showwavespic=s=%dx%d:colors=#%s[v]"
          % (int(width), int(height), color))
    ok, err = _run(["-i", path, "-filter_complex", fc, "-map", "[v]", "-frames:v", "1", out], exe)
    if not ok or not os.path.isfile(out):
        return {"ok": False, "error": err or "waveform failed"}
    import base64
    with open(out, "rb") as fh:
        b64 = base64.standard_b64encode(fh.read()).decode("ascii")
    return {"ok": True, "img": "data:image/png;base64," + b64}


def _add_music(base, music, vol, duck, out, exe):
    """Mix a looped music bed under the reel; optionally side-chain DUCK it under the
    reel's own audio (speech), so narration stays clear over the music."""
    inputs = ["-i", base, "-stream_loop", "-1", "-i", music]
    if duck:
        filt = ("[1:a]volume=%g[m];[m][0:a]sidechaincompress=threshold=0.04:ratio=8:"
                "attack=15:release=350[md];[0:a][md]amix=inputs=2:duration=first:"
                "dropout_transition=0,volume=1.4[a]" % vol)
    else:
        filt = "[1:a]volume=%g[m];[0:a][m]amix=inputs=2:duration=first:dropout_transition=0[a]" % vol
    args = inputs + ["-filter_complex", filt, "-map", "0:v", "-map", "[a]",
                     "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest",
                     "-movflags", "+faststart", out]
    return _run(args, exe)


def _add_audio_tracks(base, tracks, out, exe):
    """Mix N audio tracks onto the reel's own audio. Each track: {path, vol, duck, loop,
    start, end, tlStart}. `loop` repeats a short bed to fill (music); `duck` side-chains it
    under the reel's audio (speech stays clear). `start`/`end` trim the source and `tlStart`
    delays it (so a clip's DETACHED audio plays exactly where its video was). `normalize=0`
    keeps the reel at full level + each track at its own volume."""
    tracks = [t for t in (tracks or []) if t.get("path") and os.path.isfile(t["path"])]
    if not tracks:
        return True, ""
    inputs = ["-i", base]
    for t in tracks:
        if t.get("loop"):
            inputs += ["-stream_loop", "-1", "-i", t["path"]]
        else:
            inputs += ["-i", t["path"]]
    ducked = [i for i, t in enumerate(tracks) if t.get("duck")]
    chains, mix = [], []
    if ducked:                       # split the reel audio: 1 for the mix + 1 key per ducked track
        outs = "[bmix]" + "".join("[k%d]" % i for i in ducked)
        chains.append("[0:a]asplit=%d%s" % (1 + len(ducked), outs))
        mix.append("[bmix]")
    else:
        mix.append("[0:a]")
    for i, t in enumerate(tracks):
        vol = max(0.0, float(t.get("vol", 0.5) or 0))
        st = float(t.get("start") or 0)
        en = t.get("end")
        pre = ""
        seg_dur = None
        if en not in (None, "", 0) and float(en) > st:        # trim the source [start, end]
            pre += "atrim=%g:%g,asetpts=PTS-STARTPTS," % (st, float(en))
            seg_dur = float(en) - st
        elif st > 0:
            pre += "atrim=%g,asetpts=PTS-STARTPTS," % st
        fi = max(0.0, float(t.get("fadein") or 0))            # fade in / out (afade)
        fo = max(0.0, float(t.get("fadeout") or 0))
        fade = ""
        if fi > 0:
            fade += ",afade=t=in:st=0:d=%g" % fi
        if fo > 0 and seg_dur and seg_dur > fo:
            fade += ",afade=t=out:st=%g:d=%g" % (seg_dur - fo, fo)
        tl = float(t.get("tlStart") or 0)                     # delay to the timeline position
        post = fade + ((",adelay=%d|%d" % (int(tl * 1000), int(tl * 1000))) if tl > 0 else "")
        chains.append("[%d:a]%svolume=%g%s[v%d]" % (i + 1, pre, vol, post, i))
        if i in ducked:
            chains.append("[v%d][k%d]sidechaincompress=threshold=0.04:ratio=8:attack=15:release=350[a%d]" % (i, i, i))
            mix.append("[a%d]" % i)
        else:
            mix.append("[v%d]" % i)
    chains.append("%samix=inputs=%d:duration=first:dropout_transition=0:normalize=0[a]" % ("".join(mix), len(mix)))
    args = inputs + ["-filter_complex", ";".join(chains), "-map", "0:v", "-map", "[a]",
                     "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest",
                     "-movflags", "+faststart", out]
    return _run(args, exe)


def build_timeline(clips, out=None, w=1080, h=1920, fps=30, transition="none",
                   transition_dur=0.5, overlays=None, music=None, music_vol=0.5,
                   duck=True, background="black", fit="fit", voiceover=None,
                   voiceover_vol=1.0, audio_tracks=None, timeline_dur=None,
                   progress=None, cancel=None):
    exe = mm.ffmpeg_exe()
    if not exe:
        return {"ok": False, "error": "ffmpeg isn't installed."}
    clips = [c for c in (clips or []) if c.get("path") and os.path.isfile(c["path"])]
    if not clips:
        return {"ok": False, "error": "Add at least one clip."}
    overlays = [o for o in (overlays or []) if o.get("path") and os.path.isfile(o["path"])]
    # composite in TRACK order (z-order): V2 under V3 under V4 … (higher track = on top).
    overlays.sort(key=lambda o: int(o.get("track", 1) or 1))
    fps = int(fps or 30)
    tmpdir = tempfile.mkdtemp(prefix="wvx_tl_")

    def _p(fr):
        if progress:
            try:
                progress(min(1.0, float(fr)))
            except Exception:
                pass

    def _cancelled():
        try:
            return bool(cancel and cancel())
        except Exception:
            return False

    try:
        norm = []
        nsteps = len(clips) + 1 + (1 if overlays else 0) + (1 if music else 0)
        for i, c in enumerate(clips):
            if _cancelled():
                return {"ok": False, "cancelled": True}
            np_, err = _norm_clip(c["path"], c, w, h, fps, i, tmpdir, background=background, fit=fit)
            if not np_:
                return {"ok": False, "error": "Clip %d (%s) failed: %s"
                        % (i + 1, os.path.basename(c["path"]), err)}
            norm.append(np_)
            _p((i + 1) / nsteps)

        if _cancelled():
            return {"ok": False, "cancelled": True}
        # ── timeline positions: end-to-end by default; "positioned" once a clip is moved,
        #    a gap appears, or the timeline is stretched longer than the content ──
        dds = [_dur(n) for n in norm]
        starts, acc, contiguous = [], 0.0, True
        for i, c in enumerate(clips):
            ts = c.get("tlStart")
            ts = acc if ts is None else float(ts)
            if abs(ts - acc) > 0.05:
                contiguous = False
            starts.append(ts)
            acc += dds[i]
        content_end = max([starts[i] + dds[i] for i in range(len(norm))], default=0.0)
        tldur = float(timeline_dur or 0) or content_end
        if tldur > content_end + 0.05:
            contiguous = False
        positioned = (not contiguous) and len(norm) >= 1

        # ── join ──
        joined = os.path.join(tmpdir, "joined.mp4")
        transition = (transition or "none").lower()
        if positioned:
            xf = float(transition_dur or 0.5) if transition != "none" else 0.0
            ok, err = _join_positioned(norm, starts, max(tldur, content_end), w, h, fps, joined, exe, xfade=xf)
        elif transition != "none" and len(norm) >= 2:
            ok, err = _join_xfade(norm, transition, float(transition_dur or 0.5), joined, exe)
            if not ok or not os.path.isfile(joined):
                ok, err = _join_concat(norm, joined, exe, tmpdir)   # fall back to hard cuts
        else:
            ok, err = _join_concat(norm, joined, exe, tmpdir)
        if not ok or not os.path.isfile(joined):
            return {"ok": False, "error": err or "join failed"}
        cur = joined
        _p((len(clips) + 1) / nsteps)

        # ── overlays ──
        if overlays:
            ovout = os.path.join(tmpdir, "ov.mp4")
            ok, err = _apply_overlays(cur, overlays, w, h, ovout, exe)
            if not ok or not os.path.isfile(ovout):
                return {"ok": False, "error": "overlays failed: " + (err or "")}
            cur = ovout
            _p((len(clips) + 2) / nsteps)

        # ── audio tracks (mix N beds + narration; ducked tracks side-chain under speech) ──
        # Back-compat: fold legacy single music / voiceover into the track list.
        tracks = list(audio_tracks or [])
        if music:
            tracks.append({"path": music, "vol": float(music_vol or 0.5), "duck": bool(duck), "loop": True})
        if voiceover:
            tracks.append({"path": voiceover, "vol": float(voiceover_vol or 1.0), "duck": False, "loop": False})
        tracks = [t for t in tracks if t.get("path") and os.path.isfile(t["path"])]
        out = out or os.path.join(tempfile.gettempdir(), "wvx_reel.mp4")
        if tracks:
            ok, err = _add_audio_tracks(cur, tracks, out, exe)
            if not ok or not os.path.isfile(out):
                return {"ok": False, "error": "audio mix failed: " + (err or "")}
        else:
            shutil.copyfile(cur, out)
        _p(1.0)

        info = mm.media_info(out)
        # per-clip time-marks in the FINAL reel — the start (in reel-seconds) of each
        # source clip, so the captions editor can group segments by which clip they
        # came from (crossfades overlap adjacent clips by `td`, mirroring _join_xfade).
        marks = []
        try:
            if positioned:                      # positions are the timeline starts
                for i in range(len(norm)):
                    nm = (clips[i].get("name") if i < len(clips) else None) or ("clip %d" % (i + 1))
                    marks.append({"name": nm, "start": round(starts[i], 3)})
            else:                               # contiguous: crossfades overlap by td
                td = (max(0.1, min(float(transition_dur or 0.5), (min(dds) / 2.0) if dds else 0.5))
                      if (transition != "none" and len(norm) >= 2) else 0.0)
                off = 0.0
                for i, n in enumerate(norm):
                    nm = (clips[i].get("name") if i < len(clips) else None) or ("clip %d" % (i + 1))
                    marks.append({"name": nm, "start": round(off, 3)})
                    off += dds[i] - (td if i < len(norm) - 1 else 0.0)
        except Exception:
            marks = []
        return {"ok": True, "path": out, "kb": mm._kb(out), "clips": len(norm),
                "w": w, "h": h, "duration": info.get("duration"),
                "transition": transition if transition != "none" else None,
                "overlays": len(overlays), "music": bool(music or voiceover or audio_tracks),
                "audio_tracks": len(tracks), "clip_marks": marks}
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)


def preview_frame_at(clips, t, w=1080, h=1920, background="black", fit="fit", maxside=320):
    """Extract a single framed preview frame at TIMELINE time `t` (seconds) — maps t to
    the clip + source time (accounting for trims/speed), applies the same framing
    (fit/fill/blur) as the build, and returns it scaled-down as base64. Powers the
    canvas scrubbing playhead before the reel is ever built."""
    exe = mm.ffmpeg_exe()
    if not exe:
        return {"ok": False, "error": "ffmpeg isn't installed."}
    clips = [c for c in (clips or []) if c.get("path") and os.path.isfile(c["path"])]
    if not clips:
        return {"ok": False, "error": "no clips"}
    t = max(0.0, float(t or 0))
    # position-based: pick the clip whose [tlStart, tlStart+disp] contains t (last wins on
    # overlap, matching the build's z-order). A clip with no tlStart falls back to end-to-end.
    acc, sel, sel_img, src_t = 0.0, None, False, 0.0
    for i, c in enumerate(clips):
        is_img = (c.get("kind") == "image") or (os.path.splitext(c["path"])[1].lower() in _IMG_EXT)
        start = float(c.get("start") or 0)
        speed = max(0.25, min(4.0, float(c.get("speed") or 1)))
        if is_img:
            disp = float(c["end"]) if c.get("end") else float(c.get("dur") or 4)
        else:
            srcend = float(c["end"]) if c.get("end") not in (None, "", 0) else float(c.get("dur") or 0)
            disp = max(0.1, (srcend - start) / speed)
        ts = c.get("tlStart")
        ts = acc if ts is None else float(ts)
        acc += disp
        if ts - 1e-6 <= t < ts + disp:
            sel, sel_img = c, is_img
            off = max(0.0, min(t - ts, disp - 0.05))       # clamp inside the clip's trimmed span
            src_t = start + (0 if is_img else off * speed)
    if sel is None:                                        # a gap / past the end → a black frame
        import urllib.parse
        svg = ('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d">'
               '<rect width="100%%" height="100%%" fill="black"/></svg>' % (w, h))
        return {"ok": True, "thumb": "data:image/svg+xml," + urllib.parse.quote(svg),
                "t": t, "w": w, "h": h, "black": True}
    if h >= w:
        ph = maxside; pw = max(2, int(round(w / float(h) * maxside)) // 2 * 2)
    else:
        pw = maxside; ph = max(2, int(round(h / float(w) * maxside)) // 2 * 2)
    rot = _rot_flt(sel.get("rotate"))
    if sel.get("log") and not sel_img:                 # show the graded look in the scrub monitor too
        rot = "curves=all='%s',eq=saturation=1.28:contrast=1.05:brightness=0.01,%s" % (_slog3_709_points(), rot)
    if fit == "fill":
        fc = "[0:v]%sscale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d,scale=%d:%d[v]" % (rot, w, h, w, h, pw, ph)
    elif background == "blur":
        fc = ("[0:v]%ssplit=2[a][b];[a]scale=%d:%d:force_original_aspect_ratio=decrease[fg];"
              "[b]scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d,gblur=sigma=20[bg];"
              "[bg][fg]overlay=(W-w)/2:(H-h)/2,scale=%d:%d[v]" % (rot, w, h, w, h, w, h, pw, ph))
    else:
        fc = ("[0:v]%sscale=%d:%d:force_original_aspect_ratio=decrease,"
              "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,scale=%d:%d[v]" % (rot, w, h, w, h, pw, ph))
    out = os.path.join(tempfile.gettempdir(), "wvx_tlframe.jpg")
    try:
        if os.path.exists(out):
            os.remove(out)
    except Exception:
        pass
    # decode the scrub frame from the small cached PROXY when it exists (8-bit ~854p,
    # ~10× faster than seeking a 4K 10-bit 4:2:2 source); else from the source.
    src_path, from_proxy = sel["path"], False
    if not sel_img:
        try:
            import wivexx_captions as wc
            pp = wc.proxy_cache_path(sel["path"])
            if pp:
                src_path, from_proxy = pp, True
        except Exception:
            pass
    args = [exe, "-hide_banner", "-loglevel", "error", "-y"]
    if not sel_img and src_t > 0:
        args += ["-ss", str(src_t)]
    args += ["-i", src_path, "-filter_complex", fc, "-map", "[v]", "-frames:v", "1", "-q:v", "4", out]
    try:
        p = subprocess.run(args, capture_output=True, text=True,
                           creationflags=mm._CREATE_NO_WINDOW, timeout=60)
        if p.returncode != 0 or not os.path.isfile(out):
            return {"ok": False, "error": (p.stderr or "").strip()[-200:] or "frame failed"}
        import base64
        with open(out, "rb") as fh:
            b64 = base64.standard_b64encode(fh.read()).decode("ascii")
        return {"ok": True, "thumb": "data:image/jpeg;base64," + b64, "t": round(t, 2),
                "w": pw, "h": ph, "proxy": from_proxy}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def build_proxies(clips, progress=None) -> dict:
    """Pre-build the small preview proxies for every (non-image) source clip so that
    canvas scrubbing is fast. Idempotent + cached; safe to call on every clip change."""
    try:
        import wivexx_captions as wc
    except Exception as e:
        return {"ok": False, "error": str(e)}
    seen, paths = set(), []
    for c in (clips or []):
        p = c.get("path")
        is_img = (c.get("kind") == "image") or (os.path.splitext(p or "")[1].lower() in _IMG_EXT)
        if p and not is_img and p not in seen and os.path.isfile(p):
            seen.add(p)
            paths.append(p)
    built = 0
    for i, p in enumerate(paths):
        if wc.proxy_cache_path(p) is None:
            r = wc.make_preview_proxy(p)
            if r.get("ok"):
                built += 1
        if progress:
            try:
                progress((i + 1) / float(len(paths)))
            except Exception:
                pass
    return {"ok": True, "clips": len(paths), "built": built}


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