"""Decode a pet spritesheet and encode frames for a terminal. Shared by the base CLI (writes the escape bytes to its own stdout) and the TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the decode + capability-detection + protocol-encoding logic exists exactly once. Supported output modes, in fidelity order: - ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm). - ``iterm`` — iTerm2 inline images (iTerm2, WezTerm). - ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …). - ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal. Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the spritesheet is unavailable the renderer degrades to ``unicode`` text or an empty string rather than raising. """ from __future__ import annotations import base64 import io import logging import os import sys from functools import lru_cache from pathlib import Path from agent.pet.constants import ( DEFAULT_SCALE, FRAME_H, FRAME_W, FRAMES_PER_STATE, PetState, state_row_index, ) logger = logging.getLogger(__name__) # Public render-mode names accepted by ``display.pet.render_mode``. RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off") # ───────────────────────────────────────────────────────────────────────── # Terminal capability detection # ───────────────────────────────────────────────────────────────────────── def detect_terminal_graphics() -> str: """Best-effort detection of the richest graphics protocol available. Env-based (non-blocking — we never issue a DA1/terminal query that could hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` / ``unicode``. Conservative: unknown terminals get ``unicode``, which works anywhere with truecolor. """ term = os.environ.get("TERM", "").lower() term_program = os.environ.get("TERM_PROGRAM", "").lower() # The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode # authoritatively but does NOT scrub the terminal env vars it inherits when # launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …). # Trusting those leaks emits an image protocol the embedded xterm.js can't # display — you get a blank frame. Inline images there are opt-in # (terminal.integrated.enableImages), so default to half-blocks, which # always render in its truecolor grid. Users who enabled images can pin # display.pet.render_mode explicitly. if term_program == "vscode": return "unicode" # kitty graphics protocol if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term: return "kitty" if term_program in {"ghostty"}: return "kitty" # WezTerm speaks both kitty and iterm; prefer kitty (richer placement). if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"): return "kitty" # iTerm2 inline images if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"): return "iterm" # sixel-capable terminals (env heuristics only) if term_program in {"mintty"} or "foot" in term or "mlterm" in term: return "sixel" if "sixel" in term: return "sixel" return "unicode" def resolve_mode(configured: str | None, *, stream=None) -> str: """Resolve the effective render mode from config + the environment. ``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns ``off`` when not attached to a TTY (no point emitting graphics into a pipe or logfile). """ mode = (configured or "auto").strip().lower() if mode not in RENDER_MODES: mode = "auto" if mode == "off": return "off" stream = stream or sys.stdout try: if not (hasattr(stream, "isatty") and stream.isatty()): return "off" except (ValueError, OSError): return "off" if mode == "auto": return detect_terminal_graphics() return mode # ───────────────────────────────────────────────────────────────────────── # Frame decoding # ───────────────────────────────────────────────────────────────────────── def _open_sheet(path: Path): from PIL import Image img = Image.open(path) return img.convert("RGBA") # Max alpha at/below which a frame counts as blank padding. petdex sheets are # left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills # the trailing columns with fully transparent cells. Animating into one flashes # the pet blank, so we stop the row at the first such gap. _BLANK_ALPHA = 8 def _frame_is_blank(frame) -> bool: """True if *frame* has no meaningfully opaque pixel (transparent padding).""" return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA @lru_cache(maxsize=16) def _raw_frames( sheet_path: str, state_value: str, frame_w: int, frame_h: int, frames_per_state: int, ) -> tuple: """Cropped, padding-trimmed RGBA frames for one state row (unscaled). Steps across the row until the first blank column so pets with ragged per-state frame counts never animate into empty padding. Cached; returns ``()`` on any decode failure. """ try: sheet = _open_sheet(Path(sheet_path)) cols = max(1, sheet.width // frame_w) rows = max(1, sheet.height // frame_h) row = state_row_index(state_value, rows) top = row * frame_h # Clamp the row to the sheet (some pets ship fewer rows than the 8 the # taxonomy reserves). if top + frame_h > sheet.height: top = max(0, sheet.height - frame_h) frames = [] for i in range(min(frames_per_state, cols)): left = i * frame_w frame = sheet.crop((left, top, left + frame_w, top + frame_h)) if _frame_is_blank(frame): break # trailing transparent padding — real frames end here frames.append(frame) return tuple(frames) except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc) return () @lru_cache(maxsize=8) def _frames_for( sheet_path: str, state_value: str, frame_w: int, frame_h: int, frames_per_state: int, scale_w: int, scale_h: int, ): """Return padding-trimmed RGBA frames for one state row, scaled. Thin scaling layer over :func:`_raw_frames`; both are cached so repeated frame requests during animation are free. """ raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state) if not raw or (scale_w, scale_h) == (frame_w, frame_h): return list(raw) from PIL import Image return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw] def state_frame_counts( sheet_path: str | Path, *, frame_w: int = FRAME_W, frame_h: int = FRAME_H, frames_per_state: int = FRAMES_PER_STATE, ) -> dict[str, int]: """Map each driven :class:`PetState` → its real (padding-trimmed) frame count. The single source of truth for "how many frames does this state actually have?". The CLI/TUI consume the trimmed frame lists directly; the gateway ships this map to the desktop canvas, which steps its own loop. """ return { state.value: len( _raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state) ) for state in PetState } # ───────────────────────────────────────────────────────────────────────── # Encoders # ───────────────────────────────────────────────────────────────────────── def _png_bytes(frame) -> bytes: buf = io.BytesIO() frame.save(buf, format="PNG") 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 if len(data) <= chunk: return f"\x1b_G{ctrl},m=0;{data}\x1b\\" out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"] rest = data[chunk:] while rest: piece, rest = rest[:chunk], rest[chunk:] out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\") return "".join(out) def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: """Encode one frame via the kitty graphics protocol (transmit + display). ``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display box in terminal cells so successive frames overwrite the same area. """ ctrl = "f=100,a=T,q=2" if cell_cols: ctrl += f",c={cell_cols}" if cell_rows: ctrl += f",r={cell_rows}" return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) # ───────────────────────────────────────────────────────────────────────── # kitty Unicode placeholders # # Ink (the TUI's React-for-terminal layer) owns the screen and measures every # cell's width, so it can't host raw kitty image escapes (no width to count, # clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the # grid-safe path: transmit the image once (q=2, virtual placement U=1), then the # host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose # foreground color encodes the image id. Ink counts those as width-1 text, so # layout stays correct and the terminal paints the image underneath. # https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders # ───────────────────────────────────────────────────────────────────────── _KITTY_PLACEHOLDER = "\U0010eeee" # Row/column diacritics, in order (index → diacritic). Verbatim from kitty's # gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is # the diacritic that encodes the number i; we only ever need the row index. _ROWCOL_DIACRITICS: tuple[int, ...] = ( 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244, ) def kitty_image_id(slug: str) -> int: """Stable per-pet image id in ``[1, 0x7FFF]``. The id is encoded in the placeholder's 24-bit foreground color, so it must be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it deterministic per slug (so re-renders reuse the same terminal-side image) while making collisions between two different pets unlikely. """ import zlib return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1 def kitty_color_hex(image_id: int) -> str: """Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty.""" return "#%06x" % (image_id & 0xFFFFFF) def kitty_placeholder_rows(cols: int, rows: int) -> list[str]: """Build the placeholder text grid for an *rows*×*cols* image. Each line is one row of the grid: the first cell carries the row diacritic (column defaults to 0), and the remaining ``cols-1`` bare placeholders let the terminal auto-increment the column. The foreground color (the image id) is applied by the caller / Ink, not embedded here. """ cols = max(1, cols) out: list[str] = [] for r in range(max(1, rows)): idx = min(r, len(_ROWCOL_DIACRITICS) - 1) first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx]) out.append(first + _KITTY_PLACEHOLDER * (cols - 1)) return out def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str: """Transmit a frame as a kitty *virtual* placement for Unicode placeholders. ``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the terminal's OK/error replies that would otherwise corrupt the host app's output. Re-sending with the same ``i`` replaces the image, so the static placeholder cells animate underneath. """ ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2" return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: """Encode one frame as an iTerm2 inline image (OSC 1337 File).""" payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii") size = len(payload) args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"] if cell_cols: args.append(f"width={cell_cols}") if cell_rows: args.append(f"height={cell_rows}") return f"\x1b]1337;File={';'.join(args)}:{payload}\x07" def _encode_sixel(frame) -> str: """Encode one frame as DEC sixel. Quantizes to an adaptive palette (≤255 colors) and emits the sixel band stream. Pillow has no sixel writer, so this is a compact hand-rolled encoder. Transparent pixels render as background (color register skipped). """ from PIL import Image rgba = frame # Composite onto transparent-as-skip: track alpha to decide background. pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT) palette = pal.getpalette() or [] px = pal.load() alpha = rgba.getchannel("A").load() w, h = pal.size out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)] # Color register definitions (sixel uses 0..100 scale). used = sorted({px[x, y] for y in range(h) for x in range(w)}) for idx in used: r = palette[idx * 3] if idx * 3 < len(palette) else 0 g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0 b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0 out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255)) # Emit in 6-row bands. for band in range(0, h, 6): for color_idx in used: line = ["#%d" % color_idx] run_char = None run_len = 0 def flush(): nonlocal run_char, run_len if run_char is None: return if run_len > 3: line.append("!%d%s" % (run_len, run_char)) else: line.append(run_char * run_len) run_char, run_len = None, 0 for x in range(w): bits = 0 for bit in range(6): y = band + bit if y < h and alpha[x, y] > 32 and px[x, y] == color_idx: bits |= 1 << bit ch = chr(63 + bits) if ch == run_char: run_len += 1 else: flush() run_char, run_len = ch, 1 flush() out.append("".join(line) + "$") # carriage return within band out.append("-") # next band out.append("\x1b\\") return "".join(out) _HALF_BLOCK = "▀" # A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples. Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]] def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]: """Downscale a frame to a grid of half-block cells. Each cell pairs a top and bottom pixel so one terminal row encodes two pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the framework-neutral representation shared by the ANSI encoder (CLI) and the structured ``cells`` API (Ink). """ from PIL import Image target_cols = max(4, target_cols) aspect = frame.height / max(1, frame.width) target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2) small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA") px = small.load() grid: list[list[Cell]] = [] for y in range(0, target_rows, 2): row: list[Cell] = [] for x in range(target_cols): top = px[x, y] bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0) row.append((top, bottom)) grid.append(row) return grid def _encode_unicode(frame, *, target_cols: int) -> str: """Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels).""" lines: list[str] = [] for row in _downscale_cells(frame, target_cols=target_cols): cells: list[str] = [] for (tr, tg, tb, ta), (br, bg, bb, ba) in row: if ta < 32 and ba < 32: cells.append("\x1b[0m ") # fully transparent → blank continue cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}") lines.append("".join(cells) + "\x1b[0m") return "\n".join(lines) # ───────────────────────────────────────────────────────────────────────── # Public renderer # ───────────────────────────────────────────────────────────────────────── class PetRenderer: """Holds a pet's spritesheet and yields encoded frames per (state, index). Construct once per pet, then call :meth:`frame` on an animation timer. Cheap to call repeatedly — decoded frames are cached. """ def __init__( self, spritesheet: str | Path, *, mode: str = "unicode", scale: float = DEFAULT_SCALE, unicode_cols: int = 20, frame_w: int = FRAME_W, frame_h: int = FRAME_H, frames_per_state: int = FRAMES_PER_STATE, ) -> None: self.spritesheet = str(spritesheet) self.mode = mode if mode in RENDER_MODES else "unicode" self.scale = scale self.unicode_cols = unicode_cols self.frame_w = frame_w self.frame_h = frame_h self.frames_per_state = frames_per_state @property def available(self) -> bool: return self.mode != "off" and Path(self.spritesheet).is_file() def frame_count(self, state: PetState | str) -> int: return len(self._frames(state)) def _frames(self, state: PetState | str): value = state.value if isinstance(state, PetState) else str(state) scale_w = max(1, int(self.frame_w * self.scale)) scale_h = max(1, int(self.frame_h * self.scale)) return _frames_for( self.spritesheet, value, self.frame_w, self.frame_h, self.frames_per_state, scale_w, scale_h, ) def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]: """Return one frame as a half-block cell grid (framework-neutral). Used by the TUI, which renders the grid with native Ink color props instead of raw ANSI. Returns ``[]`` when no frame is available. """ frames = self._frames(state) if not frames: return [] frame = frames[index % len(frames)] return _downscale_cells(frame, target_cols=cols or self.unicode_cols) def _cell_box(self, frame) -> tuple[int, int]: """Terminal cell box for a scaled frame (~8×16 px per cell). Must match :meth:`frame` graphics sizing — kitty stretches the image to fill ``c``×``r`` cells, so these must reflect the scaled pixel dimensions, not a native-aspect column count (that upscales small pets). """ return max(1, frame.width // 8), max(1, frame.height // 16) def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None: """Build the kitty Unicode-placeholder payload for one state. Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a list of transmit escapes (one per animation frame, all reusing ``image_id``) and ``placeholder`` is the static text grid Ink paints. Placement geometry is derived from the scaled frame pixels (via :meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill ``c``×``r`` cells. ``None`` when no frame is available. """ 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, "rows": rows, "placeholder": kitty_placeholder_rows(cols, rows), "frames": [ _encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames ], } def frame(self, state: PetState | str, index: int) -> str: """Return the encoded escape string for one frame, or ``""``. ``index`` is taken modulo the available frame count so callers can pass a free-running counter. """ if self.mode == "off": return "" frames = self._frames(state) if not frames: return "" frame = frames[index % len(frames)] cell_cols, cell_rows = self._cell_box(frame) try: if self.mode == "kitty": return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows) if self.mode == "iterm": return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows) if self.mode == "sixel": return _encode_sixel(frame) return _encode_unicode(frame, target_cols=self.unicode_cols) except Exception as exc: # noqa: BLE001 - degrade silently logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc) return "" def build_renderer( spritesheet: str | Path, *, configured_mode: str | None = None, scale: float = DEFAULT_SCALE, unicode_cols: int = 20, stream=None, ) -> PetRenderer: """Convenience factory: resolve the mode from config+env, then construct.""" mode = resolve_mode(configured_mode, stream=stream) return PetRenderer( spritesheet, mode=mode, scale=scale, unicode_cols=unicode_cols, )