Add Pet Creator skill

This commit is contained in:
Guinness Chen
2026-04-06 12:39:56 -07:00
parent 736f600bf6
commit 0f2a6efa94
13 changed files with 1227 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf of
any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don\'t include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+216
View File
@@ -0,0 +1,216 @@
---
name: pet-creator
description: Create or edit Codex desktop pet packs that install cleanly, preview immediately, preserve required runtime semantics, and embody Codex as a charming animated desktop companion rather than a dry status indicator.
shortDescription: Create or edit Codex pet packs
---
# Pet Creator
Use this skill when the user wants to create, customize, or revise a Codex desktop pet.
Your job is to produce a valid pet pack draft that the desktop app can preview immediately.
Your deeper job is to create a pet that **embodies Codex as a living character**. A great pet should make Codex feel warmer, cuter, more expressive, and more companion-like rather than dry, mechanical, or purely utilitarian.
Animation is a core requirement, not an optional polish pass. The pet must feel alive in every runtime state.
A technically valid pack is not enough. If the pack feels emotionally flat, generic, or soulless, it is not done yet.
## Product Intent
The pet is not merely a decorative sticker or a functional status indicator. It is the emotional embodiment of Codex in the desktop app.
A strong pet pack should:
- make Codex feel warm, alive, charming, and companion-like
- give users something they can feel affection for
- communicate runtime state clearly without losing personality
- help shift perception of Codex away from cold or dry utility and toward a more lovable, memorable product identity
The four runtime states are not just system states. They are moments in a character performance. The pet should feel like the same recognizable little being across all four states, with a clear emotional arc.
If the output is valid but feels like generic loading art, abstract motion graphics, or unrelated sprite strips rather than a living companion, revise it.
## What You Must Produce
Every installed pack must end up with this shape:
- `manifest.json`
- `thumbnail.png`
- `states/idle.png`
- `states/working.png`
- `states/needsUserInput.png`
- `states/ready.png`
Supported runtime state asset format:
- transparent PNG sprite strips for all four states
Do not default to static state art. Every state, including `idle`, must have at least two frames. `idle` must be the calmest loop, but it still needs visible motion so the pet feels expressive and alive.
Draft packs must live under the OS temp directory, for example `/tmp/codex-pets/<pack-id>/`, so the OS can clean them up automatically.
This is a hard requirement for preview and install. Do not use a workspace path, repo path, or home-directory path for the draft pack, and do not emit a preview directive with a non-`/tmp` `packPath`. If you created the files somewhere else first, copy them into `/tmp/codex-pets/<pack-id>/`, validate that `/tmp` copy, and use that `/tmp` path in the directive.
## Character and Quality Bar
The pet should read as **one coherent character**, not four loosely related assets.
Aim for:
- a strong, recognizable silhouette
- one consistent visual language across all states
- one stable temperament or personality
- immediately readable emotional changes across states
- motion that feels characterful, not merely decorative
- charm and delight at small desktop sizes
The pet should feel expressive even without text or explanation.
Good packs feel like:
- “this is my little Codex”
- “I can tell what it is feeling and doing”
- “this has personality and presence”
Avoid:
- sterile or generic motion
- abstract loader-like animation with no character
- four states that feel unrelated
- overcomplicated detail that becomes unreadable at small sizes
- harsh, noisy, frantic, or annoying attention cues
- static or nearly static idle loops
## Required State Semantics
The user can steer the overall style freely. Do not force a particular aesthetic.
You must preserve the meaning of the four states:
- `idle`: least motion, calm/default pose, but still animated
- `working`: purposeful motion, more active than idle
- `ready`: positive completion cue, more energy than working
- `needsUserInput`: highest-attention state, most noticeable
These states should form a clear expressive ladder:
1. `idle` = present, calm, alive, endearing
2. `working` = focused, purposeful, engaged
3. `ready` = satisfied, rewarding, celebratory
4. `needsUserInput` = noticeable, attention-seeking, urgent in a charming way
If the user asks for something that would blur those roles, keep the style request but preserve the state ordering above.
`needsUserInput` should be the most noticeable state, but it should still feel like the same lovable character rather than an alarm.
`ready` should feel rewarding and pleasant, like a tiny moment of success.
## Manifest Guidance
Keep the manifest tight and valid. Do not invent extra runtime fields unless they are needed.
For a new pack:
- set `revision` to `1`
- write `renderWidthPx` and `renderHeightPx` to match the animation dimensions
- do not cap the pet to a square size unless the design genuinely wants it
For an edit:
- preserve the existing `id` unless the user explicitly asks for a variant
- increment the existing installed pack's `revision` by `1`
## Workflow
1. Determine whether this is a new pack or an edit to an existing pack.
2. If this is a new pack, first decide on a clear character concept that can support all four runtime states while remaining visually coherent and emotionally expressive.
3. For a new pack, create a draft directory under `/tmp`. The draft directory itself must be under `/tmp`, not in the current workspace.
4. For an edit, inspect the existing installed pack first and keep the same `id` unless the user explicitly asks for a variant.
5. For a new pack, set `revision` to `1` in `manifest.json`. For an edit, increment the existing installed pack's `revision` by `1` in the draft manifest.
6. Use the available image generation flow, such as `$imagegen`, to create a sprite-sheet concept image. Ask for four rows in this exact order: `idle`, `working`, `needsUserInput`, `ready`. Each row should contain the same number of sequential animation frames on a plain, easily removable background.
7. Run the normalizer from this skill directory to build a transparent PNG sprite-strip pack:
```bash
python3 scripts/normalize-pet-sprite-pack.py /path/to/source-sprite-sheet.png /tmp/codex-pets/your-pack-id --pack-id your-pack-id --name "Your Pack Name"
```
8. If the normalizer reports the wrong row/frame counts or leaves background artifacts, regenerate the source sheet or tune `--component-threshold`, `--large-component-area`, `--row-tolerance`, or crop margins. Do not hand-install unnormalized generated images.
9. Before validating, do a quality check:
- Are these clearly the same character in all four states?
- Is `idle` visibly alive, not a duplicate static frame?
- Does each state read immediately at small size?
- Does the pack feel charming and emotionally legible, not merely compliant?
- Does `ready` feel rewarding?
- Does `needsUserInput` attract attention without becoming unpleasant?
If not, revise before validating.
10. Validate the `/tmp` draft, but do not install it yourself:
```bash
node ./scripts/install-pet-pack.mjs --validate-only /tmp/codex-pets/your-pack-id
```
11. After validation succeeds, verify the directive `packPath` starts with `/tmp/`, then:
- briefly explain the character concept
- briefly explain the four states you created and the emotional progression between them
- emit exactly one preview directive for the draft you just created or edited:
```md
::pet-pack-preview{packId="your-pack-id" name="Your Pack Name" packPath="/tmp/codex-pets/your-pack-id" revision="1" initialState="idle"}
```
12. After the preview directive, tell the user:
- click `Install pet` or `Update pet` in the card to save it into Codex
- run `/pet` in any thread to pop out a pet
- right click a popped-out pet to change the pet for that window
## Editing Existing Packs
When the user wants to revise a pet:
- inspect the installed pack under `~/.codex/pets/packs/<pack-id>/`
- write the revised draft under `/tmp`, not in the workspace and not directly into the installed pack directory
- preserve the `id`
- increment `revision` by `1`
- update the changed sprite strips, keeping all four states animated
- even when revising, `idle` must remain a multi-frame animation unless the user explicitly asks to experiment with breaking the runtime contract
- preserve the core character identity unless the user explicitly wants a redesign
- re-run the installer script in `--validate-only` mode against the revised draft directory
When editing, avoid accidental drift where the revised pack becomes a different character. Update what changed while preserving recognizability.
## Creative Guidance
Favor pets that feel:
- cute
- expressive
- memorable
- readable at a glance
- emotionally clear
- alive even in still moments
The user can choose any style, but the resulting character should still feel like a desktop companion users would enjoy keeping around.
When in doubt, prioritize:
1. coherent character identity
2. state readability
3. charm
4. animation polish
5. ornamental detail
## References
- Pack format: [`references/pet-pack-format.md`](./references/pet-pack-format.md)
- State semantics: [`references/state-semantics.md`](./references/state-semantics.md)
@@ -0,0 +1,4 @@
interface:
display_name: "Pet Creator"
short_description: "Create animated Codex desktop pets"
default_prompt: "Use $pet-creator to create or revise an animated Codex desktop pet pack."
@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"id": "template-pet",
"name": "Template Pet",
"revision": 1,
"renderWidthPx": 96,
"renderHeightPx": 96,
"thumbnail": "thumbnail.png",
"states": {
"idle": {
"path": "states/idle.png",
"frameCount": 4,
"frameDurationMs": 240
},
"working": {
"path": "states/working.png",
"frameCount": 4,
"frameDurationMs": 160
},
"needsUserInput": {
"path": "states/needsUserInput.png",
"frameCount": 4,
"frameDurationMs": 120
},
"ready": {
"path": "states/ready.png",
"frameCount": 4,
"frameDurationMs": 160
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

@@ -0,0 +1,70 @@
# Pet Pack Format
Runtime pack layout:
- `manifest.json`
- `thumbnail.png`
- `states/idle.png`
- `states/working.png`
- `states/needsUserInput.png`
- `states/ready.png`
Preferred runtime layout:
- transparent PNG thumbnail
- transparent PNG sprite strips for all four states
- each sprite strip is one horizontal row of frames
`manifest.json` schema:
```json
{
"schemaVersion": 1,
"id": "kebab-case-pack-id",
"name": "Display Name",
"revision": 1,
"renderWidthPx": 96,
"renderHeightPx": 96,
"thumbnail": "thumbnail.png",
"states": {
"idle": {
"path": "states/idle.png",
"frameCount": 4,
"frameDurationMs": 240
},
"working": {
"path": "states/working.png",
"frameCount": 4,
"frameDurationMs": 160
},
"needsUserInput": {
"path": "states/needsUserInput.png",
"frameCount": 4,
"frameDurationMs": 120
},
"ready": {
"path": "states/ready.png",
"frameCount": 4,
"frameDurationMs": 160
}
}
}
```
Rules:
- `schemaVersion` must be `1`
- `id` must be lowercase kebab-case
- `revision` must be a positive integer
- packs must set `renderWidthPx` and `renderHeightPx` to the animation size
- all asset paths must stay inside the pack directory
- all state files must exist
- thumbnails and state assets must be PNG files
- each state file must be a horizontal transparent sprite strip
- each state file width must equal `renderWidthPx * frameCount`
- each state file height must equal `renderHeightPx`
- all four states must have at least two frames, including `idle`
- for edits, increment `revision` in the draft before emitting the preview directive
- transparent backgrounds are required
The desktop app reads the manifest, loads the state assets, and swaps between them based on thread state.
@@ -0,0 +1,33 @@
# State Semantics
Keep the overall character style consistent across all four states. Change pose, expression, or motion intensity, not the identity of the character.
State expectations:
- `idle`
- calm default pose
- least visual noise
- smallest loop
- must still be animated; never ship idle as a one-frame sticker
- if idle frames are duplicates or effectively static, the draft is not acceptable
- `working`
- purposeful motion
- visibly active but not frantic
- should read as "busy"
- `ready`
- clear success/completion cue
- more energy than working
- should read as "done"
- `needsUserInput`
- highest attention state
- strongest pose or motion
- should read as "look at me"
When revising a pack, do not let `idle` become louder than `working`, and do not let `needsUserInput` become subtler than `ready`.
The relative motion ordering matters:
- `idle` is the calmest loop, not a frozen frame
- `working` should move more than `idle`
- `ready` should feel more energized than `working`
- `needsUserInput` should be the most attention-grabbing loop
@@ -0,0 +1,215 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const PACK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const PNG_EXTENSION = ".png";
const REQUIRED_STATES = ["idle", "working", "needsUserInput", "ready"];
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
async function main() {
const args = process.argv.slice(2);
const validateOnly = args[0] === "--validate-only";
const sourceDir = validateOnly ? args[1] : args[0];
if (!sourceDir) {
throw new Error(
"Usage: node install-pet-pack.mjs [--validate-only] /absolute/path/to/pack",
);
}
const resolvedSourceDir = path.resolve(sourceDir);
const manifest = await readManifest(resolvedSourceDir);
validateManifest(manifest);
const resolvedThumbnailPath = resolvePackPath(
resolvedSourceDir,
manifest.thumbnail,
);
const thumbnailStats = await fs.stat(resolvedThumbnailPath);
if (!thumbnailStats.isFile()) {
throw new Error(`Asset is not a file: ${manifest.thumbnail}`);
}
if (path.extname(resolvedThumbnailPath).toLowerCase() !== PNG_EXTENSION) {
throw new Error(`Thumbnail must be a PNG file: ${manifest.thumbnail}`);
}
await readPngSize(resolvedThumbnailPath);
await Promise.all(
REQUIRED_STATES.map(async (state) => {
const stateConfig = manifest.states[state];
const relativeAssetPath = stateConfig.path;
const resolvedAssetPath = resolvePackPath(
resolvedSourceDir,
relativeAssetPath,
);
const stats = await fs.stat(resolvedAssetPath);
if (!stats.isFile()) {
throw new Error(`Asset is not a file: ${relativeAssetPath}`);
}
if (path.extname(resolvedAssetPath).toLowerCase() !== PNG_EXTENSION) {
throw new Error(
`State asset must be a PNG sprite strip: ${relativeAssetPath}`,
);
}
const size = await readPngSize(resolvedAssetPath);
if (size.width !== manifest.renderWidthPx * stateConfig.frameCount) {
throw new Error(
`State sprite width must equal renderWidthPx * frameCount: ${relativeAssetPath}`,
);
}
if (size.height !== manifest.renderHeightPx) {
throw new Error(
`State sprite height must equal renderHeightPx: ${relativeAssetPath}`,
);
}
}),
);
if (validateOnly) {
process.stdout.write(
JSON.stringify(
{
packId: manifest.id,
name: manifest.name,
revision: manifest.revision ?? 1,
draftPath: resolvedSourceDir,
valid: true,
},
null,
2,
),
);
return;
}
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const destinationRoot = path.join(codexHome, "pets", "packs");
const destinationDir = path.join(destinationRoot, manifest.id);
await fs.mkdir(destinationRoot, { recursive: true });
await fs.rm(destinationDir, { recursive: true, force: true });
await fs.cp(resolvedSourceDir, destinationDir, { recursive: true });
process.stdout.write(
JSON.stringify(
{
packId: manifest.id,
name: manifest.name,
revision: manifest.revision ?? 1,
installedPath: destinationDir,
},
null,
2,
),
);
}
async function readManifest(sourceDir) {
const manifestPath = path.join(sourceDir, "manifest.json");
const raw = await fs.readFile(manifestPath, "utf8");
return JSON.parse(raw);
}
function validateManifest(manifest) {
if (manifest?.schemaVersion !== 1) {
throw new Error("manifest.json must set schemaVersion to 1");
}
if (typeof manifest.id !== "string" || !PACK_ID_PATTERN.test(manifest.id)) {
throw new Error("manifest.json id must be lowercase kebab-case");
}
if (typeof manifest.name !== "string" || manifest.name.trim() === "") {
throw new Error("manifest.json name is required");
}
if (
manifest.revision != null &&
(typeof manifest.revision !== "number" ||
!Number.isInteger(manifest.revision) ||
manifest.revision < 1)
) {
throw new Error("manifest.json revision must be a positive integer");
}
const hasRenderDimensions =
typeof manifest.renderWidthPx === "number" &&
Number.isInteger(manifest.renderWidthPx) &&
manifest.renderWidthPx > 0 &&
typeof manifest.renderHeightPx === "number" &&
Number.isInteger(manifest.renderHeightPx) &&
manifest.renderHeightPx > 0;
if (!hasRenderDimensions) {
throw new Error(
"manifest.json must set positive integer renderWidthPx and renderHeightPx",
);
}
if (
typeof manifest.thumbnail !== "string" ||
manifest.thumbnail.trim() === ""
) {
throw new Error("manifest.json thumbnail is required");
}
if (typeof manifest.states !== "object" || manifest.states == null) {
throw new Error("manifest.json states is required");
}
for (const state of REQUIRED_STATES) {
const stateConfig = manifest.states[state];
if (typeof stateConfig?.path !== "string") {
throw new Error(`manifest.json states.${state}.path is required`);
}
if (
typeof stateConfig.frameCount !== "number" ||
!Number.isInteger(stateConfig.frameCount) ||
stateConfig.frameCount < 2
) {
throw new Error(
`manifest.json states.${state}.frameCount must be an integer greater than 1`,
);
}
if (
typeof stateConfig.frameDurationMs !== "number" ||
!Number.isInteger(stateConfig.frameDurationMs) ||
stateConfig.frameDurationMs < 1
) {
throw new Error(
`manifest.json states.${state}.frameDurationMs must be a positive integer`,
);
}
}
}
function resolvePackPath(packRoot, relativeAssetPath) {
const resolvedPath = path.resolve(packRoot, relativeAssetPath);
const relativeFromPack = path.relative(packRoot, resolvedPath);
if (
relativeFromPack === "" ||
relativeFromPack.startsWith("..") ||
path.isAbsolute(relativeFromPack)
) {
throw new Error(`Asset path escapes pack root: ${relativeAssetPath}`);
}
return resolvedPath;
}
async function readPngSize(assetPath) {
const handle = await fs.open(assetPath, "r");
try {
const header = Buffer.alloc(24);
await handle.read(header, 0, header.length, 0);
if (!header.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
throw new Error(`Asset is not a PNG file: ${assetPath}`);
}
return {
width: header.readUInt32BE(16),
height: header.readUInt32BE(20),
};
} finally {
await handle.close();
}
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""Build a Codex pet sprite pack from a generated PNG sprite sheet."""
from __future__ import annotations
import argparse
import json
import struct
import zlib
from collections import deque
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
STATES = ("idle", "working", "needsUserInput", "ready")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Extract sprite rows into a Codex pet pack draft.",
)
parser.add_argument("source_image", type=Path)
parser.add_argument("pack_dir", type=Path)
parser.add_argument("--pack-id", required=True)
parser.add_argument("--name", required=True)
parser.add_argument("--revision", type=int, default=1)
parser.add_argument("--row-frame-counts", default="4,4,4,4")
parser.add_argument("--frame-duration-ms", default="240,160,120,160")
parser.add_argument("--cell-width", type=int, default=192)
parser.add_argument("--cell-height", type=int, default=208)
parser.add_argument("--component-threshold", type=int, default=22)
parser.add_argument("--large-component-area", type=int, default=12000)
parser.add_argument("--row-tolerance", type=int, default=120)
parser.add_argument("--crop-margin-x", type=int, default=22)
parser.add_argument("--crop-margin-top", type=int, default=2)
parser.add_argument("--crop-margin-bottom", type=int, default=12)
parser.add_argument("--paste-bottom-margin", type=int, default=4)
parser.add_argument("--alpha-zero", type=int, default=14)
parser.add_argument("--alpha-full", type=int, default=56)
return parser.parse_args()
def read_rgb_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int]]]]:
data = path.read_bytes()
if data[:8] != PNG_SIGNATURE:
raise ValueError(f"{path} is not a PNG file")
width = 0
height = 0
bit_depth = 0
color_type = -1
idat_chunks: list[bytes] = []
offset = 8
while offset < len(data):
length = struct.unpack(">I", data[offset : offset + 4])[0]
chunk_type = data[offset + 4 : offset + 8]
chunk_payload = data[offset + 8 : offset + 8 + length]
offset += 12 + length
if chunk_type == b"IHDR":
width, height, bit_depth, color_type, _, _, _ = struct.unpack(
">IIBBBBB",
chunk_payload,
)
elif chunk_type == b"IDAT":
idat_chunks.append(chunk_payload)
elif chunk_type == b"IEND":
break
if bit_depth != 8 or color_type not in (2, 6):
raise ValueError(
f"{path} must be an 8-bit RGB/RGBA PNG, got {(bit_depth, color_type)}",
)
bytes_per_pixel = 4 if color_type == 6 else 3
stride = width * bytes_per_pixel
raw = zlib.decompress(b"".join(idat_chunks))
previous_row = [0] * stride
source_offset = 0
rows: list[list[int]] = []
for _ in range(height):
filter_type = raw[source_offset]
source_offset += 1
row = list(raw[source_offset : source_offset + stride])
source_offset += stride
if filter_type == 1:
for index in range(stride):
left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0
row[index] = (row[index] + left) & 255
elif filter_type == 2:
for index in range(stride):
row[index] = (row[index] + previous_row[index]) & 255
elif filter_type == 3:
for index in range(stride):
left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0
up = previous_row[index]
row[index] = (row[index] + ((left + up) >> 1)) & 255
elif filter_type == 4:
for index in range(stride):
left = row[index - bytes_per_pixel] if index >= bytes_per_pixel else 0
up = previous_row[index]
upper_left = (
previous_row[index - bytes_per_pixel]
if index >= bytes_per_pixel
else 0
)
row[index] = (row[index] + paeth_predictor(left, up, upper_left)) & 255
elif filter_type != 0:
raise ValueError(f"Unsupported PNG filter {filter_type}")
rows.append(row)
previous_row = row
return width, height, [
[
tuple(row[x * bytes_per_pixel : x * bytes_per_pixel + 3])
for x in range(width)
]
for row in rows
]
def paeth_predictor(left: int, up: int, upper_left: int) -> int:
estimate = left + up - upper_left
left_distance = abs(estimate - left)
up_distance = abs(estimate - up)
diagonal_distance = abs(estimate - upper_left)
if left_distance <= up_distance and left_distance <= diagonal_distance:
return left
if up_distance <= diagonal_distance:
return up
return upper_left
def write_rgba_png(
path: Path,
width: int,
height: int,
pixels: list[list[tuple[int, int, int, int]]],
) -> None:
raw_rows: list[bytes] = []
for row_pixels in pixels:
raw_row = bytearray([0])
for pixel in row_pixels:
raw_row.extend(pixel)
raw_rows.append(bytes(raw_row))
png = bytearray(PNG_SIGNATURE)
png.extend(
build_png_chunk(
b"IHDR",
struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0),
),
)
png.extend(build_png_chunk(b"IDAT", zlib.compress(b"".join(raw_rows), 9)))
png.extend(build_png_chunk(b"IEND", b""))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(png)
def build_png_chunk(chunk_type: bytes, payload: bytes) -> bytes:
return (
struct.pack(">I", len(payload))
+ chunk_type
+ payload
+ struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF)
)
def color_distance_squared(
pixel: tuple[int, int, int],
background: tuple[int, int, int],
) -> int:
return sum((pixel[channel] - background[channel]) ** 2 for channel in range(3))
def detect_large_components(
pixels: list[list[tuple[int, int, int]]],
background: tuple[int, int, int],
component_threshold: int,
large_component_area: int,
) -> list[tuple[int, int, int, int]]:
height = len(pixels)
width = len(pixels[0])
threshold_sq = component_threshold * component_threshold
mask = [
[color_distance_squared(pixels[y][x], background) > threshold_sq for x in range(width)]
for y in range(height)
]
visited = [[False] * width for _ in range(height)]
components: list[tuple[int, int, int, int]] = []
for y in range(height):
for x in range(width):
if not mask[y][x] or visited[y][x]:
continue
queue = deque([(x, y)])
visited[y][x] = True
area = 0
min_x = max_x = x
min_y = max_y = y
while queue:
cx, cy = queue.popleft()
area += 1
min_x = min(min_x, cx)
max_x = max(max_x, cx)
min_y = min(min_y, cy)
max_y = max(max_y, cy)
for nx, ny in (
(cx + 1, cy),
(cx - 1, cy),
(cx, cy + 1),
(cx, cy - 1),
):
if (
0 <= nx < width
and 0 <= ny < height
and mask[ny][nx]
and not visited[ny][nx]
):
visited[ny][nx] = True
queue.append((nx, ny))
if area >= large_component_area:
components.append((min_x, min_y, max_x, max_y))
return sorted(components, key=lambda box: ((box[1] + box[3]) / 2, (box[0] + box[2]) / 2))
def group_components_by_row(
components: list[tuple[int, int, int, int]],
expected_row_counts: list[int],
row_tolerance: int,
) -> list[list[tuple[int, int, int, int]]]:
rows: list[list[tuple[int, int, int, int]]] = []
centers: list[float] = []
for box in components:
center_y = (box[1] + box[3]) / 2
if rows and abs(centers[-1] - center_y) < row_tolerance:
rows[-1].append(box)
centers[-1] = sum((row_box[1] + row_box[3]) / 2 for row_box in rows[-1]) / len(rows[-1])
else:
rows.append([box])
centers.append(center_y)
row_counts = [len(row) for row in rows]
if row_counts != expected_row_counts:
raise ValueError(
f"Detected row counts {row_counts}, expected {expected_row_counts}. "
"Tune thresholds/crop margins for this source sheet.",
)
return [sorted(row, key=lambda box: (box[0] + box[2]) / 2) for row in rows]
def extract_frame_pixels(
pixels: list[list[tuple[int, int, int]]],
background: tuple[int, int, int],
box: tuple[int, int, int, int],
crop_margin_x: int,
crop_margin_top: int,
crop_margin_bottom: int,
alpha_zero: int,
alpha_full: int,
) -> list[list[tuple[int, int, int, int]]]:
source_height = len(pixels)
source_width = len(pixels[0])
min_x, min_y, max_x, max_y = box
crop_left = max(0, min_x - crop_margin_x)
crop_top = max(0, min_y - crop_margin_top)
crop_right = min(source_width - 1, max_x + crop_margin_x)
crop_bottom = min(source_height - 1, max_y + crop_margin_bottom)
frame: list[list[tuple[int, int, int, int]]] = []
for source_y in range(crop_top, crop_bottom + 1):
frame_row: list[tuple[int, int, int, int]] = []
for source_x in range(crop_left, crop_right + 1):
red, green, blue = pixels[source_y][source_x]
distance_sq = color_distance_squared((red, green, blue), background)
if distance_sq <= alpha_zero * alpha_zero:
frame_row.append((0, 0, 0, 0))
continue
if distance_sq >= alpha_full * alpha_full:
alpha = 255
else:
distance = distance_sq**0.5
alpha = round(
(distance - alpha_zero) * 255 / (alpha_full - alpha_zero),
)
if alpha < 255:
inverse_alpha = 255 - alpha
red = unblend_channel(red, background[0], alpha, inverse_alpha)
green = unblend_channel(green, background[1], alpha, inverse_alpha)
blue = unblend_channel(blue, background[2], alpha, inverse_alpha)
frame_row.append((red, green, blue, alpha))
frame.append(frame_row)
return frame
def unblend_channel(
source_channel: int,
background_channel: int,
alpha: int,
inverse_alpha: int,
) -> int:
return max(
0,
min(
255,
round((source_channel * 255 - background_channel * inverse_alpha) / alpha),
),
)
def resize_frame(
frame: list[list[tuple[int, int, int, int]]],
scale: float,
) -> list[list[tuple[int, int, int, int]]]:
source_height = len(frame)
source_width = len(frame[0])
target_width = max(1, round(source_width * scale))
target_height = max(1, round(source_height * scale))
resized_frame = [[(0, 0, 0, 0)] * target_width for _ in range(target_height)]
for target_y in range(target_height):
source_y = min(source_height - 1, int(target_y / scale))
for target_x in range(target_width):
source_x = min(source_width - 1, int(target_x / scale))
resized_frame[target_y][target_x] = frame[source_y][source_x]
return resized_frame
def build_sprite_strip(
frames: list[list[list[tuple[int, int, int, int]]]],
cell_width: int,
cell_height: int,
paste_bottom_margin: int,
) -> list[list[tuple[int, int, int, int]]]:
max_frame_width = max(len(frame[0]) for frame in frames)
max_frame_height = max(len(frame) for frame in frames)
scale = min(
(cell_width - 16) / max_frame_width,
(cell_height - 10) / max_frame_height,
)
strip = [[(0, 0, 0, 0)] * (len(frames) * cell_width) for _ in range(cell_height)]
for frame_index, frame in enumerate(frames):
resized_frame = resize_frame(frame, scale)
frame_height = len(resized_frame)
frame_width = len(resized_frame[0])
offset_x = frame_index * cell_width + (cell_width - frame_width) // 2
offset_y = cell_height - paste_bottom_margin - frame_height
for frame_y, frame_row in enumerate(resized_frame):
for frame_x, pixel in enumerate(frame_row):
strip[offset_y + frame_y][offset_x + frame_x] = pixel
return strip
def parse_int_list(value: str, label: str) -> list[int]:
values = [int(part) for part in value.split(",") if part]
if len(values) != len(STATES) or any(item <= 0 for item in values):
raise ValueError(f"--{label} must include {len(STATES)} positive integers")
return values
def main() -> None:
args = parse_args()
row_frame_counts = parse_int_list(args.row_frame_counts, "row-frame-counts")
frame_duration_ms = parse_int_list(args.frame_duration_ms, "frame-duration-ms")
_, _, pixels = read_rgb_png(args.source_image)
background = pixels[0][0]
component_rows = group_components_by_row(
detect_large_components(
pixels=pixels,
background=background,
component_threshold=args.component_threshold,
large_component_area=args.large_component_area,
),
expected_row_counts=row_frame_counts,
row_tolerance=args.row_tolerance,
)
frame_rows = [
[
extract_frame_pixels(
pixels=pixels,
background=background,
box=frame_box,
crop_margin_x=args.crop_margin_x,
crop_margin_top=args.crop_margin_top,
crop_margin_bottom=args.crop_margin_bottom,
alpha_zero=args.alpha_zero,
alpha_full=args.alpha_full,
)
for frame_box in component_row
]
for component_row in component_rows
]
states_dir = args.pack_dir / "states"
states_dir.mkdir(parents=True, exist_ok=True)
manifest_states: dict[str, dict[str, int | str]] = {}
for state, frames, duration_ms in zip(STATES, frame_rows, frame_duration_ms):
write_rgba_png(
states_dir / f"{state}.png",
args.cell_width * len(frames),
args.cell_height,
build_sprite_strip(frames, args.cell_width, args.cell_height, args.paste_bottom_margin),
)
manifest_states[state] = {
"path": f"states/{state}.png",
"frameCount": len(frames),
"frameDurationMs": duration_ms,
}
write_rgba_png(
args.pack_dir / "thumbnail.png",
args.cell_width,
args.cell_height,
build_sprite_strip([frame_rows[0][0]], args.cell_width, args.cell_height, args.paste_bottom_margin),
)
manifest = {
"schemaVersion": 1,
"id": args.pack_id,
"name": args.name,
"revision": args.revision,
"renderWidthPx": args.cell_width,
"renderHeightPx": args.cell_height,
"thumbnail": "thumbnail.png",
"states": manifest_states,
}
args.pack_dir.mkdir(parents=True, exist_ok=True)
(args.pack_dir / "manifest.json").write_text(
f"{json.dumps(manifest, indent=2)}\n",
encoding="utf-8",
)
print(json.dumps({"packId": args.pack_id, "packPath": str(args.pack_dir)}, indent=2))
if __name__ == "__main__":
main()