The editor's Drawn variants step prepares a request for Codex and its built-in image generation. But the request is really just three files: the source image, an edit mask, and a prompt. Any image model that can edit a reference image without changing the character will do. We tested this end-to-end — here is the honest record.
What the editor gives you
uv run tools/variant-requests.py projects/<name> writes eleven request folders — closed/half/smiling eyes, sad/surprised expression eyes, four vowel mouths, angry/sad closed mouths — each containing mask.png, prompt.md and a shared README. The rules that matter:
- Output must be full-size (same canvas as
source.png) RGBA. - Transparent mask pixels may change; opaque pixels must stay identical.
- Keep changes at least 4 px inside the mask edge for clean sprite feathering.
The model test: what failed, what worked
We tried three routes before one worked. Names differ per provider, so treat this as a checklist, not gospel:
- Native "image edit" models on the wrong endpoint — 404s. If your provider's docs name an edit model, check which endpoint it actually lives on.
- An aggregator's model alias on the provider's native API — "Model not exist." Aliases are often proxy-layer names that do not exist upstream.
- The general image model with a reference image input — worked. A model that accepts an image plus a text instruction (often called image-to-image or instruction editing) handled "close both eyes, keep everything else identical" perfectly, including line style and the transparent background.
The one step that makes it reliable: mask compositing
Even a good edit model shifts a pixel here or there outside the mask. Rather than hoping, we composite deterministically: take the generated image only inside the mask's editable (transparent) region, keep the original pixels everywhere else. Ten lines of image-library code; the outside-mask rule becomes mathematically guaranteed instead of model-dependent.
from PIL import Image
src = Image.open("source.png").convert("RGBA")
gen = Image.open("generated.png").convert("RGBA")
gen = gen.resize(src.size) # safety
mask = Image.open("mask.png").getchannel("A") # 0 = editable, 255 = protected
editable = mask.point(lambda v: 255 - v)
Image.composite(gen, src, editable).save("variants/eyes_closed.png")
Two traps we hit, so you do not have to:
- The mask's meaning lives in its alpha channel, not its colors. Converting the mask to grayscale flattens alpha away — on our mask that silently produced a copy of the source. Read
getchannel("A"). - Transparent-background sources can fool pixel checks. Fully transparent pixels keep their old RGB values; any "find white pixels" logic must also check alpha.
Then let Studio import and verify
Save each result to projects/<name>/variants/<variant>.png and run:
uv run tools/build-sprites.py projects/<name>
npm run render-poses -- projects/<name>
build-sprites validates properly: it corrects shifts up to 3 px, removes uniform color tints measured inside the mask edge, and rejects with a reason anything redrawn outside the mask. Re-render the review poses afterwards and the expression frames (angry, sad, surprise) now show the drawn eyes and mouths — see the showcase before/after.
Cost and time
Eleven variants, roughly one to two minutes each on a general image model, for a few cents total. The whole drawn-variant stage is cheaper than the coffee you will drink while it runs.