hanoman 0.9.38 → 0.9.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli.js +3 -1
- package/package.json +2 -1
- package/skills/codex-image/SKILL.md +225 -0
- package/skills/codex-image/references/sprite-sheets.md +60 -0
- package/skills/codex-image/scripts/codex-image.sh +253 -0
- package/skills/codex-image/scripts/normalize-set.py +104 -0
package/dist/build-info.json
CHANGED
package/dist/cli.js
CHANGED
|
@@ -38743,7 +38743,9 @@ function packageJsonFor(version2, deps) {
|
|
|
38743
38743
|
prepublishOnly: "node dist/cli.js __verify"
|
|
38744
38744
|
},
|
|
38745
38745
|
engines: { node: ">=20" },
|
|
38746
|
-
|
|
38746
|
+
// Whitelist ini menang atas isi dist-npm: direktori yang tak disebut dibuang `npm pack` diam-diam
|
|
38747
|
+
// (0.9.38 terbit tanpa `skills/`). Penjaganya di cli/test/pack.test.ts.
|
|
38748
|
+
files: ["bin", "dist", "web", "prisma", "docs", "scripts", "skills", "README.md", "LICENSE"],
|
|
38747
38749
|
dependencies: deps,
|
|
38748
38750
|
// MIT: paket ini didistribusikan publik supaya orang `npm i -g`. `UNLICENSED` berarti "tak ada
|
|
38749
38751
|
// izin pakai" — 0.1.0 terbit dengan kontradiksi itu, dan versi terbit tak bisa diperbaiki.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hanoman",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.39",
|
|
4
4
|
"description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"prisma",
|
|
25
25
|
"docs",
|
|
26
26
|
"scripts",
|
|
27
|
+
"skills",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codex-image
|
|
3
|
+
description: Use when a task needs a raster image that does not exist yet — mascot or character art, illustrations, hero and banner images, sprite sheets, textures, product or UI mockups, concept art, transparent-background cutouts — or when an existing PNG/JPG needs an AI edit (background removal, object swap, style transfer, relighting). Also use when you are about to hand-write SVG, CSS shapes, or an emoji placeholder to stand in for artwork you cannot actually draw, or when asked to "generate an image", "buatkan ilustrasi", "bikin gambar".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Generating images with Codex
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
You cannot render bitmaps. Codex can: its built-in `image_gen` tool (GPT Image) needs no
|
|
11
|
+
`OPENAI_API_KEY` — it rides the user's ChatGPT login. So image generation is **delegated**,
|
|
12
|
+
not simulated.
|
|
13
|
+
|
|
14
|
+
**Core principle: a request for artwork is a request for pixels.** A hand-written SVG of a
|
|
15
|
+
fox is not a fox illustration. If the deliverable is a picture, spawn Codex and return a
|
|
16
|
+
verified PNG file path.
|
|
17
|
+
|
|
18
|
+
## Pick the engine first
|
|
19
|
+
|
|
20
|
+
This skill ships with hanoman (bundled in the npm package, seeded into
|
|
21
|
+
`$HANOMAN_HOME/skills/codex-image`, injected into every agent session). Which engine you use
|
|
22
|
+
depends on the runtime you are:
|
|
23
|
+
|
|
24
|
+
| You are | Engine | How |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| **Codex** (you have the built-in `image_gen` tool) | `image_gen` directly, via Codex's own `imagegen` skill | Do **not** shell out to `codex exec` from inside Codex. Generate, then copy the chosen file from `$CODEX_HOME/generated_images/...` (default `~/.codex/generated_images/...`) to the destination path. |
|
|
27
|
+
| **Anything else** (Claude Code, Antigravity, other CLIs) | `scripts/codex-image.sh` (spawns `codex exec`) | Everything below. Needs the `codex` CLI installed and logged in (`codex login`) on this machine. |
|
|
28
|
+
|
|
29
|
+
The spec format, sizes, verification rules and the "no substitute" rule below apply to both.
|
|
30
|
+
|
|
31
|
+
## When to use
|
|
32
|
+
|
|
33
|
+
Delegate to Codex when the deliverable is a **bitmap that has to be drawn**:
|
|
34
|
+
|
|
35
|
+
- mascots, characters, avatars, sprite sheets
|
|
36
|
+
- illustrations, hero/banner/OG images, backgrounds, textures
|
|
37
|
+
- photorealistic product shots, packaging and merch mockups
|
|
38
|
+
- concept art, storyboards, book/comic panels
|
|
39
|
+
- transparent-background cutouts and object isolation
|
|
40
|
+
- editing an existing raster: remove/replace an object, change lighting or season,
|
|
41
|
+
swap a background, apply a reference style, sketch → render
|
|
42
|
+
|
|
43
|
+
## When NOT to use
|
|
44
|
+
|
|
45
|
+
Reach for code, not Codex, when the output is **structural rather than drawn**:
|
|
46
|
+
|
|
47
|
+
| Task | Do this instead |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Charts, plots, dashboards | Load the `dataviz` skill and write chart code |
|
|
50
|
+
| Flowcharts, architecture diagrams | Inline SVG or mermaid |
|
|
51
|
+
| Icons matching an existing SVG icon set | Edit the vector set directly |
|
|
52
|
+
| Logo systems already defined in vector | Edit the source vector |
|
|
53
|
+
| UI built from real components | Build it in HTML/CSS |
|
|
54
|
+
| Screenshot of something that exists | Run it and capture it |
|
|
55
|
+
|
|
56
|
+
Generated bitmaps cannot be diffed, recolored by token, or scaled cleanly. If the asset
|
|
57
|
+
belongs to a design system, that is a reason to draw it in code.
|
|
58
|
+
|
|
59
|
+
## Quick reference
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
S="${HANOMAN_HOME:-$HOME/.hanoman}/skills/codex-image/scripts/codex-image.sh"
|
|
63
|
+
|
|
64
|
+
# one asset
|
|
65
|
+
"$S" --out assets/mascot.png --size 1024x1024 --transparent --fit <<'SPEC'
|
|
66
|
+
Use case: illustration-story
|
|
67
|
+
Asset type: dashboard empty-state mascot
|
|
68
|
+
Primary request: a small orange fox sitting, waving one paw
|
|
69
|
+
Style/medium: flat vector-style illustration, thick clean outlines
|
|
70
|
+
Composition/framing: full body, centered, generous margin
|
|
71
|
+
Color palette: warm orange, cream, one teal accent
|
|
72
|
+
Constraints: no text, no watermark, no drop shadow
|
|
73
|
+
SPEC
|
|
74
|
+
|
|
75
|
+
# several assets, with references
|
|
76
|
+
"$S" --out-dir assets/icons --name icon --count 4 --ref brand/palette.png <<'SPEC'
|
|
77
|
+
...
|
|
78
|
+
SPEC
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run `"$S" --help` for every flag.
|
|
82
|
+
|
|
83
|
+
**Budget 1–3 minutes per image.** A default shell call times out at two minutes, so run the
|
|
84
|
+
script as a background command and pick it up from the notification — or raise the timeout past
|
|
85
|
+
`--count x 180s`. Three concurrent jobs are safe; use them for independent assets rather than
|
|
86
|
+
raising `--count`, so one bad asset does not cost you the batch.
|
|
87
|
+
|
|
88
|
+
## hanoman `Mockup` phase
|
|
89
|
+
|
|
90
|
+
In hanoman's `Mockup` phase (ADR-0233) the deliverable is a UI mockup that a human approves before
|
|
91
|
+
planning. One image per changed screen or state, at most 6, saved as
|
|
92
|
+
`docs/mockups/<spec-id>/<NN>-<slug>.png`:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
"$S" --out docs/mockups/spec-123/01-backlog-detail.png --size 1536x1024 --fit <<'SPEC'
|
|
96
|
+
Use case: ui-mockup
|
|
97
|
+
Asset type: product UI mockup for design approval (desktop, 1536x1024)
|
|
98
|
+
Primary request: backlog detail screen with a new "Mockup" section showing three thumbnails
|
|
99
|
+
Style/medium: crisp flat product UI screenshot, real-looking components, no device frame
|
|
100
|
+
Composition/framing: full screen, left sidebar, content column, generous whitespace
|
|
101
|
+
Color palette: <the project's design-system tokens, e.g. bone paper #F4EFE6, ink #1F1B16, brass #B08A3E>
|
|
102
|
+
Text (verbatim): "Mockup", "Lihat mockup (3)"
|
|
103
|
+
Constraints: follow the existing layout of the screen; only the new section changes
|
|
104
|
+
Avoid: lorem ipsum, watermark, browser chrome, illegible text
|
|
105
|
+
SPEC
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Add `--size 1024x1536` mobile variants when the change touches a responsive layout. A missing
|
|
109
|
+
`codex` or a failed login is a blocker to report and ask about — never a reason to hand-draw.
|
|
110
|
+
|
|
111
|
+
## Writing the spec
|
|
112
|
+
|
|
113
|
+
Codex's `imagegen` skill expects a labeled spec. Fill only the lines that carry information —
|
|
114
|
+
an unused line is noise, and a vague line ("nice colors") is worse than an absent one.
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
Use case: <photorealistic-natural | product-mockup | ui-mockup | infographic-diagram |
|
|
118
|
+
ads-marketing | logo-brand | illustration-story | stylized-concept |
|
|
119
|
+
background-extraction | precise-object-edit | style-transfer | compositing>
|
|
120
|
+
Asset type: <where this will actually be used>
|
|
121
|
+
Primary request: <the subject, in one sentence>
|
|
122
|
+
Scene/backdrop: <environment>
|
|
123
|
+
Style/medium: <flat illustration | studio photo | 3D render | watercolor | ...>
|
|
124
|
+
Composition/framing: <wide/close/top-down, placement, margins>
|
|
125
|
+
Lighting/mood: <lighting + mood>
|
|
126
|
+
Color palette: <named colors or hex>
|
|
127
|
+
Text (verbatim): "<exact string, spelled out letter by letter if tricky>"
|
|
128
|
+
Constraints: <what must be kept>
|
|
129
|
+
Avoid: <what must not appear>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Editing an existing image:** pass it with `--ref`, set an edit `Use case`, and state the
|
|
133
|
+
invariants explicitly — `change only the background; keep the product, its edges and its
|
|
134
|
+
colors unchanged`. Repeat those invariants on every retry; they drift otherwise.
|
|
135
|
+
|
|
136
|
+
## Sizes
|
|
137
|
+
|
|
138
|
+
`gpt-image-2` accepts `auto` or `WIDTHxHEIGHT` where both edges are multiples of 16, the
|
|
139
|
+
long edge is ≤ 3840, the ratio is ≤ 3:1, and total pixels fall between 655,360 and 8,294,400.
|
|
140
|
+
|
|
141
|
+
| Size | Use |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `1024x1024` | square default, fastest |
|
|
144
|
+
| `1536x1024` / `1024x1536` | landscape / portrait |
|
|
145
|
+
| `2048x1152` | 2K landscape, hero images |
|
|
146
|
+
| `3840x2160` / `2160x3840` | 4K |
|
|
147
|
+
|
|
148
|
+
`image_gen` reads this as guidance, not a contract, and will return a nearby size. Pass `--fit`
|
|
149
|
+
whenever the layout depends on exact pixels.
|
|
150
|
+
|
|
151
|
+
## Verifying the result
|
|
152
|
+
|
|
153
|
+
The script already does this, and its exit code is the honest signal:
|
|
154
|
+
|
|
155
|
+
| Exit | Meaning | Next step |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| 0 | every requested PNG exists and `file` confirms it | report the paths |
|
|
158
|
+
| 2 | usage error | fix the flags |
|
|
159
|
+
| 3 | Codex turn failed | read the reason; if auth, run `codex login` |
|
|
160
|
+
| 4 | Codex produced no image | retry with a more concrete spec |
|
|
161
|
+
| 5 | timeout | raise `--timeout`, or lower `--count` |
|
|
162
|
+
| 6 | `--transparent` asked, opaque result | the file is kept — look at it, then retry with `no checkerboard` in `Avoid` |
|
|
163
|
+
|
|
164
|
+
Never report an image as delivered without an `OK <path>` line. `ls` the file if you want a
|
|
165
|
+
second opinion — but do not describe what the picture looks like unless you read it with the
|
|
166
|
+
image-reading tool, and say so when you have not looked.
|
|
167
|
+
|
|
168
|
+
## When generation fails
|
|
169
|
+
|
|
170
|
+
**A substitute is not the asset.** If Codex cannot generate the image, deliver **no file** and
|
|
171
|
+
report the blocker. Do not hand-author an SVG, CSS shape, or emoji composite, rasterise it, and
|
|
172
|
+
save it under the filename that was asked for — the caller cannot tell the difference from the
|
|
173
|
+
file, and the next person to touch it inherits a fake.
|
|
174
|
+
|
|
175
|
+
Measured baseline: an agent given a 6-minute budget hit the auth wall, wrote fox-shaped SVG
|
|
176
|
+
paths by hand, rasterised them to `mascot.png`, and shipped it. Its reasoning: *"the 6-minute
|
|
177
|
+
budget doesn't tolerate betting on a single path."*
|
|
178
|
+
|
|
179
|
+
| Rationalization | Reality |
|
|
180
|
+
|---|---|
|
|
181
|
+
| "I'll build a fallback in parallel to be safe" | The fallback becomes the deliverable. Fix the blocker instead; it is usually one `codex login`. |
|
|
182
|
+
| "It's still a real raster PNG with real alpha" | The file format was never the requirement. The artwork was. |
|
|
183
|
+
| "I disclosed it in my report" | The report is thrown away; the file is committed. Filename `mascot.png` claims to be the mascot. |
|
|
184
|
+
| "The deadline doesn't allow a retry" | A missing asset is a known gap. A hand-drawn stand-in is an unknown one. Report the gap. |
|
|
185
|
+
| "Close enough to unblock the layout" | Then name it `mascot.placeholder.svg` and say it is a placeholder — never the requested filename. |
|
|
186
|
+
|
|
187
|
+
Blocked on auth? Say so plainly and give the user the one command: `codex login`.
|
|
188
|
+
|
|
189
|
+
## Common mistakes
|
|
190
|
+
|
|
191
|
+
| Mistake | Reality |
|
|
192
|
+
|---|---|
|
|
193
|
+
| Trusting `codex exec`'s exit code | It exits **0** on a failed turn. Only the JSONL event stream and the file on disk tell the truth — the script checks both. |
|
|
194
|
+
| Trusting `codex login status` | It reports "Logged in using ChatGPT" while the refresh token is revoked. A `turn.failed` mentioning `revoked` / 401 is the real answer: run `codex login`. |
|
|
195
|
+
| Letting Codex post-process | Its imagegen skill will happily run `remove_chroma_key.py --despill`, which **cuts red out of reds** — gold goes olive, red cloth goes dull. The script forbids it; do your own keying downstream. |
|
|
196
|
+
| Asking for "16-bit pixel art" | You get a high-resolution illustration that merely looks pixelated. Real pixels only come from a nearest-neighbour downscale afterwards. |
|
|
197
|
+
| Feeding a `.webp` reference | `--ref` takes PNG/JPG. Convert first: `dwebp in.webp -o out.png`. |
|
|
198
|
+
| Generating into the repo blindly | Raw outputs also land in `~/.codex/generated_images/<thread-id>/`. Put deliverables where the project expects them, and add large raws to `.gitignore`. |
|
|
199
|
+
| One prompt, `n` variants, many assets | Distinct assets need distinct calls. `--count` issues one `image_gen` call per file. |
|
|
200
|
+
| Correcting a retry with just the fix | A partial correction makes the model forget the constraints it already satisfied. Restate the **whole** spec plus the new constraint. |
|
|
201
|
+
| Assuming `--size` is honoured | `image_gen` treats size as a **hint**. Measured: `1024x1024` requested, `1254x1254` returned. The script prints a NOTE on any mismatch; pass `--fit` when the pixel dimensions are a real requirement. |
|
|
202
|
+
| Polling the destination while the job runs | The script copies into place only at the very end. An empty destination mid-run is not a failure — wait for the `OK <path>` line and the exit code. |
|
|
203
|
+
| Combining `--ref` with `--transparent` | A reference image routes the request through the **edit** path, which flattens alpha. Measured on one spec: 0.0% clear pixels with a reference (twice), ~59% without (twice). Anti-checkerboard wording does not rescue it. For a transparent asset, anchor style in **words**, not an attachment — and expect line weight to drift, so state it numerically ("uniform charcoal contour ~8px at 1024"). |
|
|
204
|
+
| Believing `--transparent` produced transparency | The model can **paint the checkerboard** — the visual convention for transparency — as opaque pixels. Measured: a retry came back 0.0% transparent while the first attempt was 58.9%, same flag, valid PNG, exit 0. The script now gates this (exit 6); always put `no checkerboard, no fake transparency grid` in `Avoid`. |
|
|
205
|
+
| Expecting a batch to be a matched *set* | One job gives you consistent style but not consistent **optical scale**. Measured on a 4-icon set: alpha bbox filled 81%, 82%, 95%, 85% of the frame. Normalize in post with `scripts/normalize-set.py` rather than regenerating. |
|
|
206
|
+
| Assuming the palette line is binding | It is a strong hint, not a constraint. In the same set one icon came back with red and blue ruled lines (1.0% off-palette pixels against 0.0% for the other three). Measure hue drift instead of eyeballing it. |
|
|
207
|
+
| Editing the script while a job is running | Bash reads a script incrementally from disk; patching it mid-run shifts the offsets and kills the job with a bogus syntax error. Finish the run first. |
|
|
208
|
+
|
|
209
|
+
## Finishing an icon or asset set
|
|
210
|
+
|
|
211
|
+
One job gives a set consistent style but not consistent size. Even out the optical scale
|
|
212
|
+
deterministically instead of burning generations on it:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
python3 "${HANOMAN_HOME:-$HOME/.hanoman}/skills/codex-image/scripts/normalize-set.py" \
|
|
216
|
+
assets/icons/icon-*.png --dry-run # see the scale factors first
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
It crops each icon to its alpha bounding box, rescales so the set matches on one metric, and
|
|
220
|
+
re-pads to the original canvas. `--metric bbox` (default) makes every icon fill the same box;
|
|
221
|
+
`--metric area` suits a set where one subject is mostly empty space. Look at a contact sheet
|
|
222
|
+
over a mid-grey ground before committing — transparency hides scale problems on white.
|
|
223
|
+
|
|
224
|
+
For sprite sheets and animation strips, read `references/sprite-sheets.md` — the grid, tail,
|
|
225
|
+
and registration traps there cost days to rediscover.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Sprite sheets and animation strips
|
|
2
|
+
|
|
3
|
+
Generating an animated character is not "generate 8 images". It is one sheet, then a
|
|
4
|
+
cutting-and-registration pipeline you own. These traps were each paid for once.
|
|
5
|
+
|
|
6
|
+
## Generating the sheet
|
|
7
|
+
|
|
8
|
+
Ask for **one sheet** (e.g. `1536x1024`, one row of 8 frames) rather than 8 separate images —
|
|
9
|
+
separate calls give you 8 different characters. Then:
|
|
10
|
+
|
|
11
|
+
- **Ask for the raw sheet only.** Codex's imagegen skill will otherwise run
|
|
12
|
+
`remove_chroma_key.py --soft-matte --despill` on its own. Despill against magenta **subtracts
|
|
13
|
+
red from red pixels**: gold turns olive, red cloth turns dull. Do the keying yourself.
|
|
14
|
+
- **Key on green `#00FF00`, not magenta**, for the same reason.
|
|
15
|
+
- Keep the standing character well under the cell height (a 192px character in a 192px cell
|
|
16
|
+
spills its tail into the neighbour; ~168px inside 192px is safe) and clip per cell. Treat
|
|
17
|
+
spill as a gate failure, not a rounding error.
|
|
18
|
+
- Floating ornaments (stars, sparkles) must **touch the character silhouette**. A detached blob
|
|
19
|
+
smaller than ~40×80px and ≥8 columns away gets dropped silently by blob detection.
|
|
20
|
+
- "16-bit pixel art" in the prompt yields a high-resolution illustration that looks pixelated.
|
|
21
|
+
Real pixels come from `resize(NEAREST)` to the logical size afterwards.
|
|
22
|
+
|
|
23
|
+
## Cutting
|
|
24
|
+
|
|
25
|
+
**The model does not obey your grid.** Bounding boxes drift up to ~97px along a row and sprites
|
|
26
|
+
cross cell borders. Detect sprites from transparent gutters, never by slicing at fixed offsets.
|
|
27
|
+
|
|
28
|
+
## Registration
|
|
29
|
+
|
|
30
|
+
Anchoring on the feet alone jitters ±15px, because the tail tip enters the foot bounding box.
|
|
31
|
+
|
|
32
|
+
- **Find feet by mass, not by side.** In the bottom band, a grazing tail is a thin line
|
|
33
|
+
(~80–100px of mass) next to real feet (~300–600px). A "rightmost run" rule is exactly wrong
|
|
34
|
+
for a walk-left row drawn with the tail on the right, and mid-stride feet are two runs.
|
|
35
|
+
- Register with an XOR-mask over the static region (rows ≥45%, columns ≥ feet−12) plus a ±8%
|
|
36
|
+
scale search. That gets residual to 0.01–0.07; the remainder is shape difference.
|
|
37
|
+
- **Pinning the static region freezes a rectangle** and will amputate a hand that moves through
|
|
38
|
+
it. Plain erosion cuts the arm while the palm survives — the hand reads as severed. Use
|
|
39
|
+
morphological reconstruction: grow the eroded seed inside the difference map until stable.
|
|
40
|
+
- Do not normalise the frame-1 bbox to a fixed height — anything that lengthens the bbox (a
|
|
41
|
+
dangling tail) silently shrinks the character. Gate on `body_ratio` (eroded bbox height ÷ full
|
|
42
|
+
bbox height); healthy rows sit at 0.84–0.91. For curled poses gate on body **mass** instead.
|
|
43
|
+
|
|
44
|
+
## Reviewing
|
|
45
|
+
|
|
46
|
+
- Onion-skin (mean alpha across frames, pixel ratio 0.2–0.8) is the cheapest jitter measure.
|
|
47
|
+
- A QA GIF with a **per-frame adaptive palette** shifts colours every frame and reads as a
|
|
48
|
+
flicker that is not in the strip. Use one global palette.
|
|
49
|
+
- The **8→1 loop seam is invisible** in both contact sheets and onion-skins. Gate it as a
|
|
50
|
+
ratio of largest to smallest inter-frame step (threshold ~3.5), not an absolute value, and
|
|
51
|
+
guard it with "visible motion ≥ 0.10" — otherwise a barely-moving row passes vacuously.
|
|
52
|
+
- Calibrate residual gates across all rows, not on the idle row. A gate tuned to idle punishes
|
|
53
|
+
correct animation.
|
|
54
|
+
|
|
55
|
+
## Retrying
|
|
56
|
+
|
|
57
|
+
Restate the **entire** constraint set on every retry, including the constraints already
|
|
58
|
+
satisfied. A partial correction makes the model forget what it had got right.
|
|
59
|
+
|
|
60
|
+
Cost of record: 1–3 attempts per row; ~1–3 minutes per sheet; 3 concurrent jobs are safe.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# codex-image.sh — spawn Codex to generate raster images, land verified PNGs on disk.
|
|
3
|
+
# Codex's built-in image_gen tool needs no OPENAI_API_KEY; it uses the ChatGPT login.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
usage() {
|
|
7
|
+
cat <<'USAGE'
|
|
8
|
+
codex-image.sh — generate PNG assets via Codex's built-in image_gen tool.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
codex-image.sh --out FILE.png [options] < spec.txt
|
|
12
|
+
codex-image.sh --out-dir DIR --count N [options] < spec.txt
|
|
13
|
+
|
|
14
|
+
The image spec is read from stdin (use a heredoc or a file).
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
-o, --out FILE Destination PNG for a single asset. Implies --count 1.
|
|
18
|
+
--out-dir DIR Destination directory when generating several assets.
|
|
19
|
+
--name PREFIX Filename prefix under --out-dir (default: asset).
|
|
20
|
+
-n, --count N Number of distinct assets to generate (default: 1, max 8).
|
|
21
|
+
-r, --ref FILE Reference image passed to Codex (repeatable, PNG/JPG).
|
|
22
|
+
-s, --size WxH Pixel size (default: 1024x1024). Both edges multiples of 16,
|
|
23
|
+
max edge 3840, ratio <= 3:1, 655360..8294400 total pixels.
|
|
24
|
+
-t, --transparent Ask for a genuinely transparent background.
|
|
25
|
+
--fit Resize the result to exactly --size (image_gen treats size as a hint).
|
|
26
|
+
--raw Harvest from ~/.codex/generated_images instead of Codex's copies.
|
|
27
|
+
--effort LEVEL Codex reasoning effort: low|medium|high (default: low).
|
|
28
|
+
--timeout SEC Hard timeout (default: 900).
|
|
29
|
+
--keep Keep the staging directory for debugging.
|
|
30
|
+
-h, --help Show this help.
|
|
31
|
+
|
|
32
|
+
Exit codes:
|
|
33
|
+
0 success (every requested PNG exists and is a real PNG)
|
|
34
|
+
2 usage error
|
|
35
|
+
3 Codex turn failed (see the printed reason)
|
|
36
|
+
4 Codex produced no image
|
|
37
|
+
5 timeout
|
|
38
|
+
6 --transparent was asked for but the result is opaque (file kept for inspection)
|
|
39
|
+
USAGE
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
OUT_FILE=""; OUT_DIR=""; NAME="asset"; COUNT=1; SIZE="1024x1024"
|
|
43
|
+
TRANSPARENT=0; RAW=0; EFFORT="low"; TIMEOUT=900; KEEP=0; FIT=0
|
|
44
|
+
REFS=()
|
|
45
|
+
|
|
46
|
+
while [[ $# -gt 0 ]]; do
|
|
47
|
+
case "$1" in
|
|
48
|
+
-o|--out) OUT_FILE="$2"; shift 2 ;;
|
|
49
|
+
--out-dir) OUT_DIR="$2"; shift 2 ;;
|
|
50
|
+
--name) NAME="$2"; shift 2 ;;
|
|
51
|
+
-n|--count) COUNT="$2"; shift 2 ;;
|
|
52
|
+
-r|--ref) REFS+=("$2"); shift 2 ;;
|
|
53
|
+
-s|--size) SIZE="$2"; shift 2 ;;
|
|
54
|
+
-t|--transparent) TRANSPARENT=1; shift ;;
|
|
55
|
+
--fit) FIT=1; shift ;;
|
|
56
|
+
--raw) RAW=1; shift ;;
|
|
57
|
+
--effort) EFFORT="$2"; shift 2 ;;
|
|
58
|
+
--timeout) TIMEOUT="$2"; shift 2 ;;
|
|
59
|
+
--keep) KEEP=1; shift ;;
|
|
60
|
+
-h|--help) usage; exit 0 ;;
|
|
61
|
+
*) echo "codex-image: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
|
62
|
+
esac
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
command -v codex >/dev/null 2>&1 || { echo "codex-image: codex CLI not found on PATH" >&2; exit 2; }
|
|
66
|
+
|
|
67
|
+
if [[ -n "$OUT_FILE" ]]; then
|
|
68
|
+
[[ -n "$OUT_DIR" ]] && { echo "codex-image: use --out or --out-dir, not both" >&2; exit 2; }
|
|
69
|
+
COUNT=1
|
|
70
|
+
OUT_DIR="$(cd "$(dirname "$OUT_FILE")" 2>/dev/null && pwd)" || {
|
|
71
|
+
echo "codex-image: destination directory does not exist: $(dirname "$OUT_FILE")" >&2; exit 2; }
|
|
72
|
+
elif [[ -n "$OUT_DIR" ]]; then
|
|
73
|
+
mkdir -p "$OUT_DIR"
|
|
74
|
+
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
|
75
|
+
else
|
|
76
|
+
echo "codex-image: --out or --out-dir is required" >&2; usage >&2; exit 2
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
[[ "$COUNT" =~ ^[1-8]$ ]] || { echo "codex-image: --count must be 1..8" >&2; exit 2; }
|
|
80
|
+
[[ "$SIZE" =~ ^[0-9]+x[0-9]+$ ]] || { echo "codex-image: --size must look like 1536x1024" >&2; exit 2; }
|
|
81
|
+
for r in "${REFS[@]+"${REFS[@]}"}"; do
|
|
82
|
+
[[ -f "$r" ]] || { echo "codex-image: reference image not found: $r" >&2; exit 2; }
|
|
83
|
+
done
|
|
84
|
+
|
|
85
|
+
if [[ "$TRANSPARENT" == "1" && ${#REFS[@]} -gt 0 ]]; then
|
|
86
|
+
echo "codex-image: WARNING --ref together with --transparent reliably returns an OPAQUE image" >&2
|
|
87
|
+
echo "codex-image: (measured: 0.0% clear with a reference, ~59% without, same spec). Reference" >&2
|
|
88
|
+
echo "codex-image: images route through the edit path, which flattens alpha and paints a fake" >&2
|
|
89
|
+
echo "codex-image: transparency checkerboard. Describe the style in words instead of attaching one." >&2
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
SPEC="$(cat)"
|
|
93
|
+
[[ -n "${SPEC//[[:space:]]/}" ]] || { echo "codex-image: empty spec on stdin" >&2; exit 2; }
|
|
94
|
+
|
|
95
|
+
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/codex-image.XXXXXX")"
|
|
96
|
+
cleanup() { [[ "$KEEP" == "1" ]] || rm -rf "$STAGE"; }
|
|
97
|
+
trap cleanup EXIT
|
|
98
|
+
[[ "$KEEP" == "1" ]] && echo "codex-image: staging dir $STAGE" >&2
|
|
99
|
+
|
|
100
|
+
# --- build the prompt: the caller's spec plus a hard output contract ---
|
|
101
|
+
{
|
|
102
|
+
echo "Generate raster image assets with your built-in image_gen tool (imagegen skill)."
|
|
103
|
+
echo
|
|
104
|
+
echo "=== IMAGE SPEC ==="
|
|
105
|
+
echo "$SPEC"
|
|
106
|
+
echo
|
|
107
|
+
echo "Size: ${SIZE}."
|
|
108
|
+
[[ "$TRANSPARENT" == "1" ]] && echo "Background: genuinely transparent; preserve the alpha channel."
|
|
109
|
+
if [[ ${#REFS[@]} -gt 0 ]]; then
|
|
110
|
+
echo "Attached images are references for style, subject and composition — not edit targets,"
|
|
111
|
+
echo "unless the spec above says otherwise."
|
|
112
|
+
fi
|
|
113
|
+
echo
|
|
114
|
+
echo "=== OUTPUT CONTRACT ==="
|
|
115
|
+
echo "Your deliverable is exactly ${COUNT} PNG file(s) in your current working directory:"
|
|
116
|
+
for i in $(seq 1 "$COUNT"); do printf ' %s-%02d.png\n' "$NAME" "$i"; done
|
|
117
|
+
if [[ "$COUNT" -gt 1 ]]; then
|
|
118
|
+
echo "If the spec above enumerates ${COUNT} distinct subjects, render them in the order listed,"
|
|
119
|
+
echo "one subject per file, sharing the style, palette and framing stated once at the top."
|
|
120
|
+
echo "If it describes a single subject, render ${COUNT} variants of that subject instead."
|
|
121
|
+
fi
|
|
122
|
+
echo "Each file is the image_gen output written to disk unchanged — same pixels, same alpha,"
|
|
123
|
+
echo "same dimensions as generated. Issue one image_gen call per file."
|
|
124
|
+
echo "Write nothing else: no README, no notes, no scripts, no edits to any other file."
|
|
125
|
+
echo "Do not run remove_chroma_key.py, do not despill, do not crop, resize or recolor."
|
|
126
|
+
echo "Finish with one line per file, and nothing after them:"
|
|
127
|
+
echo "SAVED: <absolute path>"
|
|
128
|
+
} > "$STAGE/prompt.txt"
|
|
129
|
+
|
|
130
|
+
GEN_DIR="${CODEX_HOME:-$HOME/.codex}/generated_images"
|
|
131
|
+
mkdir -p "$GEN_DIR"
|
|
132
|
+
CODEX_ARGS=(exec --skip-git-repo-check -s workspace-write -C "$STAGE" --add-dir "$GEN_DIR"
|
|
133
|
+
-c "model_reasoning_effort=\"$EFFORT\"" --json -o "$STAGE/last.txt")
|
|
134
|
+
for r in "${REFS[@]+"${REFS[@]}"}"; do CODEX_ARGS+=(-i "$r"); done
|
|
135
|
+
CODEX_ARGS+=(-)
|
|
136
|
+
|
|
137
|
+
echo "codex-image: generating ${COUNT} image(s) at ${SIZE} (timeout ${TIMEOUT}s)…" >&2
|
|
138
|
+
codex "${CODEX_ARGS[@]}" < "$STAGE/prompt.txt" > "$STAGE/events.jsonl" 2> "$STAGE/stderr.txt" &
|
|
139
|
+
CODEX_PID=$!
|
|
140
|
+
WAITED=0
|
|
141
|
+
while kill -0 "$CODEX_PID" 2>/dev/null; do
|
|
142
|
+
if [[ "$WAITED" -ge "$TIMEOUT" ]]; then
|
|
143
|
+
kill -TERM "$CODEX_PID" 2>/dev/null || true; sleep 2
|
|
144
|
+
kill -KILL "$CODEX_PID" 2>/dev/null || true
|
|
145
|
+
echo "codex-image: TIMEOUT after ${TIMEOUT}s" >&2
|
|
146
|
+
exit 5
|
|
147
|
+
fi
|
|
148
|
+
sleep 3; WAITED=$((WAITED + 3))
|
|
149
|
+
done
|
|
150
|
+
wait "$CODEX_PID" 2>/dev/null || true # codex exec exits 0 even when the turn failed
|
|
151
|
+
|
|
152
|
+
# --- codex exec's exit code lies; the events stream is the source of truth ---
|
|
153
|
+
FAIL="$(python3 - "$STAGE/events.jsonl" <<'PY'
|
|
154
|
+
import json, sys
|
|
155
|
+
reason = ""
|
|
156
|
+
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
|
157
|
+
line = line.strip()
|
|
158
|
+
if not line: continue
|
|
159
|
+
try: ev = json.loads(line)
|
|
160
|
+
except Exception: continue
|
|
161
|
+
if ev.get("type") == "turn.failed":
|
|
162
|
+
reason = (ev.get("error") or {}).get("message", "turn failed")
|
|
163
|
+
elif ev.get("type") == "error" and not reason:
|
|
164
|
+
reason = ev.get("message", "error")
|
|
165
|
+
print(reason)
|
|
166
|
+
PY
|
|
167
|
+
)"
|
|
168
|
+
if [[ -n "$FAIL" ]]; then
|
|
169
|
+
echo "codex-image: Codex turn FAILED: $FAIL" >&2
|
|
170
|
+
case "$FAIL" in
|
|
171
|
+
*revoked*|*"log in again"*|*[Uu]nauthorized*|*401*)
|
|
172
|
+
echo "codex-image: the ChatGPT login is stale. Run 'codex login' and retry." >&2 ;;
|
|
173
|
+
esac
|
|
174
|
+
exit 3
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
THREAD="$(python3 - "$STAGE/events.jsonl" <<'PY'
|
|
178
|
+
import json, sys
|
|
179
|
+
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
|
180
|
+
try: ev = json.loads(line)
|
|
181
|
+
except Exception: continue
|
|
182
|
+
if ev.get("type") == "thread.started":
|
|
183
|
+
print(ev.get("thread_id", "")); break
|
|
184
|
+
PY
|
|
185
|
+
)"
|
|
186
|
+
|
|
187
|
+
# --- harvest: Codex's saved deliverables, else the raw generated_images drop ---
|
|
188
|
+
RAW_DIR="${CODEX_HOME:-$HOME/.codex}/generated_images/$THREAD"
|
|
189
|
+
FOUND=()
|
|
190
|
+
if [[ "$RAW" == "0" ]]; then
|
|
191
|
+
while IFS= read -r f; do FOUND+=("$f"); done < <(find "$STAGE" -maxdepth 1 -name '*.png' | sort)
|
|
192
|
+
fi
|
|
193
|
+
if [[ ${#FOUND[@]} -eq 0 && -d "$RAW_DIR" ]]; then
|
|
194
|
+
[[ "$RAW" == "0" ]] && echo "codex-image: no PNG in staging; falling back to raw $RAW_DIR" >&2
|
|
195
|
+
while IFS= read -r f; do FOUND+=("$f"); done < <(ls -t "$RAW_DIR"/*.png 2>/dev/null | head -n "$COUNT" | sort)
|
|
196
|
+
fi
|
|
197
|
+
if [[ ${#FOUND[@]} -eq 0 ]]; then
|
|
198
|
+
echo "codex-image: Codex produced no image. Its final message was:" >&2
|
|
199
|
+
sed 's/^/ | /' "$STAGE/last.txt" >&2 2>/dev/null || true
|
|
200
|
+
exit 4
|
|
201
|
+
fi
|
|
202
|
+
if [[ ${#FOUND[@]} -lt "$COUNT" ]]; then
|
|
203
|
+
echo "codex-image: WARNING wanted $COUNT image(s), got ${#FOUND[@]}" >&2
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# --- verify every file really is a PNG, then place it ---
|
|
207
|
+
STATUS=0; i=0
|
|
208
|
+
for src in "${FOUND[@]}"; do
|
|
209
|
+
i=$((i + 1)); [[ "$i" -gt "$COUNT" ]] && break
|
|
210
|
+
if [[ -n "$OUT_FILE" ]]; then dst="$OUT_FILE"; else dst="$(printf '%s/%s-%02d.png' "$OUT_DIR" "$NAME" "$i")"; fi
|
|
211
|
+
cp -f "$src" "$dst"
|
|
212
|
+
if ! file -b "$dst" | grep -qi '^PNG image data'; then
|
|
213
|
+
echo "codex-image: NOT a PNG: $dst ($(file -b "$dst"))" >&2; STATUS=4; continue
|
|
214
|
+
fi
|
|
215
|
+
read_dims() { file -b "$1" | sed -n 's/.*, \([0-9]*\) x \([0-9]*\),.*/\1x\2/p'; }
|
|
216
|
+
dims="$(read_dims "$dst")"
|
|
217
|
+
if [[ -n "$dims" && "$dims" != "$SIZE" ]]; then
|
|
218
|
+
if [[ "$FIT" == "1" ]] && command -v sips >/dev/null 2>&1; then
|
|
219
|
+
sips -z "${SIZE#*x}" "${SIZE%x*}" "$dst" --out "$dst" >/dev/null 2>&1
|
|
220
|
+
echo "codex-image: resized $dims -> $SIZE (--fit)" >&2
|
|
221
|
+
dims="$(read_dims "$dst")"
|
|
222
|
+
else
|
|
223
|
+
echo "codex-image: NOTE image_gen returned $dims, not the requested $SIZE — it treats size as a hint. Pass --fit to resize." >&2
|
|
224
|
+
fi
|
|
225
|
+
fi
|
|
226
|
+
if [[ "$TRANSPARENT" == "1" ]]; then
|
|
227
|
+
chk="$(python3 - "$dst" <<'PYCHK'
|
|
228
|
+
import sys
|
|
229
|
+
try:
|
|
230
|
+
from PIL import Image
|
|
231
|
+
except ImportError:
|
|
232
|
+
print("skip"); raise SystemExit
|
|
233
|
+
im = Image.open(sys.argv[1]).convert("RGBA")
|
|
234
|
+
clear = sum(im.getchannel("A").histogram()[:9]) / (im.width * im.height)
|
|
235
|
+
corners = [im.getpixel(p)[3] for p in
|
|
236
|
+
[(0, 0), (im.width - 1, 0), (0, im.height - 1), (im.width - 1, im.height - 1)]]
|
|
237
|
+
print("ok" if clear >= 0.05 and max(corners) < 16 else "opaque %.1f%%" % (clear * 100))
|
|
238
|
+
PYCHK
|
|
239
|
+
)"
|
|
240
|
+
case "$chk" in
|
|
241
|
+
ok) ;;
|
|
242
|
+
skip) echo "codex-image: NOTE Pillow missing, transparency not verified: $dst" >&2 ;;
|
|
243
|
+
*) echo "codex-image: TRANSPARENCY FAILED $dst (only $chk of pixels are clear)." >&2
|
|
244
|
+
echo "codex-image: the model most likely PAINTED a transparency checkerboard instead of" >&2
|
|
245
|
+
echo "codex-image: leaving alpha. Add 'no checkerboard, no fake transparency grid' to Avoid." >&2
|
|
246
|
+
printf 'BAD %s (%s) — opaque background, not delivered as transparent\n' \
|
|
247
|
+
"$dst" "${dims:-unknown size}"
|
|
248
|
+
STATUS=6; continue ;;
|
|
249
|
+
esac
|
|
250
|
+
fi
|
|
251
|
+
printf 'OK %s (%s, %s bytes)\n' "$dst" "${dims:-unknown size}" "$(wc -c < "$dst" | tr -d ' ')"
|
|
252
|
+
done
|
|
253
|
+
[[ "$STATUS" == "0" ]] || exit "$STATUS"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Normalize a set of transparent PNG icons to one optical scale.
|
|
3
|
+
|
|
4
|
+
One generation job gives consistent style but not consistent size: subjects come back
|
|
5
|
+
filling anywhere from 80% to 95% of the frame, so the set looks ragged in a row.
|
|
6
|
+
|
|
7
|
+
Scaling by bounding box is wrong for mixed shapes — a pocket watch on a chain is tall,
|
|
8
|
+
so bbox-matching shrinks its face next to a wide open book. This matches *ink area*
|
|
9
|
+
(sqrt of opaque pixel count), which is what the eye actually reads as "the same size",
|
|
10
|
+
then clamps anything that would overflow the frame.
|
|
11
|
+
|
|
12
|
+
Three metrics, because no single one is right for mixed shapes:
|
|
13
|
+
|
|
14
|
+
bbox match the longest edge, so every icon fills the same box (default). This is
|
|
15
|
+
what icon systems usually specify, and it won a side-by-side on a measured
|
|
16
|
+
4-icon set: area and hybrid both shrank a dense solid object (a monitor, to
|
|
17
|
+
73% and 79% of frame) until it read as weaker than its neighbours.
|
|
18
|
+
area match sqrt(opaque pixel count). Reach for it when one icon is mostly empty
|
|
19
|
+
space — a long thin antenna or tail — and bbox would shrink the real subject.
|
|
20
|
+
hybrid geometric mean of the two.
|
|
21
|
+
|
|
22
|
+
Always check with --dry-run and a contact sheet before committing; the right metric is a
|
|
23
|
+
property of the particular set, not a rule.
|
|
24
|
+
|
|
25
|
+
normalize-set.py icon-*.png [--metric hybrid] [--margin 0.08] [--out-dir DIR] [--dry-run]
|
|
26
|
+
"""
|
|
27
|
+
import argparse, sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from PIL import Image
|
|
32
|
+
except ImportError:
|
|
33
|
+
sys.exit("normalize-set: needs Pillow — pip install pillow")
|
|
34
|
+
|
|
35
|
+
ALPHA_FLOOR = 8 # ignore near-transparent halo pixels
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def measure(path):
|
|
39
|
+
im = Image.open(path).convert("RGBA")
|
|
40
|
+
mask = im.getchannel("A").point(lambda v: 255 if v > ALPHA_FLOOR else 0)
|
|
41
|
+
bbox = mask.getbbox()
|
|
42
|
+
if bbox is None:
|
|
43
|
+
raise ValueError(f"{path.name} is fully transparent")
|
|
44
|
+
area = mask.crop(bbox).histogram()[255]
|
|
45
|
+
return im, bbox, area
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
ap = argparse.ArgumentParser()
|
|
50
|
+
ap.add_argument("files", nargs="+", type=Path)
|
|
51
|
+
ap.add_argument("--metric", choices=("bbox", "area", "hybrid"), default="bbox",
|
|
52
|
+
help="what to equalize across the set (default: bbox)")
|
|
53
|
+
ap.add_argument("--margin", type=float, default=0.08,
|
|
54
|
+
help="fraction of the canvas kept clear on the long edge (default 0.08)")
|
|
55
|
+
ap.add_argument("--out-dir", type=Path, default=None,
|
|
56
|
+
help="write here instead of overwriting the inputs")
|
|
57
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
58
|
+
a = ap.parse_args()
|
|
59
|
+
|
|
60
|
+
items = []
|
|
61
|
+
for f in a.files:
|
|
62
|
+
im, bbox, area = measure(f)
|
|
63
|
+
items.append({"path": f, "im": im, "bbox": bbox, "area": area})
|
|
64
|
+
|
|
65
|
+
canvas = items[0]["im"].size
|
|
66
|
+
for it in items:
|
|
67
|
+
if it["im"].size != canvas:
|
|
68
|
+
sys.exit(f"normalize-set: {it['path'].name} is {it['im'].size}, expected {canvas} "
|
|
69
|
+
"— resize the set to one canvas first")
|
|
70
|
+
|
|
71
|
+
limit = min(canvas) * (1 - a.margin)
|
|
72
|
+
target_area = sum(it["area"] for it in items) / len(items)
|
|
73
|
+
target_edge = sum(max(it["bbox"][2] - it["bbox"][0],
|
|
74
|
+
it["bbox"][3] - it["bbox"][1]) for it in items) / len(items)
|
|
75
|
+
|
|
76
|
+
print(f"{'file':<22}{'fill before':>12}{'scale':>8}{'fill after':>12}")
|
|
77
|
+
for it in items:
|
|
78
|
+
w, h = it["bbox"][2] - it["bbox"][0], it["bbox"][3] - it["bbox"][1]
|
|
79
|
+
before = max(w, h) / max(canvas)
|
|
80
|
+
s_area = (target_area / it["area"]) ** 0.5
|
|
81
|
+
s_bbox = target_edge / max(w, h)
|
|
82
|
+
scale = {"area": s_area, "bbox": s_bbox,
|
|
83
|
+
"hybrid": (s_area * s_bbox) ** 0.5}[a.metric]
|
|
84
|
+
if max(w, h) * scale > limit: # clamp: never overflow the frame
|
|
85
|
+
scale = limit / max(w, h)
|
|
86
|
+
nw, nh = max(1, round(w * scale)), max(1, round(h * scale))
|
|
87
|
+
|
|
88
|
+
out = Image.new("RGBA", canvas, (0, 0, 0, 0))
|
|
89
|
+
out.alpha_composite(it["im"].crop(it["bbox"]).resize((nw, nh), Image.LANCZOS),
|
|
90
|
+
((canvas[0] - nw) // 2, (canvas[1] - nh) // 2))
|
|
91
|
+
|
|
92
|
+
dst = it["path"] if a.out_dir is None else a.out_dir / it["path"].name
|
|
93
|
+
if not a.dry_run:
|
|
94
|
+
if a.out_dir:
|
|
95
|
+
a.out_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
out.save(dst)
|
|
97
|
+
print(f"{it['path'].name:<22}{before:>11.1%}{scale:>8.3f}{max(nw, nh)/max(canvas):>11.1%}")
|
|
98
|
+
|
|
99
|
+
if a.dry_run:
|
|
100
|
+
print("\n(dry run — nothing written)")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|