Written in the Stars is a web page that shows a night sky coming out, star by star, until the stars form a portrait of a woman. Every star is its own light: it ignites at a random moment, twinkles, swells and fades. After the reveal the sky stays alive. Bright stars blink out and reappear elsewhere, a meteor shower crosses the sky, her hair drifts in a light breeze, and she blinks now and then.
The first version, and why it failed
The project started months earlier as a "constellation portrait" that never worked. It grew to 14 Python scripts, a dozen JSON outputs of up to 52 MB each, and a 424 MB folder. Each script sampled points from a photo and drew them as particles.
The lesson: a photo can't become this kind of art by sampling alone. Her real hair hangs straight, so tracing it gave straight lines. The face came out as grey grain, not a glowing star field. The reference I was chasing was a painting, and no amount of sampling turns a photo into a painting.
The Pivot
Instead of fixing the renderer, I changed what went into it.
Repaint with AI
Gemini repainted her with dramatic lighting and flowing hair, guided by her photo.
Let the model do the style
Sending her photo, the repainted version and the reference art together, and asking for her in that style, got the closest result by far.
Edit one thing at a time
Gemini's edits kept the layout almost pixel-perfect, about a 0.05 px offset, so I could change one feature per pass: a blue face, brighter hair. I blended the hair at 30% in code, with a slider instead of another generation.
Finish in code
Removing the blue haze, filling the sky and protecting her face were done with OpenCV, never with another AI pass. That way her face could not drift.
About 26 generations in total, from the first painting to the favicon.
The Trick
The build pipeline is five Python scripts, each feeding the next.
- 1paint.py
- Calls Gemini with her photo, prompts and reference images
- 2finish.py
- Blends the hair, removes the haze, fills the sky
- 3extend.py
- Grows her sky outward to fill any screen, from patches of her own sky
- 4stars.py
- Splits the sky into individual stars plus the glow between them
- 5eyes.py
- Builds the blink frames as patches over the eyes
The key idea is step 4. It splits the image into two layers that add back up to the original exactly: the stars' own pixels, and the glow between them. A third image records which star owns each pixel.
The renderer can then animate each of the roughly 25,000 stars on its own, while the finished frame stays pixel-identical to the painting. The renderer works in two passes. First, at the sky's native resolution, a fragment shader looks up each pixel's star, reads that star's birth time, twinkle and events, and lights the pixel.
Then the lit sky is drawn to the screen, with each screen pixel averaging the full-resolution pixels under it. Standard mipmaps blurred away about half the glitter; this took sharpness from 46% to 90% of a perfect resize.
Making it alive
The reveal
Every star, flare and speck of glow ignites at a random moment between 0.5 and 14 seconds, each with a small flash. She condenses out of the sky.
- Random timing
- 0.5–14s
- Per element
- Small flash
Star events
After the reveal, about 60% of stars do something every 10 to 28 seconds: swell up to 4x brightness, or fade out and return. Stars on her face only make gentle swells and dips, because a full fade would punch a hole in her face.
- Stars trigger events
- 60%
- Event interval
- 10–28s
Hopping flares
The 61 big diffraction-spike stars blink out after 2 to 3.5 seconds and reappear somewhere else in the empty sky, never on her.
- Big stars
- 61
- Blink duration
- 2–3.5s
Meteor shower
About 33 shooting stars a minute, each with its own speed, length and brightness. 40% are fast and cross the whole screen. Paths that would cross her face are skipped.
- Shooting stars
- 33 / min
- Fast ones
- 40%
Hair in the breeze
46 control points over the free-moving hair, each swaying on its own rhythm, still at the roots and freest at the tips. My first version used one shared wave and looked like a ripple running through all of it at once.
- Control points
- 46
- Per strand
- Individual rhythm
The blink
Three keyframes made by Gemini editing only her eyes. The lids drop to half in 0.18 seconds and lift by 0.62, every 5 to 10 seconds.
- Lids down
- 0.18s
- Lids up
- 0.62s
- Interval
- 5–10s
The version I threw away
Before settling on stars that ignite in place, I built her out of 500,000 particles that spiral in from a galaxy. It worked technically, but it felt wrong. The final image was softer than the painting, and the motion felt like a screensaver.
The version I kept is simpler. Nothing moves into position. The sky is dark, and each star lights up exactly where it belongs, the way stars come out at dusk. The harder build lost to the one that felt right.
Shipping it
Loading
One breathing star in the dark, with a thin line of light that fills with the real download progress. "Tap to begin" appears only when everything has loaded, so the reveal never starts before anyone is watching.
PIN gate
A Cloudflare Worker of about 110 lines sits in front of every file. Without a valid cookie, visitors only ever get the PIN screen. The PIN is a Cloudflare secret, guesses are limited to 5 a minute per IP, and the comparison runs in constant time. If the secret is missing, the site refuses to serve instead of failing open.
PIN screen
Six faint points ignite into stars as digits are typed. A wrong code shakes them, with no text and no reload.
Deploys
Cloudflare Workers with static assets on a custom domain, deployed by Workers Builds on every merge to main, with no API token stored anywhere. The repo holds only what the site loads; the folder went from 424 MB to 24 MB.
By the numbers
- Stars, desktop
- 24,895 stars and 61 flares on 4096 × 2528 px
- Stars, phone
- 30,270 stars and 74 flares on 2100 × 3675 px
- Download, phone / desktop
- 7.5 MB / 9.2 MB
- Frame rate, desktop (Radeon 780M)
- about 140 fps
What I learned
- When the output can't reach the bar, change the input, not the renderer.
- Use AI for what it's good at, painting and style, and code for what must stay exact: her face, the layout, the stars.
- Technically harder isn't better. The particle system was the more impressive build and the worse gift.
- Small, measured fixes beat guessing. The blur, the blink and a PIN failure were each found by checking, not by tweaking.