monarch-art-generation

Use when generating Ironvellum (formerly Monarch, D:/Monarch) app artwork with tools/art.py, when an art batch stops with QUOTA_EXHAUSTED, when generated art shows a lighter box or sketched frame on t

SKILL.md

GitHub

Monarch art generation

Artwork lives in app/src/main/res/drawable-nodpi/art_*.png (empty states: art_empty_*.png) and is generated by tools/art.py — gemini-3.1-flash-image through omp's google-antigravity provider. Auth comes from omp token google-antigravity; no secret lives in the repo. The Gemini body must be nested under "request".

The transport is dead until art.py is repointed. art.py still sends every request to headroom (ENDPOINT = "http://127.0.0.1:8787/v1internal:streamGenerateContent?alt=sse"), and headroom was removed from this machine on 2026-09-23, so every run fails with a connection error. Fix it in the Monarch repo by pointing ENDPOINT at Cloud Code directly (omp's own antigravity provider uses https://daily-cloudcode-pa.googleapis.com), then replace this paragraph with the new transport. Never restart headroom to make art.py work.

Generate

python tools/art.py --style ink --alpha --size 512 "<subject only>" -o .tmp/art_<name>.png
  • --style is a contract (ink, line, woodcut). Never bake style words into the prompt; add or edit a style in the script instead, so screens cannot drift.
  • --alpha is required for anything sitting on a panel.
  • Prompt the SUBJECT only, e.g. "a closed leather ledger with a ribbon marker".
  • Size the prompt to the render size. Crests render at 34dp in the player sigil: ask for "bold thick strokes, massive simple silhouette, no fine detail, no frame, no border". Fine detail is mush at that size.

A run takes ~15s and prints chroma / contrast / backdrop / film checks.

Batches and the quota. The image quota dies mid-batch with QUOTA_EXHAUSTED from cloudcode-pa.googleapis.com (~9–10 images per reset observed). A shell loop loses its place, so use the resumable runner:

python tools/art_batches/run.py tools/art_batches/<batch>.txt [outdir]

Batch line format id|subject; the shared style suffix lives in a # SUFFIX: comment so each prompt stays in version control beside its art. The runner skips ids whose PNG already exists and stops with a named reason, so a rerun after the reset finishes the set instead of redrawing it.

Trap 1: the paper film

--alpha keys by density (ink_to_alpha): ink darkness becomes opacity, and blank paper is not perfectly white, so it lands at alpha 3–24 instead of 0 — measured on shipped art at 78–91% of the canvas (one piece: a light wash of RGB 232,232,228 at alpha 7–17). Invisible when you view the PNG alone; on the app's near-black panel (Abyss = 0xFF0C0C0B) it is a clearly visible lighter rectangle the exact size of the image. Three pieces shipped that way and the owner caught it on a real phone ("I can see the bg of it against the dark bg").

cut_paper_film() now removes it at source inside postprocess(): a soft knee (alpha ≤ 20 → 0, ramp to 40, unchanged above), chosen from the histogram's clean gap at alpha 25–40 between film and real strokes, so the drawing's feathered edges survive. backdrop_audit reports (N% ink, M% film).

Verify every generated asset independently:

from PIL import Image
im = Image.open(path).convert("RGBA")
h = im.split()[3].histogram(); tot = sum(h)
print(f"zero={100*h[0]/tot:.1f}%  film3-24={100*sum(h[3:25])/tot:.1f}%  ink>=41={100*sum(h[41:])/tot:.1f}%")
  • film > 5% → the wash is still there, whatever the tool reported.
  • Healthy output: film ≈ 0–3%, zero ≈ 75%+, ink 5–25%.

To rescue already-shipped art, apply the same LUT to the PNG in place, then confirm the corner-alpha mean is ~0 and the stroke pixel count (alpha ≥ 41) is unchanged:

LOW, HIGH = 20, 40   # cut_paper_film() defaults
lut = [0 if v <= LOW else (v if v >= HIGH else round(HIGH*(v-LOW)/(HIGH-LOW))) for v in range(256)]
im.putalpha(im.split()[3].point(lut)); im.save(path)

Trap 2: the model draws its own frame

Generated pieces routinely include a faint sketched rectangle in the margin — the same "box against the dark background" by another route. Keep "no frame, no border" in the prompt, and still crop ~7% off each edge before shipping, then re-check the histogram:

m = int(min(w, h) * 0.07); im = im.crop((m, m, w - m, h - m)).resize((512, 512))

Do not trust backdrop_audit alone

It originally checked only corner alpha (> 32) and "opaque" coverage (alpha > 180 over > 55% of the canvas), so a canvas entirely at alpha 3–24 passed as "transparent". It now also fails above 5% film — but the histogram above is the check that settles it. Judge on a dark contact sheet, not a white viewer: composite onto (12,12,11) before deciding.

Wire and verify

  • Never half-wire a catalogue. 4 drawn crests beside 6 procedural ones looks worse than either set. Generate the full set, then wire. If the quota dies part-way, leave the set unwired and record its status in the batch file's # STATUS: header.
  • Empty states take Image(painterResource(R.drawable.art_empty_*), contentDescription = null, modifier = Modifier.size(...).alpha(0.55f)) at an explicit .size(...) — fillMaxWidth + heightIn lets the intrinsic size win and renders it postage-stamp small. Imports usually missing: androidx.compose.foundation.Image, ui.res.painterResource, ui.draw.alpha, foundation.layout.size, com.ironvellum.app.R (the app's package).
  • Look at it on a device — the film was invisible in every static check and obvious on screen, and the emulator and phone render ink differently. Pin instrumented Gradle runs with ANDROID_SERIAL=emulator-5554: the suite calls pm clear and deletes sessions, and the owner's S25 Ultra (R5GL14GXV3J) holds real training data.