""" 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 sys 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, GAIT_SHIFT_X, GAIT_SHIFT_Y, Coat, COATS, Config, ) 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. """ if dest_path.exists() and dest_path.stat().st_size > 0: return True try: dest_path.parent.mkdir(parents=True, exist_ok=True) except Exception: pass 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, config: Optional[Config] = None): if assets_dir: self.assets_dir = assets_dir elif getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): mei_catser = Path(sys._MEIPASS) / "catser" / "assets" self.assets_dir = mei_catser if mei_catser.exists() else (Path(sys._MEIPASS) / "assets") else: self.assets_dir = Path(__file__).parent / "assets" self.config = config or Config(cat_width=cat_width) self.cat_width = self.config.cat_width self.cat_height = self.config.cat_height self.canvas_width = self.config.canvas_width self.canvas_height = self.config.canvas_height self.pad_x = self.config.pad_x self.pad_y = self.config.pad_y 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.""" try: self.assets_dir.mkdir(parents=True, exist_ok=True) except Exception: pass 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 canvas_w = self.canvas_width canvas_h = self.canvas_height pad_x = self.pad_x pad_y = self.pad_y # 4× supersampling for clean anti-aliased polygon edges SS = 4 ss_w = canvas_w * SS ss_h = canvas_h * SS img = Image.new("RGBA", (ss_w, ss_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # In Workcat: # The SVG viewBox is 0 0 150 120, scaled to target_w x target_h, # shifted by GAIT_SHIFT_X/Y relative to the cat unit (target_w / CAT_BOX_WIDTH), # and placed with pad_x / pad_y inside the overlay canvas. unit = (target_w / CAT_BOX_WIDTH) * SS shift_x = pad_x * SS + GAIT_SHIFT_X * unit shift_y = pad_y * SS + GAIT_SHIFT_Y * unit scale_x = (target_w / 150.0) * SS scale_y = (target_h / 120.0) * SS 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) # Face features — coordinates in 150x120 viewBox shifted into position 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": 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": 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_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 to canvas resolution with high-quality Lanczos resampling final_img = img.resize((canvas_w, canvas_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 canvas with pad offsets canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0)) offset_x = self.pad_x + int(round(self.cat_width * 0.02265)) offset_y = self.pad_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) canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0)) ox = self.pad_x + (self.cat_width - sw) // 2 oy = self.pad_y + (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 from Workcat: 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) # Composite onto padded canvas — tail extension is fully preserved canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0)) ox = self.pad_x + int(round(self.cat_width * -0.150564)) oy = self.pad_y + 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 from Workcat: 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.canvas_width, self.canvas_height), (0, 0, 0, 0)) ox = self.pad_x + int(round(self.cat_width * -0.045169)) oy = self.pad_y + 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