| """ |
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| 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), |
| }, |
| } |
|
|
| |
| |
| FONT_SIZE = 72 |
|
|
| |
| |
| 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 |
|
|
| |
| |
| |
|
|
| 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 |
| 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 |
| 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]: |
| |
| return (0.0, 0.0, 1.0, 1.0) |
|
|
| def frame_fade_to_black(t: float, d: float) -> Tuple[float, float, float, float]: |
| |
| |
| 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, |
| } |
|
|
| |
| |
| |
| |
|
|
| 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: |
| |
| return Image.blend(old, black, p * 2.0) |
| else: |
| |
| 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, |
| } |
|
|