#!/usr/bin/env python3
"""img2video — собирает видеоролик из папки картинок.

Что делает:
  * кадрирует каждую картинку под холст (16:9 или 9:16), фон добирает цветом
    из углов первой картинки — шов не виден на однотонных фонах;
  * оживляет кадры эффектом Кена Бёрнса (медленный наезд/отъезд, чередованием);
  * склеивает кадры кроссфейдом;
  * если дать wav-озвучку — подгоняет длительность ролика под дикторский
    голос и подмешивает его;
  * если дать .srt — вшивает субтитры в кадр (без дорожки, видно всегда).

Только ffmpeg + python3-stdlib. Пример:

  python3 img2video.py --src src/*.webp --out case-b.mp4 \
      --canvas 16:9 --vo vo-b.wav --srt subs.srt --title "Маскот кофейни"
"""

import argparse
import glob
import subprocess
import sys
import tempfile
from pathlib import Path

FPS = 25
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
CANVAS = {"16:9": (1280, 720), "9:16": (1080, 1920)}


def run(cmd):
    """Тихий subprocess: при ошибке показывает stderr и умирает."""
    p = subprocess.run(cmd, capture_output=True, text=True)
    if p.returncode != 0:
        sys.stderr.write(p.stderr[-3000:])
        sys.exit(f"\nкоманда не удалась: {' '.join(cmd[:8])}…")


def probe_duration(path):
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "csv=p=0", str(path)],
        capture_output=True, text=True, check=True).stdout.strip()
    return float(out)


def sample_bg(images):
    """Средний цвет углов первой картинки — фон холста под неё.

    Декодируем первый кадр в сырой rgb через ffmpeg и берём углы миниатюры.
    """
    raw = subprocess.run(
        ["ffmpeg", "-v", "error", "-i", str(images[0]),
         "-vf", "scale=64:64", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
        capture_output=True, check=True).stdout
    px = [raw[0:3], raw[-3:], raw[64 * 63 * 3:64 * 63 * 3 + 3]]
    r = sum(p[0] for p in px) // 3
    g = sum(p[1] for p in px) // 3
    b = sum(p[2] for p in px) // 3
    return f"0x{r:02X}{g:02X}{b:02X}"


def build_segment(img, w, h, bg, seg, idx, tmpdir):
    """Один кадр: кадрирование → фон → 2x апскейл → zoompan (Кен Бёрнс)."""
    n = int(round(seg * FPS))  # сколько выходных кадров
    zoom_in = (idx % 2 == 0)   # чередуем наезд/отъезд
    if zoom_in:
        z = f"min(1+{0.10:.2f}*on/{n},1.10)"
    else:
        z = f"max(1.10-{0.10:.2f}*on/{n},1.0)"
    vf = (
        f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
        f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={bg},"
        f"scale={2 * w}:{2 * h},"
        f"zoompan=z='{z}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
        f":d=1:s={w}x{h}:fps={FPS},format=yuv420p"
    )
    out = tmpdir / f"seg{idx:02d}.mp4"
    run(["ffmpeg", "-y", "-v", "error", "-loop", "1", "-t", f"{seg}",
         "-i", str(img), "-filter_complex", vf,
         "-c:v", "libx264", "-preset", "medium", "-crf", "26",
         "-pix_fmt", "yuv420p", str(out)])
    return out


def xfade_join(segments, xfade):
    """Кроссфейд-склейка: [a][b]xfade → [ab][c]xfade → …"""
    n = len(segments)
    if n == 1:
        return segments[0]
    inputs = []
    for s in segments:
        inputs += ["-i", str(s)]
    fc, prev, offset = [], "[0:v]", 0.0
    for i in range(1, n):
        dur_prev = probe_duration(segments[i - 1])
        offset += dur_prev - xfade
        label = f"[v{i}]"
        fc.append(f"{prev}[{i}:v]xfade=transition=fade:"
                  f"duration={xfade}:offset={offset:.3f}{label}")
        prev = label
    out = segments[0].parent / "joined.mp4"
    run(["ffmpeg", "-y", "-v", "error", *inputs,
         "-filter_complex", ";".join(fc), "-map", prev,
         "-c:v", "libx264",
         "-preset", "medium", "-crf", "26", "-pix_fmt", "yuv420p", str(out)])
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--src", nargs="+", required=True,
                    help="картинки (glob отбирает оболочка: src/*.png)")
    ap.add_argument("--out", required=True, help="итоговый mp4")
    ap.add_argument("--canvas", default="16:9", choices=sorted(CANVAS))
    ap.add_argument("--bg", default="auto", help="auto или 0xRRGGBB")
    ap.add_argument("--seg", type=float, default=5.0,
                    help="длительность кадра, с (без озвучки)")
    ap.add_argument("--xfade", type=float, default=0.8,
                    help="длительность кроссфейда, с")
    ap.add_argument("--vo", help="wav-озвучка: под неё подгоняется хронометраж")
    ap.add_argument("--tail", type=float, default=0.9,
                    help="тишина после озвучки, с")
    ap.add_argument("--srt", help="субтитры .srt — вшиваются в кадр")
    ap.add_argument("--title", help="заголовок сверху кадра")
    ap.add_argument("--crf", type=int, default=26)
    args = ap.parse_args()

    images = []
    for pattern in args.src:
        images += sorted(glob.glob(pattern))
    if not images:
        sys.exit("не нашёл картинок по --src")
    images = [Path(p) for p in images]

    w, h = CANVAS[args.canvas]
    bg = args.bg if args.bg != "auto" else sample_bg(images)

    # хронометраж: с озвучкой ролик = голос + хвост, кадры делят время поровну
    vo_dur = probe_duration(args.vo) if args.vo else None
    n = len(images)
    if vo_dur:
        total = vo_dur + args.tail
        seg = (total + args.xfade * (n - 1)) / n
    else:
        seg = args.seg
        total = seg * n - args.xfade * (n - 1)
    print(f"картинок: {n}, холст: {w}x{h}, фон: {bg}, "
          f"кадр: {seg:.2f}с, ролик: {total:.2f}с"
          + (f", озвучка: {vo_dur:.2f}с" if vo_dur else ""))

    with tempfile.TemporaryDirectory(prefix="img2video-") as td:
        tmpdir = Path(td)
        segments = [build_segment(img, w, h, bg, seg, i, tmpdir)
                    for i, img in enumerate(images)]
        joined = xfade_join(segments, args.xfade)

        # финальный проход: субтитры и заголовок вшиваем в кадр,
        # озвучку подгоняем под длину видео (apad + затухание в конце)
        post = []
        if args.srt:
            style = (f"FontName=DejaVu Sans,FontSize={44 if h > w else 20},"
                     f"Outline=2,Shadow=0,MarginV={140 if h > w else 40}")
            post.append(f"subtitles={args.srt}:force_style='{style}'")
        if args.title:
            tf = tmpdir / "title.txt"
            tf.write_text(args.title, encoding="utf-8")
            size = 58 if h > w else 34
            y = 210 if h > w else 52
            post.append(
                f"drawtext=fontfile={FONT_BOLD}:textfile={tf}:"
                f"fontsize={size}:fontcolor=0x2B2B33:"
                f"x=(w-text_w)/2:y={y}")

        cmd = ["ffmpeg", "-y", "-v", "error", "-i", str(joined)]
        parts, maps = [], ["-map", "0:v"]
        if post:
            parts.append("[0:v]" + ",".join(post) + "[vout]")
            maps = ["-map", "[vout]"]
        if args.vo:
            cmd += ["-i", args.vo]
            parts.append("[1:a]apad,afade=t=out:st="
                         f"{max(total - 0.4, 0):.2f}:d=0.4[aout]")
            maps += ["-map", "[aout]"]
        cmd += (["-filter_complex", ";".join(parts)] if parts else [])
        cmd += maps
        cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", str(args.crf),
                "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
        if args.vo:
            cmd += ["-c:a", "aac", "-b:a", "128k", "-t", f"{total:.3f}"]
        else:
            cmd += ["-an"]
        cmd += [args.out]
        run(cmd)

    poster = Path(args.out).with_suffix("").as_posix() + "-poster.jpg"
    run(["ffmpeg", "-y", "-v", "error", "-ss", "0.6", "-i", args.out,
         "-frames:v", "1", "-q:v", "3", poster])
    print(f"готово: {args.out} (+ {poster})")


if __name__ == "__main__":
    main()
