#!/usr/bin/env python3
"""Product 360 / turntable: a looping spin video (and GIF) of a product on a studio backdrop with a floor
shadow and reflection.

Two modes:
  views  several angle shots of the product (front, 3/4, side, back ... from change_camera_angle, a photoshoot or
         remove_background cut-outs) -> they orbit past each other in a smooth loop
  spin   one transparent cut-out (optionally a --back image) -> a card-flip 3D spin around the vertical axis

Examples
  python turntable.py --views front.png threequarter.png side.png back.png --seconds 6 --loops 2
  python turntable.py --spin cutout.png --back back.png --bg "radial:#F5F5F7,#D4D4D8" --aspect 1:1 --gif
"""
from __future__ import annotations

import argparse
import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import kmedia as K  # noqa: E402


def backdrop(spec: str, W: int, H: int):
    from PIL import Image, ImageDraw, ImageFilter
    if spec.lower().startswith("radial:"):
        a, b = (K.hex_rgb(x) for x in spec.split(":", 1)[1].split(",")[:2])
        im = Image.new("RGB", (W, H), b)
        mask = Image.new("L", (W, H), 0)
        r = int(max(W, H) * 0.75)
        ImageDraw.Draw(mask).ellipse((W // 2 - r, int(H * 0.42) - r, W // 2 + r, int(H * 0.42) + r), fill=255)
        mask = mask.filter(ImageFilter.GaussianBlur(r // 2))
        im.paste(Image.new("RGB", (W, H), a), (0, 0), mask)
        return im
    bg = K.parse_bg(spec, W, H)
    if bg is None:
        bg = K.cover(K.open_image(Path(spec)).convert("RGB"), W, H)
    return bg


def prep(img, box_w: int, box_h: int):
    from PIL import Image
    im = img.convert("RGBA")
    bb = im.getbbox()
    if bb:
        im = im.crop(bb)
    s = min(box_w / im.width, box_h / im.height)
    return im.resize((max(1, int(im.width * s)), max(1, int(im.height * s))), Image.LANCZOS)


class Stage:
    def __init__(self, bg, W: int, H: int, floor_y: float, reflection: float, shadow: float):
        self.bg, self.W, self.H, self.floor, self.refl, self.shadow = bg, W, H, int(H * floor_y), reflection, shadow

    def compose(self, sprites):
        """sprites: [(rgba, cx, scale_x, opacity, shade)] bottoms aligned on the floor."""
        from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageOps
        canvas = self.bg.copy().convert("RGBA")
        for (im, cx, sx, op, shade) in sprites:
            if op <= 0.01 or sx <= 0.01:
                continue
            w = max(1, int(im.width * sx))
            spr = im.resize((w, im.height), Image.BILINEAR)
            if shade > 0.01:
                spr = Image.merge("RGBA", (*ImageEnhance.Brightness(spr.convert("RGB")).enhance(1 - shade).split(), spr.getchannel("A")))
            spr = K.with_opacity(spr, op)
            x, y = int(cx - w / 2), self.floor - spr.height
            if self.shadow > 0:
                sw = int(max(w, im.width * 0.55) * 1.05)
                sh = Image.new("RGBA", (sw + 80, 120), (0, 0, 0, 0))
                ImageDraw.Draw(sh).ellipse((40, 40, sw + 40, 80), fill=(0, 0, 0, int(150 * self.shadow * op)))
                sh = sh.filter(ImageFilter.GaussianBlur(14))
                canvas.alpha_composite(sh, (int(cx - sh.width / 2), self.floor - 60))
            if self.refl > 0:
                r = ImageOps.flip(spr)
                fade = Image.linear_gradient("L").resize((r.width, r.height)).point(lambda v: int(max(0, 255 - v * 2.2) * self.refl))
                from PIL import ImageChops
                r.putalpha(ImageChops.multiply(r.getchannel("A"), fade))
                if self.floor < self.H:
                    K.place(canvas, r, x + w / 2, self.floor + r.height / 2)
            K.place(canvas, spr, x + w / 2, y + spr.height / 2)
        return canvas.convert("RGB")


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--views", nargs="*", default=[], help="angle images in rotation order (transparent PNGs look best)")
    ap.add_argument("--spin", help="one transparent cut-out for a 3D card spin")
    ap.add_argument("--back", help="spin mode: image for the back side (default: the mirrored front, darkened)")
    ap.add_argument("--bg", default="radial:#FAFAFA,#D9DCE1", help="'radial:#a,#b', '#hex', 'gradient:#a,#b' or an image")
    ap.add_argument("--aspect", default="1:1")
    ap.add_argument("--res", type=int, default=1080)
    ap.add_argument("--fps", type=int, default=30)
    ap.add_argument("--seconds", type=float, default=6.0, help="one full turn")
    ap.add_argument("--loops", type=int, default=1, help="how many turns in the video")
    ap.add_argument("--size", type=float, default=0.62, help="product height as a share of the frame")
    ap.add_argument("--reflection", type=float, default=0.28)
    ap.add_argument("--shadow", type=float, default=0.8)
    ap.add_argument("--gif", action="store_true", help="also write a GIF (one turn, 480 px)")
    ap.add_argument("--out", default="kenerate-out/product-360/turntable.mp4")
    ap.add_argument("--keep-temp", action="store_true")
    a = ap.parse_args()
    if not a.views and not a.spin:
        K.fail("Give --views IMG IMG ... or --spin CUTOUT.")
    if a.views and len(a.views) < 2:
        K.fail("Views mode needs at least 2 angles.", "Make more with change_camera_angle, or use --spin with one cut-out.")

    st = K.Status(4, "product-360")
    K.require(status=st)
    W, H = K.size_for(a.aspect, a.res)
    tmp = K.workdir(a.keep_temp)
    out = Path(a.out)
    out.parent.mkdir(parents=True, exist_ok=True)

    st.step("Fetching images")
    bw, bh = int(W * 0.7), int(H * a.size)
    stage = Stage(backdrop(a.bg, W, H), W, H, 0.5 + a.size / 2 + 0.02, a.reflection, a.shadow)
    if a.views:
        ims = [prep(K.open_image(p), bw, bh) for p in K.fetch_all(a.views, tmp, st, prefix="view")]
        opaque = [i for i, im in enumerate(ims) if im.getchannel("A").getextrema()[0] == 255]
        if opaque:
            st.warn(f"{len(opaque)} view(s) have no transparency: remove_background(mode='cutout') first for a clean studio look")
    else:
        from PIL import ImageEnhance, ImageOps
        front = prep(K.open_image(K.fetch(a.spin, tmp, "front", st)), bw, bh)
        if a.back:
            back = prep(K.open_image(K.fetch(a.back, tmp, "back", st)), bw, bh).resize(front.size)
        else:
            m = ImageOps.mirror(front)
            from PIL import Image
            back = Image.merge("RGBA", (*ImageEnhance.Brightness(m.convert("RGB")).enhance(0.8).split(), m.getchannel("A")))

    n_turn = int(round(a.seconds * a.fps))
    n = n_turn * max(1, a.loops)
    st.step(f"Rendering {n} frames ({'views' if a.views else 'spin'})")

    def frame(i: int):
        t = (i % n_turn) / n_turn  # 0..1 around the turn
        if a.views:
            k = len(ims)
            pos = t * k
            j, f = int(pos) % k, pos - int(pos)
            e = K.ease_in_out(K.clamp((f - 0.55) / 0.45))  # hold each view, then orbit to the next
            cur, nxt = ims[j], ims[(j + 1) % k]
            orbit = W * 0.28
            # current slides left and narrows, next comes in from the right
            sprites = [(cur, W / 2 - orbit * e, 1 - 0.35 * e, 1 - e, 0.25 * e),
                       (nxt, W / 2 + orbit * (1 - e), 0.65 + 0.35 * e, e, 0.25 * (1 - e))]
            sprites.sort(key=lambda s: s[3])
            return stage.compose(sprites)
        ang = t * 2 * math.pi
        c = math.cos(ang)
        im = front if c >= 0 else back
        shade = (1 - abs(c)) * 0.35
        return stage.compose([(im, W / 2, max(abs(c), 0.02), 1.0, shade)])

    K.FramePipe(out, W, H, a.fps, n, rgba=False, status=st).run_silent(frame(i) for i in range(n))

    st.step("Preview" + (" + GIF" if a.gif else ""))
    times = [a.seconds * f for f in (0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875)]
    sheet = K.video_preview(out, out.with_name(out.stem + "_preview.jpg"), tmp=tmp / "pv", times=times)
    gif = None
    if a.gif:
        gif = out.with_suffix(".gif")
        gw = 480
        gh = int(round(H * gw / W / 2)) * 2
        K.run_ffmpeg(["-t", f"{a.seconds:.3f}", "-i", str(out), "-filter_complex",
                      f"[0:v]fps=20,scale={gw}:{gh}:flags=lanczos,split[x][y];[x]palettegen=max_colors=160[p];[y][p]paletteuse=dither=sierra2_4a[o]",
                      "-map", "[o]", "-loop", "0", str(gif)])
    K.finish({"output": K.file_info(out), "gif": str(gif.resolve()) if gif else None, "preview": str(sheet.resolve()),
              "mode": "views" if a.views else "spin", "turn_seconds": a.seconds, "loops": a.loops, "elapsed_s": st.elapsed()})


if __name__ == "__main__":
    K.main_guard(main)
