""" animations.py — Dynamic animation registry and styling for kinetic typography. Provides functions and dictionaries mapped to the LLM's metadata schema. """ from typing import Callable, Tuple, Dict from PIL import Image, ImageFont # --- THEMES AND COLORS --- # AI Storyboard backgrounds are always darkened (see preprocess_bg), so every # theme's "theme_default" (active word) and "inactive" (not-yet-spoken word) # colors are chosen for contrast against a dark backdrop. "bg" is a solid # fallback used only if AI background generation fails entirely, and is kept # dark for the same reason. THEME_COLORS = { "Dark": { "bg": (10, 10, 10), "theme_default": (255, 255, 255), "inactive": (90, 90, 90), }, "Light": { "bg": (30, 25, 18), "theme_default": (255, 221, 140), "inactive": (150, 130, 100), }, "Neon": { "bg": (13, 13, 43), "theme_default": (0, 255, 255), "inactive": (0, 130, 130), }, } # --- FONTS --- # Single fixed lyric font size (LLM no longer picks per-word sizes). FONT_SIZE = 72 # User-selectable font families. Each maps to a list of candidate file paths # (first one found on disk wins), so the same names work across Linux distros. FONT_FAMILIES: Dict[str, list[str]] = { "Sans Serif (Bold)": [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", ], "Sans Serif": [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", ], "Serif (Bold)": [ "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", ], "Monospace (Bold)": [ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf", ], } DEFAULT_FONT_FAMILY = "Sans Serif (Bold)" _font_cache: dict[str, ImageFont.FreeTypeFont] = {} def get_font(font_family: str = DEFAULT_FONT_FAMILY) -> ImageFont.FreeTypeFont: """Returns the loaded lyric font for the requested family.""" if font_family in _font_cache: return _font_cache[font_family] candidates = FONT_FAMILIES.get(font_family, FONT_FAMILIES[DEFAULT_FONT_FAMILY]) font = None for path in candidates: try: font = ImageFont.truetype(path, FONT_SIZE) break except (IOError, OSError): continue if font is None: font = ImageFont.load_default() _font_cache[font_family] = font return font # --- FRAME ANIMATIONS --- # Signature: func(t_frame, duration) -> (x_offset, y_offset, scale, opacity) # t_frame is time since frame started. def frame_none(t: float, d: float) -> Tuple[float, float, float, float]: return (0.0, 0.0, 1.0, 1.0) def frame_zoom_in(t: float, d: float) -> Tuple[float, float, float, float]: progress = min(t / d, 1.0) if d > 0 else 0 scale = 1.0 + 0.1 * progress # Slow zoom in by 10% return (0.0, 0.0, scale, 1.0) def frame_zoom_out(t: float, d: float) -> Tuple[float, float, float, float]: progress = min(t / d, 1.0) if d > 0 else 0 scale = 1.1 - 0.1 * progress # Start slightly zoomed, zoom out to 1.0 return (0.0, 0.0, scale, 1.0) def frame_pan_left(t: float, d: float) -> Tuple[float, float, float, float]: progress = min(t / d, 1.0) if d > 0 else 0 x_off = -20.0 * progress return (x_off, 0.0, 1.0, 1.0) def frame_pan_right(t: float, d: float) -> Tuple[float, float, float, float]: progress = min(t / d, 1.0) if d > 0 else 0 x_off = 20.0 * progress return (x_off, 0.0, 1.0, 1.0) def frame_flash(t: float, d: float) -> Tuple[float, float, float, float]: # Handled by renderer drawing a white overlay return (0.0, 0.0, 1.0, 1.0) def frame_fade_to_black(t: float, d: float) -> Tuple[float, float, float, float]: # We can just return opacity and have renderer fade the frame # Starts fading in the last 0.5 seconds if d - t < 0.5 and d > 0.5: opacity = max(0.0, (d - t) / 0.5) return (0.0, 0.0, 1.0, opacity) return (0.0, 0.0, 1.0, 1.0) FRAME_ANIMATIONS: Dict[str, Callable[[float, float], Tuple[float, float, float, float]]] = { "none": frame_none, "zoom_in": frame_zoom_in, "zoom_out": frame_zoom_out, "pan_left": frame_pan_left, "pan_right": frame_pan_right, "flash": frame_flash, "fade_to_black": frame_fade_to_black, } # --- FRAME TRANSITIONS --- # Used during the gap between two frames. # Signature: func(old_img, new_img, progress) -> Image.Image # progress is 0.0 to 1.0 def trans_cut(old: Image.Image, new: Image.Image, p: float) -> Image.Image: return new if p >= 0.5 else old def trans_crossfade(old: Image.Image, new: Image.Image, p: float) -> Image.Image: return Image.blend(old, new, p) def trans_slide_left(old: Image.Image, new: Image.Image, p: float) -> Image.Image: w, h = old.size res = Image.new("RGB", (w, h), (0,0,0)) offset = int(w * p) res.paste(old, (-offset, 0)) res.paste(new, (w - offset, 0)) return res def trans_slide_up(old: Image.Image, new: Image.Image, p: float) -> Image.Image: w, h = old.size res = Image.new("RGB", (w, h), (0,0,0)) offset = int(h * p) res.paste(old, (0, -offset)) res.paste(new, (0, h - offset)) return res def trans_fade_through_black(old: Image.Image, new: Image.Image, p: float) -> Image.Image: black = Image.new("RGB", old.size, (0,0,0)) if p < 0.5: # Fade old to black return Image.blend(old, black, p * 2.0) else: # Fade black to new return Image.blend(black, new, (p - 0.5) * 2.0) FRAME_TRANSITIONS: Dict[str, Callable[[Image.Image, Image.Image, float], Image.Image]] = { "cut": trans_cut, "crossfade": trans_crossfade, "slide_left": trans_slide_left, "slide_up": trans_slide_up, "fade_through_black": trans_fade_through_black, }