Files
Catser/catser/assets_manager.py
T
me0nline 145971f6ab fix(assets): correct walk frame scaling -- remove 1.25 overcorrection and wrong GAIT_SHIFT
The walk polygon was rendered 25% too large due to an erroneous * 1.25
factor on scale_x / scale_y. On top of this, GAIT_SHIFT_X=-6.383 and
GAIT_SHIFT_Y=3.76 (internal cat.js group transforms) were incorrectly
ported as canvas offsets, pushing the polygon partially above the top
edge of the overlay window (visible clipping).

Root cause: the SVG viewBox is 0 0 150 120. Walk polygon coordinates
span 4-145 in X and 8-112 in Y -- already fitting the viewBox with no
additional scaling or translation needed.

Fix: use scale_x = (cat_width / 150) * SS and scale_y = (cat_height / 120) * SS
with zero offset. Walk frame now renders at the same visual size as paw/
scruff/tail/stretch frames (all on a cat_width x cat_height canvas).

Result: cat body is no longer clipped at the top and matches size of all
other animation states.
2026-09-08 23:20:57 +02:00

516 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,
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
# 4× supersampling for clean anti-aliased polygon edges
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)
# The Workcat SVG walk frames use viewBox="0 0 150 120".
# Polygon coordinates span ~4145 in X and ~8112 in Y, fitting the viewBox.
# We simply map viewBox coords → supersampled canvas pixels.
# No GAIT_SHIFT or overcorrection needed: those are internal cat.js transforms
# that cancel out at the viewBox level.
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, p[1] * scale_y) for p in pts]
fur_rgba = (*coat.fur_color, 255)
draw.polygon(ss_points, fill=fur_rgba)
# Face features — all coordinates in the same 150×120 viewBox space.
# Eyes: left at (120.4, 46.2), right at (140.3, 46.2), radius 2.2
# Mouth: W-shape centred around (131.6, 51.0)
ink_rgba = (*coat.ink_color, 255)
eye_l_cx = 120.4 * scale_x
eye_l_cy = 46.2 * scale_y
eye_r_cx = 140.3 * scale_x
eye_r_cy = 46.2 * scale_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
mouth_cy = 51.0 * scale_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 target resolution 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