The problem
A child asks for a fox tea party on the moon, or a dragon who loves cupcakes, and no printable exists for it. Every colouring app ships a fixed catalogue; the one thing a child actually wants is the thing nobody has drawn yet.
What it does
A child describes a scene by typing, speaking, or uploading a photo of their own drawing, and gets printable line art. They can print it or colour it in the app with chunky brushes, a flood-fill bucket, and a Magic Brush that reveals per-region colours chosen in advance by a pipeline.
How it's built
Colourise-then-sample, after measuring the obvious approach
Each page stores a compressed pixel-to-region map plus per-region metadata with several palette variants. The original design asked a model to pick colours from text labels; a headless review loop measured that as barely better than random. So instead the line art is painted with a flat-fill prompt and each region’s modal colour is read back from an eroded interior, excluding line black and unfilled white.
A model upgrade that broke the deployment model
Swapping image models pushed generation from about 52 seconds to about 215, and the cron began timing out at the platform ceiling. The fix was structural rather than a bigger timeout: cron routes became thin triggers that return in under a second, and a worker on a dedicated box owns the untimed pipeline.
Getting the database to actually sleep
This was documented as the worst offender across my apps at 100% compute uptime. The streaming listener became a single shared client with per-image fanout, polling was replaced with event-driven work, and crons stopped touching Postgres before establishing there was anything to do.
Colouring strokes that never touch the JS thread
Committed strokes are baked into a retained picture held in a shared value and mutated inside the gesture’s worklet, so committing a stroke never triggers a React-driven redraw. That invariant is what fixed an intermittent one-frame flash traced to multiple canvas presents racing each other.
Likeness routing instead of a blocklist
A static list of blocked characters goes stale immediately. Every request instead runs a fast structured extract naming each subject, whether it would trip a provider’s likeness or IP gate, and an anonymised rewrite — and the result routes to whichever provider can actually serve it.
The hard part
The colour pipeline’s review loop was measuring the wrong thing for a long time. It re-detected regions rather than using the pipeline’s own stored map, and because regions are numbered in raster-scan order, "region 20" in the fresh map was a physically different region — so every per-region comparison lined up wrongly and the whole thing masqueraded as a pipeline bug. That is the kind of failure worth knowing about: the evaluation harness was broken, not the system under test, and everything it told me was confidently wrong.
Stack
- Next.js
- React Native
- Expo
- gpt-image-2
- Replicate
- ElevenLabs
- Skia
- Sanity
- Stripe
- RevenueCat
Built, shipped and maintained by me. Try Chunky Crayon, or see the rest of the work.
