#!/usr/bin/env python3
"""Scan a video for focus (blur) and motion (shake) quality over time.

Decodes a downscaled grayscale stream via ffmpeg, then per sampled frame:
  - focus  = variance of the Laplacian  (low  => blurry / soft)
  - motion = mean abs diff vs previous  (high => movement; spiky => shake)

Output: JSON {fps_sample, w, h, frames:[{t, focus, motion}], bad:[{t0,t1,reason}]}.
v1: motion is a proxy for shake (no pan/shake separation yet — vidstabdetect refinement TODO).
"""
import sys, json, subprocess, numpy as np
from scipy.ndimage import laplace

FFMPEG = "/opt/homebrew/bin/ffmpeg"
SAMPLE_FPS = 2.0      # frames analyzed per second
W = 320               # downscale width; height derived from aspect

def scan(path, sample_fps=SAMPLE_FPS, w=W, dense_fps=None):
    # probe height for the scaled stream
    probe = subprocess.run(
        ["/opt/homebrew/bin/ffprobe","-v","error","-select_streams","v:0",
         "-show_entries","stream=width,height","-of","csv=p=0", path],
        capture_output=True, text=True)
    sw, sh = (int(x) for x in probe.stdout.strip().split(","))
    h = int(round(w * sh / sw)) // 2 * 2

    # Two modes:
    #  - keyframe-only (default): -skip_frame nokey, ~1 sample/s. Cheap on 4K but MISSES fast shake.
    #  - dense (dense_fps set): decode at a fixed fps. Only viable on light proxies — catches the
    #    0.3-0.5s camera jerks that fall between keyframes (the "treme e não troca" gap).
    # -hwaccel videotoolbox: decode HEVC/H.264 4K no HARDWARE do Mac (VideoToolbox) em vez de
    # software 1-core. -v fatal cala o spam benigno "PPS changed between slices" do decoder HEVC
    # (não é erro — o frame decodifica igual).
    HW = ["-hwaccel", "videotoolbox"]
    if dense_fps:
        cmd = [FFMPEG,"-v","fatal",*HW,"-i",path,
               "-vsync","0","-vf",f"fps={dense_fps},scale={w}:{h},format=gray",
               "-f","rawvideo","-"]
    else:
        cmd = [FFMPEG,"-v","fatal",*HW,"-skip_frame","nokey","-i",path,
               "-vsync","0","-vf",f"scale={w}:{h},format=gray",
               "-f","rawvideo","-"]
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    fsize = w*h
    raw = []
    while True:
        buf = proc.stdout.read(fsize)
        if len(buf) < fsize: break
        raw.append(np.frombuffer(buf, np.uint8).reshape(h, w).astype(np.float32))
    proc.wait()
    dur_s = float(subprocess.run(
        ["/opt/homebrew/bin/ffprobe","-v","error","-show_entries","format=duration",
         "-of","csv=p=0", path], capture_output=True, text=True).stdout.strip() or 0)
    n = len(raw)
    dt = (1.0/dense_fps) if dense_fps else ((dur_s / n) if n else 1.0)
    frames, prev = [], None
    for i, f in enumerate(raw):
        focus = float(laplace(f).var())
        motion = 0.0 if prev is None else float(np.abs(f - prev).mean()) / max(dt, 1e-3)
        frames.append({"t": round(i*dt, 2), "focus": round(focus,1), "motion": round(motion,4)})
        prev = f

    # thresholds relative to this clip's own distribution (robust to exposure/lens)
    foc = np.array([x["focus"] for x in frames])
    mot = np.array([x["motion"] for x in frames[1:]]) if len(frames)>1 else np.array([0.0])
    if len(foc) == 0:
        # decode vazio (arquivo corrompido / codec falhou) — não quebra o batch
        return {"path": path, "sample_fps": sample_fps, "w": w, "h": h, "n": 0,
                "focus_med": 0.0, "motion_med": 0.0, "foc_lo": 0.0, "mot_hi": 1.0,
                "bad": [], "frames": []}
    foc_lo = float(np.percentile(foc, 20)) * 0.6        # clearly below the soft floor
    mot_hi = float(np.percentile(mot, 80)) * 1.5 + 1e-6  # clearly above typical movement
    bad = []
    for x in frames:
        reasons = []
        if x["focus"] < foc_lo: reasons.append("blur")
        if x["motion"] > mot_hi: reasons.append("shake")
        if reasons:
            bad.append({"t": x["t"], "reason": "+".join(reasons)})
    # merge consecutive bad samples into ranges
    ranges, cur = [], None
    step = 1.0/sample_fps
    for b in bad:
        if cur and b["t"] - cur["t1"] <= step*1.5 and b["reason"]==cur["reason"]:
            cur["t1"] = b["t"]
        else:
            cur = {"t0": b["t"], "t1": b["t"], "reason": b["reason"]}
            ranges.append(cur)
    return {"path": path, "sample_fps": sample_fps, "w": w, "h": h,
            "n": len(frames),
            "focus_med": round(float(np.median(foc)),1),
            "motion_med": round(float(np.median(mot)),3),
            "foc_lo": round(foc_lo,1), "mot_hi": round(mot_hi,3),
            "bad": ranges, "frames": frames}

if __name__ == "__main__":
    path = sys.argv[1]
    full = "--full" in sys.argv
    r = scan(path)
    if not full: r.pop("frames")
    print(json.dumps(r, indent=2))
