mirror of
https://github.com/openai/skills.git
synced 2026-09-11 19:47:26 +03:00
Replace hatch-pet curated skill workflow (#399)
## Summary - add the curated `hatch-pet-v2` skill for Codex-compatible pet spritesheet generation - keep image generation lightweight with per-job workers, storage controls, and visual QA guidance - add adaptive brand discovery briefs while keeping generation prompts compact ## Verification - `python -m py_compile skills/.curated/hatch-pet-v2/scripts/*.py` - `ruff check skills/.curated/hatch-pet-v2/scripts` - smoke-tested `prepare_pet_run.py` with and without a brand discovery brief
This commit is contained in:
+356
-139
@@ -1,15 +1,15 @@
|
||||
---
|
||||
name: hatch-pet
|
||||
description: Create, repair, validate, preview, and package Codex-compatible animated pets and pet spritesheets from character art, screenshots, generated images, or visual references. Use when a user wants to hatch a Codex pet, create a custom animated pet, or build a built-in pet asset with an 8x9 atlas, transparent unused cells, row-by-row animation prompts, QA contact sheets, preview videos, and pet.json packaging. This skill composes the installed $imagegen system skill for visual generation and uses bundled scripts for deterministic spritesheet assembly.
|
||||
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, one or more reference images, or both. This skill owns pet-specific prompt planning, animation rows, frame extraction, atlas geometry, QA, previews, and packaging. It delegates visual generation to `$imagegen`.
|
||||
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 or reference filenames; if that is not possible, choose a short appropriate name. If the user omits a description, infer one from the concept or references. If the user omits reference images, generate the base pet from text first, then use that base as the canonical reference for every animation row.
|
||||
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
|
||||
|
||||
@@ -21,37 +21,110 @@ Before generating base art, row strips, or repair rows, load and follow the inst
|
||||
${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/SKILL.md
|
||||
```
|
||||
|
||||
Do not call the Image API directly for the normal path. Let `$imagegen` choose its own built-in-first path and its own CLI fallback rules. If `$imagegen` says a fallback requires confirmation, ask the user before continuing.
|
||||
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` from this skill, pass the generated pet prompt as the authoritative visual spec. Do not wrap it in the generic `$imagegen` shared prompt schema and do not add extra polish, hero-art, photo, product, or illustration-style augmentation. Pet prompts should stay terse, sprite-specific, and digital-pet oriented; only add role labels for input images and any essential user constraint.
|
||||
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 work only: preparing prompts and manifests, ingesting selected `$imagegen` outputs, extracting frames, validating rows, composing the final atlas, creating QA media, and packaging.
|
||||
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.
|
||||
|
||||
Hard boundary: do not create, draw, tile, warp, mirror, or synthesize pet visuals with local Python/Pillow scripts, SVG, canvas, HTML/CSS, or other code-native art as a substitute for `$imagegen`. For a normal pet run, expect up to 10 visual generation jobs: 1 base pet plus 9 row-strip jobs. The only exception is `running-left`, which may be derived by mirroring `running-right` only after `running-right` has been generated, visually inspected, and explicitly approved as safe to mirror. If mirroring is not appropriate, generate `running-left` as a normal grounded `$imagegen` row. If those calls are too expensive, blocked, or unavailable, stop and explain the blocker instead of fabricating row strips locally.
|
||||
## Storage Controls
|
||||
|
||||
Do not mark visual jobs complete by editing `imagegen-jobs.json`, copying files into `decoded/`, or writing helper scripts that populate row outputs. Use `record_imagegen_result.py` for selected built-in `$imagegen` outputs, or `generate_pet_images.py` only for the documented secondary fallback. The deterministic scripts may only process already-generated visual outputs.
|
||||
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:
|
||||
|
||||
Only the base job may be prompt-only. Every row-strip job generated through `$imagegen` must use the input images listed in `imagegen-jobs.json`, including the canonical base reference created after the base job is recorded. Treat any row generation without attached grounding images as invalid.
|
||||
- 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.
|
||||
|
||||
## Codex Digital Pet Style
|
||||
## Brand Discovery
|
||||
|
||||
Default pet art should match the Codex app's built-in digital pets: small pixel-art-adjacent mascots with compact chibi proportions, chunky readable silhouettes, thick dark 1-2 px outlines, visible stepped/pixel edges, limited palettes, flat cel shading, simple expressive faces, and tiny limbs. Even if the reference art is more detailed, complex or realistic, the generated pet should be simplified into this style.
|
||||
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.
|
||||
|
||||
Do NOT generate polished illustration, painterly rendering, anime key art, 3D rendering, glossy app-icon treatment, realistic fur or material texture, soft gradients, high-detail antialiasing, and complex tiny accessories. References that are more detailed than this should be simplified into the house style before row generation.
|
||||
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.
|
||||
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, pixel-style, and uses non-chroma-key colors.
|
||||
- The effect is small enough to remain readable at 192x208 without clutter.
|
||||
|
||||
Examples of allowed effects: a tear touching the face, a small smoke puff touching the box or head, or tiny stars overlapping the pet during a failed/dizzy reaction.
|
||||
- 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:
|
||||
|
||||
@@ -64,35 +137,19 @@ Avoid these by default because they usually break transparent-background cleanup
|
||||
|
||||
State-specific guidance:
|
||||
|
||||
- `idle`: keep this calm and low-distraction. Use only subtle breathing, a tiny blink, a slight head/body bob, a very small material sway, or another quiet persona-preserving motion. 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 pose only. Do not draw wave marks, motion arcs, lines, sparkles, or symbols around the paw.
|
||||
- `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.
|
||||
- `review`: show focus through lean, blink, eyes, head tilt, or paw position. Do not add magnifying glasses, papers, code, UI, punctuation, or symbols unless that prop already exists in the base pet identity.
|
||||
- `running-right` and `running-left`: show directional locomotion through body, limb, and prop movement only. Do not draw speed lines, dust clouds, floor shadows, or motion trails.
|
||||
- `running`: show an active working/in-progress loop, as if the pet is busy running a task. Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.
|
||||
|
||||
## Pet Naming
|
||||
|
||||
Ask the user for a pet name when they have not provided one and only if the conversation naturally allows it. If asking would slow down a direct execution request, choose a short appropriate name from the pet concept, reference image, or personality, then use that name consistently as the display name and as the source for the package folder slug.
|
||||
|
||||
Good built-in style examples:
|
||||
|
||||
- Codex - The original Codex companion.
|
||||
- Dewey - A tidy duck for calm workspace days.
|
||||
- Fireball - Hot path energy for fast iteration.
|
||||
- Rocky - A steady rock when the diff gets large.
|
||||
- Seedy - Small green shoots for new ideas.
|
||||
- Stacky - A balanced stack for deep work.
|
||||
- BSOD - A tiny blue-screen gremlin.
|
||||
- Null Signal - Quiet signal from the void.
|
||||
- `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.
|
||||
|
||||
Before creating the checklist, establish the pet name when possible. Use the user-provided name when available; otherwise infer a short appropriate name from the concept or references. If the name is too long, not settled, or not appropriate for a friendly checklist, use `your pet` instead.
|
||||
|
||||
Use this checklist for a normal pet run, replacing `<Pet>` with the pet's name or `your pet`:
|
||||
|
||||
1. Getting `<Pet>` ready.
|
||||
@@ -102,12 +159,12 @@ Use this checklist for a normal pet run, replacing `<Pet>` with the pet's name o
|
||||
|
||||
What each step means:
|
||||
|
||||
- `Getting <Pet> ready.` Choose or confirm the pet name, description, source images, and working folder.
|
||||
- `Imagining <Pet>'s main look.` Generate the pet's main reference image. This is required for new pets, even when the user does not provide an image, because it becomes the visual source of truth.
|
||||
- `Picturing <Pet>'s poses.` Create the pose rows, starting with `idle` and `running-right` to confirm the pet still looks consistent. Only mirror `running-left` if `running-right` clearly works when flipped.
|
||||
- `Hatching <Pet>.` Turn the approved poses into the final pet files, review the contact sheet, previews, and validation results, fix any broken parts, save `pet.json` and `spritesheet.webp` into the pet folder, then tell the user where the pet and QA files were saved.
|
||||
- `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 just a repair run, start from the first relevant step instead of restarting the whole checklist.
|
||||
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
|
||||
|
||||
@@ -121,31 +178,75 @@ python "$SKILL_DIR/scripts/prepare_pet_run.py" \
|
||||
--reference /absolute/path/to/reference.png \
|
||||
--output-dir /absolute/path/to/run \
|
||||
--pet-notes "<stable pet description>" \
|
||||
--style-notes "<style notes>" \
|
||||
--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 the next ready `$imagegen` jobs:
|
||||
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
|
||||
python "$SKILL_DIR/scripts/pet_job_status.py" --run-dir /absolute/path/to/run
|
||||
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. For each ready job, invoke `$imagegen` with:
|
||||
3. Generate visual jobs with lightweight workers by default:
|
||||
|
||||
- the prompt file listed in `imagegen-jobs.json`
|
||||
- every input image listed for the job, with its role label
|
||||
- the default built-in `image_gen` path unless `$imagegen` itself routes otherwise
|
||||
- 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.
|
||||
|
||||
The base job must complete first. If user references exist, the base job uses them. If no references exist, the base job may be prompt-only. After recording the base, `record_imagegen_result.py` writes `decoded/base.png` and `references/canonical-base.png`; all row jobs use the original references if present plus those canonical base images.
|
||||
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` also creates 9 row-specific layout guide images under `references/layout-guides/`, one per animation state. Row jobs attach the matching guide as a layout-only input so the model can follow the correct frame count, spacing, centering, and safe padding. Treat these guides as invisible construction references: the generated row strip must not include visible boxes, borders, center marks, labels, guide colors, or the guide background.
|
||||
`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: do not redesign the pet, and preserve the same head shape, face, markings, palette, prop, outline weight, body proportions, and silhouette. A row that looks like a related but different pet is failed even if the deterministic geometry QA passes.
|
||||
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.
|
||||
|
||||
Generate and record `running-right` before deciding how to complete `running-left`. Inspect `running-right` against the base and references. If the pet is visually symmetric enough that a horizontal mirror preserves identity, prop placement, handedness, markings, lighting, text-free details, and direction semantics, derive `running-left` with:
|
||||
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" \
|
||||
@@ -154,29 +255,76 @@ python "$SKILL_DIR/scripts/derive_running_left_from_running_right.py" \
|
||||
--decision-note "<why mirroring preserves this pet's identity>"
|
||||
```
|
||||
|
||||
If there is any asymmetric side-specific marking, readable text, non-mirrored logo, handed prop, one-sided accessory, lighting cue, or direction-specific pose that would become wrong when flipped, do not mirror. Generate `running-left` with `$imagegen` using its row prompt and all listed grounding images, including `decoded/running-right.png` as a gait reference.
|
||||
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.
|
||||
|
||||
For the built-in path, record the selected source image from `$CODEX_HOME/generated_images/.../ig_*.png`. Do not record files from the run directory, `tmp/`, hand-made fixtures, deterministic row folders, or post-processed copies as visual job sources.
|
||||
|
||||
4. After selecting a generated output for a job, ingest it:
|
||||
6. When all jobs are complete, run the image-processing scripts directly:
|
||||
|
||||
```bash
|
||||
python "$SKILL_DIR/scripts/record_imagegen_result.py" \
|
||||
--run-dir /absolute/path/to/run \
|
||||
--job-id <job-id> \
|
||||
--source /absolute/path/to/generated-output.png
|
||||
RUN_DIR=/absolute/path/to/run
|
||||
mkdir -p "$RUN_DIR/final" "$RUN_DIR/qa"
|
||||
```
|
||||
|
||||
This copies the image to the exact decoded path expected by the deterministic pipeline and records source metadata in `imagegen-jobs.json`.
|
||||
|
||||
5. When all jobs are complete, finalize:
|
||||
|
||||
```bash
|
||||
python "$SKILL_DIR/scripts/finalize_pet_run.py" \
|
||||
--run-dir /absolute/path/to/run
|
||||
python "$SKILL_DIR/scripts/extract_strip_frames.py" \
|
||||
--decoded-dir "$RUN_DIR/decoded" \
|
||||
--output-dir "$RUN_DIR/frames" \
|
||||
--states all \
|
||||
--method auto
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```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/
|
||||
@@ -185,13 +333,12 @@ run/
|
||||
prompts/
|
||||
decoded/
|
||||
frames/frames-manifest.json
|
||||
final/spritesheet.png
|
||||
final/spritesheet.webp
|
||||
final/validation.json
|
||||
qa/contact-sheet.png
|
||||
qa/previews/*.gif
|
||||
qa/review.json
|
||||
qa/run-summary.json
|
||||
qa/videos/*.mp4
|
||||
```
|
||||
|
||||
Package output is written outside the run directory by default. If `CODEX_HOME` is set, use it; otherwise use `$HOME/.codex`.
|
||||
@@ -202,112 +349,180 @@ ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/
|
||||
spritesheet.webp
|
||||
```
|
||||
|
||||
Review `qa/contact-sheet.png`, `qa/review.json`, `final/validation.json`, and `qa/videos/` before accepting the pet.
|
||||
Package with shell and `jq`:
|
||||
|
||||
Deterministic validation is necessary but not sufficient. Before calling the pet done, visually inspect the contact sheet for identity consistency. Block acceptance if any row changes species/body type, face, markings, palette, prop design, prop side unexpectedly, or overall silhouette.
|
||||
```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"
|
||||
```
|
||||
|
||||
## Subagent Row Generation
|
||||
Write `qa/run-summary.json` after packaging:
|
||||
|
||||
After the base job has been recorded and `references/canonical-base.png` exists, row-strip visual generation must use subagents unless the user explicitly says not to use subagents for this session. Before row generation, state that subagents are being used and which row jobs are being delegated. If subagents cannot be spawned because the current environment or tool policy blocks them, stop before row-strip generation, explain the blocker, and ask for explicit user direction before continuing sequentially.
|
||||
```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"
|
||||
```
|
||||
|
||||
The parent agent must own the manifest and package writes.
|
||||
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.
|
||||
|
||||
Default flow:
|
||||
After model visual QA accepts the contact sheet, remove intermediate run artifacts:
|
||||
|
||||
1. Parent runs `prepare_pet_run.py`.
|
||||
2. Parent generates and records `base`.
|
||||
3. Parent runs `pet_job_status.py`.
|
||||
4. Parent spawns subagents for `idle` and `running-right` first as identity and gait checks.
|
||||
5. Parent records the selected `idle` and `running-right` results returned by subagents.
|
||||
6. Parent decides whether `running-left` is safe to derive by mirror; if not, parent treats it as a normal grounded row job delegated to a subagent.
|
||||
7. Parent spawns subagents for every remaining non-derived row image-generation job.
|
||||
8. Each subagent receives the row prompt and every listed input image path, invokes `$imagegen`, and returns only the selected `$CODEX_HOME/generated_images/.../ig_*.png` source path.
|
||||
9. Parent alone runs `record_imagegen_result.py`, `derive_running_left_from_running_right.py`, repair queueing, finalization, QA, and packaging.
|
||||
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.
|
||||
|
||||
Subagent write boundary: do not let subagents edit `imagegen-jobs.json`, copy files into `decoded/`, run `record_imagegen_result.py`, run `derive_running_left_from_running_right.py`, run `finalize_pet_run.py`, or package the pet. This avoids manifest races and keeps provenance checks centralized.
|
||||
## Lightweight Visual Workers
|
||||
|
||||
Subagent handoff contract:
|
||||
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.
|
||||
|
||||
- Give each subagent exactly one row job unless you are intentionally batching adjacent simple rows.
|
||||
- Include the row id, the absolute prompt file path, the full prompt text or an instruction to read that exact prompt file, and every input image path with its role label from `imagegen-jobs.json`.
|
||||
- Explicitly remind the subagent that the prompt's transparency and effects rules are mandatory: no detached effects, no wave marks for `waving`, no speed lines or dust for 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.
|
||||
- Tell the subagent to inspect the generated candidate for frame count, identity consistency, clean flat chroma-key background, safe spacing, and forbidden detached effects before returning it.
|
||||
- Tell the subagent to return only the selected original `$CODEX_HOME/generated_images/.../ig_*.png` source path plus a one-sentence QA note. The parent decides whether to record or repair it.
|
||||
## Subagent Delegation
|
||||
|
||||
Use this template for each subagent:
|
||||
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 `<row-id>` row for this hatch-pet run.
|
||||
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>
|
||||
|
||||
Read and follow the row prompt exactly, including the Transparency and artifact rules. Use `$imagegen` only; do not use local scripts to draw, tile, edit, or synthesize sprites.
|
||||
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 requested frame count
|
||||
- same pet identity as the canonical base
|
||||
- clean flat chroma-key background
|
||||
- complete, separated, unclipped poses
|
||||
- no forbidden detached effects or slot-crossing artifacts
|
||||
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, record results, mirror rows, finalize, repair, or package. Return only:
|
||||
selected_source=/absolute/path/to/$CODEX_HOME/generated_images/.../ig_*.png
|
||||
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>
|
||||
```
|
||||
|
||||
No silent sequential fallback: if subagents cannot be used for row-strip visual generation, stop and ask for explicit user direction before continuing without them. Only an explicit user instruction such as "do not use subagents" or "run this sequentially" authorizes a normal sequential row-generation path. The final answer must report which row jobs were delegated to subagents and which, if any, were mirrored or repaired by the parent.
|
||||
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 finalization stops because row QA failed, queue targeted repair jobs:
|
||||
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.
|
||||
|
||||
```bash
|
||||
python "$SKILL_DIR/scripts/queue_pet_repairs.py" \
|
||||
--run-dir /absolute/path/to/run
|
||||
```
|
||||
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.
|
||||
|
||||
Then repeat the `$imagegen` generation and `record_imagegen_result.py` ingest loop for each reopened row job. Regenerate the smallest failing scope: the failed row, not the whole sheet.
|
||||
|
||||
For identity repairs, use the canonical base image, original references, contact sheet, and exact row failure note as grounding context. Repair only the failed row while preserving the canonical pet identity.
|
||||
|
||||
## Secondary Image Generation Fallback
|
||||
|
||||
`scripts/generate_pet_images.py` is a secondary fallback for this skill.
|
||||
|
||||
Use it only when the installed `$imagegen` system skill is unavailable or cannot be invoked in the current environment. Normal pet creation should delegate visual generation to `$imagegen`, because `$imagegen` owns the built-in-first image generation policy and its own CLI fallback behavior.
|
||||
|
||||
Run the secondary fallback only after explaining why `$imagegen` cannot be used:
|
||||
|
||||
```bash
|
||||
python "$SKILL_DIR/scripts/generate_pet_images.py" \
|
||||
--run-dir /absolute/path/to/run \
|
||||
--model gpt-image-2 \
|
||||
--states all
|
||||
```
|
||||
|
||||
The secondary fallback requires `OPENAI_API_KEY`.
|
||||
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 subagents for row-strip visual generation after the parent records the base image. The parent may generate the base, but row-strip jobs belong to subagents unless the user explicitly says not to use subagents for this session.
|
||||
- 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.
|
||||
- Delegate `running-right` first, then mirror `running-left` only when visual inspection confirms a mirror preserves identity and semantics; otherwise delegate `running-left` as a normal grounded `$imagegen` row.
|
||||
- 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.
|
||||
- Never manually mutate `imagegen-jobs.json` to claim a visual job completed.
|
||||
- Do not rely on generated images for exact atlas geometry; use this skill's deterministic scripts.
|
||||
- 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, and props consistent across all rows.
|
||||
- Enforce the transparency and effects rules above in every base, row, and repair prompt.
|
||||
- Treat visual identity drift as a blocker even when `qa/review.json` and `final/validation.json` have no errors.
|
||||
- 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.
|
||||
|
||||
@@ -316,7 +531,9 @@ The secondary fallback requires `OPENAI_API_KEY`.
|
||||
- Final atlas is PNG or WebP, `1536x1872`, transparent-capable, and based on `192x208` cells.
|
||||
- Used cells are non-empty and unused cells are fully transparent.
|
||||
- Atlas follows the row/frame counts in `references/animation-rows.md`.
|
||||
- Contact sheet and preview videos have been produced unless explicitly skipped.
|
||||
- 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Hatch Pet"
|
||||
short_description: "Hatch Codex-compatible animated pets and pet spritesheets"
|
||||
default_prompt: "Hatch a Codex-compatible animated pet from a concept, reference images, or both. Infer missing names/descriptions, use $imagegen for the base and grounded row strips, generate running-right before deciding whether running-left can be safely mirrored, then use this skill's deterministic scripts to ingest outputs, validate frames, assemble the spritesheet, and package the pet under ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/."
|
||||
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."
|
||||
|
||||
@@ -20,10 +20,10 @@ Unused cells after each row's final used column must be fully transparent.
|
||||
|
||||
- `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.
|
||||
- `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`: patient idle variant; glance, small bounce, or prop motion.
|
||||
- `running`: active working/in-progress loop, as if the pet is busy running a task. This row is not foot-running; avoid jogging, sprinting, treadmill poses, raised knees, long steps, pumping arms, or directional travel.
|
||||
- `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.
|
||||
|
||||
@@ -8,31 +8,34 @@ Do not accept an atlas until all checks pass.
|
||||
- 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 slot extraction was intentionally accepted after visual inspection.
|
||||
- `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 material, palette, lighting, and prop design.
|
||||
- Same style, material, palette, lighting, and prop design.
|
||||
- No frame introduces a new unintended character or object.
|
||||
|
||||
## Sprite Style
|
||||
## Pet-Safe Style
|
||||
|
||||
- Art reads as a Codex digital pet sprite, not a polished illustration or glossy app icon.
|
||||
- Silhouette is compact and chunky enough to read inside a `192x208` cell.
|
||||
- Outlines are dark and simple, with visible stepped/pixel-style edges.
|
||||
- Palette is limited, with flat cel shading and minimal highlights or shadow steps.
|
||||
- No painterly texture, realistic fur/material detail, soft gradients, high-detail antialiasing, or tiny accessories that disappear at pet size.
|
||||
- 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
|
||||
|
||||
@@ -49,6 +52,8 @@ Do not accept an atlas until all checks pass.
|
||||
- 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
|
||||
|
||||
@@ -58,4 +63,4 @@ Repair the smallest failing scope first:
|
||||
2. One row.
|
||||
3. Full atlas regeneration only when identity or layout is broadly broken.
|
||||
|
||||
The normal production path should queue targeted repair jobs for failing rows. Manual repair should preserve the same run directory and regenerate only the affected row prompt/image unless the base character is wrong.
|
||||
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.
|
||||
|
||||
@@ -107,12 +107,31 @@ def compose_from_frames(root: Path) -> Image.Image:
|
||||
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)
|
||||
atlas.save(
|
||||
webp_output,
|
||||
format="WEBP",
|
||||
lossless=True,
|
||||
quality=100,
|
||||
method=6,
|
||||
exact=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
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"
|
||||
@@ -33,14 +34,6 @@ def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]:
|
||||
raise SystemExit(f"unknown job id: {job_id}")
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def image_metadata(path: Path) -> dict[str, object]:
|
||||
with Image.open(path) as image:
|
||||
image.verify()
|
||||
@@ -57,6 +50,23 @@ 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)
|
||||
@@ -99,26 +109,22 @@ def main() -> None:
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(source) as image:
|
||||
mirrored = ImageOps.mirror(image.convert("RGBA"))
|
||||
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["source_provenance"] = "deterministic-mirror"
|
||||
left_job["derived_from"] = "running-right"
|
||||
left_job["source_sha256"] = file_sha256(source)
|
||||
left_job["output_sha256"] = file_sha256(output)
|
||||
left_job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
left_job["metadata"] = image_metadata(output)
|
||||
left_job["mirror_decision"] = {
|
||||
"approved": True,
|
||||
"approved_at": left_job["completed_at"],
|
||||
"note": args.decision_note.strip(),
|
||||
"transform": "framewise-horizontal-mirror-preserving-order",
|
||||
}
|
||||
for key in [
|
||||
"last_error",
|
||||
"secondary_fallback",
|
||||
"synthetic_test_source",
|
||||
"repair_reason",
|
||||
"queued_at",
|
||||
]:
|
||||
@@ -133,6 +139,7 @@ def main() -> None:
|
||||
"derived_from": "running-right",
|
||||
"output": str(output),
|
||||
"decision_note": args.decision_note.strip(),
|
||||
"transform": "framewise-horizontal-mirror-preserving-order",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ def remove_chroma_background(
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
if color_distance(red, green, blue, chroma_key) <= threshold:
|
||||
pixels[x, y] = (red, green, blue, 0)
|
||||
pixels[x, y] = (0, 0, 0, 0)
|
||||
return rgba
|
||||
|
||||
|
||||
@@ -99,6 +99,26 @@ def fit_to_cell(image: Image.Image) -> Image.Image:
|
||||
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
|
||||
@@ -183,7 +203,10 @@ def component_group_image(
|
||||
return output
|
||||
|
||||
|
||||
def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None:
|
||||
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
|
||||
@@ -215,9 +238,25 @@ def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image
|
||||
)
|
||||
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 = []
|
||||
@@ -229,6 +268,48 @@ def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Imag
|
||||
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,
|
||||
@@ -254,6 +335,10 @@ def extract_state(
|
||||
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"
|
||||
|
||||
@@ -274,9 +359,9 @@ def main() -> None:
|
||||
parser.add_argument("--key-threshold", type=float, default=96.0)
|
||||
parser.add_argument(
|
||||
"--method",
|
||||
choices=("auto", "components", "slots"),
|
||||
choices=("auto", "components", "slots", "stable-slots"),
|
||||
default="auto",
|
||||
help="Use connected sprite components when possible, or fixed equal slots.",
|
||||
help="Use connected sprite components when possible, raw equal slots, or row-stable slot viewports.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finalize a Codex pet run after all imagegen jobs are complete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def run(command: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
print("+ " + " ".join(command))
|
||||
return subprocess.run(command, check=check, text=True)
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, object]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def is_relative_to(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def default_generated_images_root() -> Path:
|
||||
return default_codex_home() / "generated_images"
|
||||
|
||||
|
||||
def default_codex_home() -> Path:
|
||||
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
|
||||
|
||||
|
||||
def manifest_path(raw: object, *, run_dir: Path, field: str, job_id: str) -> Path:
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise SystemExit(f"job {job_id} has no {field}")
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = run_dir / path
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def validate_hash(job: dict[str, object], *, source: Path, output: Path, job_id: str) -> None:
|
||||
expected_hash = job.get("source_sha256")
|
||||
if not isinstance(expected_hash, str) or not expected_hash:
|
||||
raise SystemExit(
|
||||
f"job {job_id} is missing source_sha256; ingest visual outputs with "
|
||||
"record_imagegen_result.py instead of editing imagegen-jobs.json"
|
||||
)
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"job {job_id} source image no longer exists: {source}")
|
||||
if not output.is_file():
|
||||
raise SystemExit(f"job {job_id} decoded output is missing: {output}")
|
||||
source_hash = file_sha256(source)
|
||||
output_hash = file_sha256(output)
|
||||
if source_hash != expected_hash:
|
||||
raise SystemExit(f"job {job_id} source image hash does not match imagegen-jobs.json")
|
||||
if output_hash != expected_hash:
|
||||
raise SystemExit(
|
||||
f"job {job_id} decoded output does not match its recorded source image; "
|
||||
"do not rewrite decoded visual outputs locally"
|
||||
)
|
||||
|
||||
|
||||
def validate_mirror_hash(job: dict[str, object], *, source: Path, output: Path, job_id: str) -> None:
|
||||
if job_id != "running-left":
|
||||
raise SystemExit(f"job {job_id} may not use deterministic mirror provenance")
|
||||
if job.get("derived_from") != "running-right":
|
||||
raise SystemExit("running-left mirror job must derive from running-right")
|
||||
decision = job.get("mirror_decision")
|
||||
if not isinstance(decision, dict) or decision.get("approved") is not True:
|
||||
raise SystemExit(
|
||||
"running-left mirror job is missing an approved mirror_decision; "
|
||||
"use derive_running_left_from_running_right.py after visual review"
|
||||
)
|
||||
|
||||
expected_source_hash = job.get("source_sha256")
|
||||
expected_output_hash = job.get("output_sha256")
|
||||
if not isinstance(expected_source_hash, str) or not expected_source_hash:
|
||||
raise SystemExit("running-left mirror job is missing source_sha256")
|
||||
if not isinstance(expected_output_hash, str) or not expected_output_hash:
|
||||
raise SystemExit("running-left mirror job is missing output_sha256")
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"running-left mirror source image no longer exists: {source}")
|
||||
if not output.is_file():
|
||||
raise SystemExit(f"running-left mirrored output is missing: {output}")
|
||||
if source.name != "running-right.png" or source.parent.name != "decoded":
|
||||
raise SystemExit("running-left mirror source must be decoded/running-right.png")
|
||||
if output.name != "running-left.png" or output.parent.name != "decoded":
|
||||
raise SystemExit("running-left mirror output must be decoded/running-left.png")
|
||||
if file_sha256(source) != expected_source_hash:
|
||||
raise SystemExit("running-left mirror source hash does not match imagegen-jobs.json")
|
||||
if file_sha256(output) != expected_output_hash:
|
||||
raise SystemExit(
|
||||
"running-left mirrored output hash does not match imagegen-jobs.json; "
|
||||
"rerun derive_running_left_from_running_right.py"
|
||||
)
|
||||
with Image.open(source) as source_image, Image.open(output) as output_image:
|
||||
expected = ImageOps.mirror(source_image.convert("RGBA"))
|
||||
actual = output_image.convert("RGBA")
|
||||
if expected.size != actual.size or expected.tobytes() != actual.tobytes():
|
||||
raise SystemExit(
|
||||
"running-left mirrored output is not an exact horizontal mirror of running-right"
|
||||
)
|
||||
|
||||
|
||||
def validate_completed_job_source(
|
||||
job: dict[str, object],
|
||||
*,
|
||||
run_dir: Path,
|
||||
allow_synthetic_test_sources: bool,
|
||||
) -> None:
|
||||
job_id = str(job.get("id") or "")
|
||||
source = manifest_path(job.get("source_path"), run_dir=run_dir, field="source_path", job_id=job_id)
|
||||
output = manifest_path(job.get("output_path"), run_dir=run_dir, field="output_path", job_id=job_id)
|
||||
|
||||
blocked_flags = [
|
||||
flag
|
||||
for flag in ("deterministic_pet_row", "cute_raster_row", "local_raster_row")
|
||||
if job.get(flag)
|
||||
]
|
||||
if blocked_flags:
|
||||
raise SystemExit(
|
||||
f"job {job_id} was marked as a local/synthetic row ({', '.join(blocked_flags)}); "
|
||||
"regenerate it with $imagegen"
|
||||
)
|
||||
|
||||
if job.get("synthetic_test_source"):
|
||||
if not allow_synthetic_test_sources:
|
||||
raise SystemExit(
|
||||
f"job {job_id} uses a synthetic test source; rerun with real $imagegen output"
|
||||
)
|
||||
validate_hash(job, source=source, output=output, job_id=job_id)
|
||||
return
|
||||
|
||||
if job.get("secondary_fallback"):
|
||||
if job.get("source_provenance") != "secondary-fallback-image-api":
|
||||
raise SystemExit(f"job {job_id} has invalid secondary fallback provenance")
|
||||
validate_hash(job, source=source, output=output, job_id=job_id)
|
||||
return
|
||||
|
||||
if job.get("source_provenance") == "deterministic-mirror":
|
||||
validate_mirror_hash(job, source=source, output=output, job_id=job_id)
|
||||
return
|
||||
|
||||
if job.get("source_provenance") != "built-in-imagegen":
|
||||
raise SystemExit(
|
||||
f"job {job_id} was not recorded as a built-in $imagegen output; "
|
||||
"use record_imagegen_result.py with the selected $CODEX_HOME/generated_images/.../ig_*.png file"
|
||||
)
|
||||
if is_relative_to(source, run_dir):
|
||||
raise SystemExit(
|
||||
f"job {job_id} source image is inside the pet run directory; "
|
||||
"do not use locally generated row artifacts as visual sources"
|
||||
)
|
||||
generated_root = default_generated_images_root()
|
||||
if not is_relative_to(source, generated_root) or not source.name.startswith("ig_"):
|
||||
raise SystemExit(
|
||||
f"job {job_id} source image is not a built-in $imagegen output under "
|
||||
f"{generated_root}/.../ig_*.png"
|
||||
)
|
||||
validate_hash(job, source=source, output=output, job_id=job_id)
|
||||
|
||||
|
||||
def require_complete_jobs(run_dir: Path, *, allow_synthetic_test_sources: bool) -> None:
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_json(manifest_path)
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
incomplete = [
|
||||
str(job.get("id"))
|
||||
for job in jobs
|
||||
if isinstance(job, dict) and job.get("status", "pending") != "complete"
|
||||
]
|
||||
if incomplete:
|
||||
raise SystemExit(
|
||||
"imagegen jobs are not complete; run pet_job_status.py and finish: "
|
||||
+ ", ".join(incomplete)
|
||||
)
|
||||
for job in jobs:
|
||||
if isinstance(job, dict):
|
||||
validate_completed_job_source(
|
||||
job,
|
||||
run_dir=run_dir,
|
||||
allow_synthetic_test_sources=allow_synthetic_test_sources,
|
||||
)
|
||||
|
||||
|
||||
def review_failures(review: dict[str, object]) -> list[str]:
|
||||
rows = review.get("rows")
|
||||
if not isinstance(rows, list):
|
||||
return ["review did not contain row-level results"]
|
||||
failures = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
errors = row.get("errors")
|
||||
if isinstance(errors, list) and errors:
|
||||
failures.append(f"{row.get('state')}: {'; '.join(str(error) for error in errors)}")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--allow-slot-extraction", action="store_true")
|
||||
parser.add_argument("--skip-videos", action="store_true")
|
||||
parser.add_argument("--skip-package", action="store_true")
|
||||
parser.add_argument(
|
||||
"--package-dir",
|
||||
default="",
|
||||
help="Exact pet package directory. Defaults to ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>.",
|
||||
)
|
||||
parser.add_argument("--ffmpeg", default="")
|
||||
parser.add_argument("--allow-synthetic-test-sources", action="store_true", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
scripts_dir = Path(__file__).resolve().parent
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
request = load_json(run_dir / "pet_request.json")
|
||||
pet_id = str(request.get("pet_id") or "")
|
||||
display_name = str(request.get("display_name") or "")
|
||||
description = str(request.get("description") or "")
|
||||
if not pet_id or not display_name or not description:
|
||||
raise SystemExit("pet_request.json is missing pet_id, display_name, or description")
|
||||
|
||||
require_complete_jobs(
|
||||
run_dir,
|
||||
allow_synthetic_test_sources=args.allow_synthetic_test_sources,
|
||||
)
|
||||
|
||||
final_dir = run_dir / "final"
|
||||
qa_dir = run_dir / "qa"
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
qa_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "extract_strip_frames.py"),
|
||||
"--decoded-dir",
|
||||
str(run_dir / "decoded"),
|
||||
"--output-dir",
|
||||
str(run_dir / "frames"),
|
||||
"--states",
|
||||
"all",
|
||||
"--method",
|
||||
"auto",
|
||||
]
|
||||
)
|
||||
|
||||
review_path = qa_dir / "review.json"
|
||||
inspect_command = [
|
||||
sys.executable,
|
||||
str(scripts_dir / "inspect_frames.py"),
|
||||
"--frames-root",
|
||||
str(run_dir / "frames"),
|
||||
"--json-out",
|
||||
str(review_path),
|
||||
]
|
||||
if not args.allow_slot_extraction:
|
||||
inspect_command.append("--require-components")
|
||||
run(inspect_command, check=False)
|
||||
review = load_json(review_path)
|
||||
if not review.get("ok"):
|
||||
failures = review_failures(review)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"review": str(review_path),
|
||||
"repair_hint": "Run queue_pet_repairs.py, regenerate the reopened row jobs with $imagegen, then finalize again.",
|
||||
"failures": failures,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "compose_atlas.py"),
|
||||
"--frames-root",
|
||||
str(run_dir / "frames"),
|
||||
"--output",
|
||||
str(final_dir / "spritesheet.png"),
|
||||
"--webp-output",
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
]
|
||||
)
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "validate_atlas.py"),
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--json-out",
|
||||
str(final_dir / "validation.json"),
|
||||
]
|
||||
)
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "make_contact_sheet.py"),
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--output",
|
||||
str(qa_dir / "contact-sheet.png"),
|
||||
]
|
||||
)
|
||||
|
||||
if not args.skip_videos:
|
||||
video_command = [
|
||||
sys.executable,
|
||||
str(scripts_dir / "render_animation_videos.py"),
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--output-dir",
|
||||
str(qa_dir / "videos"),
|
||||
]
|
||||
if args.ffmpeg:
|
||||
video_command.extend(["--ffmpeg", args.ffmpeg])
|
||||
run(video_command)
|
||||
|
||||
if not args.skip_package:
|
||||
package_command = [
|
||||
sys.executable,
|
||||
str(scripts_dir / "package_custom_pet.py"),
|
||||
"--pet-name",
|
||||
pet_id,
|
||||
"--display-name",
|
||||
display_name,
|
||||
"--description",
|
||||
description,
|
||||
"--spritesheet",
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--force",
|
||||
]
|
||||
if args.package_dir:
|
||||
package_command.extend(["--output-dir", str(Path(args.package_dir).expanduser().resolve())])
|
||||
run(package_command)
|
||||
|
||||
package_dir = None
|
||||
if not args.skip_package:
|
||||
package_dir = (
|
||||
Path(args.package_dir).expanduser().resolve()
|
||||
if args.package_dir
|
||||
else default_codex_home() / "pets" / pet_id
|
||||
)
|
||||
|
||||
summary = {
|
||||
"ok": True,
|
||||
"run_dir": str(run_dir),
|
||||
"spritesheet": str(final_dir / "spritesheet.webp"),
|
||||
"validation": str(final_dir / "validation.json"),
|
||||
"contact_sheet": str(qa_dir / "contact-sheet.png"),
|
||||
"review": str(review_path),
|
||||
"videos": None if args.skip_videos else str(qa_dir / "videos"),
|
||||
"package": None if package_dir is None else str(package_dir),
|
||||
}
|
||||
summary_path = qa_dir / "run-summary.json"
|
||||
summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,287 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Secondary image generation fallback for Codex pet base art and row strips."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ALL_STATES = [
|
||||
"idle",
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
"failed",
|
||||
"waiting",
|
||||
"running",
|
||||
"review",
|
||||
]
|
||||
CANONICAL_BASE_PATH = "references/canonical-base.png"
|
||||
|
||||
|
||||
def parse_states(raw: str) -> list[str]:
|
||||
if raw.strip().lower() == "all":
|
||||
return ALL_STATES
|
||||
states = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
unknown = sorted(set(states) - set(ALL_STATES))
|
||||
if unknown:
|
||||
raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
|
||||
return states
|
||||
|
||||
|
||||
def load_manifest(run_dir: Path) -> dict[str, object]:
|
||||
path = run_dir / "imagegen-jobs.json"
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def manifest_jobs(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def select_jobs(
|
||||
manifest: dict[str, object],
|
||||
*,
|
||||
states: list[str],
|
||||
skip_base: bool,
|
||||
job_ids: list[str],
|
||||
) -> list[dict[str, object]]:
|
||||
selected_ids = set(job_ids)
|
||||
if not selected_ids:
|
||||
if not skip_base:
|
||||
selected_ids.add("base")
|
||||
selected_ids.update(states)
|
||||
selected = [job for job in manifest_jobs(manifest) if job.get("id") in selected_ids]
|
||||
missing = selected_ids - {str(job.get("id")) for job in selected}
|
||||
if missing:
|
||||
raise SystemExit(f"unknown job id(s): {', '.join(sorted(missing))}")
|
||||
return selected
|
||||
|
||||
|
||||
def run_image_edit(
|
||||
*,
|
||||
model: str,
|
||||
prompt_file: Path,
|
||||
image_paths: list[Path],
|
||||
output_json: Path,
|
||||
size: str,
|
||||
api_key: str,
|
||||
) -> dict[str, object]:
|
||||
output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
"curl",
|
||||
"-sS",
|
||||
"-X",
|
||||
"POST",
|
||||
"https://api.openai.com/v1/images/edits",
|
||||
"-H",
|
||||
f"Authorization: Bearer {api_key}",
|
||||
"-F",
|
||||
f"model={model}",
|
||||
]
|
||||
for image_path in image_paths:
|
||||
command.extend(["-F", f"image[]=@{image_path}"])
|
||||
command.extend(
|
||||
[
|
||||
"-F",
|
||||
f"prompt=<{prompt_file}",
|
||||
"-F",
|
||||
f"size={size}",
|
||||
"-F",
|
||||
"output_format=png",
|
||||
"-o",
|
||||
str(output_json),
|
||||
]
|
||||
)
|
||||
subprocess.run(command, check=True)
|
||||
response = json.loads(output_json.read_text(encoding="utf-8"))
|
||||
if response.get("error"):
|
||||
raise SystemExit(json.dumps(response["error"], indent=2))
|
||||
return response
|
||||
|
||||
|
||||
def run_image_generation(
|
||||
*,
|
||||
model: str,
|
||||
prompt_file: Path,
|
||||
output_json: Path,
|
||||
size: str,
|
||||
api_key: str,
|
||||
) -> dict[str, object]:
|
||||
output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
"curl",
|
||||
"-sS",
|
||||
"-X",
|
||||
"POST",
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
"-H",
|
||||
f"Authorization: Bearer {api_key}",
|
||||
"-F",
|
||||
f"model={model}",
|
||||
"-F",
|
||||
f"prompt=<{prompt_file}",
|
||||
"-F",
|
||||
f"size={size}",
|
||||
"-F",
|
||||
"output_format=png",
|
||||
"-o",
|
||||
str(output_json),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
response = json.loads(output_json.read_text(encoding="utf-8"))
|
||||
if response.get("error"):
|
||||
raise SystemExit(json.dumps(response["error"], indent=2))
|
||||
return response
|
||||
|
||||
|
||||
def decode_response(response: dict[str, object], output_image: Path) -> None:
|
||||
data = response.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise SystemExit("image API response did not contain data[0]")
|
||||
first = data[0]
|
||||
if not isinstance(first, dict) or not isinstance(first.get("b64_json"), str):
|
||||
raise SystemExit("image API response did not contain data[0].b64_json")
|
||||
output_image.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_image.write_bytes(base64.b64decode(first["b64_json"]))
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def complete_job(job: dict[str, object], output_path: Path) -> None:
|
||||
job["status"] = "complete"
|
||||
job["source_path"] = str(output_path)
|
||||
job["source_provenance"] = "secondary-fallback-image-api"
|
||||
job["source_sha256"] = file_sha256(output_path)
|
||||
job["output_sha256"] = file_sha256(output_path)
|
||||
job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
job["secondary_fallback"] = True
|
||||
for key in [
|
||||
"last_error",
|
||||
"synthetic_test_source",
|
||||
"derived_from",
|
||||
"mirror_decision",
|
||||
"repair_reason",
|
||||
"queued_at",
|
||||
]:
|
||||
job.pop(key, None)
|
||||
|
||||
|
||||
def write_canonical_base(
|
||||
run_dir: Path, manifest: dict[str, object], output_image: Path
|
||||
) -> None:
|
||||
canonical = run_dir / CANONICAL_BASE_PATH
|
||||
canonical.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(output_image, canonical)
|
||||
reference = {
|
||||
"path": CANONICAL_BASE_PATH,
|
||||
"source_job": "base",
|
||||
"sha256": file_sha256(canonical),
|
||||
}
|
||||
manifest["canonical_identity_reference"] = reference
|
||||
request_path = run_dir / "pet_request.json"
|
||||
if request_path.exists():
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
request["canonical_identity_reference"] = reference
|
||||
request_path.write_text(json.dumps(request, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def path_list(run_dir: Path, job: dict[str, object]) -> list[Path]:
|
||||
inputs = job.get("input_images")
|
||||
if not isinstance(inputs, list):
|
||||
raise SystemExit(f"job {job.get('id')} has invalid input_images")
|
||||
paths = []
|
||||
for item in inputs:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||||
raise SystemExit(f"job {job.get('id')} has invalid input image entry")
|
||||
path = run_dir / item["path"]
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"input image for job {job.get('id')} not found: {path}")
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--model", default="gpt-image-2")
|
||||
parser.add_argument("--size", default="1024x1024")
|
||||
parser.add_argument("--states", default="all")
|
||||
parser.add_argument("--job-id", action="append", default=[])
|
||||
parser.add_argument("--skip-base", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise SystemExit("OPENAI_API_KEY is not set")
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_manifest(run_dir)
|
||||
jobs = select_jobs(
|
||||
manifest,
|
||||
states=parse_states(args.states),
|
||||
skip_base=args.skip_base,
|
||||
job_ids=args.job_id,
|
||||
)
|
||||
raw_dir = run_dir / "raw"
|
||||
|
||||
completed = []
|
||||
for job in jobs:
|
||||
job_id = str(job.get("id"))
|
||||
prompt_raw = job.get("prompt_file")
|
||||
output_raw = job.get("output_path")
|
||||
if not isinstance(prompt_raw, str) or not isinstance(output_raw, str):
|
||||
raise SystemExit(f"job {job_id} is missing prompt_file or output_path")
|
||||
prompt_file = run_dir / prompt_raw
|
||||
output_image = run_dir / output_raw
|
||||
print(f"Generating {job_id} with secondary fallback")
|
||||
image_paths = path_list(run_dir, job)
|
||||
if image_paths:
|
||||
response = run_image_edit(
|
||||
model=args.model,
|
||||
prompt_file=prompt_file,
|
||||
image_paths=image_paths,
|
||||
output_json=raw_dir / f"{job_id}.response.json",
|
||||
size=args.size,
|
||||
api_key=api_key,
|
||||
)
|
||||
else:
|
||||
response = run_image_generation(
|
||||
model=args.model,
|
||||
prompt_file=prompt_file,
|
||||
output_json=raw_dir / f"{job_id}.response.json",
|
||||
size=args.size,
|
||||
api_key=api_key,
|
||||
)
|
||||
decode_response(response, output_image)
|
||||
complete_job(job, output_image)
|
||||
if job_id == "base":
|
||||
job["canonical_reference_path"] = CANONICAL_BASE_PATH
|
||||
write_canonical_base(run_dir, manifest, output_image)
|
||||
completed.append({"job_id": job_id, "output": str(output_image)})
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"ok": True, "completed": completed}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -127,6 +127,11 @@ def inspect_state(
|
||||
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"
|
||||
)
|
||||
@@ -216,6 +221,11 @@ def main() -> None:
|
||||
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()
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package a validated atlas as a local Codex pet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ATLAS_SIZE = (1536, 1872)
|
||||
|
||||
|
||||
def default_codex_home() -> Path:
|
||||
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||
value = re.sub(r"-{2,}", "-", value)
|
||||
return value.strip("-")
|
||||
|
||||
|
||||
def validate_spritesheet(path: Path) -> str:
|
||||
with Image.open(path) as image:
|
||||
if image.size != ATLAS_SIZE:
|
||||
raise SystemExit(
|
||||
f"expected {ATLAS_SIZE[0]}x{ATLAS_SIZE[1]}, got {image.width}x{image.height}"
|
||||
)
|
||||
if image.format not in {"PNG", "WEBP"}:
|
||||
raise SystemExit(f"expected PNG or WebP, got {image.format}")
|
||||
return str(image.format)
|
||||
|
||||
|
||||
def write_webp_spritesheet(source: Path, target: Path, source_format: str) -> None:
|
||||
if source_format == "WEBP":
|
||||
shutil.copy2(source, target)
|
||||
return
|
||||
with Image.open(source) as image:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.convert("RGBA").save(
|
||||
target,
|
||||
format="WEBP",
|
||||
lossless=True,
|
||||
quality=100,
|
||||
method=6,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pet-name", default="")
|
||||
parser.add_argument("--display-name", default="")
|
||||
parser.add_argument("--description", required=True)
|
||||
parser.add_argument("--spritesheet", required=True)
|
||||
parser.add_argument("--codex-home", default=str(default_codex_home()))
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
help="Exact pet package directory. Defaults to ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>.",
|
||||
)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
raw_pet_name = (args.pet_name or args.display_name).strip()
|
||||
if not raw_pet_name:
|
||||
raise SystemExit("pet name is required")
|
||||
pet_id = slugify(raw_pet_name)
|
||||
if not pet_id:
|
||||
raise SystemExit("pet name must contain at least one letter or digit")
|
||||
display_name = (args.display_name or raw_pet_name).strip()
|
||||
|
||||
source = Path(args.spritesheet).expanduser().resolve()
|
||||
source_format = validate_spritesheet(source)
|
||||
target_dir = (
|
||||
Path(args.output_dir).expanduser().resolve()
|
||||
if args.output_dir
|
||||
else Path(args.codex_home).expanduser().resolve() / "pets" / pet_id
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target_sheet = target_dir / "spritesheet.webp"
|
||||
manifest_path = target_dir / "pet.json"
|
||||
if not args.force and (target_sheet.exists() or manifest_path.exists()):
|
||||
raise SystemExit(f"{target_dir} already contains pet files; pass --force to overwrite")
|
||||
|
||||
write_webp_spritesheet(source, target_sheet, source_format)
|
||||
manifest = {
|
||||
"id": pet_id,
|
||||
"displayName": display_name,
|
||||
"description": args.description,
|
||||
"spritesheetPath": target_sheet.name,
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{"ok": True, "pet_dir": str(target_dir), "manifest": str(manifest_path)}, indent=2
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show ready and pending $imagegen jobs for a Codex pet run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_manifest(run_dir: Path) -> dict[str, object]:
|
||||
path = run_dir / "imagegen-jobs.json"
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def jobs(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
raw = manifest.get("jobs")
|
||||
if not isinstance(raw, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in raw if isinstance(job, dict)]
|
||||
|
||||
|
||||
def completed_ids(manifest: dict[str, object]) -> set[str]:
|
||||
return {
|
||||
str(job["id"])
|
||||
for job in jobs(manifest)
|
||||
if job.get("status") == "complete" and isinstance(job.get("id"), str)
|
||||
}
|
||||
|
||||
|
||||
def missing_deps(job: dict[str, object], completed: set[str]) -> list[str]:
|
||||
deps = job.get("depends_on", [])
|
||||
if not isinstance(deps, list):
|
||||
return []
|
||||
return [dep for dep in deps if isinstance(dep, str) and dep not in completed]
|
||||
|
||||
|
||||
def job_view(
|
||||
job: dict[str, object], run_dir: Path, completed: set[str]
|
||||
) -> dict[str, object]:
|
||||
prompt_file = job.get("prompt_file")
|
||||
output_path = job.get("output_path")
|
||||
inputs = (
|
||||
job.get("input_images") if isinstance(job.get("input_images"), list) else []
|
||||
)
|
||||
input_images = []
|
||||
for item in inputs:
|
||||
path = (
|
||||
run_dir / item["path"]
|
||||
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
||||
else None
|
||||
)
|
||||
input_images.append(
|
||||
{
|
||||
"path": str(path) if path else None,
|
||||
"role": item.get("role") if isinstance(item, dict) else None,
|
||||
"exists": path.is_file() if path else False,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": job.get("id"),
|
||||
"kind": job.get("kind"),
|
||||
"status": job.get("status", "pending"),
|
||||
"prompt_file": str(run_dir / prompt_file)
|
||||
if isinstance(prompt_file, str)
|
||||
else None,
|
||||
"input_images": input_images,
|
||||
"output_path": str(run_dir / output_path)
|
||||
if isinstance(output_path, str)
|
||||
else None,
|
||||
"missing_dependencies": missing_deps(job, completed),
|
||||
"repair_attempt": job.get("repair_attempt", 0),
|
||||
"generation_skill": job.get("generation_skill"),
|
||||
"requires_grounded_generation": job.get("requires_grounded_generation", False),
|
||||
"allow_prompt_only_generation": job.get("allow_prompt_only_generation", False),
|
||||
"identity_reference_paths": job.get("identity_reference_paths", []),
|
||||
"mirror_policy": job.get("mirror_policy", {}),
|
||||
"derived_from": job.get("derived_from"),
|
||||
"source_provenance": job.get("source_provenance"),
|
||||
"mirror_decision": job.get("mirror_decision"),
|
||||
"recording_owner": job.get("recording_owner", "parent"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
manifest = load_manifest(run_dir)
|
||||
completed = completed_ids(manifest)
|
||||
pending = [
|
||||
job for job in jobs(manifest) if job.get("status", "pending") != "complete"
|
||||
]
|
||||
ready = [job for job in pending if not missing_deps(job, completed)]
|
||||
blocked = [job for job in pending if missing_deps(job, completed)]
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"run_dir": str(run_dir),
|
||||
"counts": {
|
||||
"total": len(jobs(manifest)),
|
||||
"complete": len(completed),
|
||||
"ready": len(ready),
|
||||
"blocked": len(blocked),
|
||||
},
|
||||
"ready_jobs": [job_view(job, run_dir, completed) for job in ready],
|
||||
"blocked_jobs": [job_view(job, run_dir, completed) for job in blocked],
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,40 +19,42 @@ ATLAS["width"] = ATLAS["columns"] * ATLAS["cell_width"]
|
||||
ATLAS["height"] = ATLAS["rows"] * ATLAS["cell_height"]
|
||||
|
||||
ROWS = [
|
||||
("idle", 0, 6, "neutral breathing/blinking loop"),
|
||||
("running-right", 1, 8, "rightward locomotion loop"),
|
||||
("running-left", 2, 8, "leftward locomotion loop"),
|
||||
("waving", 3, 4, "greeting gesture with raised wave and return"),
|
||||
("jumping", 4, 5, "anticipation, lift, peak, descent, settle"),
|
||||
("failed", 5, 8, "sad, failed, or deflated reaction"),
|
||||
("waiting", 6, 6, "patient waiting loop with small motion"),
|
||||
("running", 7, 6, "active working/in-progress loop"),
|
||||
("review", 8, 6, "focused inspecting or review loop"),
|
||||
("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"),
|
||||
]
|
||||
|
||||
TRANSPARENCY_ARTIFACT_RULES = [
|
||||
"Prefer pose, expression, and silhouette changes over decorative effects.",
|
||||
"Effects are allowed only when they are state-relevant, opaque, hard-edged, pixel-style, fully inside the same frame slot, and physically touching or overlapping the pet silhouette.",
|
||||
"Allowed attached effects can include a tear touching the face, a small smoke puff touching the pet or prop, or tiny stars overlapping the pet during a failed/dizzy reaction.",
|
||||
"Do not draw detached effects: floating stars, loose sparkles, floating punctuation, floating icons, falling tear drops, separated smoke clouds, loose dust, disconnected outline bits, or stray pixels.",
|
||||
"Do not draw wave marks, motion arcs, speed lines, action streaks, afterimages, blur, smears, halos, glows, auras, floor patches, cast shadows, contact shadows, drop shadows, oval floor shadows, landing marks, or impact bursts.",
|
||||
"Do not include text, labels, frame numbers, visible grids, guide marks, speech bubbles, thought bubbles, UI panels, code snippets, scenery, checkerboard transparency, white backgrounds, or black backgrounds.",
|
||||
"Do not use the chroma-key color or chroma-key-adjacent colors in the pet, prop, effects, highlights, shadows, or outlines.",
|
||||
"Reject any pose that is cropped, overlaps another pose, crosses into a neighboring frame slot, or creates a separate disconnected component that is not attached to the pet.",
|
||||
]
|
||||
STATE_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 pose only: paw down, paw raised, paw tilted, paw returning.",
|
||||
"Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the paw.",
|
||||
"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.",
|
||||
@@ -64,35 +66,87 @@ STATE_REQUIREMENTS = {
|
||||
"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 position.",
|
||||
"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 locomotion through body, limb, and prop movement only.",
|
||||
"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 locomotion through body, limb, and prop movement only.",
|
||||
"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.",
|
||||
],
|
||||
"running": [
|
||||
"Show the pet actively working or processing, as if running a task: focused posture, busy hands or paws, purposeful bobbing, thinking motion, tool/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.",
|
||||
],
|
||||
}
|
||||
|
||||
DIGITAL_PET_STYLE = (
|
||||
"Codex digital pet sprite style: pixel-art-adjacent low-resolution mascot sprite, "
|
||||
"compact chibi proportions, chunky whole-body silhouette, thick dark 1-2 px outline, "
|
||||
"visible stepped/pixel edges, limited palette, flat cel shading with at most one "
|
||||
"small highlight and one shadow step, simple readable face, tiny limbs, and no "
|
||||
"detail that disappears at 192x208. Avoid polished illustration, painterly "
|
||||
"rendering, anime key art, 3D render, vector app-icon polish, glossy lighting, "
|
||||
"soft gradients, realistic fur or material texture, anti-aliased high-detail "
|
||||
"edges, and complex tiny accessories."
|
||||
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"),
|
||||
@@ -104,6 +158,7 @@ CHROMA_KEY_CANDIDATES = [
|
||||
|
||||
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
|
||||
@@ -162,7 +217,7 @@ def infer_name(args: argparse.Namespace, reference_paths: list[Path]) -> str:
|
||||
if display:
|
||||
return display
|
||||
|
||||
for raw_value in [args.pet_notes, args.description]:
|
||||
for raw_value in [args.pet_notes, args.description, args.brand_name]:
|
||||
words = concept_words(raw_value)
|
||||
if words:
|
||||
return words[0].capitalize()
|
||||
@@ -188,10 +243,12 @@ def infer_description(args: argparse.Namespace, reference_paths: list[Path]) ->
|
||||
if args.description.strip():
|
||||
return sentence(args.description)
|
||||
if args.pet_notes.strip():
|
||||
return sentence(f"A compact Codex digital pet: {args.pet_notes}")
|
||||
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 digital pet based on the provided reference image."
|
||||
return "A compact original Codex digital pet ready for animation."
|
||||
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:
|
||||
@@ -199,9 +256,11 @@ def infer_pet_notes(args: argparse.Namespace, reference_paths: list[Path]) -> st
|
||||
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 digital pet"
|
||||
return "a compact original Codex pet"
|
||||
|
||||
|
||||
def default_output_dir(pet_id: str) -> Path:
|
||||
@@ -386,83 +445,107 @@ def write_text(path: Path, text: str) -> None:
|
||||
path.write_text(text.rstrip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def resolved_style_notes(raw_style_notes: str) -> str:
|
||||
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 DIGITAL_PET_STYLE
|
||||
return f"{DIGITAL_PET_STYLE} Additional user style notes: {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_notes = resolved_style_notes(args.style_notes)
|
||||
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 a single clean reference sprite for a Codex app digital pet named {args.display_name}.
|
||||
return f"""Create one clean full-body reference sprite for Codex pet {args.display_name}.
|
||||
|
||||
Pet: {pet_notes}.
|
||||
Style contract: {style_notes}
|
||||
|
||||
Use this prompt as an authoritative sprite-production spec. Do not expand it into a polished illustration, painterly character image, anime key art, 3D render, vector mascot, glossy app icon, realistic animal portrait, or marketing artwork.
|
||||
|
||||
Output one centered full-body pet sprite pose only, on a perfectly flat pure {chroma_name} {chroma_key} chroma-key background. The pet must be fully visible, readable as a tiny digital pet, and suitable for animation into a 192x208 sprite cell. Do not include scenery, text, labels, borders, checkerboard transparency, detached effects, shadows, glows, or extra props not present in the reference unless explicitly requested. Do not use {chroma_key}, pure {chroma_name}, or colors close to that chroma key in the pet, prop, highlights, or effects."""
|
||||
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_notes = resolved_style_notes(args.style_notes)
|
||||
style_contract = resolved_style_contract(args.style_preset, args.style_notes)
|
||||
chroma_key = args.chroma_key["hex"]
|
||||
chroma_name = args.chroma_key["name"]
|
||||
state_requirements = STATE_REQUIREMENTS.get(state, [])
|
||||
state_requirement_text = ""
|
||||
if state_requirements:
|
||||
state_requirement_text = "\n\nState-specific requirements:\n" + "\n".join(
|
||||
f"- {requirement}" for requirement in state_requirements
|
||||
)
|
||||
transparency_artifact_text = "\n".join(
|
||||
f"- {requirement}" for requirement in TRANSPARENCY_ARTIFACT_RULES
|
||||
)
|
||||
return f"""Create a single horizontal sprite strip for the Codex app digital pet `{args.pet_id}` in the state `{state}`.
|
||||
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 reference image(s) for pet identity and the attached base pet image as the canonical design. Use the attached layout guide image only for frame count, slot spacing, centering, and safe padding. Simplify any high-resolution reference details into the Codex digital pet sprite style. Do not simply copy the still reference pose. Generate distinct animation poses that create a readable cycle.
|
||||
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.
|
||||
|
||||
Identity lock:
|
||||
- Do not redesign the pet. Only change pose/action for the `{state}` animation.
|
||||
- Preserve the exact head shape, ear/horn/limb shape, face design, markings, palette, outline weight, body proportions, prop design, and overall silhouette from the canonical base pet.
|
||||
- Keep every frame recognizably the same individual pet, not a related variant.
|
||||
- If the pet has a prop or accessory, preserve its size, side, palette, and attachment style unless the row action requires a small pose-only adjustment.
|
||||
- Prefer a subtler animation over any change that mutates the pet identity.
|
||||
Output exactly {frames} 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.
|
||||
|
||||
Output exactly {frames} separate animation frames arranged left-to-right in one single row. Each frame must show the same pet: {pet_notes}.
|
||||
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.
|
||||
|
||||
Style contract: {style_notes}
|
||||
State action: {state_prompt}
|
||||
|
||||
Use this prompt as an authoritative sprite-production spec. Do not expand it into a polished illustration, painterly character image, anime key art, 3D render, vector mascot, glossy app icon, realistic animal portrait, or marketing artwork.
|
||||
State requirements:
|
||||
{state_requirements}
|
||||
|
||||
Animation action: {purpose}.
|
||||
{state_requirement_text}
|
||||
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."""
|
||||
|
||||
Transparency and artifact rules:
|
||||
{transparency_artifact_text}
|
||||
|
||||
Layout requirements:
|
||||
- Exactly {frames} full-body frames, left to right, in one horizontal row.
|
||||
- The attached layout guide shows the {frames} frame boxes and inner safe area for this row. Follow its slot count, spacing, centering, and padding.
|
||||
- Do not reproduce the layout guide itself: no visible boxes, guide lines, center marks, labels, guide colors, or guide background may appear in the output.
|
||||
- Treat the image as {frames} equal-width invisible frame slots. Fill every slot: each requested slot must contain exactly one complete full-body pose.
|
||||
- Spread the {frames} poses evenly across the whole image width. Do not leave any requested slot blank or create large empty gaps between poses.
|
||||
- Center one complete pose in each slot. No pose may cross into the neighboring slot.
|
||||
- Use a perfectly flat pure {chroma_name} {chroma_key} chroma-key background across the whole image.
|
||||
- Do not draw visible grid lines, borders, labels, numbers, text, watermarks, or checkerboard transparency.
|
||||
- Do not include scenery or a background environment.
|
||||
- Keep the rendering sprite-like: chunky silhouette, dark pixel-style outline, limited palette, flat shading, minimal tiny detail.
|
||||
- Do not use {chroma_key}, pure {chroma_name}, or colors close to that chroma key in the pet, props, highlights, shadows, motion marks, dust, landing marks, or effects.
|
||||
- Do not draw shadows, glows, smears, dust, or landing marks using darker/lighter versions of the chroma-key color.
|
||||
- Keep every frame self-contained with safe padding. No pet body part should be clipped by the frame slot.
|
||||
- Avoid motion blur. Use clear pose changes readable at 192x208.
|
||||
- Preserve the same silhouette, face, proportions, palette, material, and props across every frame."""
|
||||
def 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(
|
||||
@@ -472,7 +555,7 @@ def make_jobs(
|
||||
{"path": rel(Path(str(ref["copied_path"])), run_dir), "role": "pet reference"}
|
||||
for ref in copied_refs
|
||||
]
|
||||
identity_reference_paths = [CANONICAL_BASE_PATH, "decoded/base.png"]
|
||||
identity_reference_paths = [CANONICAL_BASE_PATH]
|
||||
jobs: list[dict[str, object]] = [
|
||||
{
|
||||
"id": "base",
|
||||
@@ -485,13 +568,15 @@ def make_jobs(
|
||||
"generation_skill": "$imagegen",
|
||||
"requires_grounded_generation": bool(reference_inputs),
|
||||
"allow_prompt_only_generation": not reference_inputs,
|
||||
"recording_owner": "parent",
|
||||
}
|
||||
]
|
||||
for state, _row, frames, _purpose in ROWS:
|
||||
depends_on = ["base"]
|
||||
extra_inputs: list[dict[str, str]] = []
|
||||
mirror_policy: dict[str, object] = {}
|
||||
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(
|
||||
@@ -500,18 +585,22 @@ def make_jobs(
|
||||
"role": "rightward gait reference for leftward row decision",
|
||||
}
|
||||
)
|
||||
mirror_policy = {
|
||||
derivation_policy = {
|
||||
"may_derive": True,
|
||||
"may_derive_from": "running-right",
|
||||
"derivation": "horizontal-mirror",
|
||||
"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,
|
||||
{
|
||||
@@ -522,7 +611,6 @@ def make_jobs(
|
||||
"path": CANONICAL_BASE_PATH,
|
||||
"role": "canonical identity reference",
|
||||
},
|
||||
{"path": "decoded/base.png", "role": "approved base pet"},
|
||||
*extra_inputs,
|
||||
],
|
||||
"output_path": f"decoded/{state}.png",
|
||||
@@ -532,8 +620,8 @@ def make_jobs(
|
||||
"allow_prompt_only_generation": False,
|
||||
"identity_reference_paths": identity_reference_paths,
|
||||
"parallelizable_after": depends_on,
|
||||
"mirror_policy": mirror_policy,
|
||||
"recording_owner": "parent",
|
||||
"derivation_policy": derivation_policy,
|
||||
"mirror_policy": derivation_policy if state == "running-left" else {},
|
||||
}
|
||||
)
|
||||
return jobs
|
||||
@@ -560,6 +648,33 @@ def main() -> None:
|
||||
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",
|
||||
@@ -572,12 +687,24 @@ def main() -> None:
|
||||
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")
|
||||
|
||||
@@ -595,10 +722,12 @@ def main() -> None:
|
||||
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",
|
||||
]:
|
||||
@@ -618,6 +747,14 @@ def main() -> None:
|
||||
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)
|
||||
|
||||
@@ -638,10 +775,17 @@ def main() -> None:
|
||||
"references": copied_refs,
|
||||
"chroma_key": args.chroma_key,
|
||||
"pet_notes": args.pet_notes,
|
||||
"style_preset": args.style_preset,
|
||||
"style_notes": args.style_notes,
|
||||
"house_style": DIGITAL_PET_STYLE,
|
||||
"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"
|
||||
)
|
||||
@@ -652,6 +796,10 @@ def main() -> None:
|
||||
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,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reopen failed Codex pet row jobs after frame QA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"file not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def rows_to_repair(
|
||||
review: dict[str, object], *, repair_on_warnings: bool
|
||||
) -> list[dict[str, object]]:
|
||||
rows = review.get("rows")
|
||||
if not isinstance(rows, list):
|
||||
raise SystemExit("review does not contain row-level results")
|
||||
|
||||
repairs: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not isinstance(row.get("state"), str):
|
||||
continue
|
||||
errors = row.get("errors") if isinstance(row.get("errors"), list) else []
|
||||
warnings = row.get("warnings") if isinstance(row.get("warnings"), list) else []
|
||||
if errors or (repair_on_warnings and warnings):
|
||||
repairs.append(
|
||||
{
|
||||
"state": row["state"],
|
||||
"reason": "; ".join(str(item) for item in [*errors, *warnings])
|
||||
or "the row did not pass visual QA",
|
||||
}
|
||||
)
|
||||
return repairs
|
||||
|
||||
|
||||
def append_repair_note(run_dir: Path, state: str, attempt: int, reason: str) -> None:
|
||||
prompt_path = run_dir / "prompts" / "rows" / f"{state}.md"
|
||||
if not prompt_path.exists():
|
||||
raise SystemExit(f"row prompt not found: {prompt_path}")
|
||||
existing = prompt_path.read_text(encoding="utf-8")
|
||||
note = f"""
|
||||
|
||||
Repair attempt {attempt}:
|
||||
- The previous `{state}` strip failed QA: {reason}
|
||||
- Regenerate the entire row, not just one pose.
|
||||
- Fill every requested frame slot with one complete centered full-body pet pose.
|
||||
- Keep large gaps of pure chroma key only between slots; do not leave a requested slot empty.
|
||||
- Avoid pose overlap, clipping, edge slivers, extra partial sprites, and detached fragments from neighboring poses.
|
||||
- Use the canonical base image and any original references listed in `imagegen-jobs.json` as grounding inputs.
|
||||
- Do not redesign the pet. Keep the exact same head shape, face design, markings, body proportions, palette, outline weight, materials, and props as the approved base pet.
|
||||
- If the contact sheet shows identity drift, repair only this row while preserving the canonical base identity.
|
||||
"""
|
||||
prompt_path.write_text(existing.rstrip() + note.rstrip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def next_archive_path(archive_dir: Path, state: str, attempt: int, suffix: str) -> Path:
|
||||
candidate = archive_dir / f"{state}-attempt-{attempt}-previous{suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter = 2
|
||||
while True:
|
||||
candidate = archive_dir / f"{state}-attempt-{attempt}-previous-{counter}{suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter += 1
|
||||
|
||||
|
||||
def archive_decoded_output(run_dir: Path, job: dict[str, object], state: str, attempt: int) -> str | None:
|
||||
output_raw = job.get("output_path")
|
||||
output = (
|
||||
run_dir / output_raw
|
||||
if isinstance(output_raw, str) and output_raw
|
||||
else run_dir / "decoded" / f"{state}.png"
|
||||
)
|
||||
if not output.exists():
|
||||
return None
|
||||
archive_dir = run_dir / "decoded" / "repair-archive"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archived = next_archive_path(archive_dir, state, attempt, output.suffix or ".png")
|
||||
shutil.move(str(output), archived)
|
||||
return str(archived.relative_to(run_dir))
|
||||
|
||||
|
||||
def queue_repair(manifest: dict[str, object], run_dir: Path, state: str, reason: str) -> dict[str, object]:
|
||||
for job in job_list(manifest):
|
||||
if job.get("id") != state:
|
||||
continue
|
||||
attempt = int(job.get("repair_attempt", 0)) + 1
|
||||
archived_output = archive_decoded_output(run_dir, job, state, attempt)
|
||||
job["status"] = "pending"
|
||||
job["repair_attempt"] = attempt
|
||||
job["repair_reason"] = reason
|
||||
job["queued_at"] = datetime.now(timezone.utc).isoformat()
|
||||
if archived_output is not None:
|
||||
previous_outputs = job.setdefault("previous_outputs", [])
|
||||
if not isinstance(previous_outputs, list):
|
||||
previous_outputs = []
|
||||
job["previous_outputs"] = previous_outputs
|
||||
previous_outputs.append(
|
||||
{
|
||||
"attempt": attempt,
|
||||
"path": archived_output,
|
||||
"archived_at": job["queued_at"],
|
||||
}
|
||||
)
|
||||
for key in [
|
||||
"source_path",
|
||||
"source_provenance",
|
||||
"source_sha256",
|
||||
"output_sha256",
|
||||
"completed_at",
|
||||
"metadata",
|
||||
"synthetic_test_source",
|
||||
"secondary_fallback",
|
||||
"derived_from",
|
||||
"mirror_decision",
|
||||
]:
|
||||
job.pop(key, None)
|
||||
result: dict[str, object] = {"attempt": attempt}
|
||||
if archived_output is not None:
|
||||
result["archived_output"] = archived_output
|
||||
return result
|
||||
raise SystemExit(f"unknown row job id: {state}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--review", default="")
|
||||
parser.add_argument("--repair-on-warnings", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
review_path = (
|
||||
Path(args.review).expanduser().resolve()
|
||||
if args.review
|
||||
else run_dir / "qa" / "review.json"
|
||||
)
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
review = load_json(review_path)
|
||||
manifest = load_json(manifest_path)
|
||||
|
||||
repairs = rows_to_repair(review, repair_on_warnings=args.repair_on_warnings)
|
||||
queued: list[dict[str, object]] = []
|
||||
for repair in repairs:
|
||||
state = str(repair["state"])
|
||||
reason = str(repair["reason"])
|
||||
queued_repair = queue_repair(manifest, run_dir, state, reason)
|
||||
attempt = int(queued_repair["attempt"])
|
||||
append_repair_note(run_dir, state, attempt, reason)
|
||||
queued.append({"state": state, "reason": reason, **queued_repair})
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"ok": True, "queued": queued}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,250 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record a selected $imagegen output for a Codex pet generation job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CANONICAL_BASE_PATH = "references/canonical-base.png"
|
||||
|
||||
|
||||
def load_jobs(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]:
|
||||
for job in job_list(manifest):
|
||||
if job.get("id") == job_id:
|
||||
return job
|
||||
raise SystemExit(f"unknown job id: {job_id}")
|
||||
|
||||
|
||||
def image_metadata(path: Path) -> dict[str, object]:
|
||||
with Image.open(path) as image:
|
||||
image.verify()
|
||||
with Image.open(path) as image:
|
||||
return {
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"mode": image.mode,
|
||||
"format": image.format,
|
||||
}
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def manifest_relative(path: Path, run_dir: Path) -> str:
|
||||
return str(path.resolve().relative_to(run_dir.resolve()))
|
||||
|
||||
|
||||
def completed_job_ids(manifest: dict[str, object]) -> set[str]:
|
||||
return {
|
||||
str(job["id"])
|
||||
for job in job_list(manifest)
|
||||
if job.get("status") == "complete" and isinstance(job.get("id"), str)
|
||||
}
|
||||
|
||||
|
||||
def is_relative_to(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def default_generated_images_root() -> Path:
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
|
||||
return codex_home / "generated_images"
|
||||
|
||||
|
||||
def validate_source_path(
|
||||
*,
|
||||
source: Path,
|
||||
run_dir: Path,
|
||||
allow_synthetic_test_source: bool,
|
||||
) -> str:
|
||||
if allow_synthetic_test_source:
|
||||
return "synthetic-test"
|
||||
if is_relative_to(source, run_dir):
|
||||
raise SystemExit(
|
||||
"source image is inside the pet run directory; record the original "
|
||||
"$imagegen output from $CODEX_HOME/generated_images/.../ig_*.png instead"
|
||||
)
|
||||
generated_root = default_generated_images_root()
|
||||
if not is_relative_to(source, generated_root) or not source.name.startswith("ig_"):
|
||||
raise SystemExit(
|
||||
"source image does not look like a built-in $imagegen output; expected "
|
||||
f"{generated_root}/.../ig_*.png. Do not ingest locally drawn or "
|
||||
"post-processed row strips as visual job outputs."
|
||||
)
|
||||
return "built-in-imagegen"
|
||||
|
||||
|
||||
def validate_required_grounding(job: dict[str, object], run_dir: Path) -> None:
|
||||
if job.get("allow_prompt_only_generation") is not False:
|
||||
return
|
||||
inputs = job.get("input_images")
|
||||
if not isinstance(inputs, list) or not inputs:
|
||||
raise SystemExit(
|
||||
f"job {job.get('id')} does not list input_images; grounded row jobs must attach references"
|
||||
)
|
||||
missing = []
|
||||
for item in inputs:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||||
raise SystemExit(f"job {job.get('id')} has an invalid input image entry")
|
||||
path = run_dir / item["path"]
|
||||
if not path.is_file():
|
||||
missing.append(str(path))
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
f"job {job.get('id')} is missing required grounding image(s): "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def update_base_canonical_reference(
|
||||
*,
|
||||
run_dir: Path,
|
||||
output: Path,
|
||||
manifest: dict[str, object],
|
||||
job: dict[str, object],
|
||||
metadata: dict[str, object],
|
||||
) -> None:
|
||||
if job.get("id") != "base":
|
||||
return
|
||||
|
||||
canonical = run_dir / CANONICAL_BASE_PATH
|
||||
canonical.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(output, canonical)
|
||||
canonical_sha = file_sha256(canonical)
|
||||
reference = {
|
||||
"path": manifest_relative(canonical, run_dir),
|
||||
"source_job": "base",
|
||||
"sha256": canonical_sha,
|
||||
"metadata": metadata,
|
||||
}
|
||||
job["canonical_reference_path"] = reference["path"]
|
||||
manifest["canonical_identity_reference"] = reference
|
||||
|
||||
request_path = run_dir / "pet_request.json"
|
||||
if request_path.exists():
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
request["canonical_identity_reference"] = reference
|
||||
request_path.write_text(json.dumps(request, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--job-id", required=True)
|
||||
parser.add_argument("--source", required=True)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument(
|
||||
"--allow-synthetic-test-source", action="store_true", help=argparse.SUPPRESS
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"source image not found: {source}")
|
||||
source_provenance = validate_source_path(
|
||||
source=source,
|
||||
run_dir=run_dir,
|
||||
allow_synthetic_test_source=args.allow_synthetic_test_source,
|
||||
)
|
||||
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_jobs(manifest_path)
|
||||
job = find_job(manifest, args.job_id)
|
||||
|
||||
missing_deps = [
|
||||
dep
|
||||
for dep in job.get("depends_on", [])
|
||||
if isinstance(dep, str) and dep not in completed_job_ids(manifest)
|
||||
]
|
||||
if missing_deps:
|
||||
raise SystemExit(
|
||||
f"job {args.job_id} is not ready; missing dependency result(s): {', '.join(missing_deps)}"
|
||||
)
|
||||
validate_required_grounding(job, run_dir)
|
||||
|
||||
output_raw = job.get("output_path")
|
||||
if not isinstance(output_raw, str):
|
||||
raise SystemExit(f"job {args.job_id} has no output_path")
|
||||
output = run_dir / output_raw
|
||||
if output.exists() and not args.force:
|
||||
raise SystemExit(f"{output} already exists; pass --force to replace it")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, output)
|
||||
metadata = image_metadata(output)
|
||||
|
||||
job["status"] = "complete"
|
||||
job["source_path"] = str(source)
|
||||
job["source_provenance"] = source_provenance
|
||||
job["source_sha256"] = file_sha256(source)
|
||||
job["output_sha256"] = file_sha256(output)
|
||||
if source_provenance == "synthetic-test":
|
||||
job["synthetic_test_source"] = True
|
||||
else:
|
||||
job.pop("synthetic_test_source", None)
|
||||
job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
job["metadata"] = metadata
|
||||
for key in [
|
||||
"last_error",
|
||||
"secondary_fallback",
|
||||
"derived_from",
|
||||
"mirror_decision",
|
||||
"repair_reason",
|
||||
"queued_at",
|
||||
]:
|
||||
job.pop(key, None)
|
||||
update_base_canonical_reference(
|
||||
run_dir=run_dir,
|
||||
output=output,
|
||||
manifest=manifest,
|
||||
job=job,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"job_id": args.job_id,
|
||||
"output": str(output),
|
||||
"metadata": metadata,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/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()
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render Codex pet state videos from an atlas using ffmpeg."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
STATES = {
|
||||
"idle": (0, [280, 110, 110, 140, 140, 320]),
|
||||
"running-right": (1, [120, 120, 120, 120, 120, 120, 120, 220]),
|
||||
"running-left": (2, [120, 120, 120, 120, 120, 120, 120, 220]),
|
||||
"waving": (3, [140, 140, 140, 280]),
|
||||
"jumping": (4, [140, 140, 140, 140, 280]),
|
||||
"failed": (5, [140, 140, 140, 140, 140, 140, 140, 240]),
|
||||
"waiting": (6, [150, 150, 150, 150, 150, 260]),
|
||||
"running": (7, [120, 120, 120, 120, 120, 220]),
|
||||
"review": (8, [150, 150, 150, 150, 150, 280]),
|
||||
}
|
||||
|
||||
|
||||
def checker(size: tuple[int, int], square: int = 16) -> Image.Image:
|
||||
image = Image.new("RGB", size, "#ffffff")
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, size[1], square):
|
||||
for x in range(0, size[0], square):
|
||||
if (x // square + y // square) % 2:
|
||||
draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8")
|
||||
return image
|
||||
|
||||
|
||||
def shell_quote_for_concat(path: Path) -> str:
|
||||
return "'" + str(path).replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def render_state(
|
||||
atlas: Image.Image,
|
||||
state: str,
|
||||
row: int,
|
||||
durations: list[int],
|
||||
output_dir: Path,
|
||||
loops: int,
|
||||
scale: int,
|
||||
ffmpeg: str,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix=f"codex-pet-{state}-") as temp_raw:
|
||||
temp = Path(temp_raw)
|
||||
frame_paths: list[Path] = []
|
||||
for column in range(len(durations)):
|
||||
crop = atlas.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
row * CELL_HEIGHT,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
(row + 1) * CELL_HEIGHT,
|
||||
)
|
||||
).convert("RGBA")
|
||||
bg = checker((CELL_WIDTH, CELL_HEIGHT))
|
||||
bg.paste(crop, (0, 0), crop)
|
||||
frame_path = temp / f"{state}-{column:02d}.png"
|
||||
bg.save(frame_path)
|
||||
frame_paths.append(frame_path)
|
||||
|
||||
concat_path = temp / f"{state}.ffconcat"
|
||||
lines = ["ffconcat version 1.0"]
|
||||
sequence: list[tuple[Path, int]] = []
|
||||
for _ in range(loops):
|
||||
sequence.extend(zip(frame_paths, durations, strict=True))
|
||||
for frame_path, duration_ms in sequence:
|
||||
lines.append(f"file {shell_quote_for_concat(frame_path)}")
|
||||
lines.append(f"duration {duration_ms / 1000:.3f}")
|
||||
lines.append(f"file {shell_quote_for_concat(sequence[-1][0])}")
|
||||
concat_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
output = output_dir / f"{state}.mp4"
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_path),
|
||||
"-vf",
|
||||
f"scale={CELL_WIDTH * scale}:{CELL_HEIGHT * scale}:flags=lanczos,format=yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--loops", type=int, default=4)
|
||||
parser.add_argument("--scale", type=int, default=2)
|
||||
parser.add_argument("--ffmpeg", default=shutil.which("ffmpeg") or "ffmpeg")
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
|
||||
for state, (row, durations) in STATES.items():
|
||||
render_state(
|
||||
atlas,
|
||||
state,
|
||||
row,
|
||||
durations,
|
||||
output_dir,
|
||||
args.loops,
|
||||
args.scale,
|
||||
args.ffmpeg,
|
||||
)
|
||||
print(f"wrote videos to {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
python3 "$SCRIPT_DIR/render_animation_videos.py" "$@"
|
||||
@@ -34,6 +34,17 @@ def alpha_nonzero_count(image: Image.Image) -> int:
|
||||
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")
|
||||
@@ -114,6 +125,12 @@ def main() -> None:
|
||||
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),
|
||||
@@ -121,6 +138,7 @@ def main() -> None:
|
||||
"mode": source_mode,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"transparent_rgb_residue_pixels": transparent_rgb_residue,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"cells": cells,
|
||||
|
||||
Reference in New Issue
Block a user