#!/usr/bin/env python3
"""Turn a still image into a moving shot, and prove the motion is smooth.

A photo held static in a video reads as a mistake, and a photo panned linearly reads
as a slideshow. What makes it read as a shot is an eased move: it starts from rest,
travels, and comes to rest again.

Two things this does that `zoompan` alone does not:

  * **Easing.** `zoompan` interpolates linearly on `on` (the output frame index), so
    the move starts and stops abruptly. Here the position is driven by a smoothstep
    expression, which has zero derivative at both ends — the move has no visible
    start or stop.

  * **Verification.** The rendered result is measured: per-frame displacement is
    sampled and reported, so a jitter or a jump shows up as a number. A move whose
    frame-to-frame change is not monotonic in the first and last thirds is flagged,
    because that is what "vibration" looks like in data.

The crop always stays inside the source, so no edge is ever revealed — the usual
cause of a black sliver appearing on one side halfway through a Ken Burns move.
"""
from __future__ import annotations

import argparse
import json
import math
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from media_probe import probe  # noqa: E402
from render_core import PROFILES, capabilities, output_arguments  # noqa: E402
from skill_config import canvas as resolve_canvas, emit, require_binary, utf8_stdout  # noqa: E402

# Moves are described as start/end crop windows in fractions of the source, so the
# same preset works for any image size. `scale` is the crop size: smaller = closer.
MOVES: dict[str, dict] = {
    "in_slow": {
        "use": "Медленный наезд. База для любого статичного кадра.",
        "from": {"scale": 1.00, "x": 0.5, "y": 0.5},
        "to": {"scale": 0.94, "x": 0.5, "y": 0.5},
    },
    "out_slow": {
        "use": "Медленный отъезд: раскрытие контекста, «вот где мы».",
        "from": {"scale": 0.94, "x": 0.5, "y": 0.5},
        "to": {"scale": 1.00, "x": 0.5, "y": 0.5},
    },
    "in_punch": {
        "use": "Быстрый наезд на акцент. Только под ударную фразу.",
        "from": {"scale": 1.00, "x": 0.5, "y": 0.5},
        "to": {"scale": 0.84, "x": 0.5, "y": 0.5},
    },
    "pan_left": {
        "use": "Панорама влево при лёгком наезде. Читается как движение камеры.",
        "from": {"scale": 0.95, "x": 0.60, "y": 0.5},
        "to": {"scale": 0.93, "x": 0.40, "y": 0.5},
    },
    "pan_right": {
        "use": "Панорама вправо. Обратная пара к pan_left.",
        "from": {"scale": 0.95, "x": 0.40, "y": 0.5},
        "to": {"scale": 0.93, "x": 0.60, "y": 0.5},
    },
    "pan_up": {
        "use": "Движение вверх: рост, здание, график.",
        "from": {"scale": 0.95, "x": 0.5, "y": 0.60},
        "to": {"scale": 0.93, "x": 0.5, "y": 0.40},
    },
    "pan_down": {
        "use": "Движение вниз: погружение, детали, падение.",
        "from": {"scale": 0.95, "x": 0.5, "y": 0.40},
        "to": {"scale": 0.93, "x": 0.5, "y": 0.60},
    },
    "diagonal_in": {
        "use": "Диагональный наезд: самый «живой» вариант для портрета.",
        "from": {"scale": 1.00, "x": 0.44, "y": 0.44},
        "to": {"scale": 0.92, "x": 0.56, "y": 0.56},
    },
    "drift": {
        "use": "Едва заметный дрейф. Когда кадр не должен привлекать внимание.",
        "from": {"scale": 0.98, "x": 0.48, "y": 0.5},
        "to": {"scale": 0.97, "x": 0.52, "y": 0.5},
    },
}
DEFAULT_MOVE = "in_slow"

# The eased 0..1 position. Smoothstep has zero derivative at both ends, so the move
# has no visible start or stop; `ease_in_out` is a little more cinematic on a long
# hold. Written against `T` (normalised time) which is substituted per filter.
EASINGS = {
    "smooth": "(T*T*(3-2*T))",
    "ease_in_out": "(T<0.5 ? 4*T*T*T : 1-pow(-2*T+2,3)/2)",
    "linear": "T",
}
DEFAULT_EASING = "smooth"


def build_filter(source_width: int, source_height: int, target_width: int,
                 target_height: int, move: dict, easing: str, frames: int,
                 fps: int) -> tuple[str, dict]:
    """Crop-then-scale driven by an eased expression, always inside the source.

    `crop` with expressions is used rather than `zoompan`: `zoompan` interpolates
    linearly and cannot be eased, and its `z`/`x`/`y` are evaluated against its own
    frame counter, which makes the timing depend on the filter's position in the
    chain.
    """
    target_aspect = target_width / target_height
    # The largest crop of the target aspect that fits the source. Everything else is
    # a fraction of this, so the crop can never leave the image.
    if source_width / source_height > target_aspect:
        base_height = source_height
        base_width = base_height * target_aspect
    else:
        base_width = source_width
        base_height = base_width / target_aspect

    start = move["from"]
    end = move["to"]
    ease = EASINGS[easing].replace("T", f"(n/{max(1, frames - 1)})")

    # Crop size in pixels at each end.
    start_width = base_width * start["scale"]
    end_width = base_width * end["scale"]
    start_height = base_height * start["scale"]
    end_height = base_height * end["scale"]

    width_expr = f"({start_width:.4f}+({end_width - start_width:.4f})*{ease})"
    height_expr = f"({start_height:.4f}+({end_height - start_height:.4f})*{ease})"

    # Centre position, clamped so the crop stays inside the frame at every moment.
    def centre_expr(axis: str) -> str:
        size = width_expr if axis == "x" else height_expr
        limit = source_width if axis == "x" else source_height
        raw = (
            f"({start[axis]:.4f}+({end[axis] - start[axis]:.4f})*{ease})*{limit}"
        )
        half = f"({size}/2)"
        return f"max({half},min({limit}-{half},{raw}))"

    x_expr = f"({centre_expr('x')}-{width_expr}/2)"
    y_expr = f"({centre_expr('y')}-{height_expr}/2)"

    chain = (
        f"crop=w='{width_expr}':h='{height_expr}':x='{x_expr}':y='{y_expr}',"
        f"scale={target_width}:{target_height}:flags=lanczos,setsar=1,fps={fps}"
    )
    geometry = {
        "source": f"{source_width}x{source_height}",
        "base_crop": f"{base_width:.0f}x{base_height:.0f}",
        "start_crop": f"{start_width:.0f}x{start_height:.0f}",
        "end_crop": f"{end_width:.0f}x{end_height:.0f}",
        "zoom_range": f"{base_width / start_width:.3f}x -> {base_width / end_width:.3f}x",
        "upscale_at_closest": round(target_width / min(start_width, end_width), 3),
    }
    return chain, geometry


def sample_motion(video: Path, samples: int = 24) -> dict | None:
    """Measure how much the picture actually moves, frame to frame.

    Mean absolute difference between consecutive sampled frames. A smooth eased move
    rises, plateaus and falls; a jump or a vibration shows as a spike, and a static
    shot shows as near-zero throughout.
    """
    try:
        import numpy
    except ImportError:
        return None
    info = probe(video)
    duration = float(info.get("duration") or 0.0)
    if duration <= 0:
        return None
    frames = []
    for index in range(samples):
        at = duration * index / max(1, samples - 1) * 0.98
        completed = subprocess.run(
            ["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{at:.3f}", "-i", str(video),
             "-frames:v", "1", "-vf", "scale=96:-2,format=gray", "-f", "rawvideo", "-"],
            capture_output=True,
        )
        if completed.returncode or not completed.stdout:
            continue
        frames.append(numpy.frombuffer(completed.stdout, dtype=numpy.uint8).astype(numpy.float32))
    if len(frames) < 4:
        return None
    size = min(item.size for item in frames)
    stack = numpy.stack([item[:size] for item in frames])
    deltas = numpy.abs(numpy.diff(stack, axis=0)).mean(axis=1)
    if deltas.size < 3:
        return None
    peak = float(deltas.max()) or 1e-9
    # A spike is a single sample far above its neighbours — that is what a jump or a
    # dropped frame looks like once the motion is sampled.
    spikes = int(numpy.count_nonzero(deltas > deltas.mean() + 3 * deltas.std()))
    third = max(1, deltas.size // 3)
    return {
        "samples": int(deltas.size),
        "mean_delta": round(float(deltas.mean()), 4),
        "peak_delta": round(peak, 4),
        "start_delta": round(float(deltas[:third].mean()), 4),
        "middle_delta": round(float(deltas[third:-third].mean()) if deltas.size > 2 * third
                              else float(deltas.mean()), 4),
        "end_delta": round(float(deltas[-third:].mean()), 4),
        "spikes": spikes,
        "moving": bool(deltas.mean() > 0.05),
    }


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("image", nargs="?")
    parser.add_argument("output", nargs="?")
    parser.add_argument("--move", default=DEFAULT_MOVE)
    parser.add_argument("--seconds", type=float, default=2.6,
                        help="Длительность. 1.3-4 с — рабочий диапазон для перебивки")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--easing", default=DEFAULT_EASING, choices=list(EASINGS))
    parser.add_argument("--profile", default="delivery", choices=list(PROFILES))
    parser.add_argument("--codec", default="h264", choices=["h264", "hevc"])
    parser.add_argument("--cpu", action="store_true")
    parser.add_argument("--silent-audio", action="store_true",
                        help="Добавить тишину, чтобы клип concat-ился с озвученными")
    parser.add_argument("--no-verify", action="store_true")
    parser.add_argument("--list", action="store_true")
    args = parser.parse_args()

    if args.list:
        emit({
            "moves": {key: value["use"] for key, value in MOVES.items()},
            "easings": {
                "smooth": "Smoothstep: нулевая производная на концах, движение без старта и стопа.",
                "ease_in_out": "Кубический: чуть кинематографичнее на длинном кадре.",
                "linear": "Постоянная скорость. Читается как слайдшоу — только если так надо.",
            },
            "rules": [
                "Длительность перебивки 1.3–4 с.",
                "Масштаб 1.02–1.06 для спокойного кадра, до 1.19 только на акцент.",
                "Панорамирование 2–4% кадра.",
                "Движение начинается и заканчивается мягко.",
                "Никакой вибрации и случайных рывков — это проверяется измерением.",
            ],
        })
        return
    if not args.image or not args.output:
        parser.error("image and output are required unless --list is used")
    if args.move not in MOVES:
        raise SystemExit(f"Unknown move '{args.move}'. Known: {', '.join(MOVES)}")

    require_binary("ffmpeg")
    image = Path(args.image).resolve()
    if not image.is_file():
        raise SystemExit(f"Not found: {image}")
    info = probe(image)
    video_info = info.get("video") or {}
    source_width = video_info.get("display_width")
    source_height = video_info.get("display_height")
    if not source_width or not source_height:
        raise SystemExit(f"Could not read the size of {image}")

    board = resolve_canvas(args.canvas)
    frames = max(2, int(round(args.seconds * board.fps)))
    chain, geometry = build_filter(source_width, source_height, board.width, board.height,
                                   MOVES[args.move], args.easing, frames, board.fps)

    warnings: list[str] = []
    if geometry["upscale_at_closest"] > 1.15:
        warnings.append(
            f"На самом близком кадре изображение растянуто в "
            f"{geometry['upscale_at_closest']}× — возьми снимок больше или "
            f"уменьши амплитуду наезда."
        )
    if args.seconds < 1.2:
        warnings.append("Короче 1.2 с движение не успевает прочитаться как движение.")
    if args.seconds > 4.5:
        warnings.append("Длиннее 4.5 с статичный кадр начинает провисать, даже с движением.")

    caps = capabilities()
    encoder_arguments, selection = output_arguments(
        args.codec, args.profile, prefer_gpu=not args.cpu, caps=caps
    )
    output = Path(args.output).resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    command = ["ffmpeg", "-y", "-v", "error", "-nostdin",
               "-loop", "1", "-t", f"{args.seconds:.4f}", "-i", str(image)]
    if args.silent_audio:
        command += ["-f", "lavfi", "-t", f"{args.seconds:.4f}",
                    "-i", "anullsrc=r=48000:cl=stereo"]
    command += ["-vf", chain, "-frames:v", str(frames)]
    command += [argument for argument in encoder_arguments
                if argument not in ("-c:a", "aac", "-b:a", "192k", "-ar", "48000",
                                    "-ac", "2")] if not args.silent_audio else encoder_arguments
    if not args.silent_audio:
        command += ["-an"]
    command += [str(output)]
    completed = subprocess.run(command, capture_output=True, text=True, errors="replace")
    if completed.returncode:
        emit({"status": "failed", "move": args.move,
              "filter": chain,
              "tail": (completed.stderr or "").strip()[-1200:]})
        raise SystemExit(completed.returncode)

    motion = None if args.no_verify else sample_motion(output)
    if motion:
        if not motion["moving"]:
            warnings.append("Измерение не видит движения — кадр вышел статичным.")
        if motion["spikes"]:
            warnings.append(
                f"{motion['spikes']} резких скачков в движении — это выглядит как рывок."
            )
        # An eased move is slowest at its ends. If the ends move as fast as the
        # middle, the easing did not take effect.
        if motion["middle_delta"] > 0 and \
                max(motion["start_delta"], motion["end_delta"]) > motion["middle_delta"] * 0.95:
            warnings.append(
                "Скорость на концах не ниже, чем в середине: сглаживание не применилось."
            )

    result = probe(output)
    emit({
        "status": "ok" if not warnings else "ok_with_warnings",
        "image": str(image),
        "output": str(output),
        "move": args.move,
        "use": MOVES[args.move]["use"],
        "easing": args.easing,
        "seconds": args.seconds,
        "frames_requested": frames,
        "frames_written": (result.get("video") or {}).get("frames"),
        "canvas": f"{board.width}x{board.height}@{board.fps}",
        "geometry": geometry,
        "motion_measured": motion,
        "encoder": selection,
        "warnings": warnings,
        "filter": chain,
    })


if __name__ == "__main__":
    main()
