hatch-pet
Create, repair, validate, visually QA, and package Codex-compatible animated pets and pet spritesheets from character art, generated images, company or prospect brand cues, or visual references. Use when a user wants a lightweight-worker Codex pet workflow, a non-pixel custom pet
Install
npx skills add https://github.com/openai/skills/tree/main/skills/.curated/hatch-pet
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install openai-skills@llmmart
git clone https://github.com/openai/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole openai/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Hatch Pet
Overview
Create a Codex-compatible animated pet from a concept, brand cue, company/prospect name, one or more reference images, or any combination of those inputs. This workflow keeps the deterministic hatch-pet pipeline for atlas geometry, validation, visual QA, and packaging, while using concise state-specific prompts and allowing any pet-safe visual style.
User-facing inputs are optional. If the user omits a pet name, infer one from the concept, brand, company, or reference filenames; if that is not possible, choose a short friendly name. If the user omits a description, infer one from the concept or references. 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:
${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/SKILL.md
Do not call the Image API, image CLI, or any other image-generation path directly. Let $imagegen choose its own built-in-first path and fallback rules. If $imagegen says a fallback requires confirmation, ask the user before continuing.
When invoking $imagegen, pass the generated pet prompt as the authoritative visual spec. Pet prompts should stay concise, state-specific, sprite-production oriented, and grounded in the listed input images. Keep longer policy and QA rules in this skill and the deterministic review scripts rather than expanding them into every image prompt. Do not wrap prompts in the generic $imagegen shared prompt schema.
Use this skill's scripts for deterministic image work only: preparing layout guides and prompts, mirroring approved running-left, extracting frames, validating rows, composing the final atlas, and creating contact-sheet plus motion-preview QA media. Parent-owned shell/jq steps handle manifest updates, packaging, and cleanup.
Storage Controls
The built-in $imagegen path stores generated PNG bytes in the rollout that invokes it, even when it also writes a file under ${CODEX_HOME:-$HOME/.codex}/generated_images. Deleting files later reduces filesystem use, but it does not shrink an already-written rollout. Keep image generation isolated and bounded:
- Use one lightweight generation worker per visual job. Do not batch multiple base/row jobs into the same worker.
- Workers must return only
selected_source=...andqa_note=...; they must not include Markdown image previews, base64, or extra visual attachments in their final response. - The parent must not open every generated PNG visually. Use worker QA for each job and inspect only the final contact sheet.
- After copying the selected generated output into
decoded/, remove the selected original from${CODEX_HOME:-$HOME/.codex}/generated_imageswhen it lives there, then remove its now-empty generation directory if possible. - For storage-sensitive full runs, ask the user whether to use the
$imagegenCLI fallback when available. That path requires local API credentials and explicit user confirmation, but it can avoid built-in image payloads being embedded in rollout events.
Brand Discovery
If the user provides a brand, company, product, or prospect name rather than a concrete avatar description or reference image, run a lightweight discovery subagent before preparing the pet run. The discovery worker must use web search and prefer official sources such as the brand site, product pages, docs, about pages, press pages, or brand pages. Use reputable secondary sources only when official pages are too thin. Keep the search narrow: enough to extract visual and personality cues, not a market-research brief.
Skip discovery when the user already provides a concrete mascot/avatar description or reference images, unless the user explicitly asks for brand research.
Discovery worker responsibilities:
- search the web for 2-4 relevant sources, preferring official pages
- write an adaptive markdown brief rather than a rigid field dump
- cover identity/category, audience/use context, visual system, personality/tone, product/domain motifs, mascot translation cues, avoidances, and evidence/confidence
- mark mascot guidance that is inferred from sources as inference
- avoid copying logos, readable marks, UI screenshots, slogans, or text
- end with a compact
Generation handoffsection containing onlybrand_name,brand_brief,avatar_seed,avoid, andbrand_sources - do not generate images, prepare run folders, or edit unrelated files
Use this discovery worker prompt:
Research a brand for hatch-pet mascot creation.
Brand/product/prospect: <brand name>
User context: <short user request>
Output file: <absolute path to brand-discovery.md>
Use web search. Prefer official brand, product, docs, about, press, or brand pages. Use reputable secondary sources only if official sources are too thin. Write an adaptive markdown brief to the output file. Headings may flex by brand, but the brief must cover:
- identity/category: canonical name, product type, what it does
- audience/use context: who it serves and where it appears
- visual system: palette, shapes, line quality, materials, typography feel, iconography, patterns
- personality/tone: emotional traits, energy, formality, playfulness
- product/domain motifs: objects, workflows, verbs, metaphors, environments
- mascot translation cues: candidate forms, signature traits, props, what must read at pet size
- avoidances: logos/text, trademark-sensitive elements, misleading cues, competitor confusion, poor mascot fits
- evidence/confidence: source URLs plus notes where evidence is weak or inferred
Do not copy logos, readable marks, UI screenshots, slogans, or text. Clearly label mascot guidance that is inferred rather than directly sourced.
End the brief with a `Generation handoff` section containing exactly:
- brand_name=<canonical brand/product name>
- brand_brief=<one sentence, max 45 words, covering palette/tone/domain motifs/personality>
- avatar_seed=<short mascot-safe visual idea, no logo copying>
- avoid=<short comma-separated list>
- brand_sources=<comma-separated source URLs>
Return exactly:
brand_discovery_file=<absolute output file path>
brand_name=<canonical brand/product name>
brand_brief=<same compact sentence from Generation handoff>
avatar_seed=<same short seed from Generation handoff>
avoid=<same short avoid list from Generation handoff>
brand_sources=<same comma-separated URLs from Generation handoff>
The parent should save the markdown brief before preparing the run, then pass it to prepare_pet_run.py as --brand-discovery-file together with --brand-name, --brand-brief, repeated --brand-source, and a concise --pet-notes value based on avatar_seed when the user did not provide a better avatar description. Keep the full brief for review; only the compact handoff fields should shape prompts. If web search is unavailable and the user gave only a bare brand name, ask for brand cues before generating.
For a normal pet run, expect up to 10 visual generation jobs: 1 base pet plus 9 row-strip jobs. The Codex app contract currently uses all 9 states: idle, running-right, running-left, waving, jumping, failed, waiting, running, and review. The only deterministic visual derivation is running-left, which may be produced 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.
After selecting a visual output, the parent agent copies that exact image into the job's decoded/ path and marks the job complete in imagegen-jobs.json. Do not write helper scripts that populate row outputs. The deterministic Python 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 selected base output is copied. Treat any row generation without attached grounding images as invalid.
Pet-Safe Styles
Default style is auto: infer the pet's style from the user's prompt and references, then preserve that style across every row. If the user names a style, honor it. Supported style presets include pixel, plush, clay, sticker, flat-vector, 3d-toy, painterly, brand-inspired, and auto.
Any style is acceptable when it remains pet-safe:
- compact whole-body silhouette readable inside a
192x208cell - consistent face, proportions, material, palette, and props across all rows
- clean removable chroma-key background
- details large enough to read at pet size
- no text, labels, UI, or readable logos unless the user explicitly provides approved reference art and asks for them
Non-pixel styles are first-class. Plush, clay, sticker, vector, 3D toy, painterly mascot, ink, and brand-inspired looks should be accepted when they satisfy the atlas and readability constraints.
Transparency And Effects
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.
The deterministic raster pipeline owns the transparency invariant: pixels that become fully transparent are normalized so they do not retain hidden RGB residue, and atlas validation should fail if exported files violate that invariant. Do not paper over colored halos or transparent-pixel residue by accepting visually inconsistent outputs.
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 enough for clean extraction, and uses non-chroma-key colors.
- The effect is small enough to remain readable at
192x208without clutter.
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:
idle: keep this calm and low-distraction. Use only subtle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet persona-preserving motion. The loop must still contain visible micro-variation; do not accept six effectively identical copies. Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.waving: show the wave through paw, hand, wing, or limb pose only. Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.jumping: show 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.waiting: show that Codex needs approval, help, or user input through an expectant asking pose. Keep it distinct from ordinary idle and review.running: show active task work, processing, thinking, scanning, typing, or focused effort. Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.review: show focus through lean, blink, eyes, head tilt, or paw/hand position. Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.running-rightandrunning-left: show directional drag movement through body, limb, and prop movement only.running-rightmust face and travel right;running-leftmust face and travel left. Their cadence must visibly alternate across the loop rather than repeating one nearly static stride. Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
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.
Use this checklist for a normal pet run, replacing <Pet> with the pet's name or your pet:
- Getting
<Pet>ready. - Imagining
<Pet>'s main look. - Picturing
<Pet>'s poses. - Hatching
<Pet>.
What each step means:
Getting <Pet> ready.Choose or confirm the pet name, description, source images, style preset, style notes, and working folder. For bare brand/product/company requests, first run the brand discovery worker and capture the compact brand brief, source URLs, and avatar seed.Imagining <Pet>'s main look.Generate the pet's main reference image. This becomes the visual source of truth.Picturing <Pet>'s poses.Generate pose rows through lightweight workers, starting withidleandrunning-rightto confirm identity and gait. Only mirrorrunning-leftifrunning-rightclearly works when flipped.Hatching <Pet>.Turn the approved poses into final pet files, review the contact sheet, previews, and validation results, fix any broken parts, savepet.jsonandspritesheet.webp, then report the output paths.
Only mark a step complete when the real file, image, or decision exists. If this is a repair run, start from the first relevant step instead of restarting the whole checklist.
Default Workflow
- Prepare a pet run folder and imagegen job manifest:
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>" \
--brand-discovery-file /absolute/path/to/brand-discovery.md \
--brand-name "<optional researched brand name>" \
--brand-brief "<optional compact researched brand cue sentence>" \
--brand-source "https://example.com/source" \
--style-preset auto \
--style-notes "<optional freeform style notes>" \
--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.
For brand-only requests, run the discovery worker first, save the markdown brief, then pass the brief path through --brand-discovery-file, avatar_seed through --pet-notes, brand_name through --brand-name, brand_brief through --brand-brief, and each source URL through repeated --brand-source.
- Inspect
imagegen-jobs.jsonfor the next ready$imagegenjobs. A job is ready when itsstatusis notcompleteand every id independs_onis already complete. Prefer reading the manifest directly withjqor the editor instead of adding helper scripts for status display:
jq '.jobs[] | {id, kind, status, depends_on, prompt_file, retry_prompt_file, input_images, output_path, derivation_policy}' /absolute/path/to/run/imagegen-jobs.json
- Generate visual jobs with lightweight workers by default:
- Generate and copy
basefirst, using a lightweight base worker. - Generate and copy
idleandrunning-rightnext as the identity and gait check, using one lightweight worker per row. - Inspect
running-right; mirrorrunning-leftonly when visual identity, prop placement, markings, lighting, and direction semantics remain correct. - Generate
running-leftnormally with a lightweight worker when mirroring would change meaning or identity. - Generate the remaining rows with lightweight workers, using every input image listed for each job.
For each ready visual job, invoke $imagegen with the prompt file listed in imagegen-jobs.json, every listed input image with its role label, and the default built-in image_gen path unless $imagegen itself routes otherwise. The parent agent must keep its own image handling minimal: do not open every generated base or row in the parent rollout. Workers return only the selected source path and a one-sentence QA note; the parent records the selected source path in the manifest.
prepare_pet_run.py 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. Preserve the same style, face, markings, palette, materials, prop design, body proportions, and silhouette from the canonical base. Row jobs attach the layout guide and canonical base by default; the decoded base is kept in the run folder for deterministic processing rather than sent as a redundant generation input.
If $imagegen returns a transport-level Bad Request for a row, retry that same row once with its generated retry_prompt_file. The retry prompt preserves the row id, frame count, chroma key, canonical-base identity, and state action. Keep the canonical base attached. If the retry still fails, stop and report the failing row and prompt paths instead of switching to any other generation path.
- After selecting a generated output for a job, copy it into the decoded output path and mark the job complete. For
base, also create the canonical identity reference:
RUN_DIR=/absolute/path/to/run
JOB_ID=<job-id>
SOURCE=/absolute/path/to/generated-output.png
OUTPUT_REL=$(jq -r --arg id "$JOB_ID" '.jobs[] | select(.id == $id) | .output_path' "$RUN_DIR/imagegen-jobs.json")
mkdir -p "$(dirname "$RUN_DIR/$OUTPUT_REL")"
cp "$SOURCE" "$RUN_DIR/$OUTPUT_REL"
if [ "$JOB_ID" = "base" ]; then mkdir -p "$RUN_DIR/references"; cp "$RUN_DIR/$OUTPUT_REL" "$RUN_DIR/references/canonical-base.png"; fi
UPDATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TMP_MANIFEST=$(mktemp)
jq --arg id "$JOB_ID" --arg source "$SOURCE" --arg at "$UPDATED_AT" '(.jobs[] | select(.id == $id)) += {status: "complete", source_path: $source, completed_at: $at}' "$RUN_DIR/imagegen-jobs.json" > "$TMP_MANIFEST"
mv "$TMP_MANIFEST" "$RUN_DIR/imagegen-jobs.json"
If the copied source is under ${CODEX_HOME:-$HOME/.codex}/generated_images, delete the original generated file after the decoded copy exists:
GENERATED_ROOT="${CODEX_HOME:-$HOME/.codex}/generated_images"
case "$SOURCE" in
"$GENERATED_ROOT"/*)
rm -f "$SOURCE"
rmdir "$(dirname "$SOURCE")" 2>/dev/null || true
;;
esac
- Derive
running-leftonly when it is visually safe:
python "$SKILL_DIR/scripts/derive_running_left_from_running_right.py" \
--run-dir /absolute/path/to/run \
--confirm-appropriate-mirror \
--decision-note "<why mirroring preserves this pet's identity>"
That script mirrors each generated frame slot in place so the leftward row preserves the rightward row's temporal order. Do not replace it with a whole-strip mirror that reverses animation timing.
- When all jobs are complete, run the image-processing scripts directly:
RUN_DIR=/absolute/path/to/run
mkdir -p "$RUN_DIR/final" "$RUN_DIR/qa"
python "$SKILL_DIR/scripts/extract_strip_frames.py" \
--decoded-dir "$RUN_DIR/decoded" \
--output-dir "$RUN_DIR/frames" \
--states all \
--method auto
python "$SKILL_DIR/scripts/inspect_frames.py" \
--frames-root "$RUN_DIR/frames" \
--json-out "$RUN_DIR/qa/review.json" \
--require-components
python "$SKILL_DIR/scripts/compose_atlas.py" \
--frames-root "$RUN_DIR/frames" \
--output "$RUN_DIR/final/spritesheet.png" \
--webp-output "$RUN_DIR/final/spritesheet.webp"
python "$SKILL_DIR/scripts/validate_atlas.py" \
"$RUN_DIR/final/spritesheet.webp" \
--json-out "$RUN_DIR/final/validation.json"
python "$SKILL_DIR/scripts/make_contact_sheet.py" \
"$RUN_DIR/final/spritesheet.webp" \
--output "$RUN_DIR/qa/contact-sheet.png"
python "$SKILL_DIR/scripts/render_animation_previews.py" \
--frames-root "$RUN_DIR/frames" \
--output-dir "$RUN_DIR/qa/previews"
If the preview GIFs show size popping or baseline jumps caused by per-frame fit-to-cell extraction, and the original row strip itself had stable scale and placement, rerun frame extraction with the explicit row-stability mode and then re-run inspection, atlas composition, validation, contact sheet generation, and previews:
python "$SKILL_DIR/scripts/extract_strip_frames.py" \
--decoded-dir "$RUN_DIR/decoded" \
--output-dir "$RUN_DIR/frames" \
--states all \
--method stable-slots
python "$SKILL_DIR/scripts/inspect_frames.py" \
--frames-root "$RUN_DIR/frames" \
--json-out "$RUN_DIR/qa/review.json" \
--require-components \
--allow-stable-slots
Use stable-slots as a deliberate QA-driven correction, not the default. It should reduce extraction-induced motion pops without hiding clipped wide poses or bad source strips.
Expected output before cleanup:
run/
pet_request.json
imagegen-jobs.json
prompts/
decoded/
frames/frames-manifest.json
final/spritesheet.webp
final/validation.json
qa/contact-sheet.png
qa/previews/*.gif
qa/review.json
qa/run-summary.json
Package output is written outside the run directory by default. If CODEX_HOME is set, use it; otherwise use $HOME/.codex.
${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/
pet.json
spritesheet.webp
Package with shell and jq:
RUN_DIR=/absolute/path/to/run
PET_ID=$(jq -r '.pet_id' "$RUN_DIR/pet_request.json")
DISPLAY_NAME=$(jq -r '.display_name' "$RUN_DIR/pet_request.json")
DESCRIPTION=$(jq -r '.description' "$RUN_DIR/pet_request.json")
PET_DIR="${CODEX_HOME:-$HOME/.codex}/pets/$PET_ID"
mkdir -p "$PET_DIR"
cp "$RUN_DIR/final/spritesheet.webp" "$PET_DIR/spritesheet.webp"
jq -n --arg id "$PET_ID" --arg displayName "$DISPLAY_NAME" --arg description "$DESCRIPTION" '{id: $id, displayName: $displayName, description: $description, spritesheetPath: "spritesheet.webp"}' > "$PET_DIR/pet.json"
Write qa/run-summary.json after packaging:
jq -n --arg run_dir "$RUN_DIR" --arg spritesheet "$RUN_DIR/final/spritesheet.webp" --arg validation "$RUN_DIR/final/validation.json" --arg contact_sheet "$RUN_DIR/qa/contact-sheet.png" --arg review "$RUN_DIR/qa/review.json" --arg package "$PET_DIR" '{ok: true, run_dir: $run_dir, spritesheet: $spritesheet, validation: $validation, contact_sheet: $contact_sheet, review: $review, package: $package}' > "$RUN_DIR/qa/run-summary.json"
After deterministic image processing, inspect qa/contact-sheet.png and qa/previews/*.gif with a lightweight visual QA worker before accepting the pet. Deterministic validation is necessary but not sufficient. Block acceptance if any row changes species/body type, face, markings, palette, material, prop design, style, prop side unexpectedly, or overall silhouette. Motion previews must also reject unintended size popping, reversed or stagnant directional cadence, wrong facing direction, and idle loops that are technically different but visually inert.
After model visual QA accepts the contact sheet, remove intermediate run artifacts:
Keep pet_request.json, final/spritesheet.webp, final/validation.json, qa/contact-sheet.png, qa/previews/, qa/review.json, and qa/run-summary.json. Remove generated prompt files, layout guides, decoded row strips, extracted frames, final/spritesheet.png, and the imagegen job manifest. Skip cleanup when the user wants debug artifacts or the run still needs repair.
Lightweight Visual Workers
Use lightweight subagents for image-heavy work by default. This bounds each $imagegen rollout to one selected image, keeps contact-sheet vision payloads out of the parent thread, and reduces cost while preserving the full 9-state app contract.
Subagent Delegation
Unless explicitly forbidden by the user, use subagents for this run. If the user has not allowed the use of subagents, or the intent on subagent use is vague, then ask the user for permission to spawn subagents for parallel lanes of work.
Parent responsibilities:
- run the brand discovery worker before preparation when the user provides a bare brand/product/company/prospect name
- prepare the run and inspect
imagegen-jobs.json - assign the base job, row jobs, and final contact-sheet QA to lightweight workers
- copy selected worker outputs into their decoded paths and mark jobs complete in
imagegen-jobs.json - create
references/canonical-base.pngfrom the selected base output - run the approved
running-leftmirror derivation when appropriate - run deterministic image processing, packaging, repair regeneration, and cleanup
Base worker responsibilities:
- handle only the
basejob - read
prompts/base-pet.mdand use any listed reference images - use
$imagegenonly - honor any compact brand inspiration line in the prompt as broad visual/personality guidance, without copying logos, readable marks, UI screenshots, slogans, or text
- return only
selected_source=/absolute/path/to/selected-output.pngandqa_note=<one sentence>
Row worker responsibilities:
- handle exactly one row job
- read the row prompt and use all listed input images
- use
$imagegenonly; do not draw, edit, tile, or synthesize sprites locally - perform a quick visual sanity check for frame count, identity, chroma background, spacing, clipping, and detached effects
- enforce the row prompt's transparency and effects rules, including no detached effects, no wave marks for
waving, no speed lines or dust for directional running rows, no literal foot-running for the non-directionalrunningrow, and only attached opaque sprite-like tears/smoke/stars when allowed by the state prompt - return only
selected_source=/absolute/path/to/selected-output.pngandqa_note=<one sentence>
Final visual QA worker responsibilities:
- inspect
qa/contact-sheet.pngplus the row GIFs underqa/previews/, withqa/review.jsonandfinal/validation.jsonas text context when useful - verify all 9 rows match the Codex app state contract and the same pet identity
- return a compact result:
visual_qa=passorvisual_qa=fail, plus row-specific repair notes when failing - do not edit files, queue repairs, package, or clean up
Model choice for workers:
- Prefer a smaller capable model for brand discovery, since it returns a compact research brief rather than doing orchestration.
- Prefer a smaller capable model for visual workers, such as
gpt-5.4-miniwith medium reasoning, when model override is available. - Use the parent/default model only for orchestration or when a smaller worker model is unavailable.
- Keep at most two generation workers active at once unless the user explicitly asks for higher parallelism. Run final visual QA as a single worker after deterministic image processing. Close workers after their result has been consumed.
Use this base worker prompt:
Generate the hatch-pet base image.
Run dir: <absolute run dir>
Job id: base
Prompt file: <absolute base prompt file>
Input images:
- <absolute path> — <role>
Use $imagegen only. Read the base prompt and attach every listed input image. If the prompt contains brand inspiration, use it only as broad mascot-safe guidance; do not copy logos, readable marks, UI screenshots, slogans, or text. Before returning, visually check that the result is one centered full-body pet on a flat chroma background, with no text, scenery, shadows, or detached effects.
Do not edit manifests, copy into decoded, mark jobs complete, generate rows, run image-processing scripts, repair, package, or open unrelated files.
Do not include Markdown image previews, base64, or extra attachments in the final response.
Return exactly:
selected_source=/absolute/path/to/selected-output.png
qa_note=<one sentence>
Use this row worker prompt:
Generate one hatch-pet row.
Run dir: <absolute run dir>
Row id: <row-id>
Prompt file: <absolute prompt file>
Retry prompt file: <absolute retry prompt file>
Input images:
- <absolute path> — <role>
- <absolute path> — <role>
Use $imagegen only. Read the row prompt and attach every listed input image. If imagegen returns Bad Request, retry once with the retry prompt and the same input images.
Before returning, visually check: exact frame count, same pet identity as canonical base, flat chroma background, complete separated unclipped poses, and no detached effects or guide marks. The prompt's transparency and effects rules are mandatory: no detached effects, no wave marks for `waving`, no speed lines or dust for directional running rows, no literal foot-running for the non-directional `running` row, and only attached opaque sprite-like tears/smoke/stars when allowed by the state prompt.
Do not edit manifests, copy into decoded, mark jobs complete, mirror rows, run image-processing scripts, repair, package, or open unrelated files.
Do not include Markdown image previews, base64, or extra attachments in the final response.
Return exactly:
selected_source=/absolute/path/to/selected-output.png
qa_note=<one sentence>
Use this final visual QA worker prompt:
Visually QA one finalized hatch-pet contact sheet.
Run dir: <absolute run dir>
Contact sheet: <absolute run dir>/qa/contact-sheet.png
Preview dir: <absolute run dir>/qa/previews
Review JSON: <absolute run dir>/qa/review.json
Validation JSON: <absolute run dir>/final/validation.json
Inspect the contact sheet and the preview GIFs visually. Confirm the same pet identity, style, palette, silhouette, face, proportions, and props across all rows:
0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping, 5 failed, 6 waiting, 7 running, 8 review.
Fail rows with identity drift, missing/blank frames, copied guide marks, white/nontransparent backgrounds, cropped bodies, slot overlap, detached effects, shadows/glows/smears/dust, chroma-key artifacts, motion that does not match the row state, unintended size popping, wrong facing direction, reversed or non-alternating gait, or idle loops that are effectively static.
Do not edit files, queue repairs, package, clean up, or inspect unrelated files.
Return exactly:
visual_qa=pass|fail
qa_note=<one sentence summary>
repair_rows=<comma-separated row ids, or none>
repair_notes=<short row-specific notes, or none>
Repair Workflow
If frame inspection or final visual QA fails, read qa/review.json, regenerate the smallest failing scope, copy the replacement row into the same decoded output path, and keep that job marked complete with the new source_path and completed_at. Repair the failed row, not the whole sheet.
For identity repairs, use the canonical base image, original references, contact sheet, and exact row failure note as grounding context. Give the row worker the existing row prompt plus a compact repair note from qa/review.json; preserve the canonical pet identity and chosen style.
For extraction-induced motion popping, do not regenerate imagery first. If the source strip already preserves row-level scale and baseline, rerun the deterministic pipeline with --method stable-slots, inspect with --allow-stable-slots, then re-check the preview GIFs. Regenerate the row only when the original strip itself is clipped, unstable, or semantically wrong.
Rules
- Keep
$imagegenas the primary generation layer. - For brand/product/company/prospect requests without a concrete avatar description or reference image, run brand discovery before base generation and pass only the compact brief into the run.
- Use
$imagegenas the only visual generation layer. Do not invoke image APIs, image CLIs, local raster generators, or one-off generation scripts from this skill. - Keep reference images attached/visible for
$imagegenwhenever the chosen path supports references. - Attach the row's
references/layout-guides/<state>.pngimage to every row-strip job as a layout-only guide, and do not accept outputs that copy guide pixels. - Use lightweight visual workers for base generation, row-strip visual generation, and final contact-sheet QA by default; the parent owns manifest updates, deterministic image scripts, packaging, and cleanup.
- Generate every normal visual job with
$imagegen: base plus all row strips that are not explicitly approvedrunning-leftmirror derivations. - Treat only the base job as eligible for prompt-only generation; every row job must attach its listed grounding images.
- Generate
running-rightbefore deciding whetherrunning-leftcan be mirrored. - When
running-leftis mirrored, preserve frame order and timing semantics; derive it through the deterministic script instead of mirroring an entire strip wholesale. - Do not derive or reuse
waiting,running,failed,review,jumping, orwavingfrom another state; each has distinct app semantics and must be generated as its own row. - Never substitute locally drawn, tiled, transformed, or code-generated row strips for missing
$imagegenoutputs. - Only mark a visual job complete after its selected output has been copied into the decoded output path.
- Do not rely on generated images for exact atlas geometry; use this skill's deterministic image 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, style, and props consistent across all rows.
- Treat visual identity or style drift as a blocker even when
qa/review.jsonandfinal/validation.jsonhave no errors. - Treat a contact sheet that shows cropped references, repeated tiles, white cell backgrounds, or non-sprite fragments as failed.
- Treat preview GIFs that show extraction-induced size popping, reversed directional timing, wrong facing direction, or inert idle loops 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.jsonerrors as blockers. Warnings require visual review.
Acceptance Criteria
- Final atlas is PNG or WebP,
1536x1872, transparent-capable, and based on192x208cells. - Used cells are non-empty and unused cells are fully transparent.
- Atlas follows the row/frame counts in
references/animation-rows.md. - Contact sheet and per-row motion previews have been produced and inspected by a lightweight visual QA worker.
qa/review.jsonhas no errors.- Row-by-row review confirms the animation cycles are complete enough for the Codex app.
- Motion previews do not show unintended size popping, reversed directional cadence, or wrong row semantics.
- Non-pixel styles are accepted when readable at pet size and consistent across rows.
${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/pet.jsonand${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/spritesheet.webpare staged together for custom pets.
Files (skills)
-
agents
-
openai.yaml 244 B
interface: display_name: "Hatch Pet" short_description: "Hatch style-flexible Codex pets" default_prompt: "Use $hatch-pet to create a Codex-compatible animated pet in any pet-safe style from a concept, company brand, or reference image."
-
-
references
-
animation-rows.md 1.7 KB
# 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`: calm, low-distraction breathing/blinking loop; use as the reduced-motion first frame. Keep motion subtle and persona-preserving. - `running-right`: locomotion to the right; 8-frame loop should read directionally. - `running-left`: mirrored or redrawn locomotion to the left; do not simply reuse right-facing frames unless the design is symmetric, and any mirror derivation must preserve frame order and timing semantics. - `waving`: greeting or attention gesture; clear start, raised gesture, return. - `jumping`: anticipation, lift, peak, descent, settle. - `failed`: error/sad/deflated reaction; readable but not visually noisy. - `waiting`: blocked-on-user-input state; expectant asking pose for approval, help, or user input. - `running`: active task work state; focused processing, thinking, scanning, typing, or effortful concentration. This row is not foot-running; avoid jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel. - `review`: focused/inspecting/thinking loop suitable for review state. -
codex-pet-contract.md 788 B
# 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/`. -
qa-rubric.md 4.1 KB
# 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. - Fully transparent atlas pixels do not retain non-zero RGB residue after export. - `qa/review.json` has no errors. - `frames/frames-manifest.json` records component extraction for production rows unless `stable-slots` was intentionally chosen to preserve row-level playback stability after visual inspection. ## Character Consistency - Same silhouette and proportions across every row. - Same face and expression language. - Same style, material, palette, lighting, and prop design. - No frame introduces a new unintended character or object. ## Pet-Safe Style - Art reads as a Codex app pet, not a scene, app icon, logo sheet, or standalone illustration. - Silhouette is compact and clear enough to read inside a `192x208` cell. - The chosen style is consistent across every row, including edge treatment, material, lighting, and palette. - Pixel, plush, clay, sticker, flat vector, 3D toy, painterly mascot, ink, and brand-inspired styles are all acceptable when readable at pet size. - No tiny accessories, texture detail, logo detail, or text that disappears or becomes noisy at pet size. ## Animation Completeness - Each row uses the exact expected number of frames. - The first and last frames can loop without an obvious pop. - Directional rows read as the intended direction. - Mirrored directional rows preserve temporal frame order rather than reversing the cadence. - State-specific actions are recognizable at pet size. - Poses are generated animation variants, not repeated copies of the same source image. - Preview GIFs do not show unintended size popping, extraction-induced baseline jumps, or wrong directional facing. ## App Fitness - First idle frame works as a static reduced-motion pet. - The `idle` row should be calm and low-distraction; reject it if it reads as waving, walking, running, jumping, talking, working, reviewing, reacting dramatically, changing props, or making large pose/silhouette changes. - No important detail is too small to read. - No frame is clipped by the cell. - Failed/review/waiting states are distinct from ordinary idle. - Contact sheets must show whole sprite poses inside cells, not cropped tiles from a larger reference image. - Contact sheets must not be accepted if every used frame is just the reference image with small geometric transforms. - Used cells must not have white or opaque rectangular backgrounds unless the pet intentionally fills the whole cell and the user accepts that tradeoff. - The chroma key must be visually absent from the character. If extraction removes character regions, choose a different key and regenerate the affected base/rows. - Contact sheets must not show edge slivers or partial neighboring sprites inside cells. - Contact sheets must not show darker/lighter versions of the chroma key as shadows, dust, smears, glows, landing marks, or motion effects. These are background extraction failures and should trigger row repair. - If `qa/review.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. - If preview GIFs show size popping even though the generated strip itself had stable scale and placement, rerun extraction with `stable-slots` before regenerating the row. - If previews show wrong facing direction, reversed cadence, non-alternating gait, or an effectively static idle loop, repair or regenerate the affected row. ## Repair Policy Repair the smallest failing scope first: 1. Single bad frame. 2. One row. 3. Full atlas regeneration only when identity or layout is broadly broken. The normal production path should regenerate only the affected row and copy the selected replacement into the same decoded output path unless the base character is wrong.
-
-
scripts
-
compose_atlas.py 5.6 KB
#!/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 clear_transparent_rgb(image: Image.Image) -> Image.Image: rgba = image.convert("RGBA") data = bytearray(rgba.tobytes()) for index in range(0, len(data), 4): if data[index + 3] == 0: data[index] = 0 data[index + 1] = 0 data[index + 2] = 0 return Image.frombytes("RGBA", rgba.size, bytes(data)) def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None: atlas = clear_transparent_rgb(atlas) output.parent.mkdir(parents=True, exist_ok=True) atlas.save(output) if webp_output is not None: webp_output.parent.mkdir(parents=True, exist_ok=True) atlas.save( webp_output, format="WEBP", lossless=True, quality=100, method=6, exact=True, ) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) source = parser.add_mutually_exclusive_group(required=True) source.add_argument("--source-atlas") source.add_argument("--frames-root") parser.add_argument("--output", required=True) parser.add_argument("--webp-output") parser.add_argument( "--resize-source", action="store_true", help="Resize a lower-resolution source atlas only when it already has the Codex atlas aspect ratio.", ) args = parser.parse_args() if args.source_atlas: atlas = compose_from_source_atlas( Path(args.source_atlas).expanduser().resolve(), args.resize_source ) else: atlas = compose_from_frames(Path(args.frames_root).expanduser().resolve()) save_outputs( atlas, Path(args.output).expanduser().resolve(), Path(args.webp_output).expanduser().resolve() if args.webp_output else None, ) print(f"wrote {Path(args.output).expanduser().resolve()}") if args.webp_output: print(f"wrote {Path(args.webp_output).expanduser().resolve()}") if __name__ == "__main__": main() -
derive_running_left_from_running_right.py 5 KB
#!/usr/bin/env python3 """Conditionally derive running-left by mirroring the approved running-right strip.""" from __future__ import annotations import argparse import json from datetime import datetime, timezone from pathlib import Path from PIL import Image, ImageOps RUNNING_FRAME_COUNT = 8 def load_manifest(run_dir: Path) -> dict[str, object]: path = run_dir / "imagegen-jobs.json" if not path.exists(): raise SystemExit(f"job manifest not found: {path}") return json.loads(path.read_text(encoding="utf-8")) def job_list(manifest: dict[str, object]) -> list[dict[str, object]]: jobs = manifest.get("jobs") if not isinstance(jobs, list): raise SystemExit("invalid imagegen-jobs.json: jobs must be a list") return [job for job in jobs if isinstance(job, dict)] def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]: for job in job_list(manifest): if job.get("id") == job_id: return job raise SystemExit(f"unknown job id: {job_id}") def image_metadata(path: Path) -> dict[str, object]: with Image.open(path) as image: image.verify() with Image.open(path) as image: return { "width": image.width, "height": image.height, "mode": image.mode, "format": image.format, } def manifest_relative(path: Path, run_dir: Path) -> str: return str(path.resolve().relative_to(run_dir.resolve())) def mirror_strip_preserving_frame_order( source: Image.Image, frame_count: int = RUNNING_FRAME_COUNT, ) -> Image.Image: rgba = source.convert("RGBA") mirrored = Image.new("RGBA", rgba.size, (0, 0, 0, 0)) slot_width = rgba.width / frame_count for index in range(frame_count): left = round(index * slot_width) right = round((index + 1) * slot_width) mirrored.alpha_composite( ImageOps.mirror(rgba.crop((left, 0, right, rgba.height))), (left, 0), ) return mirrored def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--run-dir", required=True) parser.add_argument( "--confirm-appropriate-mirror", action="store_true", help="Required after visually confirming the rightward strip can be mirrored without identity/prop issues.", ) parser.add_argument( "--decision-note", required=True, help="Short note explaining why mirroring is acceptable for this pet.", ) parser.add_argument("--force", action="store_true") args = parser.parse_args() if not args.confirm_appropriate_mirror: raise SystemExit("refusing to mirror without --confirm-appropriate-mirror") if not args.decision_note.strip(): raise SystemExit("--decision-note must explain why mirroring is appropriate") run_dir = Path(args.run_dir).expanduser().resolve() manifest_path = run_dir / "imagegen-jobs.json" manifest = load_manifest(run_dir) right_job = find_job(manifest, "running-right") left_job = find_job(manifest, "running-left") if right_job.get("status") != "complete": raise SystemExit("running-right must be complete before deriving running-left") mirror_policy = left_job.get("mirror_policy") if not isinstance(mirror_policy, dict) or mirror_policy.get("may_derive_from") != "running-right": raise SystemExit("running-left is not configured for conditional mirroring") source = run_dir / "decoded" / "running-right.png" output = run_dir / "decoded" / "running-left.png" if not source.is_file(): raise SystemExit(f"running-right decoded strip not found: {source}") if output.exists() and not args.force: raise SystemExit(f"{output} already exists; pass --force to replace it") output.parent.mkdir(parents=True, exist_ok=True) with Image.open(source) as image: mirrored = mirror_strip_preserving_frame_order(image) mirrored.save(output) left_job["status"] = "complete" left_job["source_path"] = manifest_relative(source, run_dir) left_job["derived_from"] = "running-right" left_job["completed_at"] = datetime.now(timezone.utc).isoformat() left_job["metadata"] = image_metadata(output) left_job["mirror_decision"] = { "approved": True, "approved_at": left_job["completed_at"], "note": args.decision_note.strip(), "transform": "framewise-horizontal-mirror-preserving-order", } for key in [ "last_error", "repair_reason", "queued_at", ]: left_job.pop(key, None) manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") print( json.dumps( { "ok": True, "job_id": "running-left", "derived_from": "running-right", "output": str(output), "decision_note": args.decision_note.strip(), "transform": "framewise-horizontal-mirror-preserving-order", }, indent=2, ) ) if __name__ == "__main__": main() -
extract_strip_frames.py 13.6 KB
#!/usr/bin/env python3 """Extract generated horizontal row strips into 192x208 sprite frames.""" from __future__ import annotations import argparse import json import math import re from pathlib import Path from PIL import Image CELL_WIDTH = 192 CELL_HEIGHT = 208 ROW_FRAME_COUNTS = { "idle": 6, "running-right": 8, "running-left": 8, "waving": 4, "jumping": 5, "failed": 8, "waiting": 6, "running": 6, "review": 6, } def parse_states(raw: str) -> list[str]: if raw.strip().lower() == "all": return list(ROW_FRAME_COUNTS) states = [item.strip() for item in raw.split(",") if item.strip()] unknown = sorted(set(states) - set(ROW_FRAME_COUNTS)) if unknown: raise SystemExit(f"unknown state(s): {', '.join(unknown)}") return states def parse_hex_color(value: str) -> tuple[int, int, int]: if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB") return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) def load_chroma_key(decoded_dir: Path, override: str | None) -> tuple[int, int, int]: if override: return parse_hex_color(override) request_path = decoded_dir.parent / "pet_request.json" if request_path.is_file(): request = json.loads(request_path.read_text(encoding="utf-8")) chroma_key = request.get("chroma_key") if isinstance(chroma_key, dict) and isinstance(chroma_key.get("hex"), str): return parse_hex_color(chroma_key["hex"]) return parse_hex_color("#00FF00") def color_distance( red: int, green: int, blue: int, key: tuple[int, int, int], ) -> float: return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2) def remove_chroma_background( image: Image.Image, chroma_key: tuple[int, int, int], threshold: float, ) -> Image.Image: rgba = image.convert("RGBA") pixels = rgba.load() for y in range(rgba.height): for x in range(rgba.width): red, green, blue, alpha = pixels[x, y] if color_distance(red, green, blue, chroma_key) <= threshold: pixels[x, y] = (0, 0, 0, 0) return rgba def fit_to_cell(image: Image.Image) -> Image.Image: bbox = image.getbbox() target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) if bbox is None: return target sprite = image.crop(bbox) max_width = CELL_WIDTH - 10 max_height = CELL_HEIGHT - 10 scale = min(max_width / sprite.width, max_height / sprite.height, 1.0) if scale != 1.0: sprite = sprite.resize( (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))), Image.Resampling.LANCZOS, ) left = (CELL_WIDTH - sprite.width) // 2 top = (CELL_HEIGHT - sprite.height) // 2 target.alpha_composite(sprite, (left, top)) return target def fit_viewport_to_cell(image: Image.Image) -> Image.Image: target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) if image.getbbox() is None: return target viewport = image.copy() max_width = CELL_WIDTH - 10 max_height = CELL_HEIGHT - 10 scale = min(max_width / viewport.width, max_height / viewport.height, 1.0) if scale != 1.0: viewport = viewport.resize( (max(1, round(viewport.width * scale)), max(1, round(viewport.height * scale))), Image.Resampling.LANCZOS, ) left = (CELL_WIDTH - viewport.width) // 2 top = (CELL_HEIGHT - viewport.height) // 2 target.alpha_composite(viewport, (left, top)) return target def connected_components(image: Image.Image) -> list[dict[str, object]]: alpha = image.getchannel("A") width, height = image.size data = alpha.tobytes() visited = bytearray(width * height) components: list[dict[str, object]] = [] for start, alpha_value in enumerate(data): if alpha_value <= 16 or visited[start]: continue stack = [start] visited[start] = 1 pixels: list[int] = [] min_x = width min_y = height max_x = 0 max_y = 0 while stack: current = stack.pop() pixels.append(current) x = current % width y = current // width min_x = min(min_x, x) min_y = min(min_y, y) max_x = max(max_x, x) max_y = max(max_y, y) if x > 0: neighbor = current - 1 if not visited[neighbor] and data[neighbor] > 16: visited[neighbor] = 1 stack.append(neighbor) if x + 1 < width: neighbor = current + 1 if not visited[neighbor] and data[neighbor] > 16: visited[neighbor] = 1 stack.append(neighbor) if y > 0: neighbor = current - width if not visited[neighbor] and data[neighbor] > 16: visited[neighbor] = 1 stack.append(neighbor) if y + 1 < height: neighbor = current + width if not visited[neighbor] and data[neighbor] > 16: visited[neighbor] = 1 stack.append(neighbor) components.append( { "pixels": pixels, "area": len(pixels), "bbox": (min_x, min_y, max_x + 1, max_y + 1), "center_x": (min_x + max_x + 1) / 2, } ) return components def component_group_image( source: Image.Image, components: list[dict[str, object]], padding: int = 4, ) -> Image.Image: width, height = source.size min_x = max(0, min(component["bbox"][0] for component in components) - padding) min_y = max(0, min(component["bbox"][1] for component in components) - padding) max_x = min(width, max(component["bbox"][2] for component in components) + padding) max_y = min(height, max(component["bbox"][3] for component in components) + padding) output = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0)) source_pixels = source.load() output_pixels = output.load() for component in components: for pixel_index in component["pixels"]: x = pixel_index % width y = pixel_index // width output_pixels[x - min_x, y - min_y] = source_pixels[x, y] return output def component_frame_groups( strip: Image.Image, frame_count: int, ) -> list[list[dict[str, object]]] | None: components = connected_components(strip) if not components: return None largest_area = max(component["area"] for component in components) seed_threshold = max(120, largest_area * 0.20) seeds = [component for component in components if component["area"] >= seed_threshold] if len(seeds) < frame_count: seeds = sorted(components, key=lambda component: component["area"], reverse=True)[ :frame_count ] if len(seeds) < frame_count: return None seeds = sorted( sorted(seeds, key=lambda component: component["area"], reverse=True)[:frame_count], key=lambda component: component["center_x"], ) seed_ids = {id(seed) for seed in seeds} groups: list[list[dict[str, object]]] = [[seed] for seed in seeds] noise_threshold = max(12, largest_area * 0.002) for component in components: if id(component) in seed_ids or component["area"] < noise_threshold: continue nearest_index = min( range(len(seeds)), key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]), ) groups[nearest_index].append(component) return groups def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None: groups = component_frame_groups(strip, frame_count) if groups is None: return None return [fit_to_cell(component_group_image(strip, group)) for group in groups] def component_bounds(components: list[dict[str, object]]) -> tuple[int, int, int, int]: return ( min(component["bbox"][0] for component in components), min(component["bbox"][1] for component in components), max(component["bbox"][2] for component in components), max(component["bbox"][3] for component in components), ) def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]: slot_width = strip.width / frame_count frames = [] for index in range(frame_count): left = round(index * slot_width) right = round((index + 1) * slot_width) crop = strip.crop((left, 0, right, strip.height)) frames.append(fit_to_cell(crop)) return frames def extract_stable_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]: groups = component_frame_groups(strip, frame_count) padding = 4 if groups is not None: bboxes = [component_bounds(group) for group in groups] shared_top = max(0, min(bbox[1] for bbox in bboxes) - padding) shared_bottom = min(strip.height, max(bbox[3] for bbox in bboxes) + padding) viewport_width = max(bbox[2] - bbox[0] for bbox in bboxes) + padding * 2 viewport_height = max(1, shared_bottom - shared_top) frames = [] for group, bbox in zip(groups, bboxes): grouped = component_group_image(strip, group, padding=padding) grouped_top = max(0, bbox[1] - padding) viewport = Image.new( "RGBA", (viewport_width, viewport_height), (0, 0, 0, 0), ) left = (viewport_width - grouped.width) // 2 viewport.alpha_composite(grouped, (left, grouped_top - shared_top)) frames.append(fit_viewport_to_cell(viewport)) return frames bbox = strip.getbbox() if bbox is None: return [ Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) for _ in range(frame_count) ] shared_top = max(0, bbox[1] - padding) shared_bottom = min(strip.height, bbox[3] + padding) slot_width = strip.width / frame_count frames = [] for index in range(frame_count): left = round(index * slot_width) right = round((index + 1) * slot_width) crop = strip.crop((left, shared_top, right, shared_bottom)) frames.append(fit_viewport_to_cell(crop)) return frames def extract_state( strip_path: Path, state: str, output_root: Path, chroma_key: tuple[int, int, int], threshold: float, method: str, ) -> dict[str, object]: frame_count = ROW_FRAME_COUNTS[state] with Image.open(strip_path) as opened: strip = remove_chroma_background(opened, chroma_key, threshold) state_dir = output_root / state state_dir.mkdir(parents=True, exist_ok=True) frames = None used_method = method if method in {"auto", "components"}: frames = extract_component_frames(strip, frame_count) if frames is None and method == "components": raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}") if frames is not None: used_method = "components" if frames is None: if method == "stable-slots": frames = extract_stable_slot_frames(strip, frame_count) used_method = "stable-slots" else: frames = extract_slot_frames(strip, frame_count) used_method = "slots" outputs = [] for index, frame in enumerate(frames): output = state_dir / f"{index:02d}.png" frame.save(output) outputs.append(str(output)) return {"state": state, "frames": outputs, "method": used_method} def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--decoded-dir", required=True) parser.add_argument("--output-dir", required=True) parser.add_argument("--states", default="all") parser.add_argument("--chroma-key", help="Override chroma key as #RRGGBB.") parser.add_argument("--key-threshold", type=float, default=96.0) parser.add_argument( "--method", choices=("auto", "components", "slots", "stable-slots"), default="auto", help="Use connected sprite components when possible, raw equal slots, or row-stable slot viewports.", ) args = parser.parse_args() decoded_dir = Path(args.decoded_dir).expanduser().resolve() output_dir = Path(args.output_dir).expanduser().resolve() chroma_key = load_chroma_key(decoded_dir, args.chroma_key) states = parse_states(args.states) manifest = [] for state in states: strip_path = decoded_dir / f"{state}.png" if not strip_path.is_file(): raise SystemExit(f"missing generated strip for {state}: {strip_path}") manifest.append( extract_state( strip_path, state, output_dir, chroma_key, args.key_threshold, args.method, ) ) (output_dir / "frames-manifest.json").write_text( json.dumps( { "ok": True, "chroma_key": { "hex": f"#{chroma_key[0]:02X}{chroma_key[1]:02X}{chroma_key[2]:02X}", "rgb": list(chroma_key), "threshold": args.key_threshold, }, "rows": manifest, }, indent=2, ) + "\n", encoding="utf-8", ) print(json.dumps({"ok": True, "frames_root": str(output_dir), "states": states}, indent=2)) if __name__ == "__main__": main() -
inspect_frames.py 8.8 KB
#!/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": if method == "stable-slots" and args.allow_stable_slots: row_warnings.append( f"{state} used extraction method stable-slots; confirm motion playback remains stable and unclipped" ) else: row_errors.append( f"{state} used extraction method {method}; regenerate the row or inspect slot slicing" ) elif method and method != "components": row_warnings.append( f"{state} used extraction method {method}; component extraction is preferred" ) for index, frame_path in enumerate(files[:expected_count]): with Image.open(frame_path) as opened: frame = opened.convert("RGBA") nontransparent = alpha_nonzero_count(frame) bbox = frame.getbbox() edge_pixels = edge_alpha_count(frame, args.edge_margin) chroma_adjacent_pixels = chroma_adjacent_count( frame, chroma_key, args.chroma_adjacent_threshold, ) info = { "index": index, "file": str(frame_path), "width": frame.width, "height": frame.height, "nontransparent_pixels": nontransparent, "bbox": list(bbox) if bbox else None, "edge_pixels": edge_pixels, "chroma_adjacent_pixels": chroma_adjacent_pixels, } frames.append(info) areas.append(nontransparent) if frame.size != (CELL_WIDTH, CELL_HEIGHT): row_errors.append( f"{state} frame {index:02d} is {frame.width}x{frame.height}; expected {CELL_WIDTH}x{CELL_HEIGHT}" ) if nontransparent < args.min_used_pixels: row_errors.append( f"{state} frame {index:02d} is empty or too sparse ({nontransparent} pixels)" ) if edge_pixels > args.edge_pixel_threshold: row_warnings.append( f"{state} frame {index:02d} has {edge_pixels} non-transparent pixels near the cell edge" ) if chroma_adjacent_pixels > args.chroma_adjacent_pixel_threshold: row_errors.append( f"{state} frame {index:02d} has {chroma_adjacent_pixels} non-transparent pixels close to the chroma key" ) if areas: row_median = median(areas) for index, area in enumerate(areas[:expected_count]): if row_median > 0 and area < row_median * args.small_outlier_ratio: row_warnings.append( f"{state} frame {index:02d} is much smaller than the row median ({area} vs {row_median:.0f})" ) if row_median > 0 and area > row_median * args.large_outlier_ratio: row_warnings.append( f"{state} frame {index:02d} is much larger than the row median ({area} vs {row_median:.0f})" ) return { "state": state, "expected_frames": expected_count, "actual_frames": len(files), "extraction_method": method, "ok": not row_errors, "errors": row_errors, "warnings": row_warnings, "frames": frames, } def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--frames-root", required=True) parser.add_argument("--json-out", required=True) parser.add_argument("--min-used-pixels", type=int, default=400) parser.add_argument("--edge-margin", type=int, default=2) parser.add_argument("--edge-pixel-threshold", type=int, default=24) parser.add_argument("--chroma-adjacent-threshold", type=float, default=150.0) parser.add_argument("--chroma-adjacent-pixel-threshold", type=int, default=800) parser.add_argument("--small-outlier-ratio", type=float, default=0.35) parser.add_argument("--large-outlier-ratio", type=float, default=2.75) parser.add_argument( "--require-components", action="store_true", help="Fail rows that fell back to equal-slot extraction.", ) parser.add_argument( "--allow-stable-slots", action="store_true", help="Permit explicitly chosen stable-slots extraction while still warning for visual review.", ) args = parser.parse_args() frames_root = Path(args.frames_root).expanduser().resolve() manifest_rows = load_manifest(frames_root) chroma_key = load_chroma_key(frames_root) rows = [ inspect_state(frames_root, state, count, manifest_rows, chroma_key, args) for state, count in ROW_FRAME_COUNTS.items() ] errors = [error for row in rows for error in row["errors"]] warnings = [warning for row in rows for warning in row["warnings"]] result = { "ok": not errors, "frames_root": str(frames_root), "errors": errors, "warnings": warnings, "rows": rows, } json_out = Path(args.json_out).expanduser().resolve() json_out.parent.mkdir(parents=True, exist_ok=True) json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") print(json.dumps({k: v for k, v in result.items() if k != "rows"}, indent=2)) raise SystemExit(0 if result["ok"] else 1) if __name__ == "__main__": main() -
make_contact_sheet.py 2.9 KB
#!/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() -
prepare_pet_run.py 32.1 KB
#!/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, "calm resting, breathing, and blinking loop"), ("running-right", 1, 8, "rightward drag movement loop"), ("running-left", 2, 8, "leftward drag movement loop"), ("waving", 3, 4, "greeting or attention gesture"), ("jumping", 4, 5, "hover or playful jump"), ("failed", 5, 8, "blocked, failed, or cancelled reaction"), ("waiting", 6, 6, "waiting for approval, help, or user input"), ("running", 7, 6, "active task work or processing"), ("review", 8, 6, "ready or completed output review"), ] STATE_PROMPTS = { "idle": "Calm low-distraction resting loop: subtle breathing, tiny blink, slight head/body bob, and only quiet persona-preserving motion.", "running-right": "Dragging-right loop: show directional movement to the right through body and limb poses only.", "running-left": "Dragging-left loop: show directional movement to the left through body and limb poses only.", "waving": "Greeting loop: paw or limb down, raised, tilted, and returning in a friendly attention gesture.", "jumping": "Hover jump loop: anticipation, lift, airborne peak, descent, and settle through body height.", "failed": "Blocked/failed loop: slumped or deflated reaction with sad or closed eyes.", "waiting": "Needs-input loop: expectant asking pose for approval, help, or user input.", "running": "Working loop: focused active-task processing, thinking, typing, scanning, or effortful concentration; not literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.", "review": "Ready-review loop: focused inspection of completed output with lean, blink, narrowed eyes, head tilt, or paw pose.", } STATE_REQUIREMENTS = { "idle": [ "CRITICAL: idle is the low-distraction baseline state and the first frame is also used as the reduced-motion static pet.", "Use only subtle idle motion: gentle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet motion that fits the pet persona.", "Keep the pet essentially in the same pose, facing direction, silhouette, markings, palette, and prop state across all 6 frames.", "Idle variation must stay calm but still read as animation; do not repeat effectively identical copies across the loop.", "Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.", "Feet, base, body, or object anchor should remain planted or nearly planted.", "The first and last frames should be very close visually so the loop feels calm and does not pop.", ], "waving": [ "Show the greeting through paw, hand, wing, or limb pose only.", "Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.", ], "jumping": [ "Show the jump through pose and vertical body position only: anticipation, lift, airborne peak, descent, settle.", "Do not draw ground shadows, contact shadows, drop shadows, oval shadows, landing marks, dust, smears, bounce pads, or motion marks under the pet.", "Keep the background outside the pet perfectly flat chroma key with no darker key-colored patches.", ], "failed": [ "Show failure through slumped pose, drooping ears/limbs, closed or sad eyes, and lower body position.", "Tears, small smoke puffs, or tiny stars are allowed only if attached to or overlapping the pet silhouette and kept inside the same frame slot.", "Do not draw red X marks, floating symbols, detached stars, separated smoke clouds, falling tear drops, dust, or other loose effects.", ], "waiting": [ "Show that Codex needs approval, help, or user input through an expectant asking pose.", "Keep the motion patient and readable, without turning it into ordinary idle or review.", ], "running": [ "Show the pet actively working or processing, as if running a task: focused posture, busy hands or paws, purposeful bobbing, thinking motion, tool or prop motion only if already part of the pet identity, or other non-locomotion activity.", "Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", ], "review": [ "Show review through lean, blink, narrowed eyes, head tilt, or paw/hand position.", "Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.", ], "running-right": [ "Show directional drag movement to the right through body, limb, and prop movement only.", "The row must unmistakably face and travel right.", "The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.", "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", ], "running-left": [ "Show directional drag movement to the left through body, limb, and prop movement only.", "The row must unmistakably face and travel left.", "The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.", "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", ], } NON_DERIVABLE_STATES = { "waving", "jumping", "failed", "waiting", "running", "review", } PET_SAFE_STYLE = ( "Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, " "clear silhouette, simple face, stable palette/materials, and crisp edges " "for chroma-key extraction." ) STYLE_PRESETS = { "auto": ( "Infer the most appropriate pet-safe style from the user request and " "reference images, then keep that exact style consistent across every row." ), "pixel": ( "Pixel-art-adjacent digital mascot with a chunky silhouette, simple dark " "outline, limited palette, flat cel shading, and visible stepped edges." ), "plush": ( "Soft plush toy mascot with rounded stitched forms, fuzzy fabric feel, " "simple sewn details, and readable toy-like proportions." ), "clay": ( "Handmade clay or polymer-clay mascot with rounded sculpted forms, soft " "material texture, simple features, and clean readable edges." ), "sticker": ( "Polished sticker mascot with bold clean shapes, crisp outline, flat " "colors, and minimal highlight detail." ), "flat-vector": ( "Flat vector-style mascot with simple geometric forms, crisp color areas, " "clean outline, and minimal shading." ), "3d-toy": ( "Stylized 3D toy mascot with smooth rounded forms, simple materials, " "clear silhouette, and no photoreal complexity." ), "painterly": ( "Painterly mascot with simplified brush texture, readable forms, stable " "palette, and enough edge clarity for clean extraction." ), "brand-inspired": ( "Brand-inspired mascot using approved public or user-provided brand cues " "such as colors, mascot themes, and vibe while avoiding readable text or " "logo copying unless explicitly approved." ), } CHROMA_KEY_CANDIDATES = [ ("magenta", "#FF00FF"), ("cyan", "#00FFFF"), ("yellow", "#FFFF00"), ("blue", "#0000FF"), ("orange", "#FF7F00"), ("green", "#00FF00"), ] DEFAULT_PET_NAME = "Sprout" CANONICAL_BASE_PATH = "references/canonical-base.png" BRAND_DISCOVERY_PATH = "references/brand-discovery.md" LAYOUT_GUIDE_DIR = "references/layout-guides" LAYOUT_GUIDE_SAFE_MARGIN_X = 18 LAYOUT_GUIDE_SAFE_MARGIN_Y = 16 def slugify(value: str) -> str: value = value.strip().lower() value = re.sub(r"[^a-z0-9]+", "-", value) value = re.sub(r"-{2,}", "-", value) return value.strip("-") def display_from_slug(value: str) -> str: words = [word for word in re.split(r"[^a-zA-Z0-9]+", value.strip()) if word] return " ".join(word.capitalize() for word in words) def concept_words(value: str) -> list[str]: stop_words = { "a", "an", "and", "app", "based", "codex", "compact", "digital", "for", "from", "in", "of", "on", "pet", "ready", "small", "the", "to", "with", } words = [ word.lower() for word in re.findall(r"[a-zA-Z0-9]+", value) if word.lower() not in stop_words ] return words def infer_name(args: argparse.Namespace, reference_paths: list[Path]) -> str: for raw_value in [args.display_name, args.pet_name]: value = raw_value.strip() if value: return value if args.pet_id.strip(): display = display_from_slug(args.pet_id) if display: return display for raw_value in [args.pet_notes, args.description, args.brand_name]: words = concept_words(raw_value) if words: return words[0].capitalize() for path in reference_paths: display = display_from_slug(path.stem) if display: return display return DEFAULT_PET_NAME def sentence(value: str) -> str: value = " ".join(value.strip().split()) if not value: return value if value[-1] not in ".!?": value += "." return value def infer_description(args: argparse.Namespace, reference_paths: list[Path]) -> str: if args.description.strip(): return sentence(args.description) if args.pet_notes.strip(): return sentence(f"A compact Codex pet: {args.pet_notes}") if args.brand_name.strip(): return sentence(f"A compact Codex pet inspired by {args.brand_name}") if reference_paths: return "A compact Codex pet based on the provided reference image." return "A compact original Codex pet ready for animation." def infer_pet_notes(args: argparse.Namespace, reference_paths: list[Path]) -> str: if args.pet_notes.strip(): return args.pet_notes.strip() if args.description.strip(): return args.description.strip().rstrip(".") if args.brand_name.strip(): return f"a compact mascot inspired by {args.brand_name.strip()}" if reference_paths: return "the pet shown in the reference image(s)" return "a compact original Codex 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_contract(style_preset: str, raw_style_notes: str) -> str: style_preset = style_preset.strip().lower() if style_preset not in STYLE_PRESETS: allowed = ", ".join(sorted(STYLE_PRESETS)) raise SystemExit( f"invalid style preset: {style_preset}; expected one of: {allowed}" ) raw_style_notes = raw_style_notes.strip() preset_contract = STYLE_PRESETS[style_preset] if not raw_style_notes: return f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract}" return ( f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract} " f"User style notes: {raw_style_notes}." ) def compact(value: str) -> str: return " ".join(value.strip().split()) def brand_inspiration_line(args: argparse.Namespace) -> str: brand_name = compact(args.brand_name) brand_brief = compact(args.brand_brief) if not brand_name and not brand_brief: return "" prefix = f"{brand_name}: " if brand_name else "" if brand_brief: return ( f"{prefix}{brand_brief} Use only broad mascot-safe cues; do not copy " "readable logos, marks, UI screenshots, or text." ) return ( f"{prefix}Use only broad mascot-safe brand cues. Do not copy readable " "logos, marks, UI screenshots, or text." ) def base_pet_prompt(args: argparse.Namespace) -> str: pet_notes = args.pet_notes or "the pet shown in the reference image(s)" style_contract = resolved_style_contract(args.style_preset, args.style_notes) brand_line = brand_inspiration_line(args) brand_block = f"\nBrand inspiration: {brand_line}\n" if brand_line else "\n" chroma_key = args.chroma_key["hex"] chroma_name = args.chroma_key["name"] return f"""Create one clean full-body reference sprite for Codex pet {args.display_name}. Pet identity: {pet_notes}. Style: {style_contract} {brand_block} Place a single centered pose on a perfectly flat pure {chroma_name} {chroma_key} chroma-key background. Keep the full pet visible, compact, readable at 192x208, and easy to animate. Preserve approved reference identity cues. No scenery, text, borders, checkerboard transparency, shadows, glows, detached effects, or extra props. Keep {chroma_key} and close colors out of the pet, props, highlights, and effects.""" def row_prompt( args: argparse.Namespace, state: str, row: int, frames: int, purpose: str ) -> str: pet_notes = args.pet_notes or "the same pet from the approved base reference" style_contract = resolved_style_contract(args.style_preset, args.style_notes) chroma_key = args.chroma_key["hex"] chroma_name = args.chroma_key["name"] state_prompt = STATE_PROMPTS[state] state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state]) return f"""Create one horizontal animation strip for Codex pet `{args.pet_id}`, state `{state}`. Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide. Output exactly {frames} full-body frames in one left-to-right row on flat pure {chroma_name} {chroma_key}. Treat the row as {frames} invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders. Identity: same pet in every frame: {pet_notes}. Preserve silhouette, face, proportions, markings, palette, material, style, and props. Style: {style_contract} Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame. State action: {state_prompt} State requirements: {state_requirements} Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.""" def retry_row_prompt( args: argparse.Namespace, state: str, row: int, frames: int, purpose: str ) -> str: pet_notes = args.pet_notes or "the canonical base pet" chroma_key = args.chroma_key["hex"] chroma_name = args.chroma_key["name"] state_prompt = STATE_PROMPTS[state] state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state]) return f"""Create Codex pet row `{state}` for `{args.pet_id}`: exactly {frames} full-body frames in one horizontal strip on flat pure {chroma_name} {chroma_key}. Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: {pet_notes}. Preserve silhouette, face, palette, material, proportions, markings, and props. Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Action: {state_prompt} State requirements: {state_requirements} One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or {chroma_key} colors in the pet.""" def make_jobs( run_dir: Path, copied_refs: list[dict[str, object]] ) -> list[dict[str, object]]: reference_inputs = [ {"path": rel(Path(str(ref["copied_path"])), run_dir), "role": "pet reference"} for ref in copied_refs ] identity_reference_paths = [CANONICAL_BASE_PATH] jobs: list[dict[str, object]] = [ { "id": "base", "kind": "base-pet", "status": "pending", "prompt_file": "prompts/base-pet.md", "input_images": reference_inputs, "output_path": "decoded/base.png", "depends_on": [], "generation_skill": "$imagegen", "requires_grounded_generation": bool(reference_inputs), "allow_prompt_only_generation": not reference_inputs, } ] for state, _row, frames, _purpose in ROWS: depends_on = ["base"] extra_inputs: list[dict[str, str]] = [] derivation_policy: dict[str, object] = { "may_derive": False, "reason": "state requires its own generated animation semantics", } if state == "running-left": depends_on.append("running-right") extra_inputs.append( { "path": "decoded/running-right.png", "role": "rightward gait reference for leftward row decision", } ) derivation_policy = { "may_derive": True, "may_derive_from": "running-right", "derivation": "framewise-horizontal-mirror-preserving-order", "requires_explicit_approval": True, "fallback_generation_skill": "$imagegen", } elif state not in NON_DERIVABLE_STATES: derivation_policy["reason"] = "no deterministic derivation is configured for this state" jobs.append( { "id": state, "kind": "row-strip", "status": "pending", "prompt_file": f"prompts/rows/{state}.md", "retry_prompt_file": f"prompts/row-retries/{state}.md", "input_images": [ *reference_inputs, { "path": f"{LAYOUT_GUIDE_DIR}/{state}.png", "role": f"layout guide for {frames} frame slots; use for spacing only, do not copy guide lines", }, { "path": CANONICAL_BASE_PATH, "role": "canonical identity reference", }, *extra_inputs, ], "output_path": f"decoded/{state}.png", "depends_on": depends_on, "generation_skill": "$imagegen", "requires_grounded_generation": True, "allow_prompt_only_generation": False, "identity_reference_paths": identity_reference_paths, "parallelizable_after": depends_on, "derivation_policy": derivation_policy, "mirror_policy": derivation_policy if state == "running-left" else {}, } ) return jobs def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--pet-name", default="", help="User-facing pet name. Ask the user for this when practical; otherwise choose a short appropriate name.", ) parser.add_argument( "--pet-id", default="", help="Stable pet folder/id slug. Defaults to the slugified pet name.", ) parser.add_argument( "--display-name", default="", help="Display label. Defaults to the pet name.", ) parser.add_argument("--description", default="") parser.add_argument("--reference", action="append", default=[]) parser.add_argument("--output-dir", default="") parser.add_argument("--pet-notes", default="") parser.add_argument( "--brand-name", default="", help="Brand, company, or product name used for broad mascot inspiration.", ) parser.add_argument( "--brand-brief", default="", help="Compact researched brand cue sentence for the base pet only.", ) parser.add_argument( "--brand-source", action="append", default=[], help="Source URL used to produce the brand brief. May be passed multiple times.", ) parser.add_argument( "--brand-discovery-file", default="", help="Optional markdown discovery brief to copy into the run for review.", ) parser.add_argument( "--style-preset", default="auto", choices=sorted(STYLE_PRESETS), help="Pet-safe style preset to use across the base and all animation rows.", ) parser.add_argument("--style-notes", default="") parser.add_argument( "--chroma-key", default="auto", help="Chroma key as #RRGGBB, or auto to choose a safe key from reference colors.", ) parser.add_argument("--force", action="store_true") args = parser.parse_args() raw_reference_paths = [ Path(raw_path).expanduser().resolve() for raw_path in args.reference ] raw_brand_discovery_path = ( Path(args.brand_discovery_file).expanduser().resolve() if args.brand_discovery_file.strip() else None ) args.display_name = infer_name(args, raw_reference_paths) args.pet_name = (args.pet_name or args.display_name).strip() args.description = infer_description(args, raw_reference_paths) args.pet_notes = infer_pet_notes(args, raw_reference_paths) args.pet_id = slugify(args.pet_id or args.pet_name or args.display_name) args.style_preset = args.style_preset.strip().lower() args.style_contract = resolved_style_contract(args.style_preset, args.style_notes) args.brand_name = compact(args.brand_name) args.brand_brief = compact(args.brand_brief) args.brand_source = [ compact(source) for source in args.brand_source if compact(source) ] if not args.pet_id: raise SystemExit("pet id must contain at least one letter or digit") run_dir = ( Path(args.output_dir).expanduser().resolve() if args.output_dir else default_output_dir(args.pet_id).resolve() ) if run_dir.exists() and any(run_dir.iterdir()) and not args.force: raise SystemExit( f"{run_dir} already exists and is not empty; pass --force to reuse it" ) run_dir.mkdir(parents=True, exist_ok=True) ref_dir = run_dir / "references" prompt_dir = run_dir / "prompts" row_prompt_dir = prompt_dir / "rows" row_retry_prompt_dir = prompt_dir / "row-retries" for directory in [ ref_dir, prompt_dir, row_prompt_dir, row_retry_prompt_dir, run_dir / "decoded", run_dir / "qa", ]: directory.mkdir(parents=True, exist_ok=True) copied_refs: list[dict[str, object]] = [] copied_ref_paths: list[Path] = [] for index, source in enumerate(raw_reference_paths, start=1): if not source.is_file(): raise SystemExit(f"reference not found: {source}") suffix = source.suffix.lower() or ".png" copied = ref_dir / f"reference-{index:02d}{suffix}" shutil.copy2(source, copied) meta = image_metadata(copied) meta["source_path"] = str(source) meta["copied_path"] = str(copied) copied_refs.append(meta) copied_ref_paths.append(copied) brand_discovery_path = "" if raw_brand_discovery_path is not None: if not raw_brand_discovery_path.is_file(): raise SystemExit(f"brand discovery file not found: {raw_brand_discovery_path}") copied_discovery = run_dir / BRAND_DISCOVERY_PATH shutil.copy2(raw_brand_discovery_path, copied_discovery) brand_discovery_path = rel(copied_discovery, run_dir) args.chroma_key = choose_chroma_key(copied_ref_paths, args.chroma_key) layout_guides = create_layout_guides(run_dir) request = { "pet_id": args.pet_id, "display_name": args.display_name, "description": args.description, "created_at": datetime.now(timezone.utc).isoformat(), "atlas": ATLAS, "rows": [ {"state": state, "row": row, "frames": frames, "purpose": purpose} for state, row, frames, purpose in ROWS ], "layout_guides": [ {**guide, "path": rel(Path(str(guide["path"])), run_dir)} for guide in layout_guides ], "references": copied_refs, "chroma_key": args.chroma_key, "pet_notes": args.pet_notes, "style_preset": args.style_preset, "style_notes": args.style_notes, "style_contract": args.style_contract, "brand_name": args.brand_name, "brand_brief": args.brand_brief, "brand_sources": args.brand_source, "pet_safe_style": PET_SAFE_STYLE, "primary_generation_skill": "$imagegen", } if brand_discovery_path: request["brand_discovery_path"] = brand_discovery_path (run_dir / "pet_request.json").write_text( json.dumps(request, indent=2) + "\n", encoding="utf-8" ) write_text(prompt_dir / "base-pet.md", base_pet_prompt(args)) for state, row, frames, purpose in ROWS: write_text( row_prompt_dir / f"{state}.md", row_prompt(args, state, row, frames, purpose), ) write_text( row_retry_prompt_dir / f"{state}.md", retry_row_prompt(args, state, row, frames, purpose), ) jobs = { "schema_version": 1, "created_at": datetime.now(timezone.utc).isoformat(), "run_dir": str(run_dir), "primary_generation_skill": "$imagegen", "jobs": make_jobs(run_dir, copied_refs), } (run_dir / "imagegen-jobs.json").write_text( json.dumps(jobs, indent=2) + "\n", encoding="utf-8" ) print( json.dumps( { "ok": True, "run_dir": str(run_dir), "request": str(run_dir / "pet_request.json"), "jobs": str(run_dir / "imagegen-jobs.json"), "ready_jobs": ["base"], }, indent=2, ) ) if __name__ == "__main__": main() -
render_animation_previews.py 2.5 KB
#!/usr/bin/env python3 """Render lightweight animated QA previews from extracted Codex pet frames.""" from __future__ import annotations import argparse import json from pathlib import Path from PIL import Image ROW_DURATIONS = { "idle": [280, 110, 110, 140, 140, 320], "running-right": [120, 120, 120, 120, 120, 120, 120, 220], "running-left": [120, 120, 120, 120, 120, 120, 120, 220], "waving": [140, 140, 140, 280], "jumping": [140, 140, 140, 140, 280], "failed": [140, 140, 140, 140, 140, 140, 140, 240], "waiting": [150, 150, 150, 150, 150, 260], "running": [120, 120, 120, 120, 120, 220], "review": [150, 150, 150, 150, 150, 280], } IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"} def frame_files(state_dir: Path) -> list[Path]: if not state_dir.is_dir(): return [] return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES) def load_frames(frames_root: Path, state: str, expected_count: int) -> list[Image.Image]: files = frame_files(frames_root / state) if len(files) != expected_count: raise SystemExit( f"{state} preview needs {expected_count} frames, found {len(files)} under {frames_root / state}" ) frames = [] for path in files: with Image.open(path) as opened: frames.append(opened.convert("RGBA")) return frames def save_preview(frames: list[Image.Image], durations: list[int], output: Path) -> None: output.parent.mkdir(parents=True, exist_ok=True) frames[0].save( output, save_all=True, append_images=frames[1:], duration=durations, loop=0, disposal=2, optimize=False, ) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--frames-root", required=True) parser.add_argument("--output-dir", required=True) args = parser.parse_args() frames_root = Path(args.frames_root).expanduser().resolve() output_dir = Path(args.output_dir).expanduser().resolve() previews = [] for state, durations in ROW_DURATIONS.items(): frames = load_frames(frames_root, state, len(durations)) output = output_dir / f"{state}.gif" save_preview(frames, durations, output) previews.append({"state": state, "path": str(output), "frames": len(frames)}) result = {"ok": True, "output_dir": str(output_dir), "previews": previews} print(json.dumps(result, indent=2)) if __name__ == "__main__": main() -
validate_atlas.py 5.3 KB
#!/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 transparent_rgb_residue_count(image: Image.Image) -> int: rgba = image.convert("RGBA") data = rgba.tobytes() count = 0 for index in range(0, len(data), 4): red, green, blue, alpha = data[index : index + 4] if alpha == 0 and (red or green or blue): count += 1 return count def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("atlas") parser.add_argument("--json-out") parser.add_argument("--min-used-pixels", type=int, default=50) parser.add_argument("--near-opaque-threshold", type=float, default=0.95) parser.add_argument("--allow-opaque", action="store_true") parser.add_argument("--allow-near-opaque-used-cells", action="store_true") args = parser.parse_args() atlas_path = Path(args.atlas).expanduser().resolve() errors: list[str] = [] warnings: list[str] = [] near_opaque_used_cells: dict[str, list[int]] = defaultdict(list) cells: list[dict[str, object]] = [] try: with Image.open(atlas_path) as opened: source_mode = opened.mode source_format = opened.format image = opened.convert("RGBA") except Exception as exc: # noqa: BLE001 result = {"ok": False, "errors": [f"could not open atlas: {exc}"], "warnings": []} print(json.dumps(result, indent=2)) raise SystemExit(1) if image.size != (ATLAS_WIDTH, ATLAS_HEIGHT): errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {image.width}x{image.height}") if source_format not in {"PNG", "WEBP"}: errors.append(f"expected PNG or WebP, got {source_format}") if "A" not in source_mode and not args.allow_opaque: errors.append("atlas does not have an alpha channel") for row_index in range(ROWS): state, frame_count = ROW_BY_INDEX[row_index] for column_index in range(COLUMNS): left = column_index * CELL_WIDTH top = row_index * CELL_HEIGHT cell = image.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT)) nontransparent = alpha_nonzero_count(cell) used = column_index < frame_count cell_info = { "state": state, "row": row_index, "column": column_index, "used": used, "nontransparent_pixels": nontransparent, } cells.append(cell_info) if used and nontransparent < args.min_used_pixels: errors.append( f"{state} row {row_index} column {column_index} is empty or too sparse ({nontransparent} pixels)" ) if used and nontransparent > CELL_WIDTH * CELL_HEIGHT * args.near_opaque_threshold: near_opaque_used_cells[f"{state} row {row_index}"].append(column_index) if not used and nontransparent != 0: errors.append( f"{state} row {row_index} unused column {column_index} is not transparent ({nontransparent} pixels)" ) for row_label, columns in near_opaque_used_cells.items(): message = ( f"{row_label} has {len(columns)} nearly opaque used cells; " "this usually means the sprite has a non-transparent background" ) if args.allow_near_opaque_used_cells: warnings.append(message) else: errors.append(message) alpha_count = alpha_nonzero_count(image) if alpha_count == ATLAS_WIDTH * ATLAS_HEIGHT: message = "atlas is fully opaque; custom pets require a transparent sprite background" if args.allow_opaque: warnings.append(message) else: errors.append(message) transparent_rgb_residue = transparent_rgb_residue_count(image) if transparent_rgb_residue: errors.append( f"atlas has {transparent_rgb_residue} fully transparent pixels with non-zero RGB residue" ) result = { "ok": not errors, "file": str(atlas_path), "format": source_format, "mode": source_mode, "width": image.width, "height": image.height, "transparent_rgb_residue_pixels": transparent_rgb_residue, "errors": errors, "warnings": warnings, "cells": cells, } if args.json_out: Path(args.json_out).expanduser().resolve().write_text( json.dumps(result, indent=2) + "\n", encoding="utf-8" ) print(json.dumps({k: v for k, v in result.items() if k != "cells"}, indent=2)) raise SystemExit(0 if result["ok"] else 1) if __name__ == "__main__": main()
-
-
LICENSE.txt 10.5 KB
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. -
SKILL.md 36.4 KB
--- name: hatch-pet description: Create, repair, validate, visually QA, and package Codex-compatible animated pets and pet spritesheets from character art, generated images, company or prospect brand cues, or visual references. Use when a user wants a lightweight-worker Codex pet workflow, a non-pixel custom pet style, a prospect or company mascot pet, or a full 8x9 animated pet atlas with transparent unused cells, QA contact sheets, and pet.json packaging. This skill composes the installed $imagegen system skill for visual generation and uses bundled scripts for deterministic spritesheet assembly. --- # Hatch Pet ## Overview Create a Codex-compatible animated pet from a concept, brand cue, company/prospect name, one or more reference images, or any combination of those inputs. This workflow keeps the deterministic hatch-pet pipeline for atlas geometry, validation, visual QA, and packaging, while using concise state-specific prompts and allowing any pet-safe visual style. User-facing inputs are optional. If the user omits a pet name, infer one from the concept, brand, company, or reference filenames; if that is not possible, choose a short friendly name. If the user omits a description, infer one from the concept or references. 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, image CLI, or any other image-generation path directly. Let `$imagegen` choose its own built-in-first path and fallback rules. If `$imagegen` says a fallback requires confirmation, ask the user before continuing. When invoking `$imagegen`, pass the generated pet prompt as the authoritative visual spec. Pet prompts should stay concise, state-specific, sprite-production oriented, and grounded in the listed input images. Keep longer policy and QA rules in this skill and the deterministic review scripts rather than expanding them into every image prompt. Do not wrap prompts in the generic `$imagegen` shared prompt schema. Use this skill's scripts for deterministic image work only: preparing layout guides and prompts, mirroring approved `running-left`, extracting frames, validating rows, composing the final atlas, and creating contact-sheet plus motion-preview QA media. Parent-owned shell/`jq` steps handle manifest updates, packaging, and cleanup. ## Storage Controls The built-in `$imagegen` path stores generated PNG bytes in the rollout that invokes it, even when it also writes a file under `${CODEX_HOME:-$HOME/.codex}/generated_images`. Deleting files later reduces filesystem use, but it does not shrink an already-written rollout. Keep image generation isolated and bounded: - Use one lightweight generation worker per visual job. Do not batch multiple base/row jobs into the same worker. - Workers must return only `selected_source=...` and `qa_note=...`; they must not include Markdown image previews, base64, or extra visual attachments in their final response. - The parent must not open every generated PNG visually. Use worker QA for each job and inspect only the final contact sheet. - After copying the selected generated output into `decoded/`, remove the selected original from `${CODEX_HOME:-$HOME/.codex}/generated_images` when it lives there, then remove its now-empty generation directory if possible. - For storage-sensitive full runs, ask the user whether to use the `$imagegen` CLI fallback when available. That path requires local API credentials and explicit user confirmation, but it can avoid built-in image payloads being embedded in rollout events. ## Brand Discovery If the user provides a brand, company, product, or prospect name rather than a concrete avatar description or reference image, run a lightweight discovery subagent before preparing the pet run. The discovery worker must use web search and prefer official sources such as the brand site, product pages, docs, about pages, press pages, or brand pages. Use reputable secondary sources only when official pages are too thin. Keep the search narrow: enough to extract visual and personality cues, not a market-research brief. Skip discovery when the user already provides a concrete mascot/avatar description or reference images, unless the user explicitly asks for brand research. Discovery worker responsibilities: - search the web for 2-4 relevant sources, preferring official pages - write an adaptive markdown brief rather than a rigid field dump - cover identity/category, audience/use context, visual system, personality/tone, product/domain motifs, mascot translation cues, avoidances, and evidence/confidence - mark mascot guidance that is inferred from sources as inference - avoid copying logos, readable marks, UI screenshots, slogans, or text - end with a compact `Generation handoff` section containing only `brand_name`, `brand_brief`, `avatar_seed`, `avoid`, and `brand_sources` - do not generate images, prepare run folders, or edit unrelated files Use this discovery worker prompt: ```text Research a brand for hatch-pet mascot creation. Brand/product/prospect: <brand name> User context: <short user request> Output file: <absolute path to brand-discovery.md> Use web search. Prefer official brand, product, docs, about, press, or brand pages. Use reputable secondary sources only if official sources are too thin. Write an adaptive markdown brief to the output file. Headings may flex by brand, but the brief must cover: - identity/category: canonical name, product type, what it does - audience/use context: who it serves and where it appears - visual system: palette, shapes, line quality, materials, typography feel, iconography, patterns - personality/tone: emotional traits, energy, formality, playfulness - product/domain motifs: objects, workflows, verbs, metaphors, environments - mascot translation cues: candidate forms, signature traits, props, what must read at pet size - avoidances: logos/text, trademark-sensitive elements, misleading cues, competitor confusion, poor mascot fits - evidence/confidence: source URLs plus notes where evidence is weak or inferred Do not copy logos, readable marks, UI screenshots, slogans, or text. Clearly label mascot guidance that is inferred rather than directly sourced. End the brief with a `Generation handoff` section containing exactly: - brand_name=<canonical brand/product name> - brand_brief=<one sentence, max 45 words, covering palette/tone/domain motifs/personality> - avatar_seed=<short mascot-safe visual idea, no logo copying> - avoid=<short comma-separated list> - brand_sources=<comma-separated source URLs> Return exactly: brand_discovery_file=<absolute output file path> brand_name=<canonical brand/product name> brand_brief=<same compact sentence from Generation handoff> avatar_seed=<same short seed from Generation handoff> avoid=<same short avoid list from Generation handoff> brand_sources=<same comma-separated URLs from Generation handoff> ``` The parent should save the markdown brief before preparing the run, then pass it to `prepare_pet_run.py` as `--brand-discovery-file` together with `--brand-name`, `--brand-brief`, repeated `--brand-source`, and a concise `--pet-notes` value based on `avatar_seed` when the user did not provide a better avatar description. Keep the full brief for review; only the compact handoff fields should shape prompts. If web search is unavailable and the user gave only a bare brand name, ask for brand cues before generating. For a normal pet run, expect up to 10 visual generation jobs: 1 base pet plus 9 row-strip jobs. The Codex app contract currently uses all 9 states: `idle`, `running-right`, `running-left`, `waving`, `jumping`, `failed`, `waiting`, `running`, and `review`. The only deterministic visual derivation is `running-left`, which may be produced 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. After selecting a visual output, the parent agent copies that exact image into the job's `decoded/` path and marks the job complete in `imagegen-jobs.json`. Do not write helper scripts that populate row outputs. The deterministic Python 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 selected base output is copied. Treat any row generation without attached grounding images as invalid. ## Pet-Safe Styles Default style is `auto`: infer the pet's style from the user's prompt and references, then preserve that style across every row. If the user names a style, honor it. Supported style presets include `pixel`, `plush`, `clay`, `sticker`, `flat-vector`, `3d-toy`, `painterly`, `brand-inspired`, and `auto`. Any style is acceptable when it remains pet-safe: - compact whole-body silhouette readable inside a `192x208` cell - consistent face, proportions, material, palette, and props across all rows - clean removable chroma-key background - details large enough to read at pet size - no text, labels, UI, or readable logos unless the user explicitly provides approved reference art and asks for them Non-pixel styles are first-class. Plush, clay, sticker, vector, 3D toy, painterly mascot, ink, and brand-inspired looks should be accepted when they satisfy the atlas and readability constraints. ## Transparency And Effects 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. The deterministic raster pipeline owns the transparency invariant: pixels that become fully transparent are normalized so they do not retain hidden RGB residue, and atlas validation should fail if exported files violate that invariant. Do not paper over colored halos or transparent-pixel residue by accepting visually inconsistent outputs. 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 enough for clean extraction, and uses non-chroma-key colors. - The effect is small enough to remain readable at `192x208` without clutter. 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: - `idle`: keep this calm and low-distraction. Use only subtle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet persona-preserving motion. The loop must still contain visible micro-variation; do not accept six effectively identical copies. Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props. - `waving`: show the wave through paw, hand, wing, or limb pose only. Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture. - `jumping`: show 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. - `waiting`: show that Codex needs approval, help, or user input through an expectant asking pose. Keep it distinct from ordinary idle and review. - `running`: show active task work, processing, thinking, scanning, typing, or focused effort. Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects. - `review`: show focus through lean, blink, eyes, head tilt, or paw/hand position. Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity. - `running-right` and `running-left`: show directional drag movement through body, limb, and prop movement only. `running-right` must face and travel right; `running-left` must face and travel left. Their cadence must visibly alternate across the loop rather than repeating one nearly static stride. Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects. ## 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. 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, style preset, style notes, and working folder. For bare brand/product/company requests, first run the brand discovery worker and capture the compact brand brief, source URLs, and avatar seed. - `Imagining <Pet>'s main look.` Generate the pet's main reference image. This becomes the visual source of truth. - `Picturing <Pet>'s poses.` Generate pose rows through lightweight workers, starting with `idle` and `running-right` to confirm identity and gait. Only mirror `running-left` if `running-right` clearly works when flipped. - `Hatching <Pet>.` Turn the approved poses into final pet files, review the contact sheet, previews, and validation results, fix any broken parts, save `pet.json` and `spritesheet.webp`, then report the output paths. Only mark a step complete when the real file, image, or decision exists. If this is a repair run, start from the first relevant step instead of restarting the whole checklist. ## Default Workflow 1. Prepare a 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>" \ --brand-discovery-file /absolute/path/to/brand-discovery.md \ --brand-name "<optional researched brand name>" \ --brand-brief "<optional compact researched brand cue sentence>" \ --brand-source "https://example.com/source" \ --style-preset auto \ --style-notes "<optional freeform style notes>" \ --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. For brand-only requests, run the discovery worker first, save the markdown brief, then pass the brief path through `--brand-discovery-file`, `avatar_seed` through `--pet-notes`, `brand_name` through `--brand-name`, `brand_brief` through `--brand-brief`, and each source URL through repeated `--brand-source`. 2. Inspect `imagegen-jobs.json` for the next ready `$imagegen` jobs. A job is ready when its `status` is not `complete` and every id in `depends_on` is already complete. Prefer reading the manifest directly with `jq` or the editor instead of adding helper scripts for status display: ```bash jq '.jobs[] | {id, kind, status, depends_on, prompt_file, retry_prompt_file, input_images, output_path, derivation_policy}' /absolute/path/to/run/imagegen-jobs.json ``` 3. Generate visual jobs with lightweight workers by default: - Generate and copy `base` first, using a lightweight base worker. - Generate and copy `idle` and `running-right` next as the identity and gait check, using one lightweight worker per row. - Inspect `running-right`; mirror `running-left` only when visual identity, prop placement, markings, lighting, and direction semantics remain correct. - Generate `running-left` normally with a lightweight worker when mirroring would change meaning or identity. - Generate the remaining rows with lightweight workers, using every input image listed for each job. For each ready visual job, invoke `$imagegen` with the prompt file listed in `imagegen-jobs.json`, every listed input image with its role label, and the default built-in `image_gen` path unless `$imagegen` itself routes otherwise. The parent agent must keep its own image handling minimal: do not open every generated base or row in the parent rollout. Workers return only the selected source path and a one-sentence QA note; the parent records the selected source path in the manifest. `prepare_pet_run.py` 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. Preserve the same style, face, markings, palette, materials, prop design, body proportions, and silhouette from the canonical base. Row jobs attach the layout guide and canonical base by default; the decoded base is kept in the run folder for deterministic processing rather than sent as a redundant generation input. If `$imagegen` returns a transport-level `Bad Request` for a row, retry that same row once with its generated `retry_prompt_file`. The retry prompt preserves the row id, frame count, chroma key, canonical-base identity, and state action. Keep the canonical base attached. If the retry still fails, stop and report the failing row and prompt paths instead of switching to any other generation path. 4. After selecting a generated output for a job, copy it into the decoded output path and mark the job complete. For `base`, also create the canonical identity reference: ```bash RUN_DIR=/absolute/path/to/run JOB_ID=<job-id> SOURCE=/absolute/path/to/generated-output.png OUTPUT_REL=$(jq -r --arg id "$JOB_ID" '.jobs[] | select(.id == $id) | .output_path' "$RUN_DIR/imagegen-jobs.json") mkdir -p "$(dirname "$RUN_DIR/$OUTPUT_REL")" cp "$SOURCE" "$RUN_DIR/$OUTPUT_REL" ``` ```bash if [ "$JOB_ID" = "base" ]; then mkdir -p "$RUN_DIR/references"; cp "$RUN_DIR/$OUTPUT_REL" "$RUN_DIR/references/canonical-base.png"; fi ``` ```bash UPDATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) TMP_MANIFEST=$(mktemp) jq --arg id "$JOB_ID" --arg source "$SOURCE" --arg at "$UPDATED_AT" '(.jobs[] | select(.id == $id)) += {status: "complete", source_path: $source, completed_at: $at}' "$RUN_DIR/imagegen-jobs.json" > "$TMP_MANIFEST" mv "$TMP_MANIFEST" "$RUN_DIR/imagegen-jobs.json" ``` If the copied source is under `${CODEX_HOME:-$HOME/.codex}/generated_images`, delete the original generated file after the decoded copy exists: ```bash GENERATED_ROOT="${CODEX_HOME:-$HOME/.codex}/generated_images" case "$SOURCE" in "$GENERATED_ROOT"/*) rm -f "$SOURCE" rmdir "$(dirname "$SOURCE")" 2>/dev/null || true ;; esac ``` 5. Derive `running-left` only when it is visually safe: ```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>" ``` That script mirrors each generated frame slot in place so the leftward row preserves the rightward row's temporal order. Do not replace it with a whole-strip mirror that reverses animation timing. 6. When all jobs are complete, run the image-processing scripts directly: ```bash RUN_DIR=/absolute/path/to/run mkdir -p "$RUN_DIR/final" "$RUN_DIR/qa" ``` ```bash python "$SKILL_DIR/scripts/extract_strip_frames.py" \ --decoded-dir "$RUN_DIR/decoded" \ --output-dir "$RUN_DIR/frames" \ --states all \ --method auto ``` ```bash python "$SKILL_DIR/scripts/inspect_frames.py" \ --frames-root "$RUN_DIR/frames" \ --json-out "$RUN_DIR/qa/review.json" \ --require-components ``` ```bash python "$SKILL_DIR/scripts/compose_atlas.py" \ --frames-root "$RUN_DIR/frames" \ --output "$RUN_DIR/final/spritesheet.png" \ --webp-output "$RUN_DIR/final/spritesheet.webp" ``` ```bash python "$SKILL_DIR/scripts/validate_atlas.py" \ "$RUN_DIR/final/spritesheet.webp" \ --json-out "$RUN_DIR/final/validation.json" ``` ```bash python "$SKILL_DIR/scripts/make_contact_sheet.py" \ "$RUN_DIR/final/spritesheet.webp" \ --output "$RUN_DIR/qa/contact-sheet.png" ``` ```bash python "$SKILL_DIR/scripts/render_animation_previews.py" \ --frames-root "$RUN_DIR/frames" \ --output-dir "$RUN_DIR/qa/previews" ``` If the preview GIFs show size popping or baseline jumps caused by per-frame fit-to-cell extraction, and the original row strip itself had stable scale and placement, rerun frame extraction with the explicit row-stability mode and then re-run inspection, atlas composition, validation, contact sheet generation, and previews: ```bash python "$SKILL_DIR/scripts/extract_strip_frames.py" \ --decoded-dir "$RUN_DIR/decoded" \ --output-dir "$RUN_DIR/frames" \ --states all \ --method stable-slots ``` ```bash python "$SKILL_DIR/scripts/inspect_frames.py" \ --frames-root "$RUN_DIR/frames" \ --json-out "$RUN_DIR/qa/review.json" \ --require-components \ --allow-stable-slots ``` Use `stable-slots` as a deliberate QA-driven correction, not the default. It should reduce extraction-induced motion pops without hiding clipped wide poses or bad source strips. Expected output before cleanup: ```text run/ pet_request.json imagegen-jobs.json prompts/ decoded/ frames/frames-manifest.json final/spritesheet.webp final/validation.json qa/contact-sheet.png qa/previews/*.gif qa/review.json qa/run-summary.json ``` 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 ``` Package with shell and `jq`: ```bash RUN_DIR=/absolute/path/to/run PET_ID=$(jq -r '.pet_id' "$RUN_DIR/pet_request.json") DISPLAY_NAME=$(jq -r '.display_name' "$RUN_DIR/pet_request.json") DESCRIPTION=$(jq -r '.description' "$RUN_DIR/pet_request.json") PET_DIR="${CODEX_HOME:-$HOME/.codex}/pets/$PET_ID" mkdir -p "$PET_DIR" cp "$RUN_DIR/final/spritesheet.webp" "$PET_DIR/spritesheet.webp" jq -n --arg id "$PET_ID" --arg displayName "$DISPLAY_NAME" --arg description "$DESCRIPTION" '{id: $id, displayName: $displayName, description: $description, spritesheetPath: "spritesheet.webp"}' > "$PET_DIR/pet.json" ``` Write `qa/run-summary.json` after packaging: ```bash jq -n --arg run_dir "$RUN_DIR" --arg spritesheet "$RUN_DIR/final/spritesheet.webp" --arg validation "$RUN_DIR/final/validation.json" --arg contact_sheet "$RUN_DIR/qa/contact-sheet.png" --arg review "$RUN_DIR/qa/review.json" --arg package "$PET_DIR" '{ok: true, run_dir: $run_dir, spritesheet: $spritesheet, validation: $validation, contact_sheet: $contact_sheet, review: $review, package: $package}' > "$RUN_DIR/qa/run-summary.json" ``` After deterministic image processing, inspect `qa/contact-sheet.png` and `qa/previews/*.gif` with a lightweight visual QA worker before accepting the pet. Deterministic validation is necessary but not sufficient. Block acceptance if any row changes species/body type, face, markings, palette, material, prop design, style, prop side unexpectedly, or overall silhouette. Motion previews must also reject unintended size popping, reversed or stagnant directional cadence, wrong facing direction, and idle loops that are technically different but visually inert. After model visual QA accepts the contact sheet, remove intermediate run artifacts: Keep `pet_request.json`, `final/spritesheet.webp`, `final/validation.json`, `qa/contact-sheet.png`, `qa/previews/`, `qa/review.json`, and `qa/run-summary.json`. Remove generated prompt files, layout guides, decoded row strips, extracted frames, `final/spritesheet.png`, and the imagegen job manifest. Skip cleanup when the user wants debug artifacts or the run still needs repair. ## Lightweight Visual Workers Use lightweight subagents for image-heavy work by default. This bounds each `$imagegen` rollout to one selected image, keeps contact-sheet vision payloads out of the parent thread, and reduces cost while preserving the full 9-state app contract. ## Subagent Delegation Unless explicitly forbidden by the user, use subagents for this run. If the user has not allowed the use of subagents, or the intent on subagent use is vague, then ask the user for permission to spawn subagents for parallel lanes of work. Parent responsibilities: - run the brand discovery worker before preparation when the user provides a bare brand/product/company/prospect name - prepare the run and inspect `imagegen-jobs.json` - assign the base job, row jobs, and final contact-sheet QA to lightweight workers - copy selected worker outputs into their decoded paths and mark jobs complete in `imagegen-jobs.json` - create `references/canonical-base.png` from the selected base output - run the approved `running-left` mirror derivation when appropriate - run deterministic image processing, packaging, repair regeneration, and cleanup Base worker responsibilities: - handle only the `base` job - read `prompts/base-pet.md` and use any listed reference images - use `$imagegen` only - honor any compact brand inspiration line in the prompt as broad visual/personality guidance, without copying logos, readable marks, UI screenshots, slogans, or text - return only `selected_source=/absolute/path/to/selected-output.png` and `qa_note=<one sentence>` Row worker responsibilities: - handle exactly one row job - read the row prompt and use all listed input images - use `$imagegen` only; do not draw, edit, tile, or synthesize sprites locally - perform a quick visual sanity check for frame count, identity, chroma background, spacing, clipping, and detached effects - enforce the row prompt's transparency and effects rules, including no detached effects, no wave marks for `waving`, no speed lines or dust for directional running rows, no literal foot-running for the non-directional `running` row, and only attached opaque sprite-like tears/smoke/stars when allowed by the state prompt - return only `selected_source=/absolute/path/to/selected-output.png` and `qa_note=<one sentence>` Final visual QA worker responsibilities: - inspect `qa/contact-sheet.png` plus the row GIFs under `qa/previews/`, with `qa/review.json` and `final/validation.json` as text context when useful - verify all 9 rows match the Codex app state contract and the same pet identity - return a compact result: `visual_qa=pass` or `visual_qa=fail`, plus row-specific repair notes when failing - do not edit files, queue repairs, package, or clean up Model choice for workers: - Prefer a smaller capable model for brand discovery, since it returns a compact research brief rather than doing orchestration. - Prefer a smaller capable model for visual workers, such as `gpt-5.4-mini` with medium reasoning, when model override is available. - Use the parent/default model only for orchestration or when a smaller worker model is unavailable. - Keep at most two generation workers active at once unless the user explicitly asks for higher parallelism. Run final visual QA as a single worker after deterministic image processing. Close workers after their result has been consumed. Use this base worker prompt: ```text Generate the hatch-pet base image. Run dir: <absolute run dir> Job id: base Prompt file: <absolute base prompt file> Input images: - <absolute path> — <role> Use $imagegen only. Read the base prompt and attach every listed input image. If the prompt contains brand inspiration, use it only as broad mascot-safe guidance; do not copy logos, readable marks, UI screenshots, slogans, or text. Before returning, visually check that the result is one centered full-body pet on a flat chroma background, with no text, scenery, shadows, or detached effects. Do not edit manifests, copy into decoded, mark jobs complete, generate rows, run image-processing scripts, repair, package, or open unrelated files. Do not include Markdown image previews, base64, or extra attachments in the final response. Return exactly: selected_source=/absolute/path/to/selected-output.png qa_note=<one sentence> ``` Use this row worker prompt: ```text Generate one hatch-pet row. Run dir: <absolute run dir> Row id: <row-id> Prompt file: <absolute prompt file> Retry prompt file: <absolute retry prompt file> Input images: - <absolute path> — <role> - <absolute path> — <role> Use $imagegen only. Read the row prompt and attach every listed input image. If imagegen returns Bad Request, retry once with the retry prompt and the same input images. Before returning, visually check: exact frame count, same pet identity as canonical base, flat chroma background, complete separated unclipped poses, and no detached effects or guide marks. The prompt's transparency and effects rules are mandatory: no detached effects, no wave marks for `waving`, no speed lines or dust for directional running rows, no literal foot-running for the non-directional `running` row, and only attached opaque sprite-like tears/smoke/stars when allowed by the state prompt. Do not edit manifests, copy into decoded, mark jobs complete, mirror rows, run image-processing scripts, repair, package, or open unrelated files. Do not include Markdown image previews, base64, or extra attachments in the final response. Return exactly: selected_source=/absolute/path/to/selected-output.png qa_note=<one sentence> ``` Use this final visual QA worker prompt: ```text Visually QA one finalized hatch-pet contact sheet. Run dir: <absolute run dir> Contact sheet: <absolute run dir>/qa/contact-sheet.png Preview dir: <absolute run dir>/qa/previews Review JSON: <absolute run dir>/qa/review.json Validation JSON: <absolute run dir>/final/validation.json Inspect the contact sheet and the preview GIFs visually. Confirm the same pet identity, style, palette, silhouette, face, proportions, and props across all rows: 0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping, 5 failed, 6 waiting, 7 running, 8 review. Fail rows with identity drift, missing/blank frames, copied guide marks, white/nontransparent backgrounds, cropped bodies, slot overlap, detached effects, shadows/glows/smears/dust, chroma-key artifacts, motion that does not match the row state, unintended size popping, wrong facing direction, reversed or non-alternating gait, or idle loops that are effectively static. Do not edit files, queue repairs, package, clean up, or inspect unrelated files. Return exactly: visual_qa=pass|fail qa_note=<one sentence summary> repair_rows=<comma-separated row ids, or none> repair_notes=<short row-specific notes, or none> ``` ## Repair Workflow If frame inspection or final visual QA fails, read `qa/review.json`, regenerate the smallest failing scope, copy the replacement row into the same decoded output path, and keep that job marked complete with the new `source_path` and `completed_at`. Repair the failed row, not the whole sheet. For identity repairs, use the canonical base image, original references, contact sheet, and exact row failure note as grounding context. Give the row worker the existing row prompt plus a compact repair note from `qa/review.json`; preserve the canonical pet identity and chosen style. For extraction-induced motion popping, do not regenerate imagery first. If the source strip already preserves row-level scale and baseline, rerun the deterministic pipeline with `--method stable-slots`, inspect with `--allow-stable-slots`, then re-check the preview GIFs. Regenerate the row only when the original strip itself is clipped, unstable, or semantically wrong. ## Rules - Keep `$imagegen` as the primary generation layer. - For brand/product/company/prospect requests without a concrete avatar description or reference image, run brand discovery before base generation and pass only the compact brief into the run. - Use `$imagegen` as the only visual generation layer. Do not invoke image APIs, image CLIs, local raster generators, or one-off generation scripts from this skill. - 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 lightweight visual workers for base generation, row-strip visual generation, and final contact-sheet QA by default; the parent owns manifest updates, deterministic image scripts, packaging, and cleanup. - 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. - Generate `running-right` before deciding whether `running-left` can be mirrored. - When `running-left` is mirrored, preserve frame order and timing semantics; derive it through the deterministic script instead of mirroring an entire strip wholesale. - Do not derive or reuse `waiting`, `running`, `failed`, `review`, `jumping`, or `waving` from another state; each has distinct app semantics and must be generated as its own row. - Never substitute locally drawn, tiled, transformed, or code-generated row strips for missing `$imagegen` outputs. - Only mark a visual job complete after its selected output has been copied into the decoded output path. - Do not rely on generated images for exact atlas geometry; use this skill's deterministic image 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, style, and props consistent across all rows. - Treat visual identity or style 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 preview GIFs that show extraction-induced size popping, reversed directional timing, wrong facing direction, or inert idle loops 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 per-row motion previews have been produced and inspected by a lightweight visual QA worker. - `qa/review.json` has no errors. - Row-by-row review confirms the animation cycles are complete enough for the Codex app. - Motion previews do not show unintended size popping, reversed directional cadence, or wrong row semantics. - Non-pixel styles are accepted when readable at pet size and consistent across rows. - `${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.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.