from __future__ import annotations import json import shutil import subprocess from pathlib import Path from PIL import Image, ImageDraw ROOT = Path(__file__).resolve().parents[1] ANIMATION_DIR = ROOT / "content" / "drafts" / "animations" FFMPEG = ROOT / ".tools" / "micromamba-anim-root" / "envs" / "anim" / "Library" / "bin" / "ffmpeg.exe" FFPROBE = ROOT / ".tools" / "micromamba-anim-root" / "envs" / "anim" / "Library" / "bin" / "ffprobe.exe" SCRATCH_DIR = ANIMATION_DIR / "_contact_sheet_frames" CONTACT_SHEET_FILES = [ "liouville-permutation-3card.mp4", "liouville-contours-quartic.mp4", "differential-bulk-boundary-variation.mp4", "differential-endpoint-covector-measurement.mp4", "differential-one-form-to-two-form.mp4", "differential-spring-ensemble-transport.mp4", "differential-incompressible-fluid-patch.mp4", "differential-function-to-flow.mp4", "differential-source-sink-vs-symplectic.mp4", "differential-legendre-transform-tangent.mp4", "differential-symplectic-patch-preservation.mp4", "differential-worldline-phase-space-bridge.mp4", "differential-poisson-jacobi-identity.mp4", "differential-skier-position-generates-momentum.mp4", "differential-poisson-product-rule.mp4", "differential-poisson-bidirectional-flow.mp4", "differential-poisson-bracket-vectorfield-identity.mp4", "differential-fourier-phase-shift-mechanism.mp4", "differential-weyl-order-phase.mp4", "differential-lie-generator-tangent-flow.mp4", "differential-exponential-map-accumulation.mp4", "differential-generator-vector-field-contrast.mp4", "lm-fermat-snell-huygens.mp4", ] def video_duration(path: Path) -> float: result = subprocess.run( [ str(FFPROBE), "-v", "error", "-show_entries", "format=duration", "-of", "json", str(path), ], capture_output=True, text=True, check=True, ) return float(json.loads(result.stdout)["format"]["duration"]) def extract_frame(video: Path, timestamp: float, output: Path) -> None: subprocess.run( [ str(FFMPEG), "-y", "-ss", f"{timestamp:.3f}", "-i", str(video), "-frames:v", "1", "-vf", "scale=720:-1", str(output), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, ) def fit_on_canvas(image: Image.Image, size: tuple[int, int]) -> Image.Image: image = image.convert("RGB") image.thumbnail((size[0] - 20, size[1] - 36), Image.Resampling.LANCZOS) canvas = Image.new("RGB", size, "white") canvas.paste(image, ((size[0] - image.width) // 2, 12)) return canvas def make_contact_sheet(video_name: str) -> Path: video = ANIMATION_DIR / video_name if not video.exists(): raise FileNotFoundError(video) SCRATCH_DIR.mkdir(exist_ok=True) duration = video_duration(video) fractions = [0.0, 0.25, 0.5, 0.75, 0.95] frame_size = (420, 300) frames: list[Image.Image] = [] for index, fraction in enumerate(fractions): timestamp = min(duration - 0.05, max(0.0, duration * fraction)) frame_path = SCRATCH_DIR / f"{video.stem}-{index}.png" extract_frame(video, timestamp, frame_path) frame = fit_on_canvas(Image.open(frame_path), frame_size) draw = ImageDraw.Draw(frame) draw.text((14, frame_size[1] - 22), f"{fraction:.0%}", fill=(80, 80, 80)) frames.append(frame) sheet = Image.new("RGB", (frame_size[0] * len(frames), frame_size[1]), "#F4F4F4") for index, frame in enumerate(frames): sheet.paste(frame, (frame_size[0] * index, 0)) output = ANIMATION_DIR / f"{video.stem}-contact-sheet.png" sheet.save(output) return output def main() -> None: try: for video_name in CONTACT_SHEET_FILES: print(make_contact_sheet(video_name)) finally: if SCRATCH_DIR.exists(): shutil.rmtree(SCRATCH_DIR) if __name__ == "__main__": main()