Files
Catser/catser/assets_manager.py
T
me0nline 78b3f949c3 fix(assets): unified padded canvas eliminates tail clipping and fixes animation scaling
Workcat's tail flick (-15% left), paw reach (+9% right), and stretch motions
extend beyond the nominal cat bounding box. Previously, frames were drawn
onto a compact 144x110 canvas without padding, causing negative offsets
(e.g. ox=-22px on tail) to truncate the tail tip and create mismatched scales.

Fixes:
- Added padding (pad_x=20%, pad_y=20%) to the render canvas so all extended
  poses (tail swings to x=16, paw strikes to x=185) fit completely without clipping.
- Unified the coordinate system: walk, tail, stretch, paw, and scruff now share
  the exact same body scale (height ~93-94px) and feet baseline (y=128).
2026-09-08 23:33:12 +02:00

527 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
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.
"""
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, config: Optional[Config] = None):
self.assets_dir = assets_dir or (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."""
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
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