refactor(agent): pet atlas/render/store pass-2 structural cut (median-box helper, encoder dispatch, inlined single-use helpers)
This commit is contained in:
+43
-83
@@ -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.
|
||||
|
||||
+36
-67
@@ -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 (``!<n><ch>`` 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: ``!<n><ch>`` 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)
|
||||
|
||||
+40
-75
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user