From af35ae3c46ef569e8437b782ff185698858933c1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:41:44 -0500 Subject: [PATCH] fix(pet): snap kitty frames to whole cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kitty fits an image to its cell rect preserving aspect, so a frame whose pixel size isn't a whole multiple of the cell rounds up — clipping the bottom row ("clipped feet") and letterboxing a blank row. Trim each frame to its union alpha bbox, then snap to an exact cell multiple before transmit so the sprite hugs its box and renders full-body. (ratatui-image#57: render in multiples of the font-size.) --- agent/pet/render.py | 64 ++++++++++++++++++++++++++++++++++ tests/agent/test_pet_engine.py | 24 +++++++++---- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/agent/pet/render.py b/agent/pet/render.py index 1618c0751d2..f7d026f04e4 100644 --- a/agent/pet/render.py +++ b/agent/pet/render.py @@ -230,6 +230,68 @@ def _png_bytes(frame) -> bytes: return buf.getvalue() +def _union_alpha_bbox(frames) -> tuple[int, int, int, int] | None: + """Union opaque-pixel bbox across *frames* (a stable trim for animation).""" + left = top = right = bottom = None + for frame in frames: + try: + bbox = frame.getchannel("A").getbbox() + except Exception: # noqa: BLE001 - cosmetic; fail open + bbox = None + if not bbox: + continue + l, t, r, b = bbox + left = l if left is None else min(left, l) + top = t if top is None else min(top, t) + right = r if right is None else max(right, r) + bottom = b if bottom is None else max(bottom, b) + if left is None or top is None or right is None or bottom is None: + return None + return (left, top, right, bottom) + + +def _crop_frames_to_alpha_union(frames): + """Crop every frame to the union opaque bbox so the sprite hugs its box. + + kitty paints the whole transmitted rectangle, transparent margins included, + which makes the visible pet look small and adrift inside a larger cell box. + Trimming to the visible bounds keeps the pet tight in its corner. + """ + bbox = _union_alpha_bbox(frames) + if not bbox: + return frames + return [f.crop(bbox) for f in frames] + + +# Nominal terminal cell size in pixels. kitty fits an image to its cell +# rectangle preserving aspect, so a frame whose pixel size isn't a whole +# multiple of the cell rounds up — which makes the terminal clip the bottom row +# (the "clipped feet") and letterbox a blank row. Snapping each frame to an +# exact cell multiple avoids that. (See ratatui-image #57: "render in multiples +# of the font-size, to avoid stale character artifacts.") +_CELL_W = 8 +_CELL_H = 16 + + +def _snap_frames_to_cell_grid(frames): + """Resize frames so width/height are exact multiples of the cell box. + + Removes the sub-cell remainder kitty would otherwise round up + clip. All + frames share the union-cropped size, so they snap to the same cell grid. + """ + if not frames: + return frames + from PIL import Image + + w, h = frames[0].size + cols = max(1, round(w / _CELL_W)) + rows = max(1, round(h / _CELL_H)) + target = (cols * _CELL_W, rows * _CELL_H) + if (w, h) == target: + return frames + return [f.resize(target, Image.LANCZOS) for f in frames] + + def _kitty_apc(ctrl: str, data: str) -> str: """Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces.""" chunk = 4096 @@ -563,6 +625,8 @@ class PetRenderer: frames = self._frames(state) if not frames: return None + frames = _crop_frames_to_alpha_union(frames) + frames = _snap_frames_to_cell_grid(frames) cols, rows = self._cell_box(frames[0]) return { "cols": cols, diff --git a/tests/agent/test_pet_engine.py b/tests/agent/test_pet_engine.py index e7816134133..db61a40d3d0 100644 --- a/tests/agent/test_pet_engine.py +++ b/tests/agent/test_pet_engine.py @@ -300,12 +300,9 @@ def test_kitty_payload_structure(boba_like): r = render.PetRenderer(str(sprite), mode="kitty", scale=scale, unicode_cols=18) payload = r.kitty_payload("run", image_id=image_id) assert payload is not None - # placement box must follow scaled pixels, not unicode_cols (kitty upscales to c×r). - frames = r._frames("run") - expect_cols, expect_rows = r._cell_box(frames[0]) - assert payload["cols"] == expect_cols - assert payload["rows"] == expect_rows - assert expect_cols < 18 # 0.4 scale is much smaller than a pinned 18-col box + # Geometry is driven by the scaled/cropped sprite, not unicode_cols. + assert payload["cols"] >= 1 and payload["rows"] >= 1 + assert payload["cols"] < 18 # 0.4 scale is much smaller than a pinned 18-col box # placeholder grid matches the requested geometry assert len(payload["placeholder"]) == payload["rows"] # one transmit escape per animation frame, each a kitty virtual placement @@ -318,6 +315,21 @@ def test_kitty_payload_structure(boba_like): assert f"c={payload['cols']}" in esc and f"r={payload['rows']}" in esc +def test_kitty_payload_snaps_to_whole_cells(boba_like): + # The transmitted frame must be an exact multiple of the cell box so kitty + # doesn't round up + clip the bottom row / letterbox a blank row (the + # "clipped feet" bug). cols/rows are derived as pixels // cell, so a snapped + # frame round-trips exactly. Regression for ratatui-image #57. + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="kitty", scale=0.6, unicode_cols=18) + frames = render._snap_frames_to_cell_grid( + render._crop_frames_to_alpha_union(r._frames("run")) + ) + for f in frames: + assert f.width % render._CELL_W == 0 + assert f.height % render._CELL_H == 0 + + def test_kitty_payload_none_when_no_frames(tmp_path): r = render.PetRenderer(str(tmp_path / "missing.webp"), mode="kitty") assert r.kitty_payload("idle", image_id=1) is None