Spaces:
Running on Zero
Running on Zero
| """ | |
| 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, | |
| } | |