""" Asset Manager for Catser. Handles downloading, caching, rasterizing, and color-tinting all sprite assets from Workcat (https://workcat.app). Assets managed: - Walk frames: 30 SVG polygon paths rasterized with antialiasing. - Paw frames: 17 WebP images for the strike animation. - Scruff frame: WebP image used when cat is grabbed and dragged. - Tail frames: 37 WebP images for idle tail flick. - Stretch frames: 37 WebP images for idle stretch. - Poses & Faces: Sit, sleep, open eyes, blink, happy face, mouth, alert '!', and heart '♥'. """ import os import re import json import math import logging import urllib.request from pathlib import Path from typing import Dict, List, Optional, Tuple, Any from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageColor from .config import ( GAIT_FRAME_COUNT, PAW_FRAME_COUNT, LIFE_MOTION_FRAME_COUNT, CAT_BOX_WIDTH, CAT_BOX_HEIGHT, Coat, COATS, ) logger = logging.getLogger("catser.assets") ASSET_BASE_URL = "https://workcat.app/assets/cat" USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" def _download_file(url: str, dest_path: Path) -> bool: """ Downloads a file with custom User-Agent to avoid Cloudflare 403 blocks. Returns True on success, False otherwise. """ dest_path.parent.mkdir(parents=True, exist_ok=True) if dest_path.exists() and dest_path.stat().st_size > 0: return True try: req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(req, timeout=15) as resp: content = resp.read() with open(dest_path, "wb") as f: f.write(content) logger.info(f"Downloaded {dest_path.name} ({len(content)} bytes)") return True except Exception as e: logger.error(f"Failed to download {url}: {e}") return False def _parse_svg_polygon_points(path_str: str) -> List[Tuple[float, float]]: """ Extracts (x, y) coordinate pairs from an SVG path consisting of M and L commands. Used for walk frame silhouettes. """ coords = re.findall(r"[-+]?\d*\.?\d+", path_str) points = [] for i in range(0, len(coords) - 1, 2): points.append((float(coords[i]), float(coords[i + 1]))) return points class AssetsManager: """ Central asset pipeline for Catser. Manages downloading, caching, rasterizing, and scaling of all cat sprites. """ def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144): self.assets_dir = assets_dir or (Path(__file__).parent / "assets") self.cat_width = cat_width self.cat_height = int(round(cat_width * (CAT_BOX_HEIGHT / CAT_BOX_WIDTH))) self.scale = self.cat_width / CAT_BOX_WIDTH # Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left) self._cache: Dict[str, Image.Image] = {} # Raw data loaded from disk self.walk_path_strings: List[str] = [] self.paw_images: List[Image.Image] = [] self.tail_images: List[Image.Image] = [] self.stretch_images: List[Image.Image] = [] self.scruff_image: Optional[Image.Image] = None def initialize(self) -> None: """Downloads all missing assets and prepares raw frames.""" self.assets_dir.mkdir(parents=True, exist_ok=True) self._ensure_assets_downloaded() self._load_raw_assets() def _ensure_assets_downloaded(self) -> None: """Verifies and downloads all animation assets from workcat.app.""" # 1. Walk frames JSON walk_json_path = self.assets_dir / "walk-frames.json" _download_file(f"{ASSET_BASE_URL}/walk-frames.json", walk_json_path) # 2. Paw frames (f001.webp - f017.webp) paw_dir = self.assets_dir / "paw" for i in range(1, PAW_FRAME_COUNT + 1): name = f"f{i:03d}.webp" _download_file(f"{ASSET_BASE_URL}/paw/{name}", paw_dir / name) # 3. Scruff frame _download_file(f"{ASSET_BASE_URL}/scruff.webp", self.assets_dir / "scruff.webp") # 4. Tail frames (f001.webp - f037.webp) tail_dir = self.assets_dir / "tail" for i in range(1, LIFE_MOTION_FRAME_COUNT + 1): name = f"f{i:03d}.webp" _download_file(f"{ASSET_BASE_URL}/tail/{name}", tail_dir / name) # 5. Stretch frames (f001.webp - f037.webp) stretch_dir = self.assets_dir / "stretch" for i in range(1, LIFE_MOTION_FRAME_COUNT + 1): name = f"f{i:03d}.webp" _download_file(f"{ASSET_BASE_URL}/stretch/{name}", stretch_dir / name) def _load_raw_assets(self) -> None: """Loads JSON and WebP images into memory.""" walk_json_path = self.assets_dir / "walk-frames.json" if walk_json_path.exists(): try: with open(walk_json_path, "r", encoding="utf-8") as f: data = json.load(f) self.walk_path_strings = data.get("frames", []) except Exception as e: logger.error(f"Failed to parse walk-frames.json: {e}") # Paw paw_dir = self.assets_dir / "paw" self.paw_images = [] for i in range(1, PAW_FRAME_COUNT + 1): p = paw_dir / f"f{i:03d}.webp" if p.exists(): try: self.paw_images.append(Image.open(p).convert("RGBA")) except Exception as e: logger.warning(f"Error opening {p}: {e}") # Scruff scruff_path = self.assets_dir / "scruff.webp" if scruff_path.exists(): try: self.scruff_image = Image.open(scruff_path).convert("RGBA") except Exception as e: logger.warning(f"Error opening scruff: {e}") # Tail tail_dir = self.assets_dir / "tail" self.tail_images = [] for i in range(1, LIFE_MOTION_FRAME_COUNT + 1): p = tail_dir / f"f{i:03d}.webp" if p.exists(): try: self.tail_images.append(Image.open(p).convert("RGBA")) except Exception as e: logger.warning(f"Error opening {p}: {e}") # Stretch stretch_dir = self.assets_dir / "stretch" self.stretch_images = [] for i in range(1, LIFE_MOTION_FRAME_COUNT + 1): p = stretch_dir / f"f{i:03d}.webp" if p.exists(): try: self.stretch_images.append(Image.open(p).convert("RGBA")) except Exception as e: logger.warning(f"Error opening {p}: {e}") # ========================================================================== # Frame Rendering & Raster Generation # ========================================================================== def get_walk_frame( self, frame_index: int, coat: Coat, facing_left: bool = False, face_type: str = "open", # "open", "blink", "happy" ) -> Image.Image: """ Renders a walk cycle frame with antialiased polygon vector and face features. Face types: 'open' (default eyes), 'blink' (lines), 'happy' (upward curves). """ frame_index = frame_index % max(len(self.walk_path_strings), 1) cache_key = f"walk_{frame_index}_{coat.name}_{facing_left}_{face_type}" if cache_key in self._cache: return self._cache[cache_key] target_w = self.cat_width target_h = self.cat_height # 4x Supersampling for clean anti-aliased polygon rasterization SS = 4 canvas_w = target_w * SS canvas_h = target_h * SS img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Scale from SVG viewBox (150x120) to supersampled canvas # Note: In cat.js viewBox is 0 0 150 120 and CAT_BOX is 121.2x92.4 with shift # Shift in cat.js: translate(GAIT_SHIFT_X * unit, GAIT_SHIFT_Y * unit) unit = (target_w / CAT_BOX_WIDTH) * SS shift_x = -6.383 * unit shift_y = 3.76 * unit scale_x = (target_w / 150.0) * SS * 1.25 # Aspect ratio fitting scale_y = (target_h / 120.0) * SS * 1.25 if self.walk_path_strings: pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index]) ss_points = [(p[0] * scale_x + shift_x, p[1] * scale_y + shift_y) for p in pts] fur_rgba = (*coat.fur_color, 255) draw.polygon(ss_points, fill=fur_rgba) # Draw Face Features # Face coordinates in 150x120 viewBox: # Eye L: cx=120.4, cy=46.2, r=2.2 # Eye R: cx=140.3, cy=46.2, r=2.2 # Mouth: Q curves around x=127-135, y=50-52 ink_rgba = (*coat.ink_color, 255) eye_l_cx = 120.4 * scale_x + shift_x eye_l_cy = 46.2 * scale_y + shift_y eye_r_cx = 140.3 * scale_x + shift_x eye_r_cy = 46.2 * scale_y + shift_y eye_r = 2.2 * scale_x if face_type == "open": draw.ellipse( [eye_l_cx - eye_r, eye_l_cy - eye_r, eye_l_cx + eye_r, eye_l_cy + eye_r], fill=ink_rgba, ) draw.ellipse( [eye_r_cx - eye_r, eye_r_cy - eye_r, eye_r_cx + eye_r, eye_r_cy + eye_r], fill=ink_rgba, ) elif face_type == "blink": # Downward blink lines w_eye = eye_r * 1.5 draw.line([(eye_l_cx - w_eye, eye_l_cy), (eye_l_cx + w_eye, eye_l_cy)], fill=ink_rgba, width=int(2 * SS)) draw.line([(eye_r_cx - w_eye, eye_r_cy), (eye_r_cx + w_eye, eye_r_cy)], fill=ink_rgba, width=int(2 * SS)) elif face_type == "happy": # Upward arc happy eyes (^^) w_eye = eye_r * 1.6 h_eye = eye_r * 1.3 draw.arc([eye_l_cx - w_eye, eye_l_cy - h_eye, eye_l_cx + w_eye, eye_l_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS)) draw.arc([eye_r_cx - w_eye, eye_r_cy - h_eye, eye_r_cx + w_eye, eye_r_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS)) # Mouth (w shape) mouth_cx = 131.6 * scale_x + shift_x mouth_cy = 51.0 * scale_y + shift_y mw = 4.0 * scale_x mh = 2.5 * scale_y draw.arc([mouth_cx - mw, mouth_cy - mh, mouth_cx, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS)) draw.arc([mouth_cx, mouth_cy - mh, mouth_cx + mw, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS)) # Downsample back to target dimensions with high-quality Lanczos resampling final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS) if facing_left: final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) self._cache[cache_key] = final_img return final_img def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image: """ Retrieves and tints a frame from the paw strike animation (f001-f017). Applies Workcat paw crop geometry: left: 2.265%, top: -3.083%, width: 106.72%, height: 96.833% """ frame_index = max(0, min(frame_index, len(self.paw_images) - 1)) cache_key = f"paw_{frame_index}_{coat.name}_{facing_left}" if cache_key in self._cache: return self._cache[cache_key] raw = self.paw_images[frame_index] if self.paw_images else self._get_fallback_cat(coat) tinted = self._tint_raster(raw, coat) # Scale according to paw geometry pw = int(round(self.cat_width * 1.0672)) ph = int(round(self.cat_height * 0.96833)) resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS) # Composite onto standard cat canvas with offsets canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) offset_x = int(round(self.cat_width * 0.02265)) offset_y = int(round(self.cat_height * -0.03083)) canvas.paste(resized, (offset_x, offset_y), resized) if facing_left: canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT) self._cache[cache_key] = canvas return canvas def get_scruff_frame(self, coat: Coat) -> Image.Image: """ Frame displayed when the cat is dragged by the neck scruff. Scale factor is 1.29 (Workcat standard). """ cache_key = f"scruff_{coat.name}" if cache_key in self._cache: return self._cache[cache_key] raw = self.scruff_image if self.scruff_image else self._get_fallback_cat(coat) tinted = self._tint_raster(raw, coat) sw = int(round(self.cat_width * 1.29)) sh = int(round(self.cat_height * 1.29)) resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS) # Center in canvas canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) ox = (self.cat_width - sw) // 2 oy = (self.cat_height - sh) // 2 canvas.paste(resized, (ox, oy), resized) self._cache[cache_key] = canvas return canvas def get_tail_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image: """ Frame from the 37-frame tail flick life motion. Geometry: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692% """ frame_index = max(0, min(frame_index, len(self.tail_images) - 1)) if self.tail_images else 0 cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}" if cache_key in self._cache: return self._cache[cache_key] raw = self.tail_images[frame_index] if self.tail_images else self._get_fallback_cat(coat) tinted = self._tint_raster(raw, coat) tw = int(round(self.cat_width * 1.175657)) th = int(round(self.cat_height * 1.153692)) resized = tinted.resize((tw, th), Image.Resampling.LANCZOS) canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) ox = int(round(self.cat_width * -0.150564)) oy = int(round(self.cat_height * -0.142679)) canvas.paste(resized, (ox, oy), resized) if facing_left: canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT) self._cache[cache_key] = canvas return canvas def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image: """ Frame from the 37-frame stretch life motion. Geometry: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865% """ frame_index = max(0, min(frame_index, len(self.stretch_images) - 1)) if self.stretch_images else 0 cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}" if cache_key in self._cache: return self._cache[cache_key] raw = self.stretch_images[frame_index] if self.stretch_images else self._get_fallback_cat(coat) tinted = self._tint_raster(raw, coat) sw = int(round(self.cat_width * 1.070263)) sh = int(round(self.cat_height * 1.008865)) resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS) canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) ox = int(round(self.cat_width * -0.045169)) oy = int(round(self.cat_height * 0.00215)) canvas.paste(resized, (ox, oy), resized) if facing_left: canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT) self._cache[cache_key] = canvas return canvas # ========================================================================== # Overlays & Indicators # ========================================================================== def get_alert_bubble(self, size: int = 36) -> Image.Image: """Draws the red '!' alert exclamation indicator with drop shadow.""" cache_key = f"alert_{size}" if cache_key in self._cache: return self._cache[cache_key] img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Red alert circle cx, cy = size // 2, size // 2 r = size // 2 - 2 draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 69, 58, 240), outline=(255, 255, 255, 255), width=2) # White '!' in center bar_w = max(2, size // 10) draw.rounded_rectangle([cx - bar_w // 2, cy - r // 2, cx + bar_w // 2, cy + r // 5], radius=2, fill=(255, 255, 255, 255)) dot_r = max(2, size // 12) draw.ellipse([cx - dot_r, cy + r // 3, cx + dot_r, cy + r // 3 + dot_r * 2], fill=(255, 255, 255, 255)) self._cache[cache_key] = img return img def get_heart_particle(self, size: int = 32, alpha: float = 1.0) -> Image.Image: """Draws a floating pink heart particle for the petting reaction.""" cache_key = f"heart_{size}_{int(alpha * 100)}" if cache_key in self._cache: return self._cache[cache_key] img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) a = int(round(255 * max(0.0, min(1.0, alpha)))) fill_color = (240, 100, 130, a) # Mathematical heart curve: # x = 16 sin^3(t) # y = 13 cos(t) - 5 cos(2t) - 2 cos(3t) - cos(4t) pts = [] steps = 60 scale = size / 38.0 cx, cy = size / 2.0, size / 2.0 - 2.0 * scale for s in range(steps): t = (math.pi * 2 * s) / steps x = 16 * (math.sin(t) ** 3) y = -(13 * math.cos(t) - 5 * math.cos(2 * t) - 2 * math.cos(3 * t) - math.cos(4 * t)) pts.append((cx + x * scale, cy + y * scale)) draw.polygon(pts, fill=fill_color) self._cache[cache_key] = img return img def get_zzz_indicator(self, size: int = 32, step: int = 0) -> Image.Image: """Draws floating 'z' snoring indicators.""" img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) gold = (122, 165, 214, 220) # Draw a stylish 'z' margin = size // 5 draw.line([(margin, margin), (size - margin, margin)], fill=gold, width=3) draw.line([(size - margin, margin), (margin, size - margin)], fill=gold, width=3) draw.line([(margin, size - margin), (size - margin, size - margin)], fill=gold, width=3) return img def get_shadow(self, width: int, lift_ratio: float = 0.0) -> Image.Image: """Draws a realistic floor contact shadow that fades and shrinks when lifted.""" sh_w = int(round(width * max(0.3, 0.8 - lift_ratio * 0.3))) sh_h = max(4, int(round(sh_w * 0.16))) opacity = max(0.0, min(0.7, 0.7 * (1.0 - lift_ratio * 0.85))) img = Image.new("RGBA", (sh_w, sh_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) alpha = int(255 * opacity) draw.ellipse([0, 0, sh_w, sh_h], fill=(0, 0, 0, alpha)) return img.filter(ImageFilter.GaussianBlur(radius=2)) # ========================================================================== # Private Helpers # ========================================================================== def _tint_raster(self, img: Image.Image, coat: Coat) -> Image.Image: """ Applies color shifting to raw WebP assets to match the selected coat color. Maps closely to Workcat's CSS rasterFilter: - ivory: original - charcoal: dark grey - grey: medium neutral grey - apricot: warm golden amber - sage: soft muted green - plum: purple-rose tint """ if coat.name == "ivory": return img # Split RGBA channels r, g, b, a = img.split() target_r, target_g, target_b = coat.fur_color # Calculate luminance # L = 0.299 R + 0.587 G + 0.114 B def tint_pixel(val: int, factor: float) -> int: return int(round(val * factor)) # Color tint multiplier relative to ivory (232, 229, 218) rf = target_r / 232.0 gf = target_g / 229.0 bf = target_b / 218.0 r_tint = r.point(lambda v: min(255, int(v * rf))) g_tint = g.point(lambda v: min(255, int(v * gf))) b_tint = b.point(lambda v: min(255, int(v * bf))) return Image.merge("RGBA", (r_tint, g_tint, b_tint, a)) def _get_fallback_cat(self, coat: Coat) -> Image.Image: """Fallback simple silhouette if WebP files are not yet downloaded.""" img = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Simple rounded cat shape draw.rounded_rectangle([10, 20, self.cat_width - 10, self.cat_height - 10], radius=16, fill=(*coat.fur_color, 255)) # Ears draw.polygon([(20, 25), (35, 5), (50, 25)], fill=(*coat.fur_color, 255)) draw.polygon([(self.cat_width - 50, 25), (self.cat_width - 35, 5), (self.cat_width - 20, 25)], fill=(*coat.fur_color, 255)) return img