aMuseMe / src /amuseme /animations.py
Blazestorm001's picture
chore: tidy Space repository structure
08ab8f1 verified
Raw
History Blame Contribute Delete
6.05 kB
"""
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,
}