first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
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.
+56
View File
@@ -0,0 +1,56 @@
# hatch-pet (vendored)
This directory is a **vendored copy** of the Codex `hatch-pet` skill. It is
checked into the Open Design repo (rather than pulled in as a Git submodule
or an npm package) so that:
- Any Open Design agent can run the skill end-to-end without a network
fetch, an extra install step, or an out-of-tree clone.
- The packaged desktop build can ship the skill as inert static assets
alongside the rest of `skills/`.
- Reviews of changes that touch pet generation can see the skill source in
the same diff as the daemon / web wiring that consumes it.
The vendoring trade-off is: this copy will not auto-track upstream
revisions. If the upstream skill changes (atlas geometry, manifest shape,
script CLIs), this copy must be re-synced by hand. Treat it as a frozen
snapshot, not a live dependency.
## Provenance
- Skill: `hatch-pet`
- Pinned upstream reference (declared in `SKILL.md` frontmatter): see the
`upstream:` field — at vendoring time this pointed to the Codex curated
`skills/.curated/hatch-pet` tree. That URL was not publicly resolvable
at the time this README was written; treat the vendored snapshot in this
directory as the authoritative source-of-truth for Open Design and
re-confirm the upstream pointer the next time a re-sync is performed.
- License: Apache License 2.0 (`LICENSE.txt` next to this README). The
copyright line in the bundled `LICENSE.txt` is left unfilled because no
separate copyright holder was identified at vendoring time. If a future
re-sync confirms the upstream copyright holder, populate the standard
Apache `Copyright [yyyy] [name of copyright owner]` line and add a
`NOTICE` file mirroring upstream attribution.
## Re-syncing this skill
When the upstream skill changes:
1. Locate the upstream source (Codex `skills/.curated/hatch-pet` or the
superseding location).
2. Replace the contents of this directory with the upstream snapshot,
preserving only this `README.md` and any Open-Design-specific notes
inside `SKILL.md`'s `> **Open Design integration.**` blockquote.
3. Update the `upstream:` field in `SKILL.md` frontmatter with the exact
commit SHA / tag of the snapshot.
4. Update `LICENSE.txt` and add a `NOTICE` file if upstream now ships
attribution metadata.
## Where outputs land
The skill packages each pet under
`${CODEX_HOME:-$HOME/.codex}/pets/<pet-id>/` with `pet.json` and
`spritesheet.{webp,png,gif}`. The daemon scans that directory in
`apps/daemon/src/codex-pets.ts`; the web pet settings list and one-click
adopt pets from there. See `docs/codex-pets.md` for the end-user setup
flow (including how Open Design behaves when Codex is not installed).
+355
View File
@@ -0,0 +1,355 @@
---
name: hatch-pet
description: Create, repair, validate, preview, and package Codex-compatible animated pet spritesheets from character art, screenshots, generated images, or visual references. Use when a user wants to hatch a Codex pet, create a custom animated pet, or build a built-in pet asset with an 8x9 atlas, transparent unused cells, row-by-row animation prompts, QA contact sheets, preview videos, and pet.json packaging. This skill composes the installed $imagegen system skill for visual generation and uses bundled scripts for deterministic spritesheet assembly.
triggers:
- "hatch a pet"
- "hatch pet"
- "codex pet"
- "spritesheet pet"
- "animated pet"
- "孵化宠物"
- "电子宠物"
od:
mode: image
surface: image
scenario: personal
featured: 11
preview:
type: image
entry: final/spritesheet.png
design_system:
requires: false
outputs:
primary: final/spritesheet.png
secondary:
- final/spritesheet.webp
- pet.json
- qa/contact-sheet.png
example_prompt: "Hatch me a tiny pixel-art shiba pet — friendly, sitting upright, with a small pomegranate prop. Use the hatch-pet skill end-to-end."
upstream: "https://github.com/openai/skills/tree/main/skills/.curated/hatch-pet"
---
# Hatch Pet
> **Open Design integration.** This is the unmodified Codex `hatch-pet` skill,
> vendored under `skills/hatch-pet/` so any Open Design agent can run it. After
> the skill finishes packaging, the resulting `spritesheet.webp` (under
> `${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/`) can be imported into the
> floating pet companion via **Settings → Pets → Import Codex sprite**. The
> import flow auto-detects the 8×9 / `192×208` atlas and lets the user pick
> which animation row to play (idle, running-right, waving, …).
## Overview
Create a Codex-compatible animated pet from a concept, one or more reference images, or both. This skill owns pet-specific prompt planning, animation rows, frame extraction, atlas geometry, QA, previews, and packaging. It delegates visual generation to `$imagegen`.
User-facing inputs are optional. If the user omits a pet name, infer one from the concept or reference filenames; if that is not possible, choose a short appropriate name. If the user omits a description, infer one from the concept or references. If the user omits reference images, generate the base pet from text first, then use that base as the canonical reference for every animation row.
## Generation Delegation
Use `$imagegen` for all normal visual generation.
Before generating base art, row strips, or repair rows, load and follow the installed image generation skill:
```text
${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/SKILL.md
```
Do not call the Image API directly for the normal path. Let `$imagegen` choose its own built-in-first path and its own CLI fallback rules. If `$imagegen` says a fallback requires confirmation, ask the user before continuing.
When invoking `$imagegen` from this skill, pass the generated pet prompt as the authoritative visual spec. Do not wrap it in the generic `$imagegen` shared prompt schema and do not add extra polish, hero-art, photo, product, or illustration-style augmentation. Pet prompts should stay terse, sprite-specific, and digital-pet oriented; only add role labels for input images and any essential user constraint.
Use this skill's scripts for deterministic work only: preparing prompts and manifests, ingesting selected `$imagegen` outputs, extracting frames, validating rows, composing the final atlas, creating QA media, and packaging.
Hard boundary: do not create, draw, tile, warp, mirror, or synthesize pet visuals with local Python/Pillow scripts, SVG, canvas, HTML/CSS, or other code-native art as a substitute for `$imagegen`. For a normal pet run, expect up to 10 visual generation jobs: 1 base pet plus 9 row-strip jobs. The only exception is `running-left`, which may be derived by mirroring `running-right` only after `running-right` has been generated, visually inspected, and explicitly approved as safe to mirror. If mirroring is not appropriate, generate `running-left` as a normal grounded `$imagegen` row. If those calls are too expensive, blocked, or unavailable, stop and explain the blocker instead of fabricating row strips locally.
Do not mark visual jobs complete by editing `imagegen-jobs.json`, copying files into `decoded/`, or writing helper scripts that populate row outputs. Use `record_imagegen_result.py` for selected built-in `$imagegen` outputs, or `generate_pet_images.py` only for the documented secondary fallback. The deterministic scripts may only process already-generated visual outputs.
Only the base job may be prompt-only. Every row-strip job generated through `$imagegen` must use the input images listed in `imagegen-jobs.json`, including the canonical base reference created after the base job is recorded. Treat any row generation without attached grounding images as invalid.
## Codex Digital Pet Style
Default pet art should match the Codex app's built-in digital pets: small pixel-art-adjacent mascots with compact chibi proportions, chunky readable silhouettes, thick dark 1-2 px outlines, visible stepped/pixel edges, limited palettes, flat cel shading, simple expressive faces, and tiny limbs. Even if the reference art is more detailed, complex or realistic, the generated pet should be simplified into this style.
Do NOT generate polished illustration, painterly rendering, anime key art, 3D rendering, glossy app-icon treatment, realistic fur or material texture, soft gradients, high-detail antialiasing, and complex tiny accessories. References that are more detailed than this should be simplified into the house style before row generation.
## Transparency And Effects
Pet rows are processed into transparent 192x208 cells, so every generated pixel must either belong to the pet sprite or be cleanly removable chroma-key background. Prefer pose, expression, and silhouette changes over decorative effects.
Allowed effects must satisfy all of these conditions:
- The effect is state-relevant and helps explain the animation.
- The effect is physically attached to, touching, or overlapping the pet silhouette, not floating nearby.
- The effect is inside the same frame slot as the pet and does not create a separate sprite component.
- The effect is opaque, hard-edged, pixel-style, and uses non-chroma-key colors.
- The effect is small enough to remain readable at 192x208 without clutter.
Examples of allowed effects: a tear touching the face, a small smoke puff touching the box or head, or tiny stars overlapping the pet during a failed/dizzy reaction.
Avoid these by default because they usually break transparent-background cleanup or component extraction:
- wave marks, motion arcs, speed lines, action streaks, afterimages, blur, or smears
- detached stars, loose sparkles, floating punctuation, floating 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, thought 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 any pose that crosses into a neighboring frame slot
State-specific guidance:
- `waving`: show the wave through paw pose only. Do not draw wave marks, motion arcs, lines, sparkles, or symbols around the paw.
- `jumping`: show vertical motion through body position only. Do not draw shadows, dust, landing marks, impact bursts, bounce pads, or floor cues.
- `failed`: tears, attached smoke puffs, or attached stars are allowed if they obey the allowed-effects rules; do not use red X marks, floating symbols, detached smoke, detached stars, or separate tear droplets.
- `review`: show focus through lean, blink, eyes, head tilt, or paw position. Do not add magnifying glasses, papers, code, UI, punctuation, or symbols unless that prop already exists in the base pet identity.
- `running-right`, `running-left`, and `running`: show locomotion through body, limb, and prop movement only. Do not draw speed lines, dust clouds, floor shadows, or motion trails.
## Pet Naming
Ask the user for a pet name when they have not provided one and only if the conversation naturally allows it. If asking would slow down a direct execution request, choose a short appropriate name from the pet concept, reference image, or personality, then use that name consistently as the display name and as the source for the package folder slug.
Good built-in style examples:
- Codex - The original Codex companion.
- Dewey - A tidy duck for calm workspace days.
- Fireball - Hot path energy for fast iteration.
- Rocky - A steady rock when the diff gets large.
- Seedy - Small green shoots for new ideas.
- Stacky - A balanced stack for deep work.
- BSOD - A tiny blue-screen gremlin.
- Null Signal - Quiet signal from the void.
## Visible Progress Plan
For every pet run, keep a visible checklist so the user can see where the work is up to. Create the checklist before starting, keep one step active at a time, and update it as each step finishes.
Before creating the checklist, establish the pet name when possible. Use the user-provided name when available; otherwise infer a short appropriate name from the concept or references. If the name is too long, not settled, or not appropriate for a friendly checklist, use `your pet` instead.
Use this checklist for a normal pet run, 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>`.
What each step means:
- `Getting <Pet> ready.` Choose or confirm the pet name, description, source images, and working folder.
- `Imagining <Pet>'s main look.` Generate the pet's main reference image. This is required for new pets, even when the user does not provide an image, because it becomes the visual source of truth.
- `Picturing <Pet>'s poses.` Create the pose rows, starting with `idle` and `running-right` to confirm the pet still looks consistent. Only mirror `running-left` if `running-right` clearly works when flipped.
- `Hatching <Pet>.` Turn the approved poses into the final pet files, review the contact sheet, previews, and validation results, fix any broken parts, save `pet.json` and `spritesheet.webp` into the pet folder, then tell the user where the pet and QA files were saved.
Only mark a step complete when the real file, image, or decision exists. If this is just a repair run, start from the first relevant step instead of restarting the whole checklist.
## Default Workflow
1. Prepare a pet run folder and imagegen job manifest:
```bash
SKILL_DIR="${CODEX_HOME:-$HOME/.codex}/skills/hatch-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>" \
--style-notes "<style notes>" \
--force
```
All arguments above are optional except any flags needed to express user constraints. For text-only requests, pass the concept through `--pet-notes` and omit `--reference`; `prepare_pet_run.py` will infer a name, description, chroma key, and output directory as needed.
2. Inspect the next ready `$imagegen` jobs:
```bash
python "$SKILL_DIR/scripts/pet_job_status.py" --run-dir /absolute/path/to/run
```
3. For each ready job, invoke `$imagegen` with:
- the prompt file listed in `imagegen-jobs.json`
- every input image listed for the job, with its role label
- the default built-in `image_gen` path unless `$imagegen` itself routes otherwise
The base job must complete first. If user references exist, the base job uses them. If no references exist, the base job may be prompt-only. After recording the base, `record_imagegen_result.py` writes `decoded/base.png` and `references/canonical-base.png`; all row jobs use the original references if present plus those canonical base images.
`prepare_pet_run.py` also creates 9 row-specific layout guide images under `references/layout-guides/`, one per animation state. Row jobs attach the matching guide as a layout-only input so the model can follow the correct frame count, spacing, centering, and safe padding. Treat these guides as invisible construction references: the generated row strip must not include visible boxes, borders, center marks, labels, guide colors, or the guide background.
When generating row strips, keep the identity lock in the row prompt authoritative: do not redesign the pet, and preserve the same head shape, face, markings, palette, prop, outline weight, body proportions, and silhouette. A row that looks like a related but different pet is failed even if the deterministic geometry QA passes.
Generate and record `running-right` before deciding how to complete `running-left`. Inspect `running-right` against the base and references. If the pet is visually symmetric enough that a horizontal mirror preserves identity, prop placement, handedness, markings, lighting, text-free details, and direction semantics, derive `running-left` with:
```bash
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>"
```
If there is any asymmetric side-specific marking, readable text, non-mirrored logo, handed prop, one-sided accessory, lighting cue, or direction-specific pose that would become wrong when flipped, do not mirror. Generate `running-left` with `$imagegen` using its row prompt and all listed grounding images, including `decoded/running-right.png` as a gait reference.
For the built-in path, record the selected source image from `$CODEX_HOME/generated_images/.../ig_*.png`. Do not record files from the run directory, `tmp/`, hand-made fixtures, deterministic row folders, or post-processed copies as visual job sources.
4. After selecting a generated output for a job, ingest it:
```bash
python "$SKILL_DIR/scripts/record_imagegen_result.py" \
--run-dir /absolute/path/to/run \
--job-id <job-id> \
--source /absolute/path/to/generated-output.png
```
This copies the image to the exact decoded path expected by the deterministic pipeline and records source metadata in `imagegen-jobs.json`.
5. When all jobs are complete, finalize:
```bash
python "$SKILL_DIR/scripts/finalize_pet_run.py" \
--run-dir /absolute/path/to/run
```
Expected output:
```text
run/
pet_request.json
imagegen-jobs.json
prompts/
decoded/
frames/frames-manifest.json
final/spritesheet.png
final/spritesheet.webp
final/validation.json
qa/contact-sheet.png
qa/review.json
qa/run-summary.json
qa/videos/*.mp4
```
Package output is written outside the run directory by default. If `CODEX_HOME` is set, use it; otherwise use `$HOME/.codex`.
```text
${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/
pet.json
spritesheet.webp
```
Review `qa/contact-sheet.png`, `qa/review.json`, `final/validation.json`, and `qa/videos/` before accepting the pet.
Deterministic validation is necessary but not sufficient. Before calling the pet done, visually inspect the contact sheet for identity consistency. Block acceptance if any row changes species/body type, face, markings, palette, prop design, prop side unexpectedly, or overall silhouette.
## Subagent Row Generation
After the base job has been recorded and `references/canonical-base.png` exists, row-strip visual generation must use subagents unless the user explicitly says not to use subagents for this session. Before row generation, state that subagents are being used and which row jobs are being delegated. If subagents cannot be spawned because the current environment or tool policy blocks them, stop before row-strip generation, explain the blocker, and ask for explicit user direction before continuing sequentially.
The parent agent must own the manifest and package writes.
Default flow:
1. Parent runs `prepare_pet_run.py`.
2. Parent generates and records `base`.
3. Parent runs `pet_job_status.py`.
4. Parent spawns subagents for `idle` and `running-right` first as identity and gait checks.
5. Parent records the selected `idle` and `running-right` results returned by subagents.
6. Parent decides whether `running-left` is safe to derive by mirror; if not, parent treats it as a normal grounded row job delegated to a subagent.
7. Parent spawns subagents for every remaining non-derived row image-generation job.
8. Each subagent receives the row prompt and every listed input image path, invokes `$imagegen`, and returns only the selected `$CODEX_HOME/generated_images/.../ig_*.png` source path.
9. Parent alone runs `record_imagegen_result.py`, `derive_running_left_from_running_right.py`, repair queueing, finalization, QA, and packaging.
Subagent write boundary: do not let subagents edit `imagegen-jobs.json`, copy files into `decoded/`, run `record_imagegen_result.py`, run `derive_running_left_from_running_right.py`, run `finalize_pet_run.py`, or package the pet. This avoids manifest races and keeps provenance checks centralized.
Subagent handoff contract:
- Give each subagent exactly one row job unless you are intentionally batching adjacent simple rows.
- Include the row id, the absolute prompt file path, the full prompt text or an instruction to read that exact prompt file, and every input image path with its role label from `imagegen-jobs.json`.
- Explicitly remind the subagent that the prompt's transparency and effects rules are mandatory: no detached effects, no wave marks for `waving`, no speed lines or dust for running rows, and only attached opaque sprite-like tears/smoke/stars when allowed by the state prompt.
- Tell the subagent to inspect the generated candidate for frame count, identity consistency, clean flat chroma-key background, safe spacing, and forbidden detached effects before returning it.
- Tell the subagent to return only the selected original `$CODEX_HOME/generated_images/.../ig_*.png` source path plus a one-sentence QA note. The parent decides whether to record or repair it.
Use this template for each subagent:
```text
Generate the `<row-id>` row for this hatch-pet run.
Run dir: <absolute run dir>
Prompt file: <absolute prompt file>
Input images:
- <absolute path> — <role>
- <absolute path> — <role>
Read and follow the row prompt exactly, including the Transparency and artifact rules. Use `$imagegen` only; do not use local scripts to draw, tile, edit, or synthesize sprites.
Before returning, visually check:
- exact requested frame count
- same pet identity as the canonical base
- clean flat chroma-key background
- complete, separated, unclipped poses
- no forbidden detached effects or slot-crossing artifacts
Do not edit manifests, copy into decoded, record results, mirror rows, finalize, repair, or package. Return only:
selected_source=/absolute/path/to/$CODEX_HOME/generated_images/.../ig_*.png
qa_note=<one sentence>
```
No silent sequential fallback: if subagents cannot be used for row-strip visual generation, stop and ask for explicit user direction before continuing without them. Only an explicit user instruction such as "do not use subagents" or "run this sequentially" authorizes a normal sequential row-generation path. The final answer must report which row jobs were delegated to subagents and which, if any, were mirrored or repaired by the parent.
## Repair Workflow
If finalization stops because row QA failed, queue targeted repair jobs:
```bash
python "$SKILL_DIR/scripts/queue_pet_repairs.py" \
--run-dir /absolute/path/to/run
```
Then repeat the `$imagegen` generation and `record_imagegen_result.py` ingest loop for each reopened row job. Regenerate the smallest failing scope: 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. Repair only the failed row while preserving the canonical pet identity.
## Secondary Image Generation Fallback
`scripts/generate_pet_images.py` is a secondary fallback for this skill.
Use it only when the installed `$imagegen` system skill is unavailable or cannot be invoked in the current environment. Normal pet creation should delegate visual generation to `$imagegen`, because `$imagegen` owns the built-in-first image generation policy and its own CLI fallback behavior.
Run the secondary fallback only after explaining why `$imagegen` cannot be used:
```bash
python "$SKILL_DIR/scripts/generate_pet_images.py" \
--run-dir /absolute/path/to/run \
--model gpt-image-2 \
--states all
```
The secondary fallback requires `OPENAI_API_KEY`.
## Rules
- Keep `$imagegen` as the primary generation layer.
- Keep reference images attached/visible for `$imagegen` whenever the chosen path supports references.
- Attach the row's `references/layout-guides/<state>.png` image to every row-strip job as a layout-only guide, and do not accept outputs that copy guide pixels.
- Use subagents for row-strip visual generation after the parent records the base image. The parent may generate the base, but row-strip jobs belong to subagents unless the user explicitly says not to use subagents for this session.
- Generate every normal visual job with `$imagegen`: base plus all row strips that are not explicitly approved `running-left` mirror derivations.
- Treat only the base job as eligible for prompt-only generation; every row job must attach its listed grounding images.
- Delegate `running-right` first, then mirror `running-left` only when visual inspection confirms a mirror preserves identity and semantics; otherwise delegate `running-left` as a normal grounded `$imagegen` row.
- Never substitute locally drawn, tiled, transformed, or code-generated row strips for missing `$imagegen` outputs.
- Never manually mutate `imagegen-jobs.json` to claim a visual job completed.
- Do not rely on generated images for exact atlas geometry; use this skill's deterministic scripts.
- Use the chroma key stored in `pet_request.json`; do not force a fixed green screen.
- Keep the pet's silhouette, face, materials, palette, and props consistent across all rows.
- Enforce the transparency and effects rules above in every base, row, and repair prompt.
- Treat visual identity drift as a blocker even when `qa/review.json` and `final/validation.json` have no errors.
- Treat a contact sheet that shows cropped references, repeated tiles, white cell backgrounds, or non-sprite fragments as failed.
- Treat forbidden detached effects, chroma-key-adjacent artifacts, shadows, glows, smears, dust, landing marks, wave marks, speed lines, or motion trails as failed rows.
- Treat `qa/review.json` errors as blockers. Warnings require visual review.
## Acceptance Criteria
- Final atlas is PNG or WebP, `1536x1872`, transparent-capable, and based on `192x208` cells.
- 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 preview videos have been produced unless explicitly skipped.
- `qa/review.json` has no errors.
- Row-by-row review confirms the animation cycles are complete enough for the Codex app.
- `${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/pet.json` and `${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/spritesheet.webp` are staged together for custom pets.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Hatch Pet"
short_description: "Hatch Codex-compatible animated pet spritesheets"
default_prompt: "Hatch a Codex-compatible animated pet from a concept, reference images, or both. Infer missing names/descriptions, use $imagegen for the base and grounded row strips, generate running-right before deciding whether running-left can be safely mirrored, then use this skill's deterministic scripts to ingest outputs, validate frames, assemble the spritesheet, and package the pet under ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/."
@@ -0,0 +1,29 @@
# Animation Rows
The Codex app reads 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`: neutral breathing/blinking loop; use as the reduced-motion first frame.
- `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.
- `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`: patient idle variant; glance, small bounce, or prop motion.
- `running`: generic/front-facing or in-place run loop.
- `review`: focused/inspecting/thinking loop suitable for review state.
@@ -0,0 +1,35 @@
# 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:
```text
${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/
├── pet.json
└── spritesheet.webp
```
Manifest shape:
```json
{
"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/`.
+60
View File
@@ -0,0 +1,60 @@
# QA Rubric
Do not accept an atlas until all checks pass.
## Geometry
- Exact `1536x1872` dimensions.
- 8 columns x 9 rows.
- Each frame fits inside its `192x208` cell.
- Unused cells are transparent.
- `qa/review.json` has no errors.
- `frames/frames-manifest.json` records component extraction for production rows, unless slot extraction was intentionally accepted after visual inspection.
## Character Consistency
- Same silhouette and proportions across every row.
- Same face and expression language.
- Same material, palette, lighting, and prop design.
- No frame introduces a new unintended character or object.
## Sprite Style
- Art reads as a Codex digital pet sprite, not a polished illustration or glossy app icon.
- Silhouette is compact and chunky enough to read inside a `192x208` cell.
- Outlines are dark and simple, with visible stepped/pixel-style edges.
- Palette is limited, with flat cel shading and minimal highlights or shadow steps.
- No painterly texture, realistic fur/material detail, soft gradients, high-detail antialiasing, or tiny accessories that disappear 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.
- State-specific actions are recognizable at pet size.
- Poses are generated animation variants, not repeated copies of the same source image.
## App Fitness
- First idle frame works as a static reduced-motion pet.
- 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.json` reports 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.json` reports chroma-adjacent non-transparent pixels, repair the row unless those pixels are an intentional character color and the selected key was manually accepted.
## 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 queue targeted repair jobs for failing rows. Manual repair should preserve the same run directory and regenerate only the affected row prompt/image unless the base character is wrong.
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Compose or normalize a Codex 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 save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None:
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)
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()
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Conditionally derive running-left by mirroring the approved running-right strip."""
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from PIL import Image, ImageOps
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 file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
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 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 = ImageOps.mirror(image.convert("RGBA"))
mirrored.save(output)
left_job["status"] = "complete"
left_job["source_path"] = manifest_relative(source, run_dir)
left_job["source_provenance"] = "deterministic-mirror"
left_job["derived_from"] = "running-right"
left_job["source_sha256"] = file_sha256(source)
left_job["output_sha256"] = file_sha256(output)
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(),
}
for key in [
"last_error",
"secondary_fallback",
"synthetic_test_source",
"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(),
},
indent=2,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,323 @@
#!/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] = (red, green, blue, 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 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 extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | 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 [fit_to_cell(component_group_image(strip, group)) for group in groups]
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_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:
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"),
default="auto",
help="Use connected sprite components when possible, or fixed equal slots.",
)
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()
@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""Finalize a Codex pet run after all imagegen jobs are complete."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
from PIL import Image, ImageOps
def run(command: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
print("+ " + " ".join(command))
return subprocess.run(command, check=check, text=True)
def load_json(path: Path) -> dict[str, object]:
return json.loads(path.read_text(encoding="utf-8"))
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
def default_generated_images_root() -> Path:
return default_codex_home() / "generated_images"
def default_codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
def manifest_path(raw: object, *, run_dir: Path, field: str, job_id: str) -> Path:
if not isinstance(raw, str) or not raw:
raise SystemExit(f"job {job_id} has no {field}")
path = Path(raw).expanduser()
if not path.is_absolute():
path = run_dir / path
return path.resolve()
def validate_hash(job: dict[str, object], *, source: Path, output: Path, job_id: str) -> None:
expected_hash = job.get("source_sha256")
if not isinstance(expected_hash, str) or not expected_hash:
raise SystemExit(
f"job {job_id} is missing source_sha256; ingest visual outputs with "
"record_imagegen_result.py instead of editing imagegen-jobs.json"
)
if not source.is_file():
raise SystemExit(f"job {job_id} source image no longer exists: {source}")
if not output.is_file():
raise SystemExit(f"job {job_id} decoded output is missing: {output}")
source_hash = file_sha256(source)
output_hash = file_sha256(output)
if source_hash != expected_hash:
raise SystemExit(f"job {job_id} source image hash does not match imagegen-jobs.json")
if output_hash != expected_hash:
raise SystemExit(
f"job {job_id} decoded output does not match its recorded source image; "
"do not rewrite decoded visual outputs locally"
)
def validate_mirror_hash(job: dict[str, object], *, source: Path, output: Path, job_id: str) -> None:
if job_id != "running-left":
raise SystemExit(f"job {job_id} may not use deterministic mirror provenance")
if job.get("derived_from") != "running-right":
raise SystemExit("running-left mirror job must derive from running-right")
decision = job.get("mirror_decision")
if not isinstance(decision, dict) or decision.get("approved") is not True:
raise SystemExit(
"running-left mirror job is missing an approved mirror_decision; "
"use derive_running_left_from_running_right.py after visual review"
)
expected_source_hash = job.get("source_sha256")
expected_output_hash = job.get("output_sha256")
if not isinstance(expected_source_hash, str) or not expected_source_hash:
raise SystemExit("running-left mirror job is missing source_sha256")
if not isinstance(expected_output_hash, str) or not expected_output_hash:
raise SystemExit("running-left mirror job is missing output_sha256")
if not source.is_file():
raise SystemExit(f"running-left mirror source image no longer exists: {source}")
if not output.is_file():
raise SystemExit(f"running-left mirrored output is missing: {output}")
if source.name != "running-right.png" or source.parent.name != "decoded":
raise SystemExit("running-left mirror source must be decoded/running-right.png")
if output.name != "running-left.png" or output.parent.name != "decoded":
raise SystemExit("running-left mirror output must be decoded/running-left.png")
if file_sha256(source) != expected_source_hash:
raise SystemExit("running-left mirror source hash does not match imagegen-jobs.json")
if file_sha256(output) != expected_output_hash:
raise SystemExit(
"running-left mirrored output hash does not match imagegen-jobs.json; "
"rerun derive_running_left_from_running_right.py"
)
with Image.open(source) as source_image, Image.open(output) as output_image:
expected = ImageOps.mirror(source_image.convert("RGBA"))
actual = output_image.convert("RGBA")
if expected.size != actual.size or expected.tobytes() != actual.tobytes():
raise SystemExit(
"running-left mirrored output is not an exact horizontal mirror of running-right"
)
def validate_completed_job_source(
job: dict[str, object],
*,
run_dir: Path,
allow_synthetic_test_sources: bool,
) -> None:
job_id = str(job.get("id") or "")
source = manifest_path(job.get("source_path"), run_dir=run_dir, field="source_path", job_id=job_id)
output = manifest_path(job.get("output_path"), run_dir=run_dir, field="output_path", job_id=job_id)
blocked_flags = [
flag
for flag in ("deterministic_pet_row", "cute_raster_row", "local_raster_row")
if job.get(flag)
]
if blocked_flags:
raise SystemExit(
f"job {job_id} was marked as a local/synthetic row ({', '.join(blocked_flags)}); "
"regenerate it with $imagegen"
)
if job.get("synthetic_test_source"):
if not allow_synthetic_test_sources:
raise SystemExit(
f"job {job_id} uses a synthetic test source; rerun with real $imagegen output"
)
validate_hash(job, source=source, output=output, job_id=job_id)
return
if job.get("secondary_fallback"):
if job.get("source_provenance") != "secondary-fallback-image-api":
raise SystemExit(f"job {job_id} has invalid secondary fallback provenance")
validate_hash(job, source=source, output=output, job_id=job_id)
return
if job.get("source_provenance") == "deterministic-mirror":
validate_mirror_hash(job, source=source, output=output, job_id=job_id)
return
if job.get("source_provenance") != "built-in-imagegen":
raise SystemExit(
f"job {job_id} was not recorded as a built-in $imagegen output; "
"use record_imagegen_result.py with the selected $CODEX_HOME/generated_images/.../ig_*.png file"
)
if is_relative_to(source, run_dir):
raise SystemExit(
f"job {job_id} source image is inside the pet run directory; "
"do not use locally generated row artifacts as visual sources"
)
generated_root = default_generated_images_root()
if not is_relative_to(source, generated_root) or not source.name.startswith("ig_"):
raise SystemExit(
f"job {job_id} source image is not a built-in $imagegen output under "
f"{generated_root}/.../ig_*.png"
)
validate_hash(job, source=source, output=output, job_id=job_id)
def require_complete_jobs(run_dir: Path, *, allow_synthetic_test_sources: bool) -> None:
manifest_path = run_dir / "imagegen-jobs.json"
manifest = load_json(manifest_path)
jobs = manifest.get("jobs")
if not isinstance(jobs, list):
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
incomplete = [
str(job.get("id"))
for job in jobs
if isinstance(job, dict) and job.get("status", "pending") != "complete"
]
if incomplete:
raise SystemExit(
"imagegen jobs are not complete; run pet_job_status.py and finish: "
+ ", ".join(incomplete)
)
for job in jobs:
if isinstance(job, dict):
validate_completed_job_source(
job,
run_dir=run_dir,
allow_synthetic_test_sources=allow_synthetic_test_sources,
)
def review_failures(review: dict[str, object]) -> list[str]:
rows = review.get("rows")
if not isinstance(rows, list):
return ["review did not contain row-level results"]
failures = []
for row in rows:
if not isinstance(row, dict):
continue
errors = row.get("errors")
if isinstance(errors, list) and errors:
failures.append(f"{row.get('state')}: {'; '.join(str(error) for error in errors)}")
return failures
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", required=True)
parser.add_argument("--allow-slot-extraction", action="store_true")
parser.add_argument("--skip-videos", action="store_true")
parser.add_argument("--skip-package", action="store_true")
parser.add_argument(
"--package-dir",
default="",
help="Exact pet package directory. Defaults to ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>.",
)
parser.add_argument("--ffmpeg", default="")
parser.add_argument("--allow-synthetic-test-sources", action="store_true", help=argparse.SUPPRESS)
args = parser.parse_args()
scripts_dir = Path(__file__).resolve().parent
run_dir = Path(args.run_dir).expanduser().resolve()
request = load_json(run_dir / "pet_request.json")
pet_id = str(request.get("pet_id") or "")
display_name = str(request.get("display_name") or "")
description = str(request.get("description") or "")
if not pet_id or not display_name or not description:
raise SystemExit("pet_request.json is missing pet_id, display_name, or description")
require_complete_jobs(
run_dir,
allow_synthetic_test_sources=args.allow_synthetic_test_sources,
)
final_dir = run_dir / "final"
qa_dir = run_dir / "qa"
final_dir.mkdir(parents=True, exist_ok=True)
qa_dir.mkdir(parents=True, exist_ok=True)
run(
[
sys.executable,
str(scripts_dir / "extract_strip_frames.py"),
"--decoded-dir",
str(run_dir / "decoded"),
"--output-dir",
str(run_dir / "frames"),
"--states",
"all",
"--method",
"auto",
]
)
review_path = qa_dir / "review.json"
inspect_command = [
sys.executable,
str(scripts_dir / "inspect_frames.py"),
"--frames-root",
str(run_dir / "frames"),
"--json-out",
str(review_path),
]
if not args.allow_slot_extraction:
inspect_command.append("--require-components")
run(inspect_command, check=False)
review = load_json(review_path)
if not review.get("ok"):
failures = review_failures(review)
print(
json.dumps(
{
"ok": False,
"review": str(review_path),
"repair_hint": "Run queue_pet_repairs.py, regenerate the reopened row jobs with $imagegen, then finalize again.",
"failures": failures,
},
indent=2,
)
)
raise SystemExit(1)
run(
[
sys.executable,
str(scripts_dir / "compose_atlas.py"),
"--frames-root",
str(run_dir / "frames"),
"--output",
str(final_dir / "spritesheet.png"),
"--webp-output",
str(final_dir / "spritesheet.webp"),
]
)
run(
[
sys.executable,
str(scripts_dir / "validate_atlas.py"),
str(final_dir / "spritesheet.webp"),
"--json-out",
str(final_dir / "validation.json"),
]
)
run(
[
sys.executable,
str(scripts_dir / "make_contact_sheet.py"),
str(final_dir / "spritesheet.webp"),
"--output",
str(qa_dir / "contact-sheet.png"),
]
)
if not args.skip_videos:
video_command = [
sys.executable,
str(scripts_dir / "render_animation_videos.py"),
str(final_dir / "spritesheet.webp"),
"--output-dir",
str(qa_dir / "videos"),
]
if args.ffmpeg:
video_command.extend(["--ffmpeg", args.ffmpeg])
run(video_command)
if not args.skip_package:
package_command = [
sys.executable,
str(scripts_dir / "package_custom_pet.py"),
"--pet-name",
pet_id,
"--display-name",
display_name,
"--description",
description,
"--spritesheet",
str(final_dir / "spritesheet.webp"),
"--force",
]
if args.package_dir:
package_command.extend(["--output-dir", str(Path(args.package_dir).expanduser().resolve())])
run(package_command)
package_dir = None
if not args.skip_package:
package_dir = (
Path(args.package_dir).expanduser().resolve()
if args.package_dir
else default_codex_home() / "pets" / pet_id
)
summary = {
"ok": True,
"run_dir": str(run_dir),
"spritesheet": str(final_dir / "spritesheet.webp"),
"validation": str(final_dir / "validation.json"),
"contact_sheet": str(qa_dir / "contact-sheet.png"),
"review": str(review_path),
"videos": None if args.skip_videos else str(qa_dir / "videos"),
"package": None if package_dir is None else str(package_dir),
}
summary_path = qa_dir / "run-summary.json"
summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""Secondary image generation fallback for Codex pet base art and row strips."""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
ALL_STATES = [
"idle",
"running-right",
"running-left",
"waving",
"jumping",
"failed",
"waiting",
"running",
"review",
]
CANONICAL_BASE_PATH = "references/canonical-base.png"
def parse_states(raw: str) -> list[str]:
if raw.strip().lower() == "all":
return ALL_STATES
states = [item.strip() for item in raw.split(",") if item.strip()]
unknown = sorted(set(states) - set(ALL_STATES))
if unknown:
raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
return states
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 manifest_jobs(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 select_jobs(
manifest: dict[str, object],
*,
states: list[str],
skip_base: bool,
job_ids: list[str],
) -> list[dict[str, object]]:
selected_ids = set(job_ids)
if not selected_ids:
if not skip_base:
selected_ids.add("base")
selected_ids.update(states)
selected = [job for job in manifest_jobs(manifest) if job.get("id") in selected_ids]
missing = selected_ids - {str(job.get("id")) for job in selected}
if missing:
raise SystemExit(f"unknown job id(s): {', '.join(sorted(missing))}")
return selected
def run_image_edit(
*,
model: str,
prompt_file: Path,
image_paths: list[Path],
output_json: Path,
size: str,
api_key: str,
) -> dict[str, object]:
output_json.parent.mkdir(parents=True, exist_ok=True)
command = [
"curl",
"-sS",
"-X",
"POST",
"https://api.openai.com/v1/images/edits",
"-H",
f"Authorization: Bearer {api_key}",
"-F",
f"model={model}",
]
for image_path in image_paths:
command.extend(["-F", f"image[]=@{image_path}"])
command.extend(
[
"-F",
f"prompt=<{prompt_file}",
"-F",
f"size={size}",
"-F",
"output_format=png",
"-o",
str(output_json),
]
)
subprocess.run(command, check=True)
response = json.loads(output_json.read_text(encoding="utf-8"))
if response.get("error"):
raise SystemExit(json.dumps(response["error"], indent=2))
return response
def run_image_generation(
*,
model: str,
prompt_file: Path,
output_json: Path,
size: str,
api_key: str,
) -> dict[str, object]:
output_json.parent.mkdir(parents=True, exist_ok=True)
command = [
"curl",
"-sS",
"-X",
"POST",
"https://api.openai.com/v1/images/generations",
"-H",
f"Authorization: Bearer {api_key}",
"-F",
f"model={model}",
"-F",
f"prompt=<{prompt_file}",
"-F",
f"size={size}",
"-F",
"output_format=png",
"-o",
str(output_json),
]
subprocess.run(command, check=True)
response = json.loads(output_json.read_text(encoding="utf-8"))
if response.get("error"):
raise SystemExit(json.dumps(response["error"], indent=2))
return response
def decode_response(response: dict[str, object], output_image: Path) -> None:
data = response.get("data")
if not isinstance(data, list) or not data:
raise SystemExit("image API response did not contain data[0]")
first = data[0]
if not isinstance(first, dict) or not isinstance(first.get("b64_json"), str):
raise SystemExit("image API response did not contain data[0].b64_json")
output_image.parent.mkdir(parents=True, exist_ok=True)
output_image.write_bytes(base64.b64decode(first["b64_json"]))
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def complete_job(job: dict[str, object], output_path: Path) -> None:
job["status"] = "complete"
job["source_path"] = str(output_path)
job["source_provenance"] = "secondary-fallback-image-api"
job["source_sha256"] = file_sha256(output_path)
job["output_sha256"] = file_sha256(output_path)
job["completed_at"] = datetime.now(timezone.utc).isoformat()
job["secondary_fallback"] = True
for key in [
"last_error",
"synthetic_test_source",
"derived_from",
"mirror_decision",
"repair_reason",
"queued_at",
]:
job.pop(key, None)
def write_canonical_base(
run_dir: Path, manifest: dict[str, object], output_image: Path
) -> None:
canonical = run_dir / CANONICAL_BASE_PATH
canonical.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(output_image, canonical)
reference = {
"path": CANONICAL_BASE_PATH,
"source_job": "base",
"sha256": file_sha256(canonical),
}
manifest["canonical_identity_reference"] = reference
request_path = run_dir / "pet_request.json"
if request_path.exists():
request = json.loads(request_path.read_text(encoding="utf-8"))
request["canonical_identity_reference"] = reference
request_path.write_text(json.dumps(request, indent=2) + "\n", encoding="utf-8")
def path_list(run_dir: Path, job: dict[str, object]) -> list[Path]:
inputs = job.get("input_images")
if not isinstance(inputs, list):
raise SystemExit(f"job {job.get('id')} has invalid input_images")
paths = []
for item in inputs:
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
raise SystemExit(f"job {job.get('id')} has invalid input image entry")
path = run_dir / item["path"]
if not path.is_file():
raise SystemExit(f"input image for job {job.get('id')} not found: {path}")
paths.append(path)
return paths
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", required=True)
parser.add_argument("--model", default="gpt-image-2")
parser.add_argument("--size", default="1024x1024")
parser.add_argument("--states", default="all")
parser.add_argument("--job-id", action="append", default=[])
parser.add_argument("--skip-base", action="store_true")
args = parser.parse_args()
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise SystemExit("OPENAI_API_KEY is not set")
run_dir = Path(args.run_dir).expanduser().resolve()
manifest_path = run_dir / "imagegen-jobs.json"
manifest = load_manifest(run_dir)
jobs = select_jobs(
manifest,
states=parse_states(args.states),
skip_base=args.skip_base,
job_ids=args.job_id,
)
raw_dir = run_dir / "raw"
completed = []
for job in jobs:
job_id = str(job.get("id"))
prompt_raw = job.get("prompt_file")
output_raw = job.get("output_path")
if not isinstance(prompt_raw, str) or not isinstance(output_raw, str):
raise SystemExit(f"job {job_id} is missing prompt_file or output_path")
prompt_file = run_dir / prompt_raw
output_image = run_dir / output_raw
print(f"Generating {job_id} with secondary fallback")
image_paths = path_list(run_dir, job)
if image_paths:
response = run_image_edit(
model=args.model,
prompt_file=prompt_file,
image_paths=image_paths,
output_json=raw_dir / f"{job_id}.response.json",
size=args.size,
api_key=api_key,
)
else:
response = run_image_generation(
model=args.model,
prompt_file=prompt_file,
output_json=raw_dir / f"{job_id}.response.json",
size=args.size,
api_key=api_key,
)
decode_response(response, output_image)
complete_job(job, output_image)
if job_id == "base":
job["canonical_reference_path"] = CANONICAL_BASE_PATH
write_canonical_base(run_dir, manifest, output_image)
completed.append({"job_id": job_id, "output": str(output_image)})
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"ok": True, "completed": completed}, indent=2))
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Inspect extracted Codex 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":
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.",
)
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()
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Create a labeled contact sheet from a Codex 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()
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Package a validated atlas as a local Codex pet."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
from pathlib import Path
from PIL import Image
ATLAS_SIZE = (1536, 1872)
def default_codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
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 validate_spritesheet(path: Path) -> str:
with Image.open(path) as image:
if image.size != ATLAS_SIZE:
raise SystemExit(
f"expected {ATLAS_SIZE[0]}x{ATLAS_SIZE[1]}, got {image.width}x{image.height}"
)
if image.format not in {"PNG", "WEBP"}:
raise SystemExit(f"expected PNG or WebP, got {image.format}")
return str(image.format)
def write_webp_spritesheet(source: Path, target: Path, source_format: str) -> None:
if source_format == "WEBP":
shutil.copy2(source, target)
return
with Image.open(source) as image:
target.parent.mkdir(parents=True, exist_ok=True)
image.convert("RGBA").save(
target,
format="WEBP",
lossless=True,
quality=100,
method=6,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pet-name", default="")
parser.add_argument("--display-name", default="")
parser.add_argument("--description", required=True)
parser.add_argument("--spritesheet", required=True)
parser.add_argument("--codex-home", default=str(default_codex_home()))
parser.add_argument(
"--output-dir",
help="Exact pet package directory. Defaults to ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>.",
)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
raw_pet_name = (args.pet_name or args.display_name).strip()
if not raw_pet_name:
raise SystemExit("pet name is required")
pet_id = slugify(raw_pet_name)
if not pet_id:
raise SystemExit("pet name must contain at least one letter or digit")
display_name = (args.display_name or raw_pet_name).strip()
source = Path(args.spritesheet).expanduser().resolve()
source_format = validate_spritesheet(source)
target_dir = (
Path(args.output_dir).expanduser().resolve()
if args.output_dir
else Path(args.codex_home).expanduser().resolve() / "pets" / pet_id
)
target_dir.mkdir(parents=True, exist_ok=True)
target_sheet = target_dir / "spritesheet.webp"
manifest_path = target_dir / "pet.json"
if not args.force and (target_sheet.exists() or manifest_path.exists()):
raise SystemExit(f"{target_dir} already contains pet files; pass --force to overwrite")
write_webp_spritesheet(source, target_sheet, source_format)
manifest = {
"id": pet_id,
"displayName": display_name,
"description": args.description,
"spritesheetPath": target_sheet.name,
}
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(
json.dumps(
{"ok": True, "pet_dir": str(target_dir), "manifest": str(manifest_path)}, indent=2
)
)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Show ready and pending $imagegen jobs for a Codex pet run."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
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 jobs(manifest: dict[str, object]) -> list[dict[str, object]]:
raw = manifest.get("jobs")
if not isinstance(raw, list):
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
return [job for job in raw if isinstance(job, dict)]
def completed_ids(manifest: dict[str, object]) -> set[str]:
return {
str(job["id"])
for job in jobs(manifest)
if job.get("status") == "complete" and isinstance(job.get("id"), str)
}
def missing_deps(job: dict[str, object], completed: set[str]) -> list[str]:
deps = job.get("depends_on", [])
if not isinstance(deps, list):
return []
return [dep for dep in deps if isinstance(dep, str) and dep not in completed]
def job_view(
job: dict[str, object], run_dir: Path, completed: set[str]
) -> dict[str, object]:
prompt_file = job.get("prompt_file")
output_path = job.get("output_path")
inputs = (
job.get("input_images") if isinstance(job.get("input_images"), list) else []
)
input_images = []
for item in inputs:
path = (
run_dir / item["path"]
if isinstance(item, dict) and isinstance(item.get("path"), str)
else None
)
input_images.append(
{
"path": str(path) if path else None,
"role": item.get("role") if isinstance(item, dict) else None,
"exists": path.is_file() if path else False,
}
)
return {
"id": job.get("id"),
"kind": job.get("kind"),
"status": job.get("status", "pending"),
"prompt_file": str(run_dir / prompt_file)
if isinstance(prompt_file, str)
else None,
"input_images": input_images,
"output_path": str(run_dir / output_path)
if isinstance(output_path, str)
else None,
"missing_dependencies": missing_deps(job, completed),
"repair_attempt": job.get("repair_attempt", 0),
"generation_skill": job.get("generation_skill"),
"requires_grounded_generation": job.get("requires_grounded_generation", False),
"allow_prompt_only_generation": job.get("allow_prompt_only_generation", False),
"identity_reference_paths": job.get("identity_reference_paths", []),
"mirror_policy": job.get("mirror_policy", {}),
"derived_from": job.get("derived_from"),
"source_provenance": job.get("source_provenance"),
"mirror_decision": job.get("mirror_decision"),
"recording_owner": job.get("recording_owner", "parent"),
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", required=True)
args = parser.parse_args()
run_dir = Path(args.run_dir).expanduser().resolve()
manifest = load_manifest(run_dir)
completed = completed_ids(manifest)
pending = [
job for job in jobs(manifest) if job.get("status", "pending") != "complete"
]
ready = [job for job in pending if not missing_deps(job, completed)]
blocked = [job for job in pending if missing_deps(job, completed)]
result = {
"ok": True,
"run_dir": str(run_dir),
"counts": {
"total": len(jobs(manifest)),
"complete": len(completed),
"ready": len(ready),
"blocked": len(blocked),
},
"ready_jobs": [job_view(job, run_dir, completed) for job in ready],
"blocked_jobs": [job_view(job, run_dir, completed) for job in blocked],
}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env python3
"""Create a Codex 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, "neutral breathing/blinking loop"),
("running-right", 1, 8, "rightward locomotion loop"),
("running-left", 2, 8, "leftward locomotion loop"),
("waving", 3, 4, "greeting gesture with raised wave and return"),
("jumping", 4, 5, "anticipation, lift, peak, descent, settle"),
("failed", 5, 8, "sad, failed, or deflated reaction"),
("waiting", 6, 6, "patient waiting loop with small motion"),
("running", 7, 6, "generic in-place running loop"),
("review", 8, 6, "focused inspecting or review loop"),
]
TRANSPARENCY_ARTIFACT_RULES = [
"Prefer pose, expression, and silhouette changes over decorative effects.",
"Effects are allowed only when they are state-relevant, opaque, hard-edged, pixel-style, fully inside the same frame slot, and physically touching or overlapping the pet silhouette.",
"Allowed attached effects can include a tear touching the face, a small smoke puff touching the pet or prop, or tiny stars overlapping the pet during a failed/dizzy reaction.",
"Do not draw detached effects: floating stars, loose sparkles, floating punctuation, floating icons, falling tear drops, separated smoke clouds, loose dust, disconnected outline bits, or stray pixels.",
"Do not draw wave marks, motion arcs, speed lines, action streaks, afterimages, blur, smears, halos, glows, auras, floor patches, cast shadows, contact shadows, drop shadows, oval floor shadows, landing marks, or impact bursts.",
"Do not include text, labels, frame numbers, visible grids, guide marks, speech bubbles, thought bubbles, UI panels, code snippets, scenery, checkerboard transparency, white backgrounds, or black backgrounds.",
"Do not use the chroma-key color or chroma-key-adjacent colors in the pet, prop, effects, highlights, shadows, or outlines.",
"Reject any pose that is cropped, overlaps another pose, crosses into a neighboring frame slot, or creates a separate disconnected component that is not attached to the pet.",
]
STATE_REQUIREMENTS = {
"waving": [
"Show the greeting through paw pose only: paw down, paw raised, paw tilted, paw returning.",
"Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the paw.",
],
"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.",
],
"review": [
"Show review through lean, blink, narrowed eyes, head tilt, or paw 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 locomotion through body, limb, and prop movement only.",
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
],
"running-left": [
"Show locomotion through body, limb, and prop movement only.",
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
],
"running": [
"Show in-place running through body, limb, and prop movement only.",
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
],
}
DIGITAL_PET_STYLE = (
"Codex digital pet sprite style: pixel-art-adjacent low-resolution mascot sprite, "
"compact chibi proportions, chunky whole-body silhouette, thick dark 1-2 px outline, "
"visible stepped/pixel edges, limited palette, flat cel shading with at most one "
"small highlight and one shadow step, simple readable face, tiny limbs, and no "
"detail that disappears at 192x208. Avoid polished illustration, painterly "
"rendering, anime key art, 3D render, vector app-icon polish, glossy lighting, "
"soft gradients, realistic fur or material texture, anti-aliased high-detail "
"edges, and complex tiny accessories."
)
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"
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]:
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 Codex digital pet: {args.pet_notes}")
if reference_paths:
return "A compact Codex digital pet based on the provided reference image."
return "A compact original Codex digital 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 reference_paths:
return "the pet shown in the reference image(s)"
return "a compact original Codex digital 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" / "hatch-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_notes(raw_style_notes: str) -> str:
raw_style_notes = raw_style_notes.strip()
if not raw_style_notes:
return DIGITAL_PET_STYLE
return f"{DIGITAL_PET_STYLE} Additional user style notes: {raw_style_notes}."
def base_pet_prompt(args: argparse.Namespace) -> str:
pet_notes = args.pet_notes or "the pet shown in the reference image(s)"
style_notes = resolved_style_notes(args.style_notes)
chroma_key = args.chroma_key["hex"]
chroma_name = args.chroma_key["name"]
return f"""Create a single clean reference sprite for a Codex app digital pet named {args.display_name}.
Pet: {pet_notes}.
Style contract: {style_notes}
Use this prompt as an authoritative sprite-production spec. Do not expand it into a polished illustration, painterly character image, anime key art, 3D render, vector mascot, glossy app icon, realistic animal portrait, or marketing artwork.
Output one centered full-body pet sprite pose only, on a perfectly flat pure {chroma_name} {chroma_key} chroma-key background. The pet must be fully visible, readable as a tiny digital pet, and suitable for animation into a 192x208 sprite cell. Do not include scenery, text, labels, borders, checkerboard transparency, detached effects, shadows, glows, or extra props not present in the reference unless explicitly requested. Do not use {chroma_key}, pure {chroma_name}, or colors close to that chroma key in the pet, prop, highlights, or 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_notes = resolved_style_notes(args.style_notes)
chroma_key = args.chroma_key["hex"]
chroma_name = args.chroma_key["name"]
state_requirements = STATE_REQUIREMENTS.get(state, [])
state_requirement_text = ""
if state_requirements:
state_requirement_text = "\n\nState-specific requirements:\n" + "\n".join(
f"- {requirement}" for requirement in state_requirements
)
transparency_artifact_text = "\n".join(
f"- {requirement}" for requirement in TRANSPARENCY_ARTIFACT_RULES
)
return f"""Create a single horizontal sprite strip for the Codex app digital pet `{args.pet_id}` in the state `{state}`.
Use the attached reference image(s) for pet identity and the attached base pet image as the canonical design. Use the attached layout guide image only for frame count, slot spacing, centering, and safe padding. Simplify any high-resolution reference details into the Codex digital pet sprite style. Do not simply copy the still reference pose. Generate distinct animation poses that create a readable cycle.
Identity lock:
- Do not redesign the pet. Only change pose/action for the `{state}` animation.
- Preserve the exact head shape, ear/horn/limb shape, face design, markings, palette, outline weight, body proportions, prop design, and overall silhouette from the canonical base pet.
- Keep every frame recognizably the same individual pet, not a related variant.
- If the pet has a prop or accessory, preserve its size, side, palette, and attachment style unless the row action requires a small pose-only adjustment.
- Prefer a subtler animation over any change that mutates the pet identity.
Output exactly {frames} separate animation frames arranged left-to-right in one single row. Each frame must show the same pet: {pet_notes}.
Style contract: {style_notes}
Use this prompt as an authoritative sprite-production spec. Do not expand it into a polished illustration, painterly character image, anime key art, 3D render, vector mascot, glossy app icon, realistic animal portrait, or marketing artwork.
Animation action: {purpose}.
{state_requirement_text}
Transparency and artifact rules:
{transparency_artifact_text}
Layout requirements:
- Exactly {frames} full-body frames, left to right, in one horizontal row.
- The attached layout guide shows the {frames} frame boxes and inner safe area for this row. Follow its slot count, spacing, centering, and padding.
- Do not reproduce the layout guide itself: no visible boxes, guide lines, center marks, labels, guide colors, or guide background may appear in the output.
- Treat the image as {frames} equal-width invisible frame slots. Fill every slot: each requested slot must contain exactly one complete full-body pose.
- Spread the {frames} poses evenly across the whole image width. Do not leave any requested slot blank or create large empty gaps between poses.
- Center one complete pose in each slot. No pose may cross into the neighboring slot.
- Use a perfectly flat pure {chroma_name} {chroma_key} chroma-key background across the whole image.
- Do not draw visible grid lines, borders, labels, numbers, text, watermarks, or checkerboard transparency.
- Do not include scenery or a background environment.
- Keep the rendering sprite-like: chunky silhouette, dark pixel-style outline, limited palette, flat shading, minimal tiny detail.
- Do not use {chroma_key}, pure {chroma_name}, or colors close to that chroma key in the pet, props, highlights, shadows, motion marks, dust, landing marks, or effects.
- Do not draw shadows, glows, smears, dust, or landing marks using darker/lighter versions of the chroma-key color.
- Keep every frame self-contained with safe padding. No pet body part should be clipped by the frame slot.
- Avoid motion blur. Use clear pose changes readable at 192x208.
- Preserve the same silhouette, face, proportions, palette, material, and props across every frame."""
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, "decoded/base.png"]
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,
"recording_owner": "parent",
}
]
for state, _row, frames, _purpose in ROWS:
depends_on = ["base"]
extra_inputs: list[dict[str, str]] = []
mirror_policy: dict[str, object] = {}
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",
}
)
mirror_policy = {
"may_derive_from": "running-right",
"derivation": "horizontal-mirror",
"requires_explicit_approval": True,
"fallback_generation_skill": "$imagegen",
}
jobs.append(
{
"id": state,
"kind": "row-strip",
"status": "pending",
"prompt_file": f"prompts/rows/{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",
},
{"path": "decoded/base.png", "role": "approved base pet"},
*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,
"mirror_policy": mirror_policy,
"recording_owner": "parent",
}
)
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("--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
]
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)
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"
for directory in [
ref_dir,
prompt_dir,
row_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)
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_notes": args.style_notes,
"house_style": DIGITAL_PET_STYLE,
"primary_generation_skill": "$imagegen",
}
(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),
)
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()
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Reopen failed Codex pet row jobs after frame QA."""
from __future__ import annotations
import argparse
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
def load_json(path: Path) -> dict[str, object]:
if not path.exists():
raise SystemExit(f"file not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def rows_to_repair(
review: dict[str, object], *, repair_on_warnings: bool
) -> list[dict[str, object]]:
rows = review.get("rows")
if not isinstance(rows, list):
raise SystemExit("review does not contain row-level results")
repairs: list[dict[str, object]] = []
for row in rows:
if not isinstance(row, dict) or not isinstance(row.get("state"), str):
continue
errors = row.get("errors") if isinstance(row.get("errors"), list) else []
warnings = row.get("warnings") if isinstance(row.get("warnings"), list) else []
if errors or (repair_on_warnings and warnings):
repairs.append(
{
"state": row["state"],
"reason": "; ".join(str(item) for item in [*errors, *warnings])
or "the row did not pass visual QA",
}
)
return repairs
def append_repair_note(run_dir: Path, state: str, attempt: int, reason: str) -> None:
prompt_path = run_dir / "prompts" / "rows" / f"{state}.md"
if not prompt_path.exists():
raise SystemExit(f"row prompt not found: {prompt_path}")
existing = prompt_path.read_text(encoding="utf-8")
note = f"""
Repair attempt {attempt}:
- The previous `{state}` strip failed QA: {reason}
- Regenerate the entire row, not just one pose.
- Fill every requested frame slot with one complete centered full-body pet pose.
- Keep large gaps of pure chroma key only between slots; do not leave a requested slot empty.
- Avoid pose overlap, clipping, edge slivers, extra partial sprites, and detached fragments from neighboring poses.
- Use the canonical base image and any original references listed in `imagegen-jobs.json` as grounding inputs.
- Do not redesign the pet. Keep the exact same head shape, face design, markings, body proportions, palette, outline weight, materials, and props as the approved base pet.
- If the contact sheet shows identity drift, repair only this row while preserving the canonical base identity.
"""
prompt_path.write_text(existing.rstrip() + note.rstrip() + "\n", 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 next_archive_path(archive_dir: Path, state: str, attempt: int, suffix: str) -> Path:
candidate = archive_dir / f"{state}-attempt-{attempt}-previous{suffix}"
if not candidate.exists():
return candidate
counter = 2
while True:
candidate = archive_dir / f"{state}-attempt-{attempt}-previous-{counter}{suffix}"
if not candidate.exists():
return candidate
counter += 1
def archive_decoded_output(run_dir: Path, job: dict[str, object], state: str, attempt: int) -> str | None:
output_raw = job.get("output_path")
output = (
run_dir / output_raw
if isinstance(output_raw, str) and output_raw
else run_dir / "decoded" / f"{state}.png"
)
if not output.exists():
return None
archive_dir = run_dir / "decoded" / "repair-archive"
archive_dir.mkdir(parents=True, exist_ok=True)
archived = next_archive_path(archive_dir, state, attempt, output.suffix or ".png")
shutil.move(str(output), archived)
return str(archived.relative_to(run_dir))
def queue_repair(manifest: dict[str, object], run_dir: Path, state: str, reason: str) -> dict[str, object]:
for job in job_list(manifest):
if job.get("id") != state:
continue
attempt = int(job.get("repair_attempt", 0)) + 1
archived_output = archive_decoded_output(run_dir, job, state, attempt)
job["status"] = "pending"
job["repair_attempt"] = attempt
job["repair_reason"] = reason
job["queued_at"] = datetime.now(timezone.utc).isoformat()
if archived_output is not None:
previous_outputs = job.setdefault("previous_outputs", [])
if not isinstance(previous_outputs, list):
previous_outputs = []
job["previous_outputs"] = previous_outputs
previous_outputs.append(
{
"attempt": attempt,
"path": archived_output,
"archived_at": job["queued_at"],
}
)
for key in [
"source_path",
"source_provenance",
"source_sha256",
"output_sha256",
"completed_at",
"metadata",
"synthetic_test_source",
"secondary_fallback",
"derived_from",
"mirror_decision",
]:
job.pop(key, None)
result: dict[str, object] = {"attempt": attempt}
if archived_output is not None:
result["archived_output"] = archived_output
return result
raise SystemExit(f"unknown row job id: {state}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", required=True)
parser.add_argument("--review", default="")
parser.add_argument("--repair-on-warnings", action="store_true")
args = parser.parse_args()
run_dir = Path(args.run_dir).expanduser().resolve()
review_path = (
Path(args.review).expanduser().resolve()
if args.review
else run_dir / "qa" / "review.json"
)
manifest_path = run_dir / "imagegen-jobs.json"
review = load_json(review_path)
manifest = load_json(manifest_path)
repairs = rows_to_repair(review, repair_on_warnings=args.repair_on_warnings)
queued: list[dict[str, object]] = []
for repair in repairs:
state = str(repair["state"])
reason = str(repair["reason"])
queued_repair = queue_repair(manifest, run_dir, state, reason)
attempt = int(queued_repair["attempt"])
append_repair_note(run_dir, state, attempt, reason)
queued.append({"state": state, "reason": reason, **queued_repair})
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"ok": True, "queued": queued}, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Record a selected $imagegen output for a Codex pet generation job."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
from PIL import Image
CANONICAL_BASE_PATH = "references/canonical-base.png"
def load_jobs(path: Path) -> dict[str, object]:
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 file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def manifest_relative(path: Path, run_dir: Path) -> str:
return str(path.resolve().relative_to(run_dir.resolve()))
def completed_job_ids(manifest: dict[str, object]) -> set[str]:
return {
str(job["id"])
for job in job_list(manifest)
if job.get("status") == "complete" and isinstance(job.get("id"), str)
}
def is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
def default_generated_images_root() -> Path:
codex_home = Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
return codex_home / "generated_images"
def validate_source_path(
*,
source: Path,
run_dir: Path,
allow_synthetic_test_source: bool,
) -> str:
if allow_synthetic_test_source:
return "synthetic-test"
if is_relative_to(source, run_dir):
raise SystemExit(
"source image is inside the pet run directory; record the original "
"$imagegen output from $CODEX_HOME/generated_images/.../ig_*.png instead"
)
generated_root = default_generated_images_root()
if not is_relative_to(source, generated_root) or not source.name.startswith("ig_"):
raise SystemExit(
"source image does not look like a built-in $imagegen output; expected "
f"{generated_root}/.../ig_*.png. Do not ingest locally drawn or "
"post-processed row strips as visual job outputs."
)
return "built-in-imagegen"
def validate_required_grounding(job: dict[str, object], run_dir: Path) -> None:
if job.get("allow_prompt_only_generation") is not False:
return
inputs = job.get("input_images")
if not isinstance(inputs, list) or not inputs:
raise SystemExit(
f"job {job.get('id')} does not list input_images; grounded row jobs must attach references"
)
missing = []
for item in inputs:
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
raise SystemExit(f"job {job.get('id')} has an invalid input image entry")
path = run_dir / item["path"]
if not path.is_file():
missing.append(str(path))
if missing:
raise SystemExit(
f"job {job.get('id')} is missing required grounding image(s): "
+ ", ".join(missing)
)
def update_base_canonical_reference(
*,
run_dir: Path,
output: Path,
manifest: dict[str, object],
job: dict[str, object],
metadata: dict[str, object],
) -> None:
if job.get("id") != "base":
return
canonical = run_dir / CANONICAL_BASE_PATH
canonical.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(output, canonical)
canonical_sha = file_sha256(canonical)
reference = {
"path": manifest_relative(canonical, run_dir),
"source_job": "base",
"sha256": canonical_sha,
"metadata": metadata,
}
job["canonical_reference_path"] = reference["path"]
manifest["canonical_identity_reference"] = reference
request_path = run_dir / "pet_request.json"
if request_path.exists():
request = json.loads(request_path.read_text(encoding="utf-8"))
request["canonical_identity_reference"] = reference
request_path.write_text(json.dumps(request, indent=2) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", required=True)
parser.add_argument("--job-id", required=True)
parser.add_argument("--source", required=True)
parser.add_argument("--force", action="store_true")
parser.add_argument(
"--allow-synthetic-test-source", action="store_true", help=argparse.SUPPRESS
)
args = parser.parse_args()
run_dir = Path(args.run_dir).expanduser().resolve()
source = Path(args.source).expanduser().resolve()
if not source.is_file():
raise SystemExit(f"source image not found: {source}")
source_provenance = validate_source_path(
source=source,
run_dir=run_dir,
allow_synthetic_test_source=args.allow_synthetic_test_source,
)
manifest_path = run_dir / "imagegen-jobs.json"
manifest = load_jobs(manifest_path)
job = find_job(manifest, args.job_id)
missing_deps = [
dep
for dep in job.get("depends_on", [])
if isinstance(dep, str) and dep not in completed_job_ids(manifest)
]
if missing_deps:
raise SystemExit(
f"job {args.job_id} is not ready; missing dependency result(s): {', '.join(missing_deps)}"
)
validate_required_grounding(job, run_dir)
output_raw = job.get("output_path")
if not isinstance(output_raw, str):
raise SystemExit(f"job {args.job_id} has no output_path")
output = run_dir / output_raw
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)
shutil.copy2(source, output)
metadata = image_metadata(output)
job["status"] = "complete"
job["source_path"] = str(source)
job["source_provenance"] = source_provenance
job["source_sha256"] = file_sha256(source)
job["output_sha256"] = file_sha256(output)
if source_provenance == "synthetic-test":
job["synthetic_test_source"] = True
else:
job.pop("synthetic_test_source", None)
job["completed_at"] = datetime.now(timezone.utc).isoformat()
job["metadata"] = metadata
for key in [
"last_error",
"secondary_fallback",
"derived_from",
"mirror_decision",
"repair_reason",
"queued_at",
]:
job.pop(key, None)
update_base_canonical_reference(
run_dir=run_dir,
output=output,
manifest=manifest,
job=job,
metadata=metadata,
)
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(
json.dumps(
{
"ok": True,
"job_id": args.job_id,
"output": str(output),
"metadata": metadata,
},
indent=2,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Render Codex pet state videos from an atlas using ffmpeg."""
from __future__ import annotations
import argparse
import shutil
import subprocess
import tempfile
from pathlib import Path
from PIL import Image, ImageDraw
CELL_WIDTH = 192
CELL_HEIGHT = 208
STATES = {
"idle": (0, [280, 110, 110, 140, 140, 320]),
"running-right": (1, [120, 120, 120, 120, 120, 120, 120, 220]),
"running-left": (2, [120, 120, 120, 120, 120, 120, 120, 220]),
"waving": (3, [140, 140, 140, 280]),
"jumping": (4, [140, 140, 140, 140, 280]),
"failed": (5, [140, 140, 140, 140, 140, 140, 140, 240]),
"waiting": (6, [150, 150, 150, 150, 150, 260]),
"running": (7, [120, 120, 120, 120, 120, 220]),
"review": (8, [150, 150, 150, 150, 150, 280]),
}
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 shell_quote_for_concat(path: Path) -> str:
return "'" + str(path).replace("'", "'\\''") + "'"
def render_state(
atlas: Image.Image,
state: str,
row: int,
durations: list[int],
output_dir: Path,
loops: int,
scale: int,
ffmpeg: str,
) -> None:
with tempfile.TemporaryDirectory(prefix=f"codex-pet-{state}-") as temp_raw:
temp = Path(temp_raw)
frame_paths: list[Path] = []
for column in range(len(durations)):
crop = atlas.crop(
(
column * CELL_WIDTH,
row * CELL_HEIGHT,
(column + 1) * CELL_WIDTH,
(row + 1) * CELL_HEIGHT,
)
).convert("RGBA")
bg = checker((CELL_WIDTH, CELL_HEIGHT))
bg.paste(crop, (0, 0), crop)
frame_path = temp / f"{state}-{column:02d}.png"
bg.save(frame_path)
frame_paths.append(frame_path)
concat_path = temp / f"{state}.ffconcat"
lines = ["ffconcat version 1.0"]
sequence: list[tuple[Path, int]] = []
for _ in range(loops):
sequence.extend(zip(frame_paths, durations, strict=True))
for frame_path, duration_ms in sequence:
lines.append(f"file {shell_quote_for_concat(frame_path)}")
lines.append(f"duration {duration_ms / 1000:.3f}")
lines.append(f"file {shell_quote_for_concat(sequence[-1][0])}")
concat_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
output = output_dir / f"{state}.mp4"
command = [
ffmpeg,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_path),
"-vf",
f"scale={CELL_WIDTH * scale}:{CELL_HEIGHT * scale}:flags=lanczos,format=yuv420p",
"-movflags",
"+faststart",
str(output),
]
subprocess.run(command, check=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("atlas")
parser.add_argument("--output-dir", required=True)
parser.add_argument("--loops", type=int, default=4)
parser.add_argument("--scale", type=int, default=2)
parser.add_argument("--ffmpeg", default=shutil.which("ffmpeg") or "ffmpeg")
args = parser.parse_args()
output_dir = Path(args.output_dir).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
atlas = opened.convert("RGBA")
for state, (row, durations) in STATES.items():
render_state(
atlas,
state,
row,
durations,
output_dir,
args.loops,
args.scale,
args.ffmpeg,
)
print(f"wrote videos to {output_dir}")
if __name__ == "__main__":
main()
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 "$SCRIPT_DIR/render_animation_videos.py" "$@"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Validate a Codex 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 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)
result = {
"ok": not errors,
"file": str(atlas_path),
"format": source_format,
"mode": source_mode,
"width": image.width,
"height": image.height,
"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()