mirror of
https://github.com/openai/skills.git
synced 2026-09-11 19:47:26 +03:00
Add Pet Creator skill
This commit is contained in:
@@ -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.
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user