#!/usr/bin/env python3
"""hyperframes_render.py — render a clip through a HyperFrames motion composition.

Mirror of motion_render.py (Remotion) with the same render() contract, backed by the
HyperFrames engine (HTML + one paused GSAP timeline, npx published CLI). First
capability: KineticCaptions (word-punch karaoke captions + animated hook + handle)
over the live footage, 9:16 cover-crop at SOURCE-proportional resolution.

Pipeline per render:
  1. ffprobe the source (w/h/fps)
  2. ffmpeg pre-cut the segment, cover-cropped to a source-proportional 9:16 canvas
     (crop H = min(srcH, srcW*16/9); never downscaled to 1080; canvas height capped 2160)
  3. copy compositions/hyperframes/<comp>/ into compositions/hyperframes/.builds/<id>/,
     drop the cut clip at assets/clip.mp4, rewrite canvas data-attributes
  4. write vars json {clip, words, hook, handle, accent, fps, durationSec, width, height}
  5. `npx hyperframes render --variables-file … --fps … --quality standard`
  6. verify the output, clean the build dir on success

CLI: python3 hyperframes_render.py --source V --start S --end E --out O
     [--words W.json] [--hook ".."] [--handle "@19keys"] [--accent "#D4AF37"] [--comp kinetic]
"""
import argparse
import json
import os
import re
import shutil
import subprocess
from pathlib import Path

HERE = Path(__file__).resolve().parent          # tools/
STUDIO = HERE.parent
COMPS = STUDIO / "compositions" / "hyperframes"
BUILDS = COMPS / ".builds"
FFMPEG_DIR = "/opt/homebrew/opt/ffmpeg-full/bin"
FFMPEG = f"{FFMPEG_DIR}/ffmpeg"
FFPROBE = f"{FFMPEG_DIR}/ffprobe"
# Pinned published CLI (repo vendors 0.6.110 source; published package needs no build)
HYPERFRAMES_SPEC = os.environ.get("HYPERFRAMES_SPEC", "hyperframes@0.7.53")
RENDER_FPS_CHOICES = (24, 30, 60)               # values the CLI --fps flag accepts


def _env():
    env = dict(os.environ)
    env["PATH"] = f"{FFMPEG_DIR}:/opt/homebrew/bin:" + env.get("PATH", "")
    return env


def probe(source):
    """Return (width, height, fps) of the source's first video stream."""
    r = subprocess.run(
        [FFPROBE, "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=width,height,r_frame_rate", "-of", "json", str(source)],
        capture_output=True, text=True)
    try:
        s = json.loads(r.stdout)["streams"][0]
        num, den = s["r_frame_rate"].split("/")
        fps = float(num) / float(den or 1)
        return int(s["width"]), int(s["height"]), fps
    except (KeyError, IndexError, ValueError, json.JSONDecodeError):
        raise RuntimeError(f"ffprobe failed on {source}: {r.stderr[-400:]}")


def canvas_for(src_w, src_h):
    """Source-proportional 9:16 canvas: crop H = min(srcH, srcW*16/9), capped at 2160."""
    crop_h = min(src_h, int(src_w * 16 / 9))
    h = min(crop_h, 2160)
    h -= h % 2
    w = round(h * 9 / 16 / 2) * 2
    return w, h


def render_fps_for(src_fps):
    """Nearest CLI-accepted fps (24/30/60) to the source fps."""
    return min(RENDER_FPS_CHOICES, key=lambda c: abs(c - src_fps))


def clip_words(words_path, start, end):
    """words.json ({"words":[{start,end,text,type}]} absolute source seconds) →
    clip-relative [{start,end,text}] within [start, end]."""
    try:
        words = json.loads(Path(words_path).read_text()).get("words", [])
    except (OSError, json.JSONDecodeError):
        return []
    out = []
    for w in words:
        if w.get("type", "word") != "word":
            continue
        s = w.get("start", 0)
        if start - 0.05 <= s <= end + 0.05:
            out.append({"start": round(s - start, 3),
                        "end": round(w.get("end", s) - start, 3),
                        "text": w.get("text", "")})
    return out


def _fmt(n):
    s = f"{n:.3f}".rstrip("0").rstrip(".")
    return s or "0"


def _rewrite(html, width, height, dur):
    """Rewrite the template's canvas + duration tokens for this render."""
    subs = [
        ('data-width="1080"', f'data-width="{width}"', 1),
        ('data-height="1920"', f'data-height="{height}"', 1),
        ('width: 1080px', f'width: {width}px', 1),
        ('height: 1920px', f'height: {height}px', 1),
        ('content="width=1080, height=1920"', f'content="width={width}, height={height}"', 1),
        ('data-duration="8"', f'data-duration="{_fmt(dur)}"', None),  # every clip spans full duration
    ]
    for old, new, expect in subs:
        n = html.count(old)
        if expect is not None and n != expect:
            raise RuntimeError(f"template drift: expected {expect}x {old!r}, found {n}")
        if expect is None and n < 1:
            raise RuntimeError(f"template drift: {old!r} not found")
        html = html.replace(old, new)
    return html


def render(source, start, end, out, words_path=None, hook="", handle="@19keys",
           accent="#D4AF37", comp="kinetic", font="", extra=None):
    start, end = float(start), float(end)
    dur = end - start
    if dur <= 0:
        raise ValueError("end must be after start")
    comp_dir = COMPS / comp
    if not (comp_dir / "index.html").exists():
        raise RuntimeError(f"unknown comp {comp!r}: {comp_dir}/index.html missing")

    src_w, src_h, src_fps = probe(source)
    width, height = canvas_for(src_w, src_h)
    fps = render_fps_for(src_fps)

    cid = f"{comp}_{int(start * 1000)}_{int(end * 1000)}"
    build = BUILDS / cid
    if build.exists():
        shutil.rmtree(build)
    build.parent.mkdir(parents=True, exist_ok=True)
    shutil.copytree(comp_dir, build)

    # Pre-cut the segment, cover-cropped onto the canvas, straight into the build assets
    clip_path = build / "assets" / "clip.mp4"
    r = subprocess.run(
        [FFMPEG, "-y", "-ss", str(start), "-to", str(end), "-i", str(source),
         "-vf", f"scale={width}:{height}:force_original_aspect_ratio=increase,"
                f"crop={width}:{height},setsar=1",
         "-r", str(fps), "-c:v", "libx264", "-preset", "fast", "-crf", "17",
         "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", str(clip_path)],
        capture_output=True, text=True)
    if r.returncode != 0 or not clip_path.exists():
        raise RuntimeError("pre-cut failed: " + (r.stderr or "")[-500:])

    index = build / "index.html"
    index.write_text(_rewrite(index.read_text(), width, height, dur))

    variables = {"clip": "clip.mp4",
                 "words": json.dumps(clip_words(words_path, start, end) if words_path else []),
                 "hook": hook, "handle": handle, "accent": accent, "font": font or "",
                 "fps": fps, "durationSec": round(dur, 3),
                 "width": width, "height": height}
    if extra:
        variables.update(extra)
    vars_file = build / "vars.json"
    vars_file.write_text(json.dumps(variables))

    out = Path(out).expanduser()
    out.parent.mkdir(parents=True, exist_ok=True)
    r = subprocess.run(
        ["npx", "-y", HYPERFRAMES_SPEC, "render", str(build),
         "--output", str(out), "--variables-file", str(vars_file),
         "--fps", str(fps), "--quality", "standard"],
        cwd=str(build), env=_env(), capture_output=True, text=True, timeout=1800)
    if r.returncode != 0 or not out.exists() or out.stat().st_size == 0:
        raise RuntimeError(f"hyperframes render failed (build kept at {build}): "
                           + ((r.stderr or "") + (r.stdout or ""))[-700:])
    shutil.rmtree(build, ignore_errors=True)
    return str(out)


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", required=True)
    ap.add_argument("--start", type=float, required=True)
    ap.add_argument("--end", type=float, required=True)
    ap.add_argument("--out", required=True)
    ap.add_argument("--words", default=None)
    ap.add_argument("--hook", default="")
    ap.add_argument("--handle", default="@19keys")
    ap.add_argument("--accent", default="#D4AF37")
    ap.add_argument("--comp", default="kinetic")
    ap.add_argument("--font", default="")
    a = ap.parse_args()
    print(render(a.source, a.start, a.end, a.out, words_path=a.words, hook=a.hook,
                 handle=a.handle, accent=a.accent, comp=a.comp, font=a.font))
