
AgentBroPet
- 8 installs
- 1 repo stars
- Updated May 31, 2026
- shirenchuang/agentbro-pet
Helps with ai & agent building tasks.
About
AgentBroPet is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- AgentBroPet
- AI & Agent Building
- AI-coding skill
AgentBroPet by the numbers
- 8 all-time installs (skills.sh)
- Ranked #12,335 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/shirenchuang/agentbro-pet --skill agentbropetAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 1 |
| Last updated | May 31, 2026 |
| Repository | shirenchuang/agentbro-pet ↗ |
What it does
Helps with ai & agent building tasks.
Files
AgentBroPet
Overview
Create an AgentBro/Codex-compatible animated pet from a concept, brand cue, company/prospect name, one or more reference images, or any combination of those inputs.
This skill is a backend-flexible version of hatch-pet: it keeps the deterministic scripts for prompts, layout guides, frame extraction, atlas composition, validation, visual QA, and packaging, but it does not require Codex's $imagegen path. Any agent can use it if it can:
- run the bundled Python scripts
- read
imagegen-jobs.json - generate or obtain the requested base and row-strip images
- copy selected outputs into the run folder's
decoded/paths
User-facing inputs are optional. If the user omits a pet name, infer one from the concept, brand, company, or reference filenames; if that is not possible, choose a short friendly name. If the user omits a description, infer one from the concept or references.
Output Contract
The deterministic pipeline targets the same app pet format used by AgentBro/Codex:
- atlas: PNG or WebP
- dimensions:
1536x1872 - grid: 8 columns x 9 rows
- cell size:
192x208 - background: transparent
- unused cells: fully transparent
- package:
pet.jsonplusspritesheet.webp
The 9 animation states are fixed:
idle, running-right, running-left, waving, jumping, failed, waiting, running, reviewKeep imagegen-jobs.json as the manifest filename. In AgentBroPet it means "visual generation jobs"; it is not limited to Codex $imagegen.
Backend Discovery
Before generating base art, row strips, or repair rows, discover the best visual generation backend available in the current environment. Prefer explicit user choice over auto-detection.
Use this order:
1. User-named backend or model. If the user asks for a specific image skill, model, service, or local command, use it when available. 2. Available image-generation skill. If the current agent environment exposes a visual generation skill or tool, use it. Examples include $imagegen, $nano-banana-pro, a browser-attached image tool, an MCP image tool, or a project-local image-generation skill. Read that skill's own instructions before use. 3. Known local CLI/API backend. If an image CLI is available and credentials are configured, use it only when the user has allowed that backend. For OpenAI image CLI/API, gpt-image-2 is the normal default; use true/native transparency models such as gpt-image-1.5 only when the user explicitly asks for or confirms that path. 4. Manual external backend. If no callable image backend is available, prepare the run and prompts, then ask the user or another agent/model to generate each listed job. Continue once generated PNGs are available as local paths.
Do not silently switch to a weaker, paid, remote, or credential-requiring backend. Ask first when the backend requires new credentials, costs, network setup, model downgrades, or manual user work.
For Codex specifically, $imagegen is a good default if present. It is not required. If using $imagegen, follow its installed skill at:
${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/SKILL.mdIf using another image skill, follow that skill's storage, model, and transparency rules instead of copying $imagegen-specific assumptions.
Visual Backend Contract
Every backend must satisfy the same contract:
- read the job's
prompt_file - attach every listed
input_imagesitem when the backend supports references - generate exactly one selected image for the job
- for
base, generate one centered full-body pet on a flat removable background or true transparent background - for row jobs, generate one horizontal animation strip with the exact requested frame count
- avoid visible guide marks, grids, labels, text, UI, scenery, shadows, and detached effects
- return or record an absolute
selected_sourcepath - let the parent agent copy that selected source into the job's
output_path - mark the job complete in
imagegen-jobs.jsononly after the decoded copy exists
Layout guides are internal construction references. Do not show them in user-facing progress updates, do not choose them as selected_source, and never copy them into decoded/. If guide boxes, blue safety frames, center lines, or labels appear in a generated row strip, treat that row as failed and regenerate it.
Only the base job may be prompt-only. Every row-strip job must use the input images listed in imagegen-jobs.json, including the canonical base reference created after the base output is copied.
When a backend cannot attach references, stop and tell the user which row cannot be generated reliably. Do not fake row strips with local drawing, tiling, transforms, or code-generated placeholders.
Brand Discovery
If the user provides only a brand, company, product, or prospect name rather than a concrete avatar description or reference image, run lightweight brand discovery before preparing the pet run. Use web search when available, preferring official sources such as the brand site, product pages, docs, about pages, press pages, or brand pages.
Skip discovery when the user already provides a concrete mascot/avatar description or reference images, unless the user explicitly asks for brand research.
Discovery output should be a compact markdown brief covering:
- identity/category
- audience/use context
- visual system
- personality/tone
- product/domain motifs
- mascot translation cues
- avoidances
- evidence/confidence
End the brief with a compact Generation handoff section containing exactly:
brand_name=<canonical brand/product name>
brand_brief=<one sentence, max 45 words, covering palette/tone/domain motifs/personality>
avatar_seed=<short mascot-safe visual idea, no logo copying>
avoid=<short comma-separated list>
brand_sources=<comma-separated source URLs>Do not copy logos, readable marks, UI screenshots, slogans, or text. Clearly label mascot guidance that is inferred rather than directly sourced.
Pass the brief path to prepare_pet_run.py as --brand-discovery-file, pass avatar_seed through --pet-notes when the user did not provide a better avatar description, and pass brand_name, brand_brief, and repeated --brand-source values.
If web search is unavailable and the user gave only a bare brand name, ask for brand cues before generating.
Pet-Safe Styles
Default style is auto: infer the pet's style from the user's prompt and references, then preserve that style across every row. If the user names a style, honor it.
Supported style presets include:
pixel, plush, clay, sticker, flat-vector, 3d-toy, painterly, brand-inspired, autoAny style is acceptable when it remains pet-safe:
- compact whole-body silhouette readable inside a
192x208cell - consistent face, proportions, material, palette, and props across all rows
- clean removable chroma-key background or true transparent background
- details large enough to read at pet size
- no text, labels, UI, or readable logos unless the user explicitly provides approved reference art and asks for them
Non-pixel styles are first-class. Plush, clay, sticker, vector, 3D toy, painterly mascot, ink, and brand-inspired looks should be accepted when they satisfy the atlas and readability constraints.
Transparency And Effects
Rows are processed into transparent 192x208 cells, so every generated pixel must either belong to the pet sprite or be cleanly removable background. Prefer pose, expression, and silhouette changes over decorative effects.
Allowed effects must satisfy all of these:
- state-relevant and useful for explaining the animation
- physically attached to, touching, or overlapping the pet silhouette
- inside the same frame slot as the pet
- opaque and hard-edged enough for clean extraction
- non-chroma-key colored
- small enough to remain readable at
192x208
Avoid by default:
- wave marks, motion arcs, speed lines, action streaks, afterimages, blur, or smears
- detached stars, loose sparkles, floating punctuation/icons, falling tear drops, separated smoke clouds, or loose dust
- cast shadows, contact shadows, drop shadows, oval floor shadows, floor patches, landing marks, impact bursts, glow, halo, aura, or soft transparent effects
- text, labels, frame numbers, visible grids, guide marks, speech bubbles, UI panels, code snippets, checkerboard transparency, white backgrounds, black backgrounds, or scenery
- chroma-key-adjacent colors in the pet, prop, effects, highlights, or shadows
- stray pixels, disconnected outline bits, speckle/noise, cropped body parts, overlapping poses, or poses crossing into neighboring frame slots
State-specific guidance:
idle: subtle breathing, blink, head/body bob, or tiny material sway. Must have visible micro-variation, but no waving, walking, running, jumping, working, reviewing, large gestures, item interactions, or new props.waving: show the wave through limb pose only. No wave marks, arcs, sparkles, symbols, or floating effects.jumping: show vertical motion through body position only. No shadows, dust, landing marks, impact bursts, bounce pads, or floor cues.failed: attached tears, smoke puffs, or stars are allowed if they obey the effects rules. No red X marks, floating symbols, detached smoke/stars, or separate tear droplets.waiting: show an expectant asking pose for approval, help, or user input. Keep distinct from idle and review.running: show active task work, processing, thinking, scanning, typing, or focused effort. Do not show literal foot-running, jogging, sprinting, directional travel, speed lines, dust, shadows, trails, or detached motion effects.review: show focus through lean, blink, eyes, head tilt, or paw/hand position. Avoid new props unless already part of the base identity.running-rightandrunning-left: show directional drag movement through body, limb, and prop movement only.running-rightfaces/travels right;running-leftfaces/travels left. Cadence must visibly alternate across the loop.
Visible Progress Plan
For every pet run, keep a visible checklist. Create it before starting, keep one step active at a time, and update it as each step finishes.
Use this checklist, replacing <Pet> with the pet's name or your pet:
1. Getting <Pet> ready. 2. Imagining <Pet>'s main look. 3. Picturing <Pet>'s poses. 4. Hatching <Pet>.
Only mark a step complete when the real file, image, or decision exists. If this is a repair run, start from the first relevant step instead of restarting the whole checklist.
Default Workflow
1. Prepare a run folder and job manifest:
SKILL_DIR="${CODEX_HOME:-$HOME/.codex}/skills/agentbro-pet"
python "$SKILL_DIR/scripts/prepare_pet_run.py" \
--pet-name "<Name>" \
--description "<one sentence>" \
--reference /absolute/path/to/reference.png \
--output-dir /absolute/path/to/run \
--pet-notes "<stable pet description>" \
--brand-discovery-file /absolute/path/to/brand-discovery.md \
--brand-name "<optional researched brand name>" \
--brand-brief "<optional compact researched brand cue sentence>" \
--brand-source "https://example.com/source" \
--style-preset auto \
--style-notes "<optional freeform style notes>" \
--forceFor text-only requests, pass the concept through --pet-notes and omit --reference. For brand-only requests, run brand discovery first.
2. Inspect imagegen-jobs.json for the next ready jobs:
jq '.jobs[] | {id, kind, status, depends_on, prompt_file, retry_prompt_file, input_images, output_path, derivation_policy}' /absolute/path/to/run/imagegen-jobs.jsonA job is ready when its status is not complete and every id in depends_on is already complete.
3. Generate visual jobs through the selected backend:
- Generate and copy
basefirst. - Generate and copy
idleandrunning-rightnext as the identity and gait check. - Inspect
running-right; mirrorrunning-leftonly when visual identity, prop placement, markings, lighting, and direction semantics remain correct. - Generate
running-leftnormally when mirroring would change meaning or identity. - Generate the remaining rows using every input image listed for each job.
For each ready visual job, use the prompt file and all listed input images with their role labels. The backend may be a skill, model API, CLI, local UI workflow, or manual external model. The parent agent records the selected source path in the manifest.
4. After selecting a generated output for a job, copy it into the decoded output path and mark the job complete. For base, also create the canonical identity reference:
RUN_DIR=/absolute/path/to/run
JOB_ID=<job-id>
SOURCE=/absolute/path/to/generated-output.png
OUTPUT_REL=$(jq -r --arg id "$JOB_ID" '.jobs[] | select(.id == $id) | .output_path' "$RUN_DIR/imagegen-jobs.json")
mkdir -p "$(dirname "$RUN_DIR/$OUTPUT_REL")"
cp "$SOURCE" "$RUN_DIR/$OUTPUT_REL"if [ "$JOB_ID" = "base" ]; then mkdir -p "$RUN_DIR/references"; cp "$RUN_DIR/$OUTPUT_REL" "$RUN_DIR/references/canonical-base.png"; fiUPDATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TMP_MANIFEST=$(mktemp)
jq --arg id "$JOB_ID" --arg source "$SOURCE" --arg at "$UPDATED_AT" '(.jobs[] | select(.id == $id)) += {status: "complete", source_path: $source, completed_at: $at}' "$RUN_DIR/imagegen-jobs.json" > "$TMP_MANIFEST"
mv "$TMP_MANIFEST" "$RUN_DIR/imagegen-jobs.json"5. Derive running-left only when it is visually safe:
python "$SKILL_DIR/scripts/derive_running_left_from_running_right.py" \
--run-dir /absolute/path/to/run \
--confirm-appropriate-mirror \
--decision-note "<why mirroring preserves this pet's identity>"That script mirrors each generated frame slot in place so the leftward row preserves the rightward row's temporal order. Do not replace it with a whole-strip mirror that reverses animation timing.
6. When all jobs are complete, run the image-processing scripts:
RUN_DIR=/absolute/path/to/run
mkdir -p "$RUN_DIR/final" "$RUN_DIR/qa"python "$SKILL_DIR/scripts/extract_strip_frames.py" \
--decoded-dir "$RUN_DIR/decoded" \
--output-dir "$RUN_DIR/frames" \
--states all \
--method autopython "$SKILL_DIR/scripts/inspect_frames.py" \
--frames-root "$RUN_DIR/frames" \
--json-out "$RUN_DIR/qa/review.json" \
--require-componentspython "$SKILL_DIR/scripts/compose_atlas.py" \
--frames-root "$RUN_DIR/frames" \
--output "$RUN_DIR/final/spritesheet.png" \
--webp-output "$RUN_DIR/final/spritesheet.webp"python "$SKILL_DIR/scripts/validate_atlas.py" \
"$RUN_DIR/final/spritesheet.webp" \
--json-out "$RUN_DIR/final/validation.json"python "$SKILL_DIR/scripts/make_contact_sheet.py" \
"$RUN_DIR/final/spritesheet.webp" \
--output "$RUN_DIR/qa/contact-sheet.png"python "$SKILL_DIR/scripts/render_animation_previews.py" \
--frames-root "$RUN_DIR/frames" \
--output-dir "$RUN_DIR/qa/previews"If preview GIFs show size popping or baseline jumps caused by extraction, and the original row strip itself had stable scale and placement, rerun extraction with --method stable-slots, then rerun inspection, atlas composition, validation, contact sheet generation, and previews:
python "$SKILL_DIR/scripts/extract_strip_frames.py" \
--decoded-dir "$RUN_DIR/decoded" \
--output-dir "$RUN_DIR/frames" \
--states all \
--method stable-slotspython "$SKILL_DIR/scripts/inspect_frames.py" \
--frames-root "$RUN_DIR/frames" \
--json-out "$RUN_DIR/qa/review.json" \
--require-components \
--allow-stable-slotsUse stable-slots only as a QA-driven correction.
7. Package the output.
For Codex-compatible local pets:
RUN_DIR=/absolute/path/to/run
PET_ID=$(jq -r '.pet_id' "$RUN_DIR/pet_request.json")
DISPLAY_NAME=$(jq -r '.display_name' "$RUN_DIR/pet_request.json")
DESCRIPTION=$(jq -r '.description' "$RUN_DIR/pet_request.json")
PET_DIR="${CODEX_HOME:-$HOME/.codex}/pets/$PET_ID"
mkdir -p "$PET_DIR"
cp "$RUN_DIR/final/spritesheet.webp" "$PET_DIR/spritesheet.webp"
jq -n --arg id "$PET_ID" --arg displayName "$DISPLAY_NAME" --arg description "$DESCRIPTION" '{id: $id, displayName: $displayName, description: $description, spritesheetPath: "spritesheet.webp"}' > "$PET_DIR/pet.json"For AgentBro or generic project output, put the same two files under a user-selected project folder such as:
output/agentbro-pets/<pet-id>/
pet.json
spritesheet.webpWrite qa/run-summary.json after packaging:
jq -n --arg run_dir "$RUN_DIR" --arg spritesheet "$RUN_DIR/final/spritesheet.webp" --arg validation "$RUN_DIR/final/validation.json" --arg contact_sheet "$RUN_DIR/qa/contact-sheet.png" --arg review "$RUN_DIR/qa/review.json" --arg package "$PET_DIR" '{ok: true, run_dir: $run_dir, spritesheet: $spritesheet, validation: $validation, contact_sheet: $contact_sheet, review: $review, package: $package}' > "$RUN_DIR/qa/run-summary.json"Visual QA
After deterministic image processing, inspect qa/contact-sheet.png and qa/previews/*.gif before accepting the pet. Deterministic validation is necessary but not sufficient.
Block acceptance if any row changes species/body type, face, markings, palette, material, prop design, style, prop side unexpectedly, or overall silhouette. Motion previews must also reject unintended size popping, reversed or stagnant directional cadence, wrong facing direction, and idle loops that are technically different but visually inert.
If the environment supports visual QA workers or subagents, use them. Otherwise, inspect the contact sheet and preview GIFs directly. The QA result must cover all 9 rows.
Repair Workflow
If frame inspection or visual QA fails, read qa/review.json, regenerate the smallest failing scope, copy the replacement row into the same decoded output path, and keep that job marked complete with the new source_path and completed_at. Repair the failed row, not the whole sheet.
For identity repairs, use the canonical base image, original references, contact sheet, and exact row failure note as grounding context.
For extraction-induced motion popping, do not regenerate imagery first. If the source strip already preserves row-level scale and baseline, rerun the deterministic pipeline with --method stable-slots, inspect with --allow-stable-slots, then re-check the preview GIFs. Regenerate the row only when the original strip itself is clipped, unstable, or semantically wrong.
Rules
- Keep image generation backend-flexible; do not hard-require Codex
$imagegen. - Prefer auto-discovered image-generation skills/models already available to the user's current agent.
- Ask before using a backend that requires credentials, network setup, paid API calls, model downgrades, or manual user work.
- Keep reference images attached/visible whenever the chosen backend supports references.
- Attach the row's
references/layout-guides/<state>.pngimage to every row-strip job when the backend supports layout references, and do not accept outputs that copy guide pixels. - Generate every normal visual job through an image backend: base plus all row strips that are not explicitly approved
running-leftmirror derivations. - Treat only the base job as eligible for prompt-only generation; every row job must attach its listed grounding images when supported.
- Generate
running-rightbefore deciding whetherrunning-leftcan be mirrored. - When
running-leftis mirrored, preserve frame order and timing semantics through the deterministic script. - Do not derive or reuse
waiting,running,failed,review,jumping, orwavingfrom another state. - Never substitute locally drawn, tiled, transformed, or code-generated row strips for missing backend outputs.
- Only mark a visual job complete after its selected output has been copied into the decoded output path.
- Use the chroma key stored in
pet_request.json; do not force a fixed green screen. - Treat visual identity or style drift as a blocker even when
qa/review.jsonandfinal/validation.jsonhave no errors. - Treat
qa/review.jsonerrors as blockers. Warnings require visual review.
Acceptance Criteria
- Final atlas is PNG or WebP,
1536x1872, transparent-capable, and based on192x208cells. - Used cells are non-empty and unused cells are fully transparent.
- Atlas follows the row/frame counts in
references/animation-rows.md. - Contact sheet and per-row motion previews have been produced and visually inspected.
qa/review.jsonhas no errors.- Row-by-row review confirms the animation cycles are complete enough for AgentBro/Codex app use.
- Motion previews do not show unintended size popping, reversed directional cadence, or wrong row semantics.
- Non-pixel styles are accepted when readable at pet size and consistent across rows.
pet.jsonandspritesheet.webpare packaged together.
interface:
display_name: "AgentBroPet"
short_description: "Create backend-flexible app pets"
default_prompt: "Use $AgentBroPet to create an AgentBro-compatible animated pet from a concept, brand, or reference image."
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf of
any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don\'t include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
AgentBroPet
中文
AgentBroPet 是一个面向 AgentBro 和 OpenAI Codex Desktop 宠物体系的 Codex Skill。
它可以把一个角色概念、品牌线索、参考图或已有生成图,制作成可在 AgentBro / Codex 中使用的动态宠物包:
pet.json
spritesheet.webp生成结果遵循 AgentBro 宠物市场 使用的同一套格式:透明背景、8 x 9 动画图集、每格 192x208,并包含 AgentBro/Codex 标准状态。
相关链接
- AgentBro 开源项目:github.com/shirenchuang/agentbro
- 作者开源主页:github.com/shirenchuang
- AgentBro 宠物市场:agentbro.net/pets
- 当前 Skill 仓库:github.com/shirenchuang/agentbro-pet
宠物市场
在 AgentBro 宠物市场里可以浏览、安装、分享社区宠物:
!AgentBro pet market desktop view
!AgentBro pet market full page
这个 Skill 做什么
AgentBroPet 保留了 hatch-pet 的确定性宠物图集流程,但不再强绑定 Codex 原生 $imagegen。
它会优先寻找当前 Agent 环境里可用的图像生成能力,例如:
- Codex
$imagegen - 其他已安装的生图 Skill
- OpenAI 图像 CLI/API,例如
gpt-image-2 - Nano Banana / Gemini / Flux / ComfyUI / Midjourney 等外部工作流
- 手动外部后端:由另一个 Agent 或模型生成 PNG,再把本地路径交回流程
核心思路是:生图后端可以替换,但图集组装、验证和 QA 保持确定性。
安装这个 Skill
把仓库克隆到 Codex skills 目录:
mkdir -p ~/.codex/skills
git clone https://github.com/shirenchuang/agentbro-pet.git ~/.codex/skills/agentbro-pet然后在 Codex 里调用:
$AgentBroPet create a Teemo-inspired scout pet for AgentBro非 Codex Agent 也可以使用这个目录作为工作流包:只需要能运行脚本、读取 imagegen-jobs.json、生成需要的图片,并把选中的输出复制到 decoded/ 路径。
用 npx 安装宠物市场里的宠物
AgentBro 宠物市场由 `abpets` CLI 驱动。你不需要全局安装,直接用 npx:
# 推荐:使用完整作者/宠物名
npx abpets install shirenchuang/teemo
# 如果 slug 不冲突,也可以只写宠物名
npx abpets install teemoabpets 会把宠物同时安装到:
~/.codex/pets/<slug>/
~/.agentbro/pets/<slug>/所以同一个宠物可以被 Codex Desktop 和 AgentBro 里的 Claude Code、Codex CLI、Cursor、GitHub Copilot、Cline、Gemini CLI 等 Agent 使用。
常用命令:
# 搜索市场宠物
npx abpets search
npx abpets search teemo
# 查看本机已安装宠物
npx abpets list
# 卸载宠物
npx abpets uninstall teemo
# 登录并提交自己制作的宠物
npx abpets login
npx abpets submit ~/.codex/pets/my-pet要求:Node.js 18 或更高版本。
输出格式
最终宠物包包含:
<pet-id>/
├── pet.json
└── spritesheet.webp图集规格:
1536x1872- 8 列 x 9 行
- 每格
192x208 - 透明背景
- 未使用格子必须完全透明
- WebP 或 PNG 图集
动画行:
0 idle
1 running-right
2 running-left
3 waving
4 jumping
5 failed
6 waiting
7 running
8 review基础工作流
准备一次宠物生成任务:
SKILL_DIR="$HOME/.codex/skills/agentbro-pet"
python3 "$SKILL_DIR/scripts/prepare_pet_run.py" \
--pet-name "My Pet" \
--pet-notes "a tiny friendly coding companion" \
--output-dir ./output/agentbro-pet/my-pet \
--style-preset auto \
--force查看待生成图片任务:
jq '.jobs[] | {id, status, depends_on, prompt_file, input_images, output_path}' \
./output/agentbro-pet/my-pet/imagegen-jobs.json使用当前可用的图像后端生成每个 ready job,把选中的结果复制到 job 的 output_path,再在 imagegen-jobs.json 中标记完成。
所有图片任务完成后,运行确定性图集流程:
RUN_DIR=./output/agentbro-pet/my-pet
python3 "$SKILL_DIR/scripts/extract_strip_frames.py" \
--decoded-dir "$RUN_DIR/decoded" \
--output-dir "$RUN_DIR/frames" \
--states all \
--method auto
python3 "$SKILL_DIR/scripts/inspect_frames.py" \
--frames-root "$RUN_DIR/frames" \
--json-out "$RUN_DIR/qa/review.json" \
--require-components
python3 "$SKILL_DIR/scripts/compose_atlas.py" \
--frames-root "$RUN_DIR/frames" \
--output "$RUN_DIR/final/spritesheet.png" \
--webp-output "$RUN_DIR/final/spritesheet.webp"
python3 "$SKILL_DIR/scripts/validate_atlas.py" \
"$RUN_DIR/final/spritesheet.webp" \
--json-out "$RUN_DIR/final/validation.json"
python3 "$SKILL_DIR/scripts/make_contact_sheet.py" \
"$RUN_DIR/final/spritesheet.webp" \
--output "$RUN_DIR/qa/contact-sheet.png"
python3 "$SKILL_DIR/scripts/render_animation_previews.py" \
--frames-root "$RUN_DIR/frames" \
--output-dir "$RUN_DIR/qa/previews"后端契约
每个图像后端都必须:
- 读取每个 job 的 prompt 文件
- 在支持参考图时使用 job 中列出的
input_images - 为 base 或 row strip 生成一个选中的 PNG
- 返回本地
selected_source路径 - 不得把 layout guide 当作最终输出
- 不得把蓝色安全框、中心线、标签或引导网格画进最终 row
- 保持所有行中的宠物身份一致
imagegen-jobs.json 保留历史命名,但在 AgentBroPet 中它表示「视觉生成任务」,并不绑定 Codex $imagegen。
质量检查
不要接受未通过以下检查的宠物:
qa/review.json没有错误final/validation.json通过- 已视觉检查
qa/contact-sheet.png - 已视觉检查
qa/previews/*.gif - 所有行保持相同宠物身份、风格、色板、轮廓和道具
- 方向行动画朝向正确
- idle 不是视觉静止
- 没有引导线、分离特效、白色格子背景、裁切 sprite 或明显尺寸跳动
English
AgentBroPet is a Codex skill for creating animated pets that work with AgentBro and OpenAI Codex Desktop.
It turns a concept, brand cue, reference image, or existing generated artwork into an app-ready pet package:
pet.json
spritesheet.webpGenerated pets follow the same format used by the AgentBro Pet Market: a transparent 8 x 9 animation atlas with 192x208 cells and the standard AgentBro/Codex states.
Links
- AgentBro open-source project: github.com/shirenchuang/agentbro
- Author / open-source home: github.com/shirenchuang
- AgentBro Pet Market: agentbro.net/pets
- This skill repo: github.com/shirenchuang/agentbro-pet
Pet Market
Browse, install, and share community pets in the AgentBro pet market:
!AgentBro pet market desktop view
!AgentBro pet market full page
What This Skill Does
AgentBroPet keeps the deterministic hatch-pet pipeline, but removes the hard dependency on Codex's native $imagegen skill. It can use whatever image-generation backend is available in the current agent environment:
- Codex
$imagegen - another installed image-generation skill
- OpenAI image CLI/API such as
gpt-image-2 - Nano Banana / Gemini / Flux / ComfyUI / Midjourney-style external workflows
- a manual backend where another agent or model generates the PNGs and returns local file paths
The key idea is simple: image generation is replaceable, but atlas assembly and QA stay deterministic.
Install This Skill
Clone this repo into your Codex skills folder:
mkdir -p ~/.codex/skills
git clone https://github.com/shirenchuang/agentbro-pet.git ~/.codex/skills/agentbro-petThen invoke the skill in Codex:
$AgentBroPet create a Teemo-inspired scout pet for AgentBroFor non-Codex agents, use the same folder as a workflow package. The agent only needs to run the scripts, read imagegen-jobs.json, generate the requested images, and copy selected outputs into the decoded paths.
Install Market Pets With npx
The AgentBro pet market is powered by the `abpets` CLI. You do not need a global install; use npx:
# Recommended: full author/pet reference
npx abpets install shirenchuang/teemo
# Bare slug works when it is not ambiguous
npx abpets install teemoabpets installs each pet into both:
~/.codex/pets/<slug>/
~/.agentbro/pets/<slug>/That makes the same pet available to Codex Desktop and AgentBro-hosted agents such as Claude Code, Codex CLI, Cursor, GitHub Copilot, Cline, and Gemini CLI.
Useful commands:
# Search the market
npx abpets search
npx abpets search teemo
# List locally installed pets
npx abpets list
# Uninstall a pet
npx abpets uninstall teemo
# Log in and submit your own pet
npx abpets login
npx abpets submit ~/.codex/pets/my-petRequirement: Node.js 18 or newer.
Output Format
Final pet packages contain:
<pet-id>/
├── pet.json
└── spritesheet.webpThe atlas contract:
1536x1872pixels- 8 columns x 9 rows
192x208pixels per cell- transparent background
- unused cells fully transparent
- WebP or PNG atlas
Animation rows:
0 idle
1 running-right
2 running-left
3 waving
4 jumping
5 failed
6 waiting
7 running
8 reviewBasic Workflow
Prepare a pet run:
SKILL_DIR="$HOME/.codex/skills/agentbro-pet"
python3 "$SKILL_DIR/scripts/prepare_pet_run.py" \
--pet-name "My Pet" \
--pet-notes "a tiny friendly coding companion" \
--output-dir ./output/agentbro-pet/my-pet \
--style-preset auto \
--forceInspect ready visual jobs:
jq '.jobs[] | {id, status, depends_on, prompt_file, input_images, output_path}' \
./output/agentbro-pet/my-pet/imagegen-jobs.jsonGenerate each ready job with your available image backend, copy the selected result into the job's output_path, then mark the job complete in imagegen-jobs.json.
When all jobs are complete, run the deterministic pipeline:
RUN_DIR=./output/agentbro-pet/my-pet
python3 "$SKILL_DIR/scripts/extract_strip_frames.py" \
--decoded-dir "$RUN_DIR/decoded" \
--output-dir "$RUN_DIR/frames" \
--states all \
--method auto
python3 "$SKILL_DIR/scripts/inspect_frames.py" \
--frames-root "$RUN_DIR/frames" \
--json-out "$RUN_DIR/qa/review.json" \
--require-components
python3 "$SKILL_DIR/scripts/compose_atlas.py" \
--frames-root "$RUN_DIR/frames" \
--output "$RUN_DIR/final/spritesheet.png" \
--webp-output "$RUN_DIR/final/spritesheet.webp"
python3 "$SKILL_DIR/scripts/validate_atlas.py" \
"$RUN_DIR/final/spritesheet.webp" \
--json-out "$RUN_DIR/final/validation.json"
python3 "$SKILL_DIR/scripts/make_contact_sheet.py" \
"$RUN_DIR/final/spritesheet.webp" \
--output "$RUN_DIR/qa/contact-sheet.png"
python3 "$SKILL_DIR/scripts/render_animation_previews.py" \
--frames-root "$RUN_DIR/frames" \
--output-dir "$RUN_DIR/qa/previews"Backend Contract
Every image backend must:
- read each job's prompt file
- use the listed input images whenever references are supported
- generate one selected PNG for the base or row strip
- return a local
selected_sourcepath - never use layout guides as final output
- never copy guide boxes, blue safety frames, labels, or center lines into generated rows
- keep the pet identity consistent across all rows
imagegen-jobs.json keeps its historical name, but in AgentBroPet it means "visual generation jobs"; it is not tied to Codex $imagegen.
Quality Gates
Do not accept a pet until:
qa/review.jsonhas no errorsfinal/validation.jsonpassesqa/contact-sheet.pnghas been visually checkedqa/previews/*.gifhave been visually checked- all rows preserve the same pet identity, style, palette, silhouette, and props
- directional rows face the correct direction
- idle is not visually inert
- no row contains guide marks, detached effects, white cell backgrounds, cropped sprites, or obvious size popping
License
Apache-2.0. See LICENSE.txt.
Animation Rows
AgentBro/Codex-compatible pets use one fixed atlas: 8 columns, 9 rows, 192x208 pixels per cell.
| Row | State | Used columns | Durations |
|---|---|---|---|
| 0 | idle | 0-5 | 280, 110, 110, 140, 140, 320 ms |
| 1 | running-right | 0-7 | 120 ms each, final 220 ms |
| 2 | running-left | 0-7 | 120 ms each, final 220 ms |
| 3 | waving | 0-3 | 140 ms each, final 280 ms |
| 4 | jumping | 0-4 | 140 ms each, final 280 ms |
| 5 | failed | 0-7 | 140 ms each, final 240 ms |
| 6 | waiting | 0-5 | 150 ms each, final 260 ms |
| 7 | running | 0-5 | 120 ms each, final 220 ms |
| 8 | review | 0-5 | 150 ms each, final 280 ms |
Unused cells after each row's final used column must be fully transparent.
Row Purposes
idle: calm, low-distraction breathing/blinking loop; use as the reduced-motion first frame. Keep motion subtle and persona-preserving.running-right: locomotion to the right; 8-frame loop should read directionally.running-left: mirrored or redrawn locomotion to the left; do not simply reuse right-facing frames unless the design is symmetric, and any mirror derivation must preserve frame order and timing semantics.waving: greeting or attention gesture; clear start, raised gesture, return.jumping: anticipation, lift, peak, descent, settle.failed: error/sad/deflated reaction; readable but not visually noisy.waiting: blocked-on-user-input state; expectant asking pose for approval, help, or user input.running: active task work state; focused processing, thinking, scanning, typing, or effortful concentration. This row is not foot-running; avoid jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.review: focused/inspecting/thinking loop suitable for review state.
Codex Pet Contract
Sprite Atlas
- Format: PNG or WebP.
- Dimensions:
1536x1872. - Grid: 8 columns x 9 rows.
- Cell:
192x208. - Background: transparent.
- Unused cells: fully transparent.
The webview animation uses CSS background positions from the fixed row and column counts. Do not add labels, gutters, borders, grid lines, shadows outside the cell, or extra frames.
Local Custom Pet Package
Place files under:
${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/
├── pet.json
└── spritesheet.webpManifest shape:
{
"id": "pet-name",
"displayName": "Pet Name",
"description": "One short sentence.",
"spritesheetPath": "spritesheet.webp"
}The app loads custom pets from the folder name under ${CODEX_HOME:-$HOME/.codex}/pets/.
QA Rubric
Do not accept an atlas until all checks pass.
Geometry
- Exact
1536x1872dimensions. - 8 columns x 9 rows.
- Each frame fits inside its
192x208cell. - Unused cells are transparent.
- Fully transparent atlas pixels do not retain non-zero RGB residue after export.
qa/review.jsonhas no errors.frames/frames-manifest.jsonrecords component extraction for production rows unlessstable-slotswas intentionally chosen to preserve row-level playback stability after visual inspection.
Character Consistency
- Same silhouette and proportions across every row.
- Same face and expression language.
- Same style, material, palette, lighting, and prop design.
- No frame introduces a new unintended character or object.
Pet-Safe Style
- Art reads as an AgentBro/Codex-compatible app pet, not a scene, app icon, logo sheet, or standalone illustration.
- Silhouette is compact and clear enough to read inside a
192x208cell. - The chosen style is consistent across every row, including edge treatment, material, lighting, and palette.
- Pixel, plush, clay, sticker, flat vector, 3D toy, painterly mascot, ink, and brand-inspired styles are all acceptable when readable at pet size.
- No tiny accessories, texture detail, logo detail, or text that disappears or becomes noisy at pet size.
Animation Completeness
- Each row uses the exact expected number of frames.
- The first and last frames can loop without an obvious pop.
- Directional rows read as the intended direction.
- Mirrored directional rows preserve temporal frame order rather than reversing the cadence.
- State-specific actions are recognizable at pet size.
- Poses are generated animation variants, not repeated copies of the same source image.
- Preview GIFs do not show unintended size popping, extraction-induced baseline jumps, or wrong directional facing.
App Fitness
- First idle frame works as a static reduced-motion pet.
- The
idlerow should be calm and low-distraction; reject it if it reads as waving, walking, running, jumping, talking, working, reviewing, reacting dramatically, changing props, or making large pose/silhouette changes. - No important detail is too small to read.
- No frame is clipped by the cell.
- Failed/review/waiting states are distinct from ordinary idle.
- Contact sheets must show whole sprite poses inside cells, not cropped tiles from a larger reference image.
- Contact sheets must not be accepted if every used frame is just the reference image with small geometric transforms.
- Used cells must not have white or opaque rectangular backgrounds unless the pet intentionally fills the whole cell and the user accepts that tradeoff.
- The chroma key must be visually absent from the character. If extraction removes character regions, choose a different key and regenerate the affected base/rows.
- Contact sheets must not show edge slivers or partial neighboring sprites inside cells.
- Contact sheets must not show darker/lighter versions of the chroma key as shadows, dust, smears, glows, landing marks, or motion effects. These are background extraction failures and should trigger row repair.
- If
qa/review.jsonreports edge pixels, sparse frames, size outliers, or slot-extraction fallback, inspect the row visually and repair it when the issue is visible. - If
qa/review.jsonreports chroma-adjacent non-transparent pixels, repair the row unless those pixels are an intentional character color and the selected key was manually accepted. - If preview GIFs show size popping even though the generated strip itself had stable scale and placement, rerun extraction with
stable-slotsbefore regenerating the row. - If previews show wrong facing direction, reversed cadence, non-alternating gait, or an effectively static idle loop, repair or regenerate the affected row.
Repair Policy
Repair the smallest failing scope first:
1. Single bad frame. 2. One row. 3. Full atlas regeneration only when identity or layout is broadly broken.
The normal production path should regenerate only the affected row and copy the selected replacement into the same decoded output path unless the base character is wrong.
#!/usr/bin/env python3
"""Compose or normalize an AgentBro-compatible pet spritesheet atlas."""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image
COLUMNS = 8
ROWS = 9
CELL_WIDTH = 192
CELL_HEIGHT = 208
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
ATLAS_ASPECT_RATIO = ATLAS_WIDTH / ATLAS_HEIGHT
ROW_SPECS = [
("idle", 0, 6),
("running-right", 1, 8),
("running-left", 2, 8),
("waving", 3, 4),
("jumping", 4, 5),
("failed", 5, 8),
("waiting", 6, 6),
("running", 7, 6),
("review", 8, 6),
]
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
def image_files(path: Path) -> list[Path]:
return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
def find_row_frames(root: Path, state: str, row_index: int) -> list[Path]:
candidates = [
root / state,
root / f"row-{row_index}",
root / f"row{row_index}",
root / f"{row_index}-{state}",
]
for candidate in candidates:
if candidate.is_dir():
files = image_files(candidate)
if files:
return files
globs = [
f"{state}_*",
f"{state}-*",
f"row{row_index}_*",
f"row-{row_index}-*",
]
files: list[Path] = []
for pattern in globs:
files.extend(p for p in root.glob(pattern) if p.suffix.lower() in IMAGE_SUFFIXES)
return sorted(set(files))
def paste_centered(atlas: Image.Image, source: Image.Image, row: int, column: int) -> None:
frame = source.convert("RGBA")
if frame.size != (CELL_WIDTH, CELL_HEIGHT):
frame.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS)
left = column * CELL_WIDTH + (CELL_WIDTH - frame.width) // 2
top = row * CELL_HEIGHT + (CELL_HEIGHT - frame.height) // 2
atlas.alpha_composite(frame, (left, top))
def compose_from_source_atlas(path: Path, resize_source: bool) -> Image.Image:
with Image.open(path) as opened:
source = opened.convert("RGBA")
if source.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
if not resize_source:
raise SystemExit(
f"source atlas must be {ATLAS_WIDTH}x{ATLAS_HEIGHT}; got {source.width}x{source.height}"
)
source_ratio = source.width / source.height
if abs(source_ratio - ATLAS_ASPECT_RATIO) > 0.02:
raise SystemExit(
"refusing to resize source atlas because its aspect ratio does not match "
f"the Codex atlas ratio {ATLAS_ASPECT_RATIO:.3f}; got {source_ratio:.3f}. "
"Generate exact atlas dimensions or use --frames-root."
)
source = source.resize((ATLAS_WIDTH, ATLAS_HEIGHT), Image.Resampling.LANCZOS)
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
for _state, row, frame_count in ROW_SPECS:
for column in range(frame_count):
left = column * CELL_WIDTH
top = row * CELL_HEIGHT
cell = source.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
atlas.alpha_composite(cell, (left, top))
return atlas
def compose_from_frames(root: Path) -> Image.Image:
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
for state, row, frame_count in ROW_SPECS:
files = find_row_frames(root, state, row)
if len(files) < frame_count:
raise SystemExit(
f"{state} row needs {frame_count} frames, found {len(files)} under {root}"
)
for column, frame_path in enumerate(files[:frame_count]):
with Image.open(frame_path) as frame:
paste_centered(atlas, frame, row, column)
return atlas
def clear_transparent_rgb(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
data = bytearray(rgba.tobytes())
for index in range(0, len(data), 4):
if data[index + 3] == 0:
data[index] = 0
data[index + 1] = 0
data[index + 2] = 0
return Image.frombytes("RGBA", rgba.size, bytes(data))
def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None:
atlas = clear_transparent_rgb(atlas)
output.parent.mkdir(parents=True, exist_ok=True)
atlas.save(output)
if webp_output is not None:
webp_output.parent.mkdir(parents=True, exist_ok=True)
atlas.save(
webp_output,
format="WEBP",
lossless=True,
quality=100,
method=6,
exact=True,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--source-atlas")
source.add_argument("--frames-root")
parser.add_argument("--output", required=True)
parser.add_argument("--webp-output")
parser.add_argument(
"--resize-source",
action="store_true",
help="Resize a lower-resolution source atlas only when it already has the Codex atlas aspect ratio.",
)
args = parser.parse_args()
if args.source_atlas:
atlas = compose_from_source_atlas(
Path(args.source_atlas).expanduser().resolve(), args.resize_source
)
else:
atlas = compose_from_frames(Path(args.frames_root).expanduser().resolve())
save_outputs(
atlas,
Path(args.output).expanduser().resolve(),
Path(args.webp_output).expanduser().resolve() if args.webp_output else None,
)
print(f"wrote {Path(args.output).expanduser().resolve()}")
if args.webp_output:
print(f"wrote {Path(args.webp_output).expanduser().resolve()}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Conditionally derive running-left by mirroring the approved running-right strip."""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from PIL import Image, ImageOps
RUNNING_FRAME_COUNT = 8
def load_manifest(run_dir: Path) -> dict[str, object]:
path = run_dir / "imagegen-jobs.json"
if not path.exists():
raise SystemExit(f"job manifest not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
jobs = manifest.get("jobs")
if not isinstance(jobs, list):
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
return [job for job in jobs if isinstance(job, dict)]
def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]:
for job in job_list(manifest):
if job.get("id") == job_id:
return job
raise SystemExit(f"unknown job id: {job_id}")
def image_metadata(path: Path) -> dict[str, object]:
with Image.open(path) as image:
image.verify()
with Image.open(path) as image:
return {
"width": image.width,
"height": image.height,
"mode": image.mode,
"format": image.format,
}
def manifest_relative(path: Path, run_dir: Path) -> str:
return str(path.resolve().relative_to(run_dir.resolve()))
def mirror_strip_preserving_frame_order(
source: Image.Image,
frame_count: int = RUNNING_FRAME_COUNT,
) -> Image.Image:
rgba = source.convert("RGBA")
mirrored = Image.new("RGBA", rgba.size, (0, 0, 0, 0))
slot_width = rgba.width / frame_count
for index in range(frame_count):
left = round(index * slot_width)
right = round((index + 1) * slot_width)
mirrored.alpha_composite(
ImageOps.mirror(rgba.crop((left, 0, right, rgba.height))),
(left, 0),
)
return mirrored
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", required=True)
parser.add_argument(
"--confirm-appropriate-mirror",
action="store_true",
help="Required after visually confirming the rightward strip can be mirrored without identity/prop issues.",
)
parser.add_argument(
"--decision-note",
required=True,
help="Short note explaining why mirroring is acceptable for this pet.",
)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
if not args.confirm_appropriate_mirror:
raise SystemExit("refusing to mirror without --confirm-appropriate-mirror")
if not args.decision_note.strip():
raise SystemExit("--decision-note must explain why mirroring is appropriate")
run_dir = Path(args.run_dir).expanduser().resolve()
manifest_path = run_dir / "imagegen-jobs.json"
manifest = load_manifest(run_dir)
right_job = find_job(manifest, "running-right")
left_job = find_job(manifest, "running-left")
if right_job.get("status") != "complete":
raise SystemExit("running-right must be complete before deriving running-left")
mirror_policy = left_job.get("mirror_policy")
if not isinstance(mirror_policy, dict) or mirror_policy.get("may_derive_from") != "running-right":
raise SystemExit("running-left is not configured for conditional mirroring")
source = run_dir / "decoded" / "running-right.png"
output = run_dir / "decoded" / "running-left.png"
if not source.is_file():
raise SystemExit(f"running-right decoded strip not found: {source}")
if output.exists() and not args.force:
raise SystemExit(f"{output} already exists; pass --force to replace it")
output.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as image:
mirrored = mirror_strip_preserving_frame_order(image)
mirrored.save(output)
left_job["status"] = "complete"
left_job["source_path"] = manifest_relative(source, run_dir)
left_job["derived_from"] = "running-right"
left_job["completed_at"] = datetime.now(timezone.utc).isoformat()
left_job["metadata"] = image_metadata(output)
left_job["mirror_decision"] = {
"approved": True,
"approved_at": left_job["completed_at"],
"note": args.decision_note.strip(),
"transform": "framewise-horizontal-mirror-preserving-order",
}
for key in [
"last_error",
"repair_reason",
"queued_at",
]:
left_job.pop(key, None)
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(
json.dumps(
{
"ok": True,
"job_id": "running-left",
"derived_from": "running-right",
"output": str(output),
"decision_note": args.decision_note.strip(),
"transform": "framewise-horizontal-mirror-preserving-order",
},
indent=2,
)
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Extract generated horizontal row strips into 192x208 sprite frames."""
from __future__ import annotations
import argparse
import json
import math
import re
from pathlib import Path
from PIL import Image
CELL_WIDTH = 192
CELL_HEIGHT = 208
ROW_FRAME_COUNTS = {
"idle": 6,
"running-right": 8,
"running-left": 8,
"waving": 4,
"jumping": 5,
"failed": 8,
"waiting": 6,
"running": 6,
"review": 6,
}
def parse_states(raw: str) -> list[str]:
if raw.strip().lower() == "all":
return list(ROW_FRAME_COUNTS)
states = [item.strip() for item in raw.split(",") if item.strip()]
unknown = sorted(set(states) - set(ROW_FRAME_COUNTS))
if unknown:
raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
return states
def parse_hex_color(value: str) -> tuple[int, int, int]:
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
def load_chroma_key(decoded_dir: Path, override: str | None) -> tuple[int, int, int]:
if override:
return parse_hex_color(override)
request_path = decoded_dir.parent / "pet_request.json"
if request_path.is_file():
request = json.loads(request_path.read_text(encoding="utf-8"))
chroma_key = request.get("chroma_key")
if isinstance(chroma_key, dict) and isinstance(chroma_key.get("hex"), str):
return parse_hex_color(chroma_key["hex"])
return parse_hex_color("#00FF00")
def color_distance(
red: int,
green: int,
blue: int,
key: tuple[int, int, int],
) -> float:
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
def remove_chroma_background(
image: Image.Image,
chroma_key: tuple[int, int, int],
threshold: float,
) -> Image.Image:
rgba = image.convert("RGBA")
pixels = rgba.load()
for y in range(rgba.height):
for x in range(rgba.width):
red, green, blue, alpha = pixels[x, y]
if color_distance(red, green, blue, chroma_key) <= threshold:
pixels[x, y] = (0, 0, 0, 0)
return rgba
def fit_to_cell(image: Image.Image) -> Image.Image:
bbox = image.getbbox()
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
if bbox is None:
return target
sprite = image.crop(bbox)
max_width = CELL_WIDTH - 10
max_height = CELL_HEIGHT - 10
scale = min(max_width / sprite.width, max_height / sprite.height, 1.0)
if scale != 1.0:
sprite = sprite.resize(
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
Image.Resampling.LANCZOS,
)
left = (CELL_WIDTH - sprite.width) // 2
top = (CELL_HEIGHT - sprite.height) // 2
target.alpha_composite(sprite, (left, top))
return target
def fit_viewport_to_cell(image: Image.Image) -> Image.Image:
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
if image.getbbox() is None:
return target
viewport = image.copy()
max_width = CELL_WIDTH - 10
max_height = CELL_HEIGHT - 10
scale = min(max_width / viewport.width, max_height / viewport.height, 1.0)
if scale != 1.0:
viewport = viewport.resize(
(max(1, round(viewport.width * scale)), max(1, round(viewport.height * scale))),
Image.Resampling.LANCZOS,
)
left = (CELL_WIDTH - viewport.width) // 2
top = (CELL_HEIGHT - viewport.height) // 2
target.alpha_composite(viewport, (left, top))
return target
def connected_components(image: Image.Image) -> list[dict[str, object]]:
alpha = image.getchannel("A")
width, height = image.size
data = alpha.tobytes()
visited = bytearray(width * height)
components: list[dict[str, object]] = []
for start, alpha_value in enumerate(data):
if alpha_value <= 16 or visited[start]:
continue
stack = [start]
visited[start] = 1
pixels: list[int] = []
min_x = width
min_y = height
max_x = 0
max_y = 0
while stack:
current = stack.pop()
pixels.append(current)
x = current % width
y = current // width
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x)
max_y = max(max_y, y)
if x > 0:
neighbor = current - 1
if not visited[neighbor] and data[neighbor] > 16:
visited[neighbor] = 1
stack.append(neighbor)
if x + 1 < width:
neighbor = current + 1
if not visited[neighbor] and data[neighbor] > 16:
visited[neighbor] = 1
stack.append(neighbor)
if y > 0:
neighbor = current - width
if not visited[neighbor] and data[neighbor] > 16:
visited[neighbor] = 1
stack.append(neighbor)
if y + 1 < height:
neighbor = current + width
if not visited[neighbor] and data[neighbor] > 16:
visited[neighbor] = 1
stack.append(neighbor)
components.append(
{
"pixels": pixels,
"area": len(pixels),
"bbox": (min_x, min_y, max_x + 1, max_y + 1),
"center_x": (min_x + max_x + 1) / 2,
}
)
return components
def component_group_image(
source: Image.Image,
components: list[dict[str, object]],
padding: int = 4,
) -> Image.Image:
width, height = source.size
min_x = max(0, min(component["bbox"][0] for component in components) - padding)
min_y = max(0, min(component["bbox"][1] for component in components) - padding)
max_x = min(width, max(component["bbox"][2] for component in components) + padding)
max_y = min(height, max(component["bbox"][3] for component in components) + padding)
output = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0))
source_pixels = source.load()
output_pixels = output.load()
for component in components:
for pixel_index in component["pixels"]:
x = pixel_index % width
y = pixel_index // width
output_pixels[x - min_x, y - min_y] = source_pixels[x, y]
return output
def component_frame_groups(
strip: Image.Image,
frame_count: int,
) -> list[list[dict[str, object]]] | None:
components = connected_components(strip)
if not components:
return None
largest_area = max(component["area"] for component in components)
seed_threshold = max(120, largest_area * 0.20)
seeds = [component for component in components if component["area"] >= seed_threshold]
if len(seeds) < frame_count:
seeds = sorted(components, key=lambda component: component["area"], reverse=True)[
:frame_count
]
if len(seeds) < frame_count:
return None
seeds = sorted(
sorted(seeds, key=lambda component: component["area"], reverse=True)[:frame_count],
key=lambda component: component["center_x"],
)
seed_ids = {id(seed) for seed in seeds}
groups: list[list[dict[str, object]]] = [[seed] for seed in seeds]
noise_threshold = max(12, largest_area * 0.002)
for component in components:
if id(component) in seed_ids or component["area"] < noise_threshold:
continue
nearest_index = min(
range(len(seeds)),
key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]),
)
groups[nearest_index].append(component)
return groups
def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None:
groups = component_frame_groups(strip, frame_count)
if groups is None:
return None
return [fit_to_cell(component_group_image(strip, group)) for group in groups]
def component_bounds(components: list[dict[str, object]]) -> tuple[int, int, int, int]:
return (
min(component["bbox"][0] for component in components),
min(component["bbox"][1] for component in components),
max(component["bbox"][2] for component in components),
max(component["bbox"][3] for component in components),
)
def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
slot_width = strip.width / frame_count
frames = []
for index in range(frame_count):
left = round(index * slot_width)
right = round((index + 1) * slot_width)
crop = strip.crop((left, 0, right, strip.height))
frames.append(fit_to_cell(crop))
return frames
def extract_stable_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
groups = component_frame_groups(strip, frame_count)
padding = 4
if groups is not None:
bboxes = [component_bounds(group) for group in groups]
shared_top = max(0, min(bbox[1] for bbox in bboxes) - padding)
shared_bottom = min(strip.height, max(bbox[3] for bbox in bboxes) + padding)
viewport_width = max(bbox[2] - bbox[0] for bbox in bboxes) + padding * 2
viewport_height = max(1, shared_bottom - shared_top)
frames = []
for group, bbox in zip(groups, bboxes):
grouped = component_group_image(strip, group, padding=padding)
grouped_top = max(0, bbox[1] - padding)
viewport = Image.new(
"RGBA",
(viewport_width, viewport_height),
(0, 0, 0, 0),
)
left = (viewport_width - grouped.width) // 2
viewport.alpha_composite(grouped, (left, grouped_top - shared_top))
frames.append(fit_viewport_to_cell(viewport))
return frames
bbox = strip.getbbox()
if bbox is None:
return [
Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
for _ in range(frame_count)
]
shared_top = max(0, bbox[1] - padding)
shared_bottom = min(strip.height, bbox[3] + padding)
slot_width = strip.width / frame_count
frames = []
for index in range(frame_count):
left = round(index * slot_width)
right = round((index + 1) * slot_width)
crop = strip.crop((left, shared_top, right, shared_bottom))
frames.append(fit_viewport_to_cell(crop))
return frames
def extract_state(
strip_path: Path,
state: str,
output_root: Path,
chroma_key: tuple[int, int, int],
threshold: float,
method: str,
) -> dict[str, object]:
frame_count = ROW_FRAME_COUNTS[state]
with Image.open(strip_path) as opened:
strip = remove_chroma_background(opened, chroma_key, threshold)
state_dir = output_root / state
state_dir.mkdir(parents=True, exist_ok=True)
frames = None
used_method = method
if method in {"auto", "components"}:
frames = extract_component_frames(strip, frame_count)
if frames is None and method == "components":
raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}")
if frames is not None:
used_method = "components"
if frames is None:
if method == "stable-slots":
frames = extract_stable_slot_frames(strip, frame_count)
used_method = "stable-slots"
else:
frames = extract_slot_frames(strip, frame_count)
used_method = "slots"
outputs = []
for index, frame in enumerate(frames):
output = state_dir / f"{index:02d}.png"
frame.save(output)
outputs.append(str(output))
return {"state": state, "frames": outputs, "method": used_method}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--decoded-dir", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--states", default="all")
parser.add_argument("--chroma-key", help="Override chroma key as #RRGGBB.")
parser.add_argument("--key-threshold", type=float, default=96.0)
parser.add_argument(
"--method",
choices=("auto", "components", "slots", "stable-slots"),
default="auto",
help="Use connected sprite components when possible, raw equal slots, or row-stable slot viewports.",
)
args = parser.parse_args()
decoded_dir = Path(args.decoded_dir).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
chroma_key = load_chroma_key(decoded_dir, args.chroma_key)
states = parse_states(args.states)
manifest = []
for state in states:
strip_path = decoded_dir / f"{state}.png"
if not strip_path.is_file():
raise SystemExit(f"missing generated strip for {state}: {strip_path}")
manifest.append(
extract_state(
strip_path,
state,
output_dir,
chroma_key,
args.key_threshold,
args.method,
)
)
(output_dir / "frames-manifest.json").write_text(
json.dumps(
{
"ok": True,
"chroma_key": {
"hex": f"#{chroma_key[0]:02X}{chroma_key[1]:02X}{chroma_key[2]:02X}",
"rgb": list(chroma_key),
"threshold": args.key_threshold,
},
"rows": manifest,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
print(json.dumps({"ok": True, "frames_root": str(output_dir), "states": states}, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Inspect extracted AgentBro-compatible pet frames before atlas composition."""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from statistics import median
from PIL import Image
CELL_WIDTH = 192
CELL_HEIGHT = 208
ROW_FRAME_COUNTS = {
"idle": 6,
"running-right": 8,
"running-left": 8,
"waving": 4,
"jumping": 5,
"failed": 8,
"waiting": 6,
"running": 6,
"review": 6,
}
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
def alpha_nonzero_count(image: Image.Image) -> int:
alpha = image if image.mode == "L" else image.getchannel("A")
return sum(alpha.histogram()[1:])
def edge_alpha_count(image: Image.Image, margin: int) -> int:
alpha = image.getchannel("A")
width, height = alpha.size
total = 0
for box in (
(0, 0, width, margin),
(0, height - margin, width, height),
(0, 0, margin, height),
(width - margin, 0, width, height),
):
total += alpha_nonzero_count(alpha.crop(box))
return total
def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
def chroma_adjacent_count(
image: Image.Image,
chroma_key: tuple[int, int, int] | None,
threshold: float,
) -> int:
if chroma_key is None:
return 0
rgba = image.convert("RGBA")
data = rgba.tobytes()
count = 0
for index in range(0, len(data), 4):
red, green, blue, alpha = data[index : index + 4]
if alpha > 16 and color_distance((red, green, blue), chroma_key) <= threshold:
count += 1
return count
def frame_files(state_dir: Path) -> list[Path]:
if not state_dir.is_dir():
return []
return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
def load_manifest(frames_root: Path) -> dict[str, dict[str, object]]:
manifest_path = frames_root / "frames-manifest.json"
if not manifest_path.is_file():
return {}
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
rows = manifest.get("rows", [])
if not isinstance(rows, list):
return {}
return {
row["state"]: row
for row in rows
if isinstance(row, dict) and isinstance(row.get("state"), str)
}
def load_chroma_key(frames_root: Path) -> tuple[int, int, int] | None:
manifest_path = frames_root / "frames-manifest.json"
if not manifest_path.is_file():
return None
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
chroma_key = manifest.get("chroma_key")
if not isinstance(chroma_key, dict):
return None
rgb = chroma_key.get("rgb")
if (
not isinstance(rgb, list)
or len(rgb) != 3
or not all(isinstance(value, int) for value in rgb)
):
return None
return (rgb[0], rgb[1], rgb[2])
def inspect_state(
frames_root: Path,
state: str,
expected_count: int,
manifest_rows: dict[str, dict[str, object]],
chroma_key: tuple[int, int, int] | None,
args: argparse.Namespace,
) -> dict[str, object]:
state_dir = frames_root / state
files = frame_files(state_dir)
row_errors: list[str] = []
row_warnings: list[str] = []
frames: list[dict[str, object]] = []
areas: list[int] = []
manifest_row = manifest_rows.get(state, {})
method = manifest_row.get("method")
if len(files) != expected_count:
row_errors.append(f"expected {expected_count} frame files for {state}, found {len(files)}")
if args.require_components and method and method != "components":
if method == "stable-slots" and args.allow_stable_slots:
row_warnings.append(
f"{state} used extraction method stable-slots; confirm motion playback remains stable and unclipped"
)
else:
row_errors.append(
f"{state} used extraction method {method}; regenerate the row or inspect slot slicing"
)
elif method and method != "components":
row_warnings.append(
f"{state} used extraction method {method}; component extraction is preferred"
)
for index, frame_path in enumerate(files[:expected_count]):
with Image.open(frame_path) as opened:
frame = opened.convert("RGBA")
nontransparent = alpha_nonzero_count(frame)
bbox = frame.getbbox()
edge_pixels = edge_alpha_count(frame, args.edge_margin)
chroma_adjacent_pixels = chroma_adjacent_count(
frame,
chroma_key,
args.chroma_adjacent_threshold,
)
info = {
"index": index,
"file": str(frame_path),
"width": frame.width,
"height": frame.height,
"nontransparent_pixels": nontransparent,
"bbox": list(bbox) if bbox else None,
"edge_pixels": edge_pixels,
"chroma_adjacent_pixels": chroma_adjacent_pixels,
}
frames.append(info)
areas.append(nontransparent)
if frame.size != (CELL_WIDTH, CELL_HEIGHT):
row_errors.append(
f"{state} frame {index:02d} is {frame.width}x{frame.height}; expected {CELL_WIDTH}x{CELL_HEIGHT}"
)
if nontransparent < args.min_used_pixels:
row_errors.append(
f"{state} frame {index:02d} is empty or too sparse ({nontransparent} pixels)"
)
if edge_pixels > args.edge_pixel_threshold:
row_warnings.append(
f"{state} frame {index:02d} has {edge_pixels} non-transparent pixels near the cell edge"
)
if chroma_adjacent_pixels > args.chroma_adjacent_pixel_threshold:
row_errors.append(
f"{state} frame {index:02d} has {chroma_adjacent_pixels} non-transparent pixels close to the chroma key"
)
if areas:
row_median = median(areas)
for index, area in enumerate(areas[:expected_count]):
if row_median > 0 and area < row_median * args.small_outlier_ratio:
row_warnings.append(
f"{state} frame {index:02d} is much smaller than the row median ({area} vs {row_median:.0f})"
)
if row_median > 0 and area > row_median * args.large_outlier_ratio:
row_warnings.append(
f"{state} frame {index:02d} is much larger than the row median ({area} vs {row_median:.0f})"
)
return {
"state": state,
"expected_frames": expected_count,
"actual_frames": len(files),
"extraction_method": method,
"ok": not row_errors,
"errors": row_errors,
"warnings": row_warnings,
"frames": frames,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--frames-root", required=True)
parser.add_argument("--json-out", required=True)
parser.add_argument("--min-used-pixels", type=int, default=400)
parser.add_argument("--edge-margin", type=int, default=2)
parser.add_argument("--edge-pixel-threshold", type=int, default=24)
parser.add_argument("--chroma-adjacent-threshold", type=float, default=150.0)
parser.add_argument("--chroma-adjacent-pixel-threshold", type=int, default=800)
parser.add_argument("--small-outlier-ratio", type=float, default=0.35)
parser.add_argument("--large-outlier-ratio", type=float, default=2.75)
parser.add_argument(
"--require-components",
action="store_true",
help="Fail rows that fell back to equal-slot extraction.",
)
parser.add_argument(
"--allow-stable-slots",
action="store_true",
help="Permit explicitly chosen stable-slots extraction while still warning for visual review.",
)
args = parser.parse_args()
frames_root = Path(args.frames_root).expanduser().resolve()
manifest_rows = load_manifest(frames_root)
chroma_key = load_chroma_key(frames_root)
rows = [
inspect_state(frames_root, state, count, manifest_rows, chroma_key, args)
for state, count in ROW_FRAME_COUNTS.items()
]
errors = [error for row in rows for error in row["errors"]]
warnings = [warning for row in rows for warning in row["warnings"]]
result = {
"ok": not errors,
"frames_root": str(frames_root),
"errors": errors,
"warnings": warnings,
"rows": rows,
}
json_out = Path(args.json_out).expanduser().resolve()
json_out.parent.mkdir(parents=True, exist_ok=True)
json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps({k: v for k, v in result.items() if k != "rows"}, indent=2))
raise SystemExit(0 if result["ok"] else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Create a labeled contact sheet from an AgentBro-compatible pet atlas."""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
COLUMNS = 8
ROWS = 9
CELL_WIDTH = 192
CELL_HEIGHT = 208
LABEL_HEIGHT = 22
ROW_NAMES = [
"idle",
"running-right",
"running-left",
"waving",
"jumping",
"failed",
"waiting",
"running",
"review",
]
USED_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6]
def checker(size: tuple[int, int], square: int = 16) -> Image.Image:
image = Image.new("RGB", size, "#ffffff")
draw = ImageDraw.Draw(image)
for y in range(0, size[1], square):
for x in range(0, size[0], square):
if (x // square + y // square) % 2:
draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8")
return image
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("atlas")
parser.add_argument("--output", required=True)
parser.add_argument("--scale", type=float, default=0.5)
args = parser.parse_args()
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
atlas = opened.convert("RGBA")
cell_w = max(1, round(CELL_WIDTH * args.scale))
cell_h = max(1, round(CELL_HEIGHT * args.scale))
width = COLUMNS * cell_w
height = ROWS * (cell_h + LABEL_HEIGHT)
sheet = Image.new("RGB", (width, height), "#f7f7f7")
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default()
for row in range(ROWS):
y = row * (cell_h + LABEL_HEIGHT)
draw.rectangle((0, y, width, y + LABEL_HEIGHT - 1), fill="#111111")
draw.text((6, y + 5), f"row {row}: {ROW_NAMES[row]}", fill="#ffffff", font=font)
draw.text(
(width - 92, y + 5),
f"{USED_COUNTS[row]} frames",
fill="#ffffff",
font=font,
)
for column in range(COLUMNS):
crop = atlas.crop(
(
column * CELL_WIDTH,
row * CELL_HEIGHT,
(column + 1) * CELL_WIDTH,
(row + 1) * CELL_HEIGHT,
)
)
crop = crop.resize((cell_w, cell_h), Image.Resampling.LANCZOS)
bg = checker((cell_w, cell_h))
bg.paste(crop, (0, 0), crop)
x = column * cell_w
sheet.paste(bg, (x, y + LABEL_HEIGHT))
outline = "#18a058" if column < USED_COUNTS[row] else "#cc3344"
draw.rectangle(
(x, y + LABEL_HEIGHT, x + cell_w - 1, y + LABEL_HEIGHT + cell_h - 1),
outline=outline,
)
draw.text((x + 4, y + LABEL_HEIGHT + 4), str(column), fill="#111111", font=font)
output = Path(args.output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
sheet.save(output)
print(f"wrote {output}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Create an AgentBro-compatible pet run folder, prompts, and imagegen job manifest."""
from __future__ import annotations
import argparse
import json
import math
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from PIL import Image
from PIL import ImageDraw
ATLAS = {"columns": 8, "rows": 9, "cell_width": 192, "cell_height": 208}
ATLAS["width"] = ATLAS["columns"] * ATLAS["cell_width"]
ATLAS["height"] = ATLAS["rows"] * ATLAS["cell_height"]
ROWS = [
("idle", 0, 6, "calm resting, breathing, and blinking loop"),
("running-right", 1, 8, "rightward drag movement loop"),
("running-left", 2, 8, "leftward drag movement loop"),
("waving", 3, 4, "greeting or attention gesture"),
("jumping", 4, 5, "hover or playful jump"),
("failed", 5, 8, "blocked, failed, or cancelled reaction"),
("waiting", 6, 6, "waiting for approval, help, or user input"),
("running", 7, 6, "active task work or processing"),
("review", 8, 6, "ready or completed output review"),
]
STATE_PROMPTS = {
"idle": "Calm low-distraction resting loop: subtle breathing, tiny blink, slight head/body bob, and only quiet persona-preserving motion.",
"running-right": "Dragging-right loop: show directional movement to the right through body and limb poses only.",
"running-left": "Dragging-left loop: show directional movement to the left through body and limb poses only.",
"waving": "Greeting loop: paw or limb down, raised, tilted, and returning in a friendly attention gesture.",
"jumping": "Hover jump loop: anticipation, lift, airborne peak, descent, and settle through body height.",
"failed": "Blocked/failed loop: slumped or deflated reaction with sad or closed eyes.",
"waiting": "Needs-input loop: expectant asking pose for approval, help, or user input.",
"running": "Working loop: focused active-task processing, thinking, typing, scanning, or effortful concentration; not literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.",
"review": "Ready-review loop: focused inspection of completed output with lean, blink, narrowed eyes, head tilt, or paw pose.",
}
STATE_REQUIREMENTS = {
"idle": [
"CRITICAL: idle is the low-distraction baseline state and the first frame is also used as the reduced-motion static pet.",
"Use only subtle idle motion: gentle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet motion that fits the pet persona.",
"Keep the pet essentially in the same pose, facing direction, silhouette, markings, palette, and prop state across all 6 frames.",
"Idle variation must stay calm but still read as animation; do not repeat effectively identical copies across the loop.",
"Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.",
"Feet, base, body, or object anchor should remain planted or nearly planted.",
"The first and last frames should be very close visually so the loop feels calm and does not pop.",
],
"waving": [
"Show the greeting through paw, hand, wing, or limb pose only.",
"Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.",
],
"jumping": [
"Show the jump through pose and vertical body position only: anticipation, lift, airborne peak, descent, settle.",
"Do not draw ground shadows, contact shadows, drop shadows, oval shadows, landing marks, dust, smears, bounce pads, or motion marks under the pet.",
"Keep the background outside the pet perfectly flat chroma key with no darker key-colored patches.",
],
"failed": [
"Show failure through slumped pose, drooping ears/limbs, closed or sad eyes, and lower body position.",
"Tears, small smoke puffs, or tiny stars are allowed only if attached to or overlapping the pet silhouette and kept inside the same frame slot.",
"Do not draw red X marks, floating symbols, detached stars, separated smoke clouds, falling tear drops, dust, or other loose effects.",
],
"waiting": [
"Show that Codex needs approval, help, or user input through an expectant asking pose.",
"Keep the motion patient and readable, without turning it into ordinary idle or review.",
],
"running": [
"Show the pet actively working or processing, as if running a task: focused posture, busy hands or paws, purposeful bobbing, thinking motion, tool or prop motion only if already part of the pet identity, or other non-locomotion activity.",
"Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
],
"review": [
"Show review through lean, blink, narrowed eyes, head tilt, or paw/hand position.",
"Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.",
],
"running-right": [
"Show directional drag movement to the right through body, limb, and prop movement only.",
"The row must unmistakably face and travel right.",
"The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.",
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
],
"running-left": [
"Show directional drag movement to the left through body, limb, and prop movement only.",
"The row must unmistakably face and travel left.",
"The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.",
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
],
}
NON_DERIVABLE_STATES = {
"waving",
"jumping",
"failed",
"waiting",
"running",
"review",
}
PET_SAFE_STYLE = (
"Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, "
"clear silhouette, simple face, stable palette/materials, and crisp edges "
"for chroma-key extraction."
)
STYLE_PRESETS = {
"auto": (
"Infer the most appropriate pet-safe style from the user request and "
"reference images, then keep that exact style consistent across every row."
),
"pixel": (
"Pixel-art-adjacent digital mascot with a chunky silhouette, simple dark "
"outline, limited palette, flat cel shading, and visible stepped edges."
),
"plush": (
"Soft plush toy mascot with rounded stitched forms, fuzzy fabric feel, "
"simple sewn details, and readable toy-like proportions."
),
"clay": (
"Handmade clay or polymer-clay mascot with rounded sculpted forms, soft "
"material texture, simple features, and clean readable edges."
),
"sticker": (
"Polished sticker mascot with bold clean shapes, crisp outline, flat "
"colors, and minimal highlight detail."
),
"flat-vector": (
"Flat vector-style mascot with simple geometric forms, crisp color areas, "
"clean outline, and minimal shading."
),
"3d-toy": (
"Stylized 3D toy mascot with smooth rounded forms, simple materials, "
"clear silhouette, and no photoreal complexity."
),
"painterly": (
"Painterly mascot with simplified brush texture, readable forms, stable "
"palette, and enough edge clarity for clean extraction."
),
"brand-inspired": (
"Brand-inspired mascot using approved public or user-provided brand cues "
"such as colors, mascot themes, and vibe while avoiding readable text or "
"logo copying unless explicitly approved."
),
}
CHROMA_KEY_CANDIDATES = [
("magenta", "#FF00FF"),
("cyan", "#00FFFF"),
("yellow", "#FFFF00"),
("blue", "#0000FF"),
("orange", "#FF7F00"),
("green", "#00FF00"),
]
DEFAULT_PET_NAME = "Sprout"
CANONICAL_BASE_PATH = "references/canonical-base.png"
BRAND_DISCOVERY_PATH = "references/brand-discovery.md"
LAYOUT_GUIDE_DIR = "references/layout-guides"
LAYOUT_GUIDE_SAFE_MARGIN_X = 18
LAYOUT_GUIDE_SAFE_MARGIN_Y = 16
def slugify(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value)
return value.strip("-")
def display_from_slug(value: str) -> str:
words = [word for word in re.split(r"[^a-zA-Z0-9]+", value.strip()) if word]
return " ".join(word.capitalize() for word in words)
def concept_words(value: str) -> list[str]:
stop_words = {
"a",
"an",
"and",
"app",
"based",
"codex",
"compact",
"digital",
"for",
"from",
"in",
"of",
"on",
"pet",
"ready",
"small",
"the",
"to",
"with",
}
words = [
word.lower()
for word in re.findall(r"[a-zA-Z0-9]+", value)
if word.lower() not in stop_words
]
return words
def infer_name(args: argparse.Namespace, reference_paths: list[Path]) -> str:
for raw_value in [args.display_name, args.pet_name]:
value = raw_value.strip()
if value:
return value
if args.pet_id.strip():
display = display_from_slug(args.pet_id)
if display:
return display
for raw_value in [args.pet_notes, args.description, args.brand_name]:
words = concept_words(raw_value)
if words:
return words[0].capitalize()
for path in reference_paths:
display = display_from_slug(path.stem)
if display:
return display
return DEFAULT_PET_NAME
def sentence(value: str) -> str:
value = " ".join(value.strip().split())
if not value:
return value
if value[-1] not in ".!?":
value += "."
return value
def infer_description(args: argparse.Namespace, reference_paths: list[Path]) -> str:
if args.description.strip():
return sentence(args.description)
if args.pet_notes.strip():
return sentence(f"A compact AgentBro-compatible pet: {args.pet_notes}")
if args.brand_name.strip():
return sentence(f"A compact AgentBro-compatible pet inspired by {args.brand_name}")
if reference_paths:
return "A compact AgentBro-compatible pet based on the provided reference image."
return "A compact original AgentBro-compatible pet ready for animation."
def infer_pet_notes(args: argparse.Namespace, reference_paths: list[Path]) -> str:
if args.pet_notes.strip():
return args.pet_notes.strip()
if args.description.strip():
return args.description.strip().rstrip(".")
if args.brand_name.strip():
return f"a compact mascot inspired by {args.brand_name.strip()}"
if reference_paths:
return "the pet shown in the reference image(s)"
return "a compact original AgentBro-compatible pet"
def default_output_dir(pet_id: str) -> Path:
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return Path.cwd() / "output" / "agentbro-pet" / f"{pet_id}-{timestamp}"
def rel(path: Path, root: Path) -> str:
return str(path.resolve().relative_to(root.resolve()))
def image_metadata(path: Path) -> dict[str, object]:
with Image.open(path) as image:
return {
"path": str(path),
"width": image.width,
"height": image.height,
"mode": image.mode,
"format": image.format,
}
def draw_dashed_line(
draw: ImageDraw.ImageDraw,
start: tuple[int, int],
end: tuple[int, int],
*,
fill: str,
dash: int = 8,
gap: int = 6,
) -> None:
x1, y1 = start
x2, y2 = end
if x1 == x2:
step = dash + gap
for y in range(min(y1, y2), max(y1, y2), step):
draw.line((x1, y, x2, min(y + dash, max(y1, y2))), fill=fill)
return
if y1 == y2:
step = dash + gap
for x in range(min(x1, x2), max(x1, x2), step):
draw.line((x, y1, min(x + dash, max(x1, x2)), y2), fill=fill)
return
raise ValueError("draw_dashed_line only supports horizontal or vertical lines")
def create_layout_guide(path: Path, state: str, frames: int) -> dict[str, object]:
width = frames * ATLAS["cell_width"]
height = ATLAS["cell_height"]
cell_width = ATLAS["cell_width"]
image = Image.new("RGB", (width, height), "#f7f7f7")
draw = ImageDraw.Draw(image)
for index in range(frames):
left = index * cell_width
right = left + cell_width - 1
draw.rectangle((left, 0, right, height - 1), outline="#111111", width=2)
safe_left = left + LAYOUT_GUIDE_SAFE_MARGIN_X
safe_top = LAYOUT_GUIDE_SAFE_MARGIN_Y
safe_right = right - LAYOUT_GUIDE_SAFE_MARGIN_X
safe_bottom = height - 1 - LAYOUT_GUIDE_SAFE_MARGIN_Y
draw.rectangle(
(safe_left, safe_top, safe_right, safe_bottom),
outline="#2f80ed",
width=2,
)
center_x = left + cell_width // 2
center_y = height // 2
draw_dashed_line(
draw,
(center_x, safe_top),
(center_x, safe_bottom),
fill="#b8b8b8",
)
draw_dashed_line(
draw,
(safe_left, center_y),
(safe_right, center_y),
fill="#b8b8b8",
)
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
return {
"state": state,
"path": str(path),
"width": width,
"height": height,
"frames": frames,
"cell_width": ATLAS["cell_width"],
"cell_height": ATLAS["cell_height"],
"safe_margin_x": LAYOUT_GUIDE_SAFE_MARGIN_X,
"safe_margin_y": LAYOUT_GUIDE_SAFE_MARGIN_Y,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips",
}
def create_layout_guides(run_dir: Path) -> list[dict[str, object]]:
guide_dir = run_dir / LAYOUT_GUIDE_DIR
return [
create_layout_guide(guide_dir / f"{state}.png", state, frames)
for state, _row, frames, _purpose in ROWS
]
def parse_hex_color(value: str) -> tuple[int, int, int]:
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
def sampled_reference_pixels(paths: list[Path]) -> list[tuple[int, int, int]]:
pixels: list[tuple[int, int, int]] = []
for path in paths:
with Image.open(path) as opened:
image = opened.convert("RGBA")
image.thumbnail((128, 128), Image.Resampling.LANCZOS)
data = image.tobytes()
for index in range(0, len(data), 4):
red, green, blue, alpha = data[index : index + 4]
if alpha <= 16:
continue
pixels.append((red, green, blue))
non_background = [
pixel
for pixel in pixels
if not (pixel[0] > 244 and pixel[1] > 244 and pixel[2] > 244)
]
return non_background or pixels
def choose_chroma_key(reference_paths: list[Path], requested: str) -> dict[str, object]:
if requested.lower() != "auto":
rgb = parse_hex_color(requested)
return {
"hex": rgb_to_hex(rgb),
"rgb": list(rgb),
"name": "user-selected",
"selection": "manual",
}
pixels = sampled_reference_pixels(reference_paths)
if not pixels:
rgb = parse_hex_color("#FF00FF")
return {
"hex": "#FF00FF",
"rgb": list(rgb),
"name": "magenta",
"selection": "fallback",
}
scored: list[tuple[float, int, str, tuple[int, int, int]]] = []
for preference_index, (name, hex_color) in enumerate(CHROMA_KEY_CANDIDATES):
rgb = parse_hex_color(hex_color)
distances = sorted(color_distance(rgb, pixel) for pixel in pixels)
percentile_index = max(0, min(len(distances) - 1, int(len(distances) * 0.01)))
scored.append((distances[percentile_index], -preference_index, name, rgb))
score, _preference, name, rgb = max(scored)
return {
"hex": rgb_to_hex(rgb),
"rgb": list(rgb),
"name": name,
"selection": "auto",
"score": round(score, 2),
}
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text.rstrip() + "\n", encoding="utf-8")
def resolved_style_contract(style_preset: str, raw_style_notes: str) -> str:
style_preset = style_preset.strip().lower()
if style_preset not in STYLE_PRESETS:
allowed = ", ".join(sorted(STYLE_PRESETS))
raise SystemExit(
f"invalid style preset: {style_preset}; expected one of: {allowed}"
)
raw_style_notes = raw_style_notes.strip()
preset_contract = STYLE_PRESETS[style_preset]
if not raw_style_notes:
return f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract}"
return (
f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract} "
f"User style notes: {raw_style_notes}."
)
def compact(value: str) -> str:
return " ".join(value.strip().split())
def brand_inspiration_line(args: argparse.Namespace) -> str:
brand_name = compact(args.brand_name)
brand_brief = compact(args.brand_brief)
if not brand_name and not brand_brief:
return ""
prefix = f"{brand_name}: " if brand_name else ""
if brand_brief:
return (
f"{prefix}{brand_brief} Use only broad mascot-safe cues; do not copy "
"readable logos, marks, UI screenshots, or text."
)
return (
f"{prefix}Use only broad mascot-safe brand cues. Do not copy readable "
"logos, marks, UI screenshots, or text."
)
def base_pet_prompt(args: argparse.Namespace) -> str:
pet_notes = args.pet_notes or "the pet shown in the reference image(s)"
style_contract = resolved_style_contract(args.style_preset, args.style_notes)
brand_line = brand_inspiration_line(args)
brand_block = f"\nBrand inspiration: {brand_line}\n" if brand_line else "\n"
chroma_key = args.chroma_key["hex"]
chroma_name = args.chroma_key["name"]
return f"""Create one clean full-body reference sprite for AgentBro-compatible pet {args.display_name}.
Pet identity: {pet_notes}.
Style: {style_contract}
{brand_block}
Place a single centered pose on a perfectly flat pure {chroma_name} {chroma_key} chroma-key background. Keep the full pet visible, compact, readable at 192x208, and easy to animate. Preserve approved reference identity cues. No scenery, text, borders, checkerboard transparency, shadows, glows, detached effects, or extra props. Keep {chroma_key} and close colors out of the pet, props, highlights, and effects."""
def row_prompt(
args: argparse.Namespace, state: str, row: int, frames: int, purpose: str
) -> str:
pet_notes = args.pet_notes or "the same pet from the approved base reference"
style_contract = resolved_style_contract(args.style_preset, args.style_notes)
chroma_key = args.chroma_key["hex"]
chroma_name = args.chroma_key["name"]
state_prompt = STATE_PROMPTS[state]
state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state])
return f"""Create one horizontal animation strip for AgentBro-compatible pet `{args.pet_id}`, state `{state}`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly {frames} full-body frames in one left-to-right row on flat pure {chroma_name} {chroma_key}. Treat the row as {frames} invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: {pet_notes}. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: {style_contract}
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: {state_prompt}
State requirements:
{state_requirements}
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet."""
def retry_row_prompt(
args: argparse.Namespace, state: str, row: int, frames: int, purpose: str
) -> str:
pet_notes = args.pet_notes or "the canonical base pet"
chroma_key = args.chroma_key["hex"]
chroma_name = args.chroma_key["name"]
state_prompt = STATE_PROMPTS[state]
state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state])
return f"""Create AgentBro-compatible pet row `{state}` for `{args.pet_id}`: exactly {frames} full-body frames in one horizontal strip on flat pure {chroma_name} {chroma_key}.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: {pet_notes}. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: {state_prompt}
State requirements:
{state_requirements}
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or {chroma_key} colors in the pet."""
def make_jobs(
run_dir: Path, copied_refs: list[dict[str, object]]
) -> list[dict[str, object]]:
reference_inputs = [
{"path": rel(Path(str(ref["copied_path"])), run_dir), "role": "pet reference"}
for ref in copied_refs
]
identity_reference_paths = [CANONICAL_BASE_PATH]
jobs: list[dict[str, object]] = [
{
"id": "base",
"kind": "base-pet",
"status": "pending",
"prompt_file": "prompts/base-pet.md",
"input_images": reference_inputs,
"output_path": "decoded/base.png",
"depends_on": [],
"generation_skill": "$imagegen",
"requires_grounded_generation": bool(reference_inputs),
"allow_prompt_only_generation": not reference_inputs,
}
]
for state, _row, frames, _purpose in ROWS:
depends_on = ["base"]
extra_inputs: list[dict[str, str]] = []
derivation_policy: dict[str, object] = {
"may_derive": False,
"reason": "state requires its own generated animation semantics",
}
if state == "running-left":
depends_on.append("running-right")
extra_inputs.append(
{
"path": "decoded/running-right.png",
"role": "rightward gait reference for leftward row decision",
}
)
derivation_policy = {
"may_derive": True,
"may_derive_from": "running-right",
"derivation": "framewise-horizontal-mirror-preserving-order",
"requires_explicit_approval": True,
"fallback_generation_skill": "$imagegen",
}
elif state not in NON_DERIVABLE_STATES:
derivation_policy["reason"] = "no deterministic derivation is configured for this state"
jobs.append(
{
"id": state,
"kind": "row-strip",
"status": "pending",
"prompt_file": f"prompts/rows/{state}.md",
"retry_prompt_file": f"prompts/row-retries/{state}.md",
"input_images": [
*reference_inputs,
{
"path": f"{LAYOUT_GUIDE_DIR}/{state}.png",
"role": f"layout guide for {frames} frame slots; use for spacing only, do not copy guide lines",
},
{
"path": CANONICAL_BASE_PATH,
"role": "canonical identity reference",
},
*extra_inputs,
],
"output_path": f"decoded/{state}.png",
"depends_on": depends_on,
"generation_skill": "$imagegen",
"requires_grounded_generation": True,
"allow_prompt_only_generation": False,
"identity_reference_paths": identity_reference_paths,
"parallelizable_after": depends_on,
"derivation_policy": derivation_policy,
"mirror_policy": derivation_policy if state == "running-left" else {},
}
)
return jobs
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--pet-name",
default="",
help="User-facing pet name. Ask the user for this when practical; otherwise choose a short appropriate name.",
)
parser.add_argument(
"--pet-id",
default="",
help="Stable pet folder/id slug. Defaults to the slugified pet name.",
)
parser.add_argument(
"--display-name",
default="",
help="Display label. Defaults to the pet name.",
)
parser.add_argument("--description", default="")
parser.add_argument("--reference", action="append", default=[])
parser.add_argument("--output-dir", default="")
parser.add_argument("--pet-notes", default="")
parser.add_argument(
"--brand-name",
default="",
help="Brand, company, or product name used for broad mascot inspiration.",
)
parser.add_argument(
"--brand-brief",
default="",
help="Compact researched brand cue sentence for the base pet only.",
)
parser.add_argument(
"--brand-source",
action="append",
default=[],
help="Source URL used to produce the brand brief. May be passed multiple times.",
)
parser.add_argument(
"--brand-discovery-file",
default="",
help="Optional markdown discovery brief to copy into the run for review.",
)
parser.add_argument(
"--style-preset",
default="auto",
choices=sorted(STYLE_PRESETS),
help="Pet-safe style preset to use across the base and all animation rows.",
)
parser.add_argument("--style-notes", default="")
parser.add_argument(
"--chroma-key",
default="auto",
help="Chroma key as #RRGGBB, or auto to choose a safe key from reference colors.",
)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
raw_reference_paths = [
Path(raw_path).expanduser().resolve() for raw_path in args.reference
]
raw_brand_discovery_path = (
Path(args.brand_discovery_file).expanduser().resolve()
if args.brand_discovery_file.strip()
else None
)
args.display_name = infer_name(args, raw_reference_paths)
args.pet_name = (args.pet_name or args.display_name).strip()
args.description = infer_description(args, raw_reference_paths)
args.pet_notes = infer_pet_notes(args, raw_reference_paths)
args.pet_id = slugify(args.pet_id or args.pet_name or args.display_name)
args.style_preset = args.style_preset.strip().lower()
args.style_contract = resolved_style_contract(args.style_preset, args.style_notes)
args.brand_name = compact(args.brand_name)
args.brand_brief = compact(args.brand_brief)
args.brand_source = [
compact(source) for source in args.brand_source if compact(source)
]
if not args.pet_id:
raise SystemExit("pet id must contain at least one letter or digit")
run_dir = (
Path(args.output_dir).expanduser().resolve()
if args.output_dir
else default_output_dir(args.pet_id).resolve()
)
if run_dir.exists() and any(run_dir.iterdir()) and not args.force:
raise SystemExit(
f"{run_dir} already exists and is not empty; pass --force to reuse it"
)
run_dir.mkdir(parents=True, exist_ok=True)
ref_dir = run_dir / "references"
prompt_dir = run_dir / "prompts"
row_prompt_dir = prompt_dir / "rows"
row_retry_prompt_dir = prompt_dir / "row-retries"
for directory in [
ref_dir,
prompt_dir,
row_prompt_dir,
row_retry_prompt_dir,
run_dir / "decoded",
run_dir / "qa",
]:
directory.mkdir(parents=True, exist_ok=True)
copied_refs: list[dict[str, object]] = []
copied_ref_paths: list[Path] = []
for index, source in enumerate(raw_reference_paths, start=1):
if not source.is_file():
raise SystemExit(f"reference not found: {source}")
suffix = source.suffix.lower() or ".png"
copied = ref_dir / f"reference-{index:02d}{suffix}"
shutil.copy2(source, copied)
meta = image_metadata(copied)
meta["source_path"] = str(source)
meta["copied_path"] = str(copied)
copied_refs.append(meta)
copied_ref_paths.append(copied)
brand_discovery_path = ""
if raw_brand_discovery_path is not None:
if not raw_brand_discovery_path.is_file():
raise SystemExit(f"brand discovery file not found: {raw_brand_discovery_path}")
copied_discovery = run_dir / BRAND_DISCOVERY_PATH
shutil.copy2(raw_brand_discovery_path, copied_discovery)
brand_discovery_path = rel(copied_discovery, run_dir)
args.chroma_key = choose_chroma_key(copied_ref_paths, args.chroma_key)
layout_guides = create_layout_guides(run_dir)
request = {
"pet_id": args.pet_id,
"display_name": args.display_name,
"description": args.description,
"created_at": datetime.now(timezone.utc).isoformat(),
"atlas": ATLAS,
"rows": [
{"state": state, "row": row, "frames": frames, "purpose": purpose}
for state, row, frames, purpose in ROWS
],
"layout_guides": [
{**guide, "path": rel(Path(str(guide["path"])), run_dir)}
for guide in layout_guides
],
"references": copied_refs,
"chroma_key": args.chroma_key,
"pet_notes": args.pet_notes,
"style_preset": args.style_preset,
"style_notes": args.style_notes,
"style_contract": args.style_contract,
"brand_name": args.brand_name,
"brand_brief": args.brand_brief,
"brand_sources": args.brand_source,
"pet_safe_style": PET_SAFE_STYLE,
"primary_generation_skill": "$imagegen",
}
if brand_discovery_path:
request["brand_discovery_path"] = brand_discovery_path
(run_dir / "pet_request.json").write_text(
json.dumps(request, indent=2) + "\n", encoding="utf-8"
)
write_text(prompt_dir / "base-pet.md", base_pet_prompt(args))
for state, row, frames, purpose in ROWS:
write_text(
row_prompt_dir / f"{state}.md",
row_prompt(args, state, row, frames, purpose),
)
write_text(
row_retry_prompt_dir / f"{state}.md",
retry_row_prompt(args, state, row, frames, purpose),
)
jobs = {
"schema_version": 1,
"created_at": datetime.now(timezone.utc).isoformat(),
"run_dir": str(run_dir),
"primary_generation_skill": "$imagegen",
"jobs": make_jobs(run_dir, copied_refs),
}
(run_dir / "imagegen-jobs.json").write_text(
json.dumps(jobs, indent=2) + "\n", encoding="utf-8"
)
print(
json.dumps(
{
"ok": True,
"run_dir": str(run_dir),
"request": str(run_dir / "pet_request.json"),
"jobs": str(run_dir / "imagegen-jobs.json"),
"ready_jobs": ["base"],
},
indent=2,
)
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Render lightweight animated QA previews from extracted AgentBro-compatible pet frames."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from PIL import Image
ROW_DURATIONS = {
"idle": [280, 110, 110, 140, 140, 320],
"running-right": [120, 120, 120, 120, 120, 120, 120, 220],
"running-left": [120, 120, 120, 120, 120, 120, 120, 220],
"waving": [140, 140, 140, 280],
"jumping": [140, 140, 140, 140, 280],
"failed": [140, 140, 140, 140, 140, 140, 140, 240],
"waiting": [150, 150, 150, 150, 150, 260],
"running": [120, 120, 120, 120, 120, 220],
"review": [150, 150, 150, 150, 150, 280],
}
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
def frame_files(state_dir: Path) -> list[Path]:
if not state_dir.is_dir():
return []
return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
def load_frames(frames_root: Path, state: str, expected_count: int) -> list[Image.Image]:
files = frame_files(frames_root / state)
if len(files) != expected_count:
raise SystemExit(
f"{state} preview needs {expected_count} frames, found {len(files)} under {frames_root / state}"
)
frames = []
for path in files:
with Image.open(path) as opened:
frames.append(opened.convert("RGBA"))
return frames
def save_preview(frames: list[Image.Image], durations: list[int], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
frames[0].save(
output,
save_all=True,
append_images=frames[1:],
duration=durations,
loop=0,
disposal=2,
optimize=False,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--frames-root", required=True)
parser.add_argument("--output-dir", required=True)
args = parser.parse_args()
frames_root = Path(args.frames_root).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
previews = []
for state, durations in ROW_DURATIONS.items():
frames = load_frames(frames_root, state, len(durations))
output = output_dir / f"{state}.gif"
save_preview(frames, durations, output)
previews.append({"state": state, "path": str(output), "frames": len(frames)})
result = {"ok": True, "output_dir": str(output_dir), "previews": previews}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Validate an AgentBro-compatible pet spritesheet atlas."""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from pathlib import Path
from PIL import Image
COLUMNS = 8
ROWS = 9
CELL_WIDTH = 192
CELL_HEIGHT = 208
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
ROW_BY_INDEX = {
0: ("idle", 6),
1: ("running-right", 8),
2: ("running-left", 8),
3: ("waving", 4),
4: ("jumping", 5),
5: ("failed", 8),
6: ("waiting", 6),
7: ("running", 6),
8: ("review", 6),
}
def alpha_nonzero_count(image: Image.Image) -> int:
alpha = image.getchannel("A")
return sum(alpha.histogram()[1:])
def transparent_rgb_residue_count(image: Image.Image) -> int:
rgba = image.convert("RGBA")
data = rgba.tobytes()
count = 0
for index in range(0, len(data), 4):
red, green, blue, alpha = data[index : index + 4]
if alpha == 0 and (red or green or blue):
count += 1
return count
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("atlas")
parser.add_argument("--json-out")
parser.add_argument("--min-used-pixels", type=int, default=50)
parser.add_argument("--near-opaque-threshold", type=float, default=0.95)
parser.add_argument("--allow-opaque", action="store_true")
parser.add_argument("--allow-near-opaque-used-cells", action="store_true")
args = parser.parse_args()
atlas_path = Path(args.atlas).expanduser().resolve()
errors: list[str] = []
warnings: list[str] = []
near_opaque_used_cells: dict[str, list[int]] = defaultdict(list)
cells: list[dict[str, object]] = []
try:
with Image.open(atlas_path) as opened:
source_mode = opened.mode
source_format = opened.format
image = opened.convert("RGBA")
except Exception as exc: # noqa: BLE001
result = {"ok": False, "errors": [f"could not open atlas: {exc}"], "warnings": []}
print(json.dumps(result, indent=2))
raise SystemExit(1)
if image.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {image.width}x{image.height}")
if source_format not in {"PNG", "WEBP"}:
errors.append(f"expected PNG or WebP, got {source_format}")
if "A" not in source_mode and not args.allow_opaque:
errors.append("atlas does not have an alpha channel")
for row_index in range(ROWS):
state, frame_count = ROW_BY_INDEX[row_index]
for column_index in range(COLUMNS):
left = column_index * CELL_WIDTH
top = row_index * CELL_HEIGHT
cell = image.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
nontransparent = alpha_nonzero_count(cell)
used = column_index < frame_count
cell_info = {
"state": state,
"row": row_index,
"column": column_index,
"used": used,
"nontransparent_pixels": nontransparent,
}
cells.append(cell_info)
if used and nontransparent < args.min_used_pixels:
errors.append(
f"{state} row {row_index} column {column_index} is empty or too sparse ({nontransparent} pixels)"
)
if used and nontransparent > CELL_WIDTH * CELL_HEIGHT * args.near_opaque_threshold:
near_opaque_used_cells[f"{state} row {row_index}"].append(column_index)
if not used and nontransparent != 0:
errors.append(
f"{state} row {row_index} unused column {column_index} is not transparent ({nontransparent} pixels)"
)
for row_label, columns in near_opaque_used_cells.items():
message = (
f"{row_label} has {len(columns)} nearly opaque used cells; "
"this usually means the sprite has a non-transparent background"
)
if args.allow_near_opaque_used_cells:
warnings.append(message)
else:
errors.append(message)
alpha_count = alpha_nonzero_count(image)
if alpha_count == ATLAS_WIDTH * ATLAS_HEIGHT:
message = "atlas is fully opaque; custom pets require a transparent sprite background"
if args.allow_opaque:
warnings.append(message)
else:
errors.append(message)
transparent_rgb_residue = transparent_rgb_residue_count(image)
if transparent_rgb_residue:
errors.append(
f"atlas has {transparent_rgb_residue} fully transparent pixels with non-zero RGB residue"
)
result = {
"ok": not errors,
"file": str(atlas_path),
"format": source_format,
"mode": source_mode,
"width": image.width,
"height": image.height,
"transparent_rgb_residue_pixels": transparent_rgb_residue,
"errors": errors,
"warnings": warnings,
"cells": cells,
}
if args.json_out:
Path(args.json_out).expanduser().resolve().write_text(
json.dumps(result, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps({k: v for k, v in result.items() if k != "cells"}, indent=2))
raise SystemExit(0 if result["ok"] else 1)
if __name__ == "__main__":
main()