From 7309c3aef3530f79d0204f43a91d2b79d11dea06 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:04:29 -0700 Subject: [PATCH] refactor(agent): pet atlas/render/store pass-2 structural cut (median-box helper, encoder dispatch, inlined single-use helpers) --- agent/pet/generate/atlas.py | 126 ++++++++++++------------------------ agent/pet/render.py | 103 +++++++++++------------------ agent/pet/store.py | 115 ++++++++++++-------------------- 3 files changed, 119 insertions(+), 225 deletions(-) diff --git a/agent/pet/generate/atlas.py b/agent/pet/generate/atlas.py index 2eeb334bac..0e456b65f9 100644 --- a/agent/pet/generate/atlas.py +++ b/agent/pet/generate/atlas.py @@ -49,6 +49,11 @@ def _median(values) -> int: return ordered[len(ordered) // 2] +def _median_box_size(boxes) -> tuple[int, int]: + """``(median width, median height)`` of ``(l, t, r, b)`` *boxes*, each at least 1.""" + return max(1, _median(r - l for l, _t, r, _b in boxes)), max(1, _median(b - t for _l, t, _r, b in boxes)) + + def _blank(size=(CELL_WIDTH, CELL_HEIGHT)): return Image.new("RGBA", size, (0, 0, 0, 0)) @@ -111,26 +116,10 @@ def _unvisited_components(w: int, h: int, visited: bytearray, accept): yield _flood(w, h, visited, [(x, y)], accept) -def _has_transparency(image) -> bool: - """True if the strip already carries a real alpha background (>5% of pixels at/below the floor).""" - alpha = image.getchannel("A") - return alpha.getextrema()[0] <= _ALPHA_FLOOR and sum(alpha.histogram()[: _ALPHA_FLOOR + 1]) > image.width * image.height * 0.05 - - -def _dominant_corner_color(image) -> tuple[int, int, int]: - """Most common opaque color among the four corners.""" - w, h = image.width, image.height - px = image.load() - corners = [px[x, y] for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1))] - counter = Counter(tuple(c[:3]) for c in corners if c[3] > _ALPHA_FLOOR) - return counter.most_common(1)[0][0] if counter else (0, 255, 0) - - def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48): """``L`` mask, 255 where a pixel is within *tol* per-channel of *key*. - Tight on purpose: marks only near-pure backdrop so trapped chroma pockets - seed the flood while chroma-tinted character pixels stay outside it. + Tight on purpose: marks only near-pure backdrop so trapped chroma pockets seed the flood while chroma-tinted character pixels stay outside it. """ r, g, b = (ch.point(lambda v, k=k: 255 if abs(v - k) <= tol else 0) for ch, k in zip(image.split()[:3], key)) return ImageChops.darker(ImageChops.darker(r, g), b) @@ -139,8 +128,7 @@ def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48): def _remove_masked(rgba, mask): """Clear the pixels *mask* (``L``, 255 = remove) selects, then erode alpha by 1px (3x3 min). - The erosion drops the antialiased key/sprite blend ring (too far from the key to - match); the sprite's own thick outline keeps the silhouette intact. + The erosion drops the antialiased key/sprite blend ring (too far from the key to match); the sprite's thick outline keeps the silhouette. """ out = Image.composite(_blank(rgba.size), rgba, mask) out.putalpha(out.getchannel("A").filter(ImageFilter.MinFilter(3))) @@ -150,23 +138,26 @@ def _remove_masked(rgba, mask): def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 90.0): """Return *image* (RGBA) with its flat background keyed out to transparent. - Already-transparent strips are left alone (holes repaired). Otherwise key out - *chroma_key* (or the dominant corner color) from the border inward: a global - color match punched holes wherever an interior highlight matched the backdrop. + Strips already carrying a real alpha background (>5% of pixels at/below the floor) + are left alone (holes repaired). Otherwise key out *chroma_key* (or the most common + opaque corner color) from the border inward: a global color match punched holes + wherever an interior highlight matched the backdrop. """ rgba = image.convert("RGBA") - if _has_transparency(rgba): + w, h = rgba.size + alpha = rgba.getchannel("A") + if alpha.getextrema()[0] <= _ALPHA_FLOOR and sum(alpha.histogram()[: _ALPHA_FLOOR + 1]) > w * h * 0.05: return _repair_internal_alpha_holes(rgba) - key = chroma_key or _dominant_corner_color(rgba) + px = rgba.load() + if not (key := chroma_key): + corners = Counter(tuple(px[x, y][:3]) for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)) if px[x, y][3] > _ALPHA_FLOOR) + key = corners.most_common(1)[0][0] if corners else (0, 255, 0) # Fast path for saturated chroma keys (our prompts use hot magenta): C-level # channel ops clear border backdrop and enclosed pockets alike, no Python flood. if max(key) - min(key) >= 120: opaque = rgba.getchannel("A").point(lambda a: 255 if a > _ALPHA_FLOOR else 0) return _remove_masked(rgba, ImageChops.darker(_near_key_mask(rgba, key), opaque)) - w, h = rgba.size - px = rgba.load() - def _is_bg(x: int, y: int) -> bool: r, g, b, a = px[x, y] return a > _ALPHA_FLOOR and math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) <= threshold @@ -183,17 +174,13 @@ def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, def _repair_internal_alpha_holes(image): """Fill transparent islands fully enclosed by opaque sprite pixels. - Some providers return "transparent" PNGs with swiss-cheese alpha inside the - character; enclosed holes get the average color of their opaque neighbours. + Some providers return "transparent" PNGs with swiss-cheese alpha inside the character; enclosed holes take the average opaque-neighbour color. """ rgba = image.convert("RGBA") w, h = rgba.size px = rgba.load() visited = bytearray(w * h) - - def _is_transparent(x: int, y: int) -> bool: - return px[x, y][3] <= _ALPHA_FLOOR - + _is_transparent = lambda x, y: px[x, y][3] <= _ALPHA_FLOOR # noqa: E731 _border_flood(w, h, visited, _is_transparent) # edge-connected transparency = background for hole in _unvisited_components(w, h, visited, _is_transparent): seen = set(hole) @@ -224,8 +211,7 @@ def _fit_to_cell(image): def _drop_side_bleed(image): """Remove tiny separated left/right lobes (neighbour-pose slivers) before fitting. - Component extraction may have grouped a near sliver with the subject; the - column profile still shows it as a low-mass lobe. Only those go, so wide poses survive. + Component extraction may group a near sliver with the subject; the column profile still shows it as a low-mass lobe. Only those go (wide poses survive). """ rgba = image.convert("RGBA") w, h = rgba.size @@ -270,11 +256,7 @@ def _component_boxes(image) -> list[tuple[tuple[int, int, int, int], int]]: w, h = r0 - l0, b0 - t0 alpha = rgba.getchannel("A").load() components = _unvisited_components(w, h, bytearray(w * h), lambda x, y: alpha[l0 + x, t0 + y] > _ALPHA_FLOOR) - out: list[tuple[tuple[int, int, int, int], int]] = [] - for pixels in components: - xs, ys = zip(*pixels) - out.append(((l0 + min(xs), t0 + min(ys), l0 + max(xs) + 1, t0 + max(ys) + 1), len(pixels))) - return out + return [((l0 + min(xs), t0 + min(ys), l0 + max(xs) + 1, t0 + max(ys) + 1), len(xs)) for xs, ys in map(lambda p: zip(*p), components)] def _isolate_slot_subject(image): @@ -286,17 +268,13 @@ def _isolate_slot_subject(image): main_box, main_mass = max(comps, key=lambda item: item[1]) ml, _mt, mr, _mb = main_box mw = max(1, mr - ml) - - def _keep(box, mass) -> bool: + out = _blank(rgba.size) + for box, mass in comps: # Keep attached-looking accessories (halos); drop sparkles/tears/noise. left, _top, right, _bottom = box overlap = max(0, min(right, mr) - max(left, ml)) near_main = (ml - mw * 0.25) <= (left + right) / 2 <= (mr + mw * 0.25) - return box == main_box or (mass >= max(24, main_mass * 0.035) and (overlap >= mw * 0.3 or near_main)) - - out = _blank(rgba.size) - for box, mass in comps: - if _keep(box, mass): + if box == main_box or (mass >= max(24, main_mass * 0.035) and (overlap >= mw * 0.3 or near_main)): out.alpha_composite(rgba.crop(box), (box[0], box[1])) return out @@ -314,7 +292,7 @@ def _group_component_rows(boxes: list[tuple[int, int, int, int]]) -> list[list[t if not boxes: return [] cy = lambda b: (b[1] + b[3]) / 2 # noqa: E731 - row_tol = max(12, _median(max(1, b[3] - b[1]) for b in boxes) * 0.55) + row_tol = max(12, _median_box_size(boxes)[1] * 0.55) rows: list[list[tuple[int, int, int, int]]] = [] centers: list[float] = [] for box in sorted(boxes, key=cy): @@ -334,10 +312,8 @@ def _group_component_rows(boxes: list[tuple[int, int, int, int]]) -> list[list[t def _merge_related_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]: """Merge disconnected parts of one subject (capes, tails, props) on the same row. - Merges when vertical spans overlap and the horizontal gap is tiny relative to - the component size; never bridges the larger gaps between separate poses. + Merges when vertical spans overlap and the horizontal gap is tiny relative to component size; never bridges the larger gaps between separate poses. """ - def related(a, b) -> bool: (al, at, ar, ab), (bl, bt, br, bb) = a, b v_overlap, min_h = max(0, min(ab, bb) - max(at, bt)), max(1, min(ab - at, bb - bt)) @@ -354,9 +330,8 @@ def _merge_related_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[i if used[i]: continue used[i] = True - for j in range(i + 1, len(boxes)): - if not used[j] and related(a, boxes[j]): - b = boxes[j] + for j, b in enumerate(boxes[i + 1 :], i + 1): + if not used[j] and related(a, b): a = (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])) used[j] = changed = True merged.append(a) @@ -366,7 +341,6 @@ def _merge_related_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[i def _component_crops(strip, frame_count: int, *, require_padding: bool = False) -> list | None: """Frames as connected subjects in reading order (robust to 2D grids); ``None`` when *frame_count* can't be met.""" - def attempt(source) -> list | None: subjects = _significant_subject_boxes(source, min_mass=64) if len(subjects) < frame_count: @@ -434,8 +408,7 @@ def _content_runs(profile: list[int], *, threshold: int = 2) -> list[tuple[tuple def _frame_x_ranges(strip, frame_count: int) -> list[tuple[int, int]] | None: """Per-frame ``(left, right)`` column ranges from the row's empty gutters. - Extra spans merge across the smallest gaps (a detached halo sits closer to its - body than to the next pose); fewer spans than frames → ``None`` (poses touching). + Extra spans merge across the smallest gaps (a detached halo sits closer to its body than to the next pose); fewer spans than frames → ``None``. """ runs = _content_runs(_column_profile(strip)) groups = [[l, r] for (l, r), m in runs if m >= max(m for _run, m in runs) * 0.02] if runs else [] @@ -475,8 +448,7 @@ def _validate_extracted_frames(frames: list, frame_count: int) -> None: boxes.append(bbox) if frame_count <= 1: return - med_w = max(1, _median(b[2] - b[0] for b in boxes)) - med_h = max(1, _median(b[3] - b[1] for b in boxes)) + med_w, med_h = _median_box_size(boxes) for i, (left, top, right, bottom) in enumerate(boxes): if _is_multi_pose_outlier(right - left, bottom - top, med_w, med_h): raise ValueError(f"frame {i} is a multi-pose width outlier") @@ -487,16 +459,15 @@ def extract_strip_frames( ) -> list: """Turn one generated row strip into *frame_count* frames. - Keys out the background, then isolates padded subjects (components, then equal - slots). When that fails ``components`` raises while ``auto`` salvages leniently. - *fit* centers each frame into a cell; hatching passes ``fit=False`` so + Keys out the background, then isolates padded subjects (components, then equal slots). When that fails ``components`` + raises while ``auto`` salvages leniently. *fit* centers each frame into a cell; hatching passes ``fit=False`` so :func:`normalize_cells` can register the whole pet with one shared scale. """ strip = remove_background(_load_rgba(strip), chroma_key=chroma_key) frames = _component_crops(strip, frame_count, require_padding=True) or _slot_crops(strip, frame_count, require_padding=True) - if frames is None and method == "components": - raise ValueError(f"could not segment {frame_count} padded sprites from strip") if frames is None: + if method == "components": + raise ValueError(f"could not segment {frame_count} padded sprites from strip") frames = _component_crops(strip, frame_count, require_padding=False) if frames is None: frames = _salvage_frames(strip, frame_count) @@ -506,8 +477,7 @@ def extract_strip_frames( def _salvage_frames(strip, frame_count: int) -> list: """Lenient last resort: gutter ranges (severing expected gutters if needed), else raw slots.""" - source = strip - ranges = _frame_x_ranges(source, frame_count) + source, ranges = strip, _frame_x_ranges(strip, frame_count) if ranges is None: source = _sever_expected_gutters(strip, frame_count) ranges = _frame_x_ranges(source, frame_count) @@ -526,10 +496,7 @@ def _column_profile(image) -> list[int]: def _best_shift(ref: list[int], prof: list[int], window: int) -> int: """Integer dx that best aligns *prof* onto *ref* (1-D cross-correlation; the body dominates, limbs barely move it).""" n = len(ref) - - def score(d: int) -> int: - return sum(ref[x] * prof[x - d] for x in range(max(0, d), min(n, n + d))) - + score = lambda d: sum(ref[x] * prof[x - d] for x in range(max(0, d), min(n, n + d))) # noqa: E731 return max(range(-window, window + 1), key=score) # ties → smallest dx @@ -554,9 +521,7 @@ def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_P profiles = [_column_profile(f) for f in canvas] ref = [_median(p[x] for p in profiles) for x in range(w0)] window = max(8, w0 // 5) - aligned = [ - _place(f, (w0 + 2 * window, h0), (window + _best_shift(ref, prof, window), 0)) for f, prof in zip(canvas, profiles) - ] + aligned = [_place(f, (w0 + 2 * window, h0), (window + _best_shift(ref, prof, window), 0)) for f, prof in zip(canvas, profiles)] boxes = [b for b in (a.getbbox() for a in aligned) if b] union = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)) prepared[state] = (aligned, union, _median(b[3] - b[1] for b in boxes)) @@ -603,8 +568,7 @@ def compose_atlas(frames_by_state: dict[str, list]): atlas = _blank((ATLAS_WIDTH, ATLAS_HEIGHT)) for state, row, count in ROW_SPECS: for col, frame in enumerate((frames_by_state.get(state) or [])[:count]): - cell = frame.convert("RGBA") - cell = cell if cell.size == (CELL_WIDTH, CELL_HEIGHT) else _fit_to_cell(cell) + cell = cell if (cell := frame.convert("RGBA")).size == (CELL_WIDTH, CELL_HEIGHT) else _fit_to_cell(cell) atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT)) return _clear_transparent_rgb(atlas) @@ -621,9 +585,7 @@ def validate_atlas(atlas) -> dict: def _check_atlas_cells(atlas) -> tuple[list[str], list[str], list[str]]: """Occupancy/collapse/residue checks for a correctly-sized atlas → ``(errors, warnings, filled_states)``.""" - errors: list[str] = [] - warnings: list[str] = [] - filled_states: list[str] = [] + errors, warnings, filled_states = [], [], [] cell_boxes_by_state: dict[str, list[tuple[int, int, int, int]]] = {} for state, row, count in ROW_SPECS: cells = [atlas.crop((c * CELL_WIDTH, row * CELL_HEIGHT, (c + 1) * CELL_WIDTH, (row + 1) * CELL_HEIGHT)) for c in range(count)] @@ -638,16 +600,14 @@ def _check_atlas_cells(atlas) -> tuple[list[str], list[str], list[str]]: # A valid pet must occupy the cell: one bad row can poison global # normalization and shrink every state while still passing "non-empty". all_boxes = [b for boxes in cell_boxes_by_state.values() for b in boxes] - global_med_w = _median(r - l for l, _t, r, _b in all_boxes) if all_boxes else 0 - global_med_h = _median(b - t for _l, t, _r, b in all_boxes) if all_boxes else 0 + global_med_w, global_med_h = _median_box_size(all_boxes) if all_boxes else (0, 0) if all_boxes and global_med_h < max(56, round(CELL_HEIGHT * 0.28)): errors.append(f"atlas sprites are too small after normalization (median frame height {global_med_h}px)") for state, boxes in cell_boxes_by_state.items(): if len(boxes) <= 1: continue - widths, heights = [r - l for l, _t, r, _b in boxes], [b - t for _l, t, _r, b in boxes] - med_w, med_h = max(1, _median(widths)), max(1, _median(heights)) - if _is_multi_pose_outlier(max(widths), max(heights), med_w, med_h): + med_w, med_h = _median_box_size(boxes) + if _is_multi_pose_outlier(max(r - l for l, _t, r, _b in boxes), max(b - t for _l, t, _r, b in boxes), med_w, med_h): errors.append(f"state '{state}' contains a multi-pose frame outlier") # Per-state collapse guard: one malformed row must not pass on the # strength of the healthy ones. diff --git a/agent/pet/render.py b/agent/pet/render.py index c8cb8139c0..7c36ac4041 100644 --- a/agent/pet/render.py +++ b/agent/pet/render.py @@ -18,7 +18,7 @@ import sys import zlib from dataclasses import KW_ONLY, dataclass from functools import lru_cache -from itertools import groupby +from itertools import groupby, takewhile from pathlib import Path from agent.pet.constants import DEFAULT_SCALE, FRAME_H, FRAME_W, FRAMES_PER_STATE, PetState, state_row_index @@ -43,10 +43,8 @@ def detect_terminal_graphics() -> str: # half-blocks; users who enabled them can pin display.pet.render_mode. if term_program == "vscode": return "unicode" - if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term or term_program == "ghostty": - return "kitty" - if _is_wezterm(): # speaks kitty and iterm; kitty has richer placement - return "kitty" + if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term or term_program == "ghostty" or _is_wezterm(): + return "kitty" # WezTerm speaks kitty and iterm; kitty has richer placement if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"): return "iterm" if term_program == "mintty" or "foot" in term or "mlterm" in term or "sixel" in term: @@ -63,11 +61,9 @@ def resolve_mode(configured: str | None, *, stream=None) -> str: """Effective render mode from ``display.pet.render_mode`` + env; ``off`` when not a TTY.""" mode = (configured or "auto").strip().lower() mode = mode if mode in RENDER_MODES else "auto" - if mode == "off": - return "off" stream = stream or sys.stdout try: - if not (hasattr(stream, "isatty") and stream.isatty()): + if mode == "off" or not (hasattr(stream, "isatty") and stream.isatty()): return "off" except (ValueError, OSError): return "off" @@ -93,13 +89,8 @@ def _raw_frames(sheet_path: str, state_value: str, frame_w: int, frame_h: int, f cols, rows = max(1, sheet.width // frame_w), max(1, sheet.height // frame_h) # Clamp to the sheet: some pets ship fewer rows than the taxonomy reserves. top = min(state_row_index(state_value, rows) * frame_h, max(0, sheet.height - frame_h)) - frames = [] - for i in range(min(frames_per_state, cols)): - frame = sheet.crop((i * frame_w, top, (i + 1) * frame_w, top + frame_h)) - if _frame_is_blank(frame): - break - frames.append(frame) - return tuple(frames) + crops = (sheet.crop((i * frame_w, top, (i + 1) * frame_w, top + frame_h)) for i in range(min(frames_per_state, cols))) + return tuple(takewhile(lambda f: not _frame_is_blank(f), crops)) 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 () @@ -129,27 +120,23 @@ def _png_b64(frame) -> str: return base64.standard_b64encode(buf.getvalue()).decode("ascii") -def _crop_frames_to_alpha_union(frames): - """Crop every frame to the union opaque bbox: kitty paints transparent margins too, so an untrimmed pet looks small and adrift.""" - boxes = [b for b in (f.getchannel("A").getbbox() for f in frames) if b] - if not boxes: - return frames - union = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)) - return [f.crop(union) for f in frames] - - # Nominal terminal cell size in pixels. kitty fits an image to its cell rectangle # preserving aspect, so a frame that isn't a whole cell multiple rounds up, clipping # the bottom row ("clipped feet"); snapping to an exact multiple avoids that. _CELL_W, _CELL_H = 8, 16 -def _snap_frames_to_cell_grid(frames): - """Resize frames so width/height are exact multiples of the cell box (all frames share the union-cropped size).""" - if not frames: - return frames +def _fit_frames_to_cell_grid(frames): + """Crop *frames* (non-empty) to their union opaque bbox, then resize so width/height are exact cell-box multiples. + + kitty paints transparent margins too, so an untrimmed pet looks small and adrift. + """ from PIL import Image + boxes = [b for b in (f.getchannel("A").getbbox() for f in frames) if b] + if boxes: + union = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)) + frames = [f.crop(union) for f in frames] w, h = frames[0].size target = (max(1, round(w / _CELL_W)) * _CELL_W, max(1, round(h / _CELL_H)) * _CELL_H) return frames if (w, h) == target else [f.resize(target, Image.LANCZOS) for f in frames] @@ -162,10 +149,10 @@ def _kitty_apc(ctrl: str, data: str) -> str: return "".join(f"\x1b_G{ctrl + ',' if i == 0 else ''}m={int(i != last)};{piece}\x1b\\" for i, piece in enumerate(pieces)) -def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: +def _encode_kitty(frame) -> str: """kitty transmit+display at the cursor; ``c``/``r`` pin the cell box so frames overwrite each other.""" - ctrl = "f=100,a=T,q=2" + (f",c={cell_cols}" if cell_cols else "") + (f",r={cell_rows}" if cell_rows else "") - return _kitty_apc(ctrl, _png_b64(frame)) + cols, rows = _cell_box(frame) + return _kitty_apc(f"f=100,a=T,q=2,c={cols},r={rows}", _png_b64(frame)) # kitty Unicode placeholders: Ink owns the screen and measures every cell, so it @@ -218,19 +205,11 @@ def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str: return _kitty_apc(f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2", _png_b64(frame)) -def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: - """iTerm2 inline image (OSC 1337 File).""" +def _encode_iterm(frame) -> str: + """iTerm2 inline image (OSC 1337 File) pinned to the frame's cell box.""" payload = _png_b64(frame) - args = ["inline=1", f"size={len(payload)}", "preserveAspectRatio=1"] - args += [f"width={cell_cols}"] if cell_cols else [] - args += [f"height={cell_rows}"] if cell_rows else [] - return f"\x1b]1337;File={';'.join(args)}:{payload}\x07" - - -def _sixel_runs(chars: list[str]) -> str: - """Run-length encode one sixel band line (``!`` for runs longer than 3).""" - runs = ((ch, len(list(group))) for ch, group in groupby(chars)) - return "".join("!%d%s" % (n, ch) if n > 3 else ch * n for ch, n in runs) + cols, rows = _cell_box(frame) + return f"\x1b]1337;File=inline=1;size={len(payload)};preserveAspectRatio=1;width={cols};height={rows}:{payload}\x07" def _encode_sixel(frame) -> str: @@ -252,12 +231,12 @@ def _encode_sixel(frame) -> str: ys = range(band, min(band + 6, h)) for color_idx in used: chars = [chr(63 + sum(1 << (y - band) for y in ys if alpha[x, y] > 32 and px[x, y] == color_idx)) for x in range(w)] - out.append("#%d" % color_idx + _sixel_runs(chars) + "$") # carriage return within band + runs = ((ch, len(list(group))) for ch, group in groupby(chars)) # run-length: ``!`` for runs longer than 3 + out.append("#%d" % color_idx + "".join("!%d%s" % (n, ch) if n > 3 else ch * n for ch, n in runs) + "$") # ``$`` = band CR out.append("-") # next band return "".join(out) + "\x1b\\" -_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]] @@ -277,10 +256,9 @@ def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]: def _encode_unicode(frame, *, target_cols: int) -> str: """Truecolor ANSI half-blocks (one char = 2 vertical pixels).""" - def cell(top, bottom) -> str: (tr, tg, tb, ta), (br, bg, bb, ba) = top, bottom - return "\x1b[0m " if ta < 32 and ba < 32 else f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}" + return "\x1b[0m " if ta < 32 and ba < 32 else f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m▀" return "\n".join("".join(cell(t, b) for t, b in row) + "\x1b[0m" for row in _downscale_cells(frame, target_cols=target_cols)) @@ -288,12 +266,14 @@ def _encode_unicode(frame, *, target_cols: int) -> str: def _cell_box(frame) -> tuple[int, int]: """Terminal cell box (~8×16 px per cell) for a scaled frame. - kitty stretches the image to fill ``c``×``r`` cells, so this must track the - scaled pixel size, not a native-aspect column count (that upscales small pets). + kitty stretches the image to fill ``c``×``r`` cells, so track the scaled pixel size, not a native-aspect column count (that upscales small pets). """ return max(1, frame.width // _CELL_W), max(1, frame.height // _CELL_H) +_ENCODERS = {"kitty": _encode_kitty, "iterm": _encode_iterm, "sixel": _encode_sixel} + + @dataclass(eq=False) class PetRenderer: """Holds a pet's spritesheet and yields encoded frames per (state, index); decoded frames are cached.""" @@ -309,8 +289,7 @@ class PetRenderer: def __post_init__(self) -> None: self.spritesheet = str(self.spritesheet) - if self.mode not in RENDER_MODES: - self.mode = "unicode" + self.mode = self.mode if self.mode in RENDER_MODES else "unicode" @property def available(self) -> bool: @@ -327,35 +306,25 @@ class PetRenderer: def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]: """One frame as a half-block cell grid for Ink's native color props; ``[]`` when unavailable.""" frames = self._frames(state) - if not frames: - return [] - return _downscale_cells(frames[index % len(frames)], target_cols=cols or self.unicode_cols) + return _downscale_cells(frames[index % len(frames)], target_cols=cols or self.unicode_cols) if frames else [] def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None: """kitty placeholder payload ``{cols, rows, placeholder, frames}`` (transmit escapes + static text grid); ``None`` if no frames.""" - frames = self._frames(state) - if not frames: + if not (frames := self._frames(state)): return None - frames = _snap_frames_to_cell_grid(_crop_frames_to_alpha_union(frames)) + frames = _fit_frames_to_cell_grid(frames) cols, rows = _cell_box(frames[0]) encoded = [_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames] return {"cols": cols, "rows": rows, "placeholder": kitty_placeholder_rows(cols, rows), "frames": encoded} def frame(self, state: PetState | str, index: int) -> str: """Encoded escape string for one frame (``index`` taken modulo the frame count), or ``""``.""" - if self.mode == "off": - return "" - frames = self._frames(state) - if not frames: + if self.mode == "off" or not (frames := self._frames(state)): return "" frame = frames[index % len(frames)] try: - if self.mode in ("kitty", "iterm"): - cell_cols, cell_rows = _cell_box(frame) - encode = _encode_kitty if self.mode == "kitty" else _encode_iterm - return encode(frame, cell_cols=cell_cols, cell_rows=cell_rows) - if self.mode == "sixel": - return _encode_sixel(frame) + if self.mode in _ENCODERS: + return _ENCODERS[self.mode](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) diff --git a/agent/pet/store.py b/agent/pet/store.py index b0c6092afd..afc4f0f7fb 100644 --- a/agent/pet/store.py +++ b/agent/pet/store.py @@ -55,18 +55,19 @@ class InstalledPet: return self.created_by == "generator" -def pets_dir() -> Path: - """Return the profile-scoped pets directory (created on demand).""" - path = get_hermes_home() / "pets" +def _ensure_dir(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) return path +def pets_dir() -> Path: + """Return the profile-scoped pets directory (created on demand).""" + return _ensure_dir(get_hermes_home() / "pets") + + def _thumb_path(slug: str) -> Path: """Cached thumbnail for *slug* (lives OUTSIDE the pet dir, under ``pets/.thumbs/``).""" - path = pets_dir() / ".thumbs" - path.mkdir(parents=True, exist_ok=True) - return path / f"{slug}.png" + return _ensure_dir(pets_dir() / ".thumbs") / f"{slug}.png" def _read_pet_json(directory: Path) -> dict: @@ -97,25 +98,16 @@ def _safe_slug(slug: str) -> str: def load_pet(slug: str) -> InstalledPet | None: """Return the :class:`InstalledPet` for *slug*, or ``None`` if absent.""" - slug = _safe_slug(slug) - directory = pets_dir() / slug - if not slug or not directory.is_dir(): + if not (slug := _safe_slug(slug)) or not (directory := pets_dir() / slug).is_dir(): return None meta = _read_pet_json(directory) - return InstalledPet( - slug=slug, - display_name=str(meta.get("displayName", "") or slug), - description=str(meta.get("description", "") or ""), - directory=directory, - spritesheet=_resolve_spritesheet(directory, meta), - created_by=str(meta.get("createdBy", "") or ""), - ) + name, desc, by = (str(meta.get(k, "") or d) for k, d in (("displayName", slug), ("description", ""), ("createdBy", ""))) + return InstalledPet(slug, name, desc, directory, _resolve_spritesheet(directory, meta), by) def _usable_pet(slug: str, error: str = "") -> InstalledPet | None: """:func:`load_pet` result only when its spritesheet is on disk; raises :class:`PetStoreError` *error* if given.""" - pet = load_pet(slug) - if pet and pet.exists: + if (pet := load_pet(slug)) and pet.exists: return pet if error: raise PetStoreError(error) @@ -124,16 +116,14 @@ def _usable_pet(slug: str, error: str = "") -> InstalledPet | None: def installed_pets() -> list[InstalledPet]: """Return every installed pet (dirs containing a usable spritesheet).""" - pets = (_usable_pet(child.name) for child in sorted(pets_dir().iterdir()) if child.is_dir()) - return [pet for pet in pets if pet] + return [pet for child in sorted(pets_dir().iterdir()) if child.is_dir() and (pet := _usable_pet(child.name))] def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None: """The configured slug (``display.pet.slug``) if installed, else the first pet alphabetically.""" if configured_slug and (pet := _usable_pet(configured_slug.strip())): return pet - pets = installed_pets() - return pets[0] if pets else None + return next(iter(installed_pets()), None) def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet: @@ -153,8 +143,7 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI if not _is_petdex_host(entry.spritesheet_url): raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'") - directory = pets_dir() / slug - directory.mkdir(parents=True, exist_ok=True) + directory = _ensure_dir(pets_dir() / slug) sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp" sprite_path = directory / f"spritesheet{sprite_ext}" _download(entry.spritesheet_url, sprite_path, timeout=timeout) @@ -163,8 +152,7 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI meta: dict = {} if entry.pet_json_url and _is_petdex_host(entry.pet_json_url): try: - data = _http_get(entry.pet_json_url, timeout).json() - meta = data if isinstance(data, dict) else {} + meta = data if isinstance(data := _http_get(entry.pet_json_url, timeout).json(), dict) else {} except Exception as exc: # noqa: BLE001 - non-fatal, fall back below logger.debug("pet.json fetch failed for %s: %s", slug, exc) meta = meta or {"id": slug, "displayName": entry.display_name, "description": ""} @@ -177,8 +165,7 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI def slugify(name: str) -> str: """Lowercase, hyphenate, and strip a display name into a filesystem slug.""" - slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-") - return slug or "pet" + return re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-") or "pet" def unique_slug(name: str) -> str: @@ -188,28 +175,21 @@ def unique_slug(name: str) -> str: return next(slug for slug in candidates if not (pets_dir() / slug).exists()) -def _write_spritesheet(source, dest: Path) -> None: - """Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*.""" - if isinstance(source, (bytes, bytearray)): - dest.write_bytes(bytes(source)) - return - from agent.pet.generate.atlas import _load_rgba - - _load_rgba(source).save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True) - - def register_local_pet(spritesheet, *, slug: str, display_name: str = "", description: str = "") -> InstalledPet: - """Write a locally-generated pet (PIL image, WebP/PNG bytes, or path) into the store. + """Write a locally-generated pet (PIL image, WebP/PNG bytes, or path) into the store as lossless WebP. - Appears in :func:`installed_pets` immediately; :func:`install_pet` returns on-disk - pets before consulting the manifest, so no manifest entry is needed. + Appears in :func:`installed_pets` immediately (no manifest entry needed). """ slug = slugify(slug) - directory = pets_dir() / slug - directory.mkdir(parents=True, exist_ok=True) + directory = _ensure_dir(pets_dir() / slug) sprite_path = directory / "spritesheet.webp" try: - _write_spritesheet(spritesheet, sprite_path) + if isinstance(spritesheet, (bytes, bytearray)): + sprite_path.write_bytes(bytes(spritesheet)) + else: + from agent.pet.generate.atlas import _load_rgba + + _load_rgba(spritesheet).save(sprite_path, format="WEBP", lossless=True, quality=100, method=6, exact=True) except Exception as exc: # noqa: BLE001 - normalize to one error type raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc meta = {"id": slug, "displayName": display_name or slug, "description": description or "", "spritesheetPath": sprite_path.name} @@ -241,20 +221,6 @@ def _is_petdex_host(url: str) -> bool: return host == "petdex.dev" or host.endswith(".petdex.dev") -def _thumb_source_bytes(slug: str, source_url: str, timeout: float) -> bytes | None: - """Spritesheet bytes for a thumbnail: the installed sheet, else a petdex-hosted *source_url*.""" - pet = _usable_pet(slug) - if pet: - with contextlib.suppress(OSError): - return pet.spritesheet.read_bytes() - if source_url and _is_petdex_host(source_url): - try: - return _http_get(source_url, timeout).content - except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder - logger.debug("thumb fetch failed for %s: %s", slug, exc) - return None - - def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None: """Small idle-frame (top-left cell) PNG for *slug*, cached on disk; ``None`` on any failure. @@ -262,14 +228,20 @@ def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> never fetches arbitrary client URLs). Server-side so it rides the authenticated gateway as a same-origin data URL, sidestepping CSP/hotlink limits. """ - slug = slug.strip() - if not slug: + if not (slug := slug.strip()): return None - cache = _thumb_path(slug) - if cache.is_file(): + if (cache := _thumb_path(slug)).is_file(): with contextlib.suppress(OSError): return cache.read_bytes() - sheet_bytes = _thumb_source_bytes(slug, source_url, timeout) + sheet_bytes = None + if pet := _usable_pet(slug): + with contextlib.suppress(OSError): + sheet_bytes = pet.spritesheet.read_bytes() + if sheet_bytes is None and source_url and _is_petdex_host(source_url): + try: + sheet_bytes = _http_get(source_url, timeout).content + except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder + logger.debug("thumb fetch failed for %s: %s", slug, exc) if not sheet_bytes: return None try: @@ -296,19 +268,16 @@ def remove_pet(slug: str) -> bool: # Drop the cached thumb too or a later pet reusing this slug shows the stale one. with contextlib.suppress(OSError): _thumb_path(slug).unlink(missing_ok=True) - directory = pets_dir() / slug - if not directory.is_dir(): + if not (directory := pets_dir() / slug).is_dir(): return False shutil.rmtree(directory, ignore_errors=True) return not directory.exists() def rename_pet(slug: str, display_name: str) -> str | None: - """Rename a pet's ``displayName`` AND realign its slug/dir to match. + """Rename a pet's ``displayName`` AND move its dir/thumb to ``slugify(name)`` when that's a free, different slug. - Generated pets hatch under a provisional slug; naming makes that the identity. - The dir (and thumb) moves to ``slugify(name)`` when that's a free, different - slug. Returns the resulting slug, or ``None`` on failure. + Generated pets hatch under a provisional slug; naming makes that the identity. Returns the resulting slug, or ``None``. """ slug = _safe_slug(slug) display_name = (display_name or "").strip() @@ -321,16 +290,12 @@ def rename_pet(slug: str, display_name: str) -> str | None: new_slug = slug desired = slugify(display_name) if desired and desired != slug and not (pets_dir() / desired).exists(): - try: + with contextlib.suppress(OSError): # keep the provisional slug if the move fails directory.rename(pets_dir() / desired) - except OSError: - pass # keep the provisional slug if the move fails - else: with contextlib.suppress(OSError): _thumb_path(slug).rename(_thumb_path(desired)) directory = pets_dir() / desired new_slug = meta["id"] = desired - try: _write_pet_json(directory, meta) except OSError: