diff --git a/catser/assets_manager.py b/catser/assets_manager.py new file mode 100644 index 0000000..46fbc70 --- /dev/null +++ b/catser/assets_manager.py @@ -0,0 +1,520 @@ +""" +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