diff --git a/skills/.curated/codex-pet-generator/LICENSE.txt b/skills/.curated/codex-pet-generator/LICENSE.txt new file mode 100644 index 00000000..a5ffc278 --- /dev/null +++ b/skills/.curated/codex-pet-generator/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniil Makeev (wyddy7) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/.curated/codex-pet-generator/SKILL.md b/skills/.curated/codex-pet-generator/SKILL.md new file mode 100644 index 00000000..07f8d46a --- /dev/null +++ b/skills/.curated/codex-pet-generator/SKILL.md @@ -0,0 +1,177 @@ +--- +name: "codex-pet-generator" +description: "Create Codex desktop pets from character references by defining row semantics, generating tiny-UI-readable loops, approving or rejecting rows, following slicing guidance, packing a 1536x1872 spritesheet with the bundled packer, validating it, and installing under ~/.codex/pets." +license: "MIT" +--- + +# Codex Pet Generator + +Use this skill when the user wants to create, revise, validate, package, or install a custom Codex desktop pet. + +## Core Principles + +- Animation direction first, image generation second, deterministic packing third. +- Optimize for tiny UI readability, not for how pretty a large strip looks in isolation. +- Treat each row as one loopable state machine, not eight poster poses. +- Keep canon anchors: one approved scale reference, one seated-body reference, one standing-body reference, and one prop-language reference. +- Every generated row must be marked `approved` or `unapproved` before it can advance. + +## Codex Format + +Custom pets live at: + +```text +~/.codex/pets// + pet.json + spritesheet.png|webp +``` + +Required spritesheet: + +- `1536x1872` +- `8x9` grid +- `192x208` per frame +- transparent final background +- PNG or WebP + +Required `pet.json`: + +```json +{ + "id": "", + "displayName": "", + "description": "", + "spritesheetPath": "spritesheet.webp" +} +``` + +`spritesheetPath` is resolved relative to the `pet.json` file. Use the same `` as the parent directory name. + +Row order is fixed: + +1. `idle` +2. `running-right` +3. `running-left` +4. `waving` +5. `jumping` +6. `failed` +7. `waiting` +8. `running` +9. `review` + +For row-by-row semantics, common failure modes, and tiny-UI design guidance, read [references/row-semantics.md](references/row-semantics.md) before prompting. + +## Workflow + +1. Gather references and write a character bible in plain language. +2. Define all 9 row semantics before generating anything. +3. Pick canon anchors: + - one approved overall scale reference + - one approved seated reference + - one approved standing reference + - one approved prop-language reference +4. Generate one row strip at a time, or individual frames. Do not generate a full 72-frame sheet as the final asset. +5. Review each row with the acceptance ladder in [references/acceptance-checklist.md](references/acceptance-checklist.md). +6. Mark every candidate `approved` or `unapproved`. Only approved rows advance. +7. Extract individual frames into per-row directories. The skill provides written slicing guidance in [references/packing-and-slicing.md](references/packing-and-slicing.md), but the actual extraction is manual — either export frames directly from the generator, or cut strips with your image tool of choice. The bundled packer assumes per-frame inputs and does not slice strips for you. +8. Pack with `scripts/pack_codex_pet.py`. +9. Validate with `scripts/validate_codex_pet.py`. +10. Install into `~/.codex/pets/`. +11. When iterating in the Codex UI, use a new pet id such as `-v2` to avoid cached spritesheets. + +## Prompting Rules + +Prompts should explicitly state: + +- one row equals one loopable action +- frame `8` must hand off cleanly to frame `1` +- frames are sequential animation states, not unrelated poster poses +- same character, same camera, same scale across the row +- exactly two arms and exactly two legs +- no cropped body parts +- no props outside the row-specific action +- final target is a tiny desktop pet sprite, so readability beats nuance + +If a row is hard to read: + +- do not merely “polish” it +- redesign the motion around bolder state changes +- prefer binary readable mechanics such as `open/closed`, `upright/flat`, `connect/fail`, `tension/release`, or `in/out` + +High-risk guidance: + +- Standing rows are high-risk for vertical stretch. Always anchor them to an approved compact standing canon. +- Directional rows often collapse into generic running. Reject them if the intended action semantics vanish. +- Gestures often fail when they rely on finger nuance alone. If needed, redesign them into chunkier hand states. +- Failure rows often become generic sadness. Prefer unmistakable failure silhouettes or prop logic. +- Jump rows often become athletic. Heavy toy-like characters usually need compression, shock, or flop logic instead. + +## Background and Alpha Rules + +- Prefer generating with a removable flat background when possible. +- If you need chroma key, use flat `#00ff00`, no shadows, no gradients, and no green on the character. +- Remove the background locally before packing. +- Zero the RGB channels of fully transparent pixels before final install artifacts if your cleanup leaves preview garbage. + +## Approval Gate + +Do not carry ambiguous rows forward. + +- `approved`: chosen final candidate for that row +- `unapproved`: rejected, superseded, or still exploratory + +Only approved rows should be sliced, packed, validated, and installed. + +The acceptance ladder and rejection patterns live in [references/acceptance-checklist.md](references/acceptance-checklist.md). + +## Slicing and Packing Rules + +Slicing is **manual** — the skill provides guidance, not an automated slicer. The packer takes per-frame inputs and assembles them into the final sheet using one uniform scale per row. + +- Do not assume a generated row strip is cut into 8 equal visual columns. +- Do not slice approved strips into equal widths unless the spacing is known to be uniform. +- Prefer per-frame export or gap-aware manual slicing based on real empty space between poses. +- The packer uses one scale per row, not one scale per frame. +- Keep at least `8-12px` of padding from cell edges (packer default is `10`, hard cap is half-cell-minus-one). +- Keep exactly one directory per row index inside the rows root. Multiple matching directories (`01-idle-explore`, `01-idle-final`) cause the packer to abort instead of silently picking one — this protects the approval gate. + +See [references/packing-and-slicing.md](references/packing-and-slicing.md) for manual slicing workflow guidance. + +## Bundled Scripts + +Pack approved rows into a final sheet: + +```bash +python scripts/pack_codex_pet.py +``` + +Validate the final sheet: + +```bash +python scripts/validate_codex_pet.py +``` + +If `pet.json` sits next to the spritesheet, the validator picks it up automatically and checks the install contract (`id`, `displayName`, `description`, `spritesheetPath`). To validate against a `pet.json` in a different location use `--pet-json `. To skip pet.json entirely use `--no-pet-json`. + +Both scripts require `Pillow`. Install with `pip install -r requirements.txt`. Run from the bundle root, or use absolute paths. + +Interpretation rule: + +- validator `errors` are hard blockers — wrong dimensions, empty frames, missing alpha, fully-opaque sheet, malformed `pet.json` +- validator `warnings` still require visual review — fully-opaque single frame, edge contact, width/height drift, `spritesheetPath` mismatch +- width or height drift warnings may reflect real performance changes, but edge-contact warnings usually mean crop risk + +## Install Notes + +- Install final assets under `~/.codex/pets//` +- Keep `pet.json` and the spritesheet in the same folder +- Prefer a new id when testing revisions in the UI + +If the UI looks broken, first suspect: + +- bad row semantics +- bad slicing +- bad alpha cleanup +- bad packing + +Do not assume the generator is the only failure point. diff --git a/skills/.curated/codex-pet-generator/agents/openai.yaml b/skills/.curated/codex-pet-generator/agents/openai.yaml new file mode 100644 index 00000000..5882dd4b --- /dev/null +++ b/skills/.curated/codex-pet-generator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Codex Pet Generator" + short_description: "Build Codex pets with safe row workflows" + default_prompt: "Use $codex-pet-generator to turn these character refs into a validated Codex desktop pet." diff --git a/skills/.curated/codex-pet-generator/references/acceptance-checklist.md b/skills/.curated/codex-pet-generator/references/acceptance-checklist.md new file mode 100644 index 00000000..f21c46fd --- /dev/null +++ b/skills/.curated/codex-pet-generator/references/acceptance-checklist.md @@ -0,0 +1,124 @@ +# Acceptance Checklist + +Use this file when reviewing generated candidates and deciding what advances to packing. + +## Rule Zero + +Do not debate polish until the row passes semantics. + +If the row does not read correctly at tiny UI size, reject it before discussing detail cleanup. + +## Acceptance Ladder + +Review rows in this order: + +1. Semantic read +2. Loop closure +3. Scale match +4. Anatomy +5. Prop clarity +6. Packing safety + +## 1. Semantic Read + +Ask: + +- does this row clearly express the intended action? +- does it still read when mentally shrunk down? +- is the motion built around strong state changes instead of vague nuance? + +Reject if: + +- the row tells the wrong story +- the row feels like eight poster poses +- the row only works when viewed large + +## 2. Loop Closure + +Ask: + +- does frame `8` hand off naturally to frame `1`? +- is this a cycle rather than a one-way process shot? + +Reject if: + +- the loop visibly snaps +- the motion is narrative instead of cyclical + +## 3. Scale Match + +Ask: + +- does this still feel like the same physical figurine? +- does it match the approved scale canon for seated or standing rows? + +Standing-row rule: + +- standing rows are high-risk by default +- reject rows that grow taller, longer-legged, or more human-like than the approved compact canon + +Reject if: + +- perceived leg length increases +- overall toy height increases +- seated and standing rows no longer look like the same object + +## 4. Anatomy + +Ask: + +- exactly two arms? +- exactly two legs? +- no duplicate hands, feet, or merged limbs? +- no cropped essential body parts? + +Reject if: + +- anatomy errors exist +- the “fix” would rely on cropping them away + +## 5. Prop Clarity + +Ask: + +- does the prop help the action read? +- is the prop readable at tiny size? +- is the prop design simple enough? + +Reject if: + +- the prop blends into the costume +- the prop becomes a blob +- the row requires detail that will vanish in the app + +## 6. Packing Safety + +Ask: + +- will this row survive slicing? +- do limbs, hair, or props extend into neighboring visual gaps? +- is the spacing uniform enough for simple slicing, or does it need gap-aware extraction? + +Reject or rework if: + +- important parts sit on would-be cut lines +- the strip depends on equal-width slicing when the spacing is visibly uneven + +## Approved vs Unapproved + +Every candidate must end up in one bucket: + +- `approved`: final chosen candidate for that row +- `unapproved`: rejected, exploratory, superseded, or unresolved + +Only approved rows should be packed. + +## Common Reject Patterns + +- tiny-UI unreadable gesture +- standing row stretch +- directional row collapsing into generic running +- jump row becoming too athletic or floaty +- failure row becoming generic sadness +- prop clutter overpowering the character +- frame spacing that will cut off limbs during slicing diff --git a/skills/.curated/codex-pet-generator/references/packing-and-slicing.md b/skills/.curated/codex-pet-generator/references/packing-and-slicing.md new file mode 100644 index 00000000..37e6dfe4 --- /dev/null +++ b/skills/.curated/codex-pet-generator/references/packing-and-slicing.md @@ -0,0 +1,100 @@ +# Packing and Slicing + +Use this file before extracting frames from approved strips and before building the final spritesheet. + +## The Main Rule + +Do not assume a generated strip is safely sliceable into 8 equal columns. + +Many good-looking row strips still fail at packing because: + +- spacing between poses is uneven +- limbs or props cross the visual midpoint between frames +- the background is still opaque white when the packer expects alpha + +## Preferred Extraction Order + +1. Best: export individual frames directly +2. Good: slice a strip using known uniform geometry +3. Acceptable: gap-aware slicing using real empty space between poses +4. Avoid: blind equal-width slicing of a strip with uneven spacing + +## Before Slicing + +- remove the background so alpha is real +- confirm that empty space between characters is actually empty +- inspect the strip for legs, hands, cables, hair, or props near likely cut lines + +If the source background is white: + +- remove only border-connected white background +- do not key out interior whites that belong to the design + +## Gap-Aware Slicing + +When a strip is not uniform: + +- inspect the alpha or occupancy per x-column +- look for local valleys near expected frame boundaries +- cut on real gaps, not on equal fractions alone + +This is especially important for: + +- directional rows with stretched limbs +- rows with long cables or tools +- flop or jump rows with large pose width changes + +## Transparent Pixel Cleanup + +After background removal: + +- zero RGB in fully transparent pixels if preview garbage remains + +This does not change the visible sprite, but it prevents ugly viewer artifacts in some previews. + +## Packing Rules + +When building the final `1536x1872` sheet: + +- keep frames in fixed `192x208` cells +- use one scale per row, not per frame +- use one baseline per row +- keep `8-12px` of padding from the cell edge + +Do not “solve” bad anatomy or bad slicing by shrinking everything until it fits. + +## Validator Interpretation + +Run the validator after packing. + +Hard blockers (errors): + +- wrong sheet dimensions +- empty frame +- spritesheet has no transparent pixels (background was not removed) +- malformed `pet.json` — missing required fields, wrong types, absolute or traversing `spritesheetPath` + +Warnings that still need visual review: + +- a single frame is fully opaque +- edge contact +- high row width drift +- high row height drift +- `spritesheetPath` in `pet.json` does not match the sheet under validation +- `id` in `pet.json` differs from the parent directory name + +Interpretation: + +- edge contact usually means crop risk +- width/height drift can be acceptable if the action truly changes shape, but still needs visual confirmation +- a fully-opaque frame is almost always a missing alpha-cleanup step, not a creative choice + +## Install Discipline + +Only install: + +- approved rows +- correctly sliced frames +- validated final sheet + +If the installed pet looks broken, suspect slicing before suspecting Codex. diff --git a/skills/.curated/codex-pet-generator/references/row-semantics.md b/skills/.curated/codex-pet-generator/references/row-semantics.md new file mode 100644 index 00000000..31cce6e4 --- /dev/null +++ b/skills/.curated/codex-pet-generator/references/row-semantics.md @@ -0,0 +1,194 @@ +# Row Semantics + +Use this file when defining the 9 animation rows before prompting image generation. + +Global rule: + +- each row must read at tiny UI size +- each row must be one loopable action +- if a row only works because of subtle face acting or tiny finger motion, redesign it + +## Shared Design Heuristics + +- Lead with silhouette and 2-3 strong state changes. +- Favor readable mechanics over nuanced acting. +- If a gesture is weak, convert it to a more binary motion rather than adding noise. +- For compact toy characters, “heavier” motion often reads better than athletic motion. + +## Row 1: `idle` + +What it is: + +- a calm loop with one attitude change +- breathing, blink, eye slide, side-eye, head tilt, or similar small beats + +What it is not: + +- eight unrelated cute expressions +- a noisy acting showcase + +Tiny-UI mechanic: + +- one small facial or head-state change that loops cleanly + +High-risk failure: + +- extra legs in seated poses +- turning the row into generic “cute idle” + +## Row 2: `running-right` + +What it is: + +- a cyclical rightward motion loop +- either true travel or a deliberate forced-motion concept + +What it is not: + +- a one-way dash or process shot +- a pose series that never closes back to frame 1 + +Tiny-UI mechanic: + +- big body lean, clear leg separation, readable motion direction + +High-risk failure: + +- collapsing into generic run when the intended action was something else + +## Row 3: `running-left` + +What it is: + +- the leftward counterpart of row 2 + +What it is not: + +- a lazy mirror if the character concept requires different body logic + +Tiny-UI mechanic: + +- same as row 2, but still readable when the UI flips or alternates states + +High-risk failure: + +- scale or hair logic drifting away from the rightward row + +## Row 4: `waving` + +What it is: + +- a communication row +- wave, beckon, salute, point, or other character-specific callout + +What it is not: + +- automatically a friendly hello +- subtle finger animation that disappears when the pet is small + +Tiny-UI mechanic: + +- use a gesture with strong hand-state contrast +- if needed, prefer `open hand -> closed hand` or `raise -> pull` over micro finger curls + +High-risk failure: + +- the gesture reads as nothing at small size +- the gesture says the wrong thing, for example “stop” instead of “come here” + +## Row 5: `jumping` + +What it is: + +- a loop with anticipation, lift, apex, descent, impact, and recovery + +What it is not: + +- automatically an athletic or celebratory jump + +Tiny-UI mechanic: + +- make compression and impact readable +- for heavy characters, a low hop, forced shock, or flat flop may read better than a tall leap + +High-risk failure: + +- the row becomes too tall, floaty, or gymnast-like + +## Row 6: `failed` + +What it is: + +- an unmistakable “this did not work” loop + +What it is not: + +- generic sadness +- random bug vibes without a readable failure mechanic + +Tiny-UI mechanic: + +- the failure must be obvious by silhouette or prop logic +- examples: mismatch, jam, tangle, stuck state, short deadpan recoil + +High-risk failure: + +- it becomes emotionally vague instead of instantly readable + +## Row 7: `waiting` + +What it is: + +- impatient idle +- foot tap, weight shift, look-around, narrowed eyes, or similar annoyance loop + +What it is not: + +- just another neutral standing pose + +Tiny-UI mechanic: + +- one readable impatience mechanic + +High-risk failure: + +- standing scale stretches vertically +- the row loses its character and becomes generic idle + +## Row 8: `running` + +What it is: + +- an active energetic loop distinct from rows 2 and 3 +- can be sprint, repeated work, or repeated busy motion if that fits the character + +What it is not: + +- visually redundant with the directional rows + +Tiny-UI mechanic: + +- pick one active repeated action and make the rhythm obvious + +High-risk failure: + +- row meaning overlaps too strongly with travel rows + +## Row 9: `review` + +What it is: + +- reading, inspecting, evaluating, or thinking + +What it is not: + +- a detailed prop showcase that only works when zoomed in + +Tiny-UI mechanic: + +- use very simple readable props +- keep book, glasses, device, or tool shapes chunky and clear + +High-risk failure: + +- prop clutter overwhelms the character diff --git a/skills/.curated/codex-pet-generator/requirements.txt b/skills/.curated/codex-pet-generator/requirements.txt new file mode 100644 index 00000000..c7e63dd8 --- /dev/null +++ b/skills/.curated/codex-pet-generator/requirements.txt @@ -0,0 +1 @@ +Pillow>=10.0.0 diff --git a/skills/.curated/codex-pet-generator/scripts/pack_codex_pet.py b/skills/.curated/codex-pet-generator/scripts/pack_codex_pet.py new file mode 100644 index 00000000..8e44c89b --- /dev/null +++ b/skills/.curated/codex-pet-generator/scripts/pack_codex_pet.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Pack validated per-row frames into a Codex-compatible spritesheet. + +Expected input layout: + + rows/ + 01-idle/ + 01.png + ... + 08.png + 02-running-right/ + ... + +Each row directory must contain exactly 8 RGBA-compatible images. The packer +uses one uniform scale per row, keeps a shared baseline inside that row, and +centers frames horizontally within each 192x208 cell. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image + + +SHEET_WIDTH = 1536 +SHEET_HEIGHT = 1872 +COLS = 8 +ROWS = 9 +FRAME_WIDTH = SHEET_WIDTH // COLS +FRAME_HEIGHT = SHEET_HEIGHT // ROWS +ROW_NAMES = [ + "idle", + "running-right", + "running-left", + "waving", + "jumping", + "failed", + "waiting", + "running", + "review", +] + + +@dataclass +class FrameAsset: + path: Path + image: Image.Image + bbox: tuple[int, int, int, int] + + +def load_rgba(path: Path) -> Image.Image: + return Image.open(path).convert("RGBA") + + +def alpha_bbox(image: Image.Image) -> tuple[int, int, int, int]: + bbox = image.getchannel("A").getbbox() + if bbox is None: + raise ValueError(f"{path_label(image)} has empty alpha") + return bbox + + +def path_label(image: Image.Image) -> str: + filename = image.info.get("filename") + return filename if filename else "" + + +def discover_row_dir(rows_root: Path, row_index: int, row_name: str) -> Path: + expected_prefix = f"{row_index + 1:02d}-" + candidates = sorted( + path + for path in rows_root.iterdir() + if path.is_dir() and path.name.startswith(expected_prefix) + ) + if not candidates: + fallback = rows_root / row_name + if fallback.is_dir(): + return fallback + raise FileNotFoundError( + f"missing row directory for row {row_index + 1} ({row_name})" + ) + if len(candidates) > 1: + names = ", ".join(path.name for path in candidates) + raise ValueError( + f"row {row_index + 1} ({row_name}) is ambiguous — multiple directories " + f"match prefix {expected_prefix!r}: {names}. " + f"Keep only the approved variant in {rows_root} and move the rest aside." + ) + return candidates[0] + + +def load_row_frames(row_dir: Path) -> list[FrameAsset]: + frame_paths = sorted( + [ + path + for path in row_dir.iterdir() + if path.is_file() and path.suffix.lower() in {".png", ".webp"} + ] + ) + if len(frame_paths) != COLS: + raise ValueError( + f"{row_dir} must contain exactly {COLS} frame images, found {len(frame_paths)}" + ) + + frames: list[FrameAsset] = [] + for path in frame_paths: + image = load_rgba(path) + image.info["filename"] = str(path) + bbox = alpha_bbox(image) + frames.append(FrameAsset(path=path, image=image, bbox=bbox)) + return frames + + +def scale_frame( + image: Image.Image, bbox: tuple[int, int, int, int], scale: float +) -> tuple[Image.Image, tuple[int, int, int, int]]: + x0, y0, x1, y1 = bbox + cropped = image.crop((x0, y0, x1, y1)) + width = max(1, round(cropped.width * scale)) + height = max(1, round(cropped.height * scale)) + resized = cropped.resize((width, height), Image.Resampling.LANCZOS) + return resized, (x0, y0, x1, y1) + + +def pack_sheet(rows_root: Path, output_path: Path, padding: int) -> None: + canvas = Image.new("RGBA", (SHEET_WIDTH, SHEET_HEIGHT), (0, 0, 0, 0)) + + for row_index, row_name in enumerate(ROW_NAMES): + row_dir = discover_row_dir(rows_root, row_index, row_name) + frames = load_row_frames(row_dir) + + widths = [bbox[2] - bbox[0] for bbox in (frame.bbox for frame in frames)] + heights = [bbox[3] - bbox[1] for bbox in (frame.bbox for frame in frames)] + max_width = max(widths) + max_height = max(heights) + available_width = FRAME_WIDTH - (padding * 2) + available_height = FRAME_HEIGHT - (padding * 2) + scale = min(available_width / max_width, available_height / max_height, 1.0) + + scaled_sizes: list[tuple[int, int]] = [] + scaled_bottoms: list[int] = [] + for frame in frames: + bbox = frame.bbox + scaled_sizes.append( + ( + max(1, round((bbox[2] - bbox[0]) * scale)), + max(1, round((bbox[3] - bbox[1]) * scale)), + ) + ) + scaled_bottoms.append(max(1, round(bbox[3] * scale))) + + baseline = min(FRAME_HEIGHT - padding, max(scaled_bottoms) + padding) + row_top = row_index * FRAME_HEIGHT + + for col_index, frame in enumerate(frames): + sprite, _ = scale_frame(frame.image, frame.bbox, scale) + x = col_index * FRAME_WIDTH + (FRAME_WIDTH - sprite.width) // 2 + y = row_top + baseline - sprite.height + canvas.alpha_composite(sprite, (x, y)) + + output_path.parent.mkdir(parents=True, exist_ok=True) + suffix = output_path.suffix.lower() + if suffix == ".png": + canvas.save(output_path, lossless=True) + elif suffix == ".webp": + canvas.save(output_path, format="WEBP", lossless=True, quality=100) + else: + raise ValueError( + f"output extension {suffix or ''!r} is not supported; " + f"use .png or .webp" + ) + + +MAX_PADDING = min(FRAME_WIDTH, FRAME_HEIGHT) // 2 - 1 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("rows_root", type=Path, help="Directory containing 9 row folders") + parser.add_argument("output", type=Path, help="Output spritesheet path (.png or .webp)") + parser.add_argument( + "--padding", + type=int, + default=10, + help=f"Edge padding inside each cell, in pixels (recommended 8-12, max {MAX_PADDING})", + ) + args = parser.parse_args() + if not 0 <= args.padding <= MAX_PADDING: + parser.error( + f"--padding must be between 0 and {MAX_PADDING}, got {args.padding}" + ) + return args + + +def main() -> int: + args = parse_args() + pack_sheet(args.rows_root, args.output, args.padding) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/.curated/codex-pet-generator/scripts/validate_codex_pet.py b/skills/.curated/codex-pet-generator/scripts/validate_codex_pet.py new file mode 100755 index 00000000..723bd4d0 --- /dev/null +++ b/skills/.curated/codex-pet-generator/scripts/validate_codex_pet.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Validate the mechanical parts of a Codex pet spritesheet and pet.json. + +This cannot prove anatomy is correct. It catches the boring failures that make +the Codex UI drift: wrong dimensions, empty frames, missing alpha, opaque +backgrounds, edge contact, row-scale instability, and a malformed pet.json. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from PIL import Image + + +SHEET_WIDTH = 1536 +SHEET_HEIGHT = 1872 +COLS = 8 +ROWS = 9 +FRAME_WIDTH = SHEET_WIDTH // COLS +FRAME_HEIGHT = SHEET_HEIGHT // ROWS + +PET_JSON_REQUIRED_FIELDS = ("id", "displayName", "description", "spritesheetPath") + + +def alpha_bbox(frame: Image.Image) -> tuple[int, int, int, int] | None: + return frame.getchannel("A").getbbox() + + +def alpha_extrema(frame: Image.Image) -> tuple[int, int]: + return frame.getchannel("A").getextrema() + + +def validate_pet_json( + pet_json_path: Path, spritesheet_path: Path +) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + + if not pet_json_path.is_file(): + errors.append(f"pet.json not found at {pet_json_path}") + return errors, warnings + + try: + data = json.loads(pet_json_path.read_text()) + except json.JSONDecodeError as exc: + errors.append(f"pet.json: invalid JSON ({exc})") + return errors, warnings + + if not isinstance(data, dict): + errors.append("pet.json: top-level value must be an object") + return errors, warnings + + for field in PET_JSON_REQUIRED_FIELDS: + if field not in data: + errors.append(f"pet.json: missing required field {field!r}") + elif not isinstance(data[field], str) or not data[field].strip(): + errors.append(f"pet.json: field {field!r} must be a non-empty string") + + sheet_field = data.get("spritesheetPath") + if isinstance(sheet_field, str) and sheet_field: + as_path = Path(sheet_field) + pet_dir = pet_json_path.parent.resolve() + if as_path.is_absolute(): + errors.append( + f"pet.json: spritesheetPath must be a relative filename inside the " + f"pet directory, not an absolute path ({sheet_field!r})" + ) + elif as_path.name != sheet_field or ".." in as_path.parts: + errors.append( + f"pet.json: spritesheetPath must be a single filename inside the " + f"pet directory, not a nested or traversing path ({sheet_field!r})" + ) + else: + resolved = (pet_dir / sheet_field).resolve() + try: + resolved.relative_to(pet_dir) + except ValueError: + errors.append( + f"pet.json: spritesheetPath resolves outside the pet directory " + f"({resolved})" + ) + if resolved != spritesheet_path.resolve(): + warnings.append( + f"pet.json: spritesheetPath points to {resolved}, " + f"but the sheet under validation is {spritesheet_path.resolve()}" + ) + + pet_id = data.get("id") + parent_dir = pet_json_path.parent.name + if isinstance(pet_id, str) and pet_id and parent_dir and pet_id != parent_dir: + warnings.append( + f"pet.json: id {pet_id!r} differs from parent directory {parent_dir!r}; " + f"Codex resolves pets by the directory name under ~/.codex/pets/" + ) + + return errors, warnings + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("spritesheet", type=Path) + parser.add_argument("--edge-padding", type=int, default=6) + parser.add_argument("--max-row-height-drift", type=float, default=0.28) + parser.add_argument( + "--pet-json", + type=Path, + default=None, + help="Path to pet.json. Defaults to /pet.json if present.", + ) + parser.add_argument( + "--no-pet-json", + action="store_true", + help="Skip pet.json validation entirely.", + ) + args = parser.parse_args() + + image = Image.open(args.spritesheet).convert("RGBA") + errors: list[str] = [] + warnings: list[str] = [] + + if image.size != (SHEET_WIDTH, SHEET_HEIGHT): + errors.append( + f"expected {SHEET_WIDTH}x{SHEET_HEIGHT}, got {image.width}x{image.height}" + ) + + sheet_alpha_min, sheet_alpha_max = alpha_extrema(image) + if sheet_alpha_min == 255 and sheet_alpha_max == 255: + errors.append( + "spritesheet has no transparent pixels; background was not removed" + ) + + for row in range(ROWS): + heights: list[int] = [] + widths: list[int] = [] + for col in range(COLS): + left = col * FRAME_WIDTH + top = row * FRAME_HEIGHT + frame = image.crop((left, top, left + FRAME_WIDTH, top + FRAME_HEIGHT)) + bbox = alpha_bbox(frame) + label = f"row {row + 1}, col {col + 1}" + if bbox is None: + errors.append(f"{label}: empty frame") + continue + + x0, y0, x1, y1 = bbox + widths.append(x1 - x0) + heights.append(y1 - y0) + + frame_alpha_min, _ = alpha_extrema(frame) + if frame_alpha_min == 255: + warnings.append( + f"{label}: frame is fully opaque; either the cell is filled " + f"edge-to-edge or the background was not removed" + ) + + if ( + x0 < args.edge_padding + or y0 < args.edge_padding + or FRAME_WIDTH - x1 < args.edge_padding + or FRAME_HEIGHT - y1 < args.edge_padding + ): + warnings.append(f"{label}: sprite is close to the cell edge {bbox}") + + if heights: + min_h = min(heights) + max_h = max(heights) + if min_h > 0 and (max_h - min_h) / min_h > args.max_row_height_drift: + warnings.append( + f"row {row + 1}: frame height drift is high " + f"(min={min_h}, max={max_h})" + ) + + if widths: + min_w = min(widths) + max_w = max(widths) + if min_w > 0 and (max_w - min_w) / min_w > 0.5: + warnings.append( + f"row {row + 1}: frame width drift is high " + f"(min={min_w}, max={max_w})" + ) + + if not args.no_pet_json: + pet_json_path = args.pet_json or (args.spritesheet.parent / "pet.json") + if pet_json_path.exists() or args.pet_json is not None: + pj_errors, pj_warnings = validate_pet_json(pet_json_path, args.spritesheet) + errors.extend(pj_errors) + warnings.extend(pj_warnings) + else: + warnings.append( + f"pet.json not found next to spritesheet; pass --pet-json to validate " + f"the install contract or --no-pet-json to suppress this warning" + ) + + if errors: + print("ERRORS") + for error in errors: + print(f"- {error}") + if warnings: + print("WARNINGS") + for warning in warnings: + print(f"- {warning}") + + if not errors and not warnings: + print("OK") + + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main())