from __future__ import annotations from PIL import Image from .config import MotionPreset def apply_camera_motion(frame: Image.Image, *, t: float, motion: MotionPreset) -> Image.Image: img = frame.convert("RGB") w, h = img.size t = max(0.0, min(1.0, float(t))) if motion in {"push_in", "zoom"}: scale = 1.0 + 0.055 * t return _center_crop_zoom(img, scale) if motion == "pull_out": scale = 1.055 - 0.055 * t return _center_crop_zoom(img, scale) if motion == "pan_left": return _translate(img, dx=int((t - 0.5) * w * 0.06), dy=0) if motion == "pan_right": return _translate(img, dx=int((0.5 - t) * w * 0.06), dy=0) if motion == "slow_orbit": return _center_crop_zoom(img.rotate((t - 0.5) * 2.5, resample=Image.Resampling.BICUBIC), 1.02) if motion == "ken_burns": moved = _translate(img, dx=int((0.5 - t) * w * 0.035), dy=int((t - 0.5) * h * 0.025)) return _center_crop_zoom(moved, 1.015 + 0.04 * t) if motion == "product_turntable_fake": moved = _translate(img, dx=int((0.5 - t) * w * 0.025), dy=0) return _center_crop_zoom(moved, 1.015 + 0.015 * (1.0 - abs(0.5 - t) * 2.0)) if motion == "handheld_subtle": dx = int(w * 0.008 * _wave(t, 1.0)) dy = int(h * 0.006 * _wave(t, 1.7)) return _center_crop_zoom(_translate(img, dx=dx, dy=dy), 1.015) return img def _center_crop_zoom(img: Image.Image, scale: float) -> Image.Image: w, h = img.size scale = max(1.0, float(scale)) nw, nh = max(1, int(w / scale)), max(1, int(h / scale)) left = max(0, (w - nw) // 2) top = max(0, (h - nh) // 2) return img.crop((left, top, left + nw, top + nh)).resize((w, h), Image.Resampling.BICUBIC) def _translate(img: Image.Image, *, dx: int, dy: int) -> Image.Image: w, h = img.size shifted = img.transform((w, h), Image.Transform.AFFINE, (1, 0, dx, 0, 1, dy), resample=Image.Resampling.BICUBIC) if dx == 0 and dy == 0: return shifted return _center_crop_zoom(shifted, 1.01) def _wave(t: float, phase: float) -> float: import math return math.sin((float(t) + phase) * math.tau)