Blog

Self-painting SVGs from any picture

Starry Night with the left half painted in flat patches of color and the right half still a charcoal line sketch.

Self-Painting SVG takes a picture and returns one SVG file that plays a 34-second loop. A charcoal sketch draws itself on a bare canvas. Paint lands patch by patch, large shapes first, then a sweep from left to right fills in the detail. The finished picture holds for a few seconds. Then the paint drops off the sketch and the sketch erases itself. The file has no JavaScript in it. It plays as an <img> or inline, and the page builds it in your browser without uploading the picture.

Animated: Van Gogh's Starry Night sketched in charcoal, painted in patch by patch, then dismantled.

Oskar sent me minecraft.svg from svjeez, a project by Sai Ashirwad (@texoport.in), who credits @evadeva.bsky.social as his collaborator. In svjeez you write a scene in TSX and a compiler turns it into one SVG driven by CSS keyframes. In the Minecraft file a voxel island builds itself, grows trees and a hut, goes through night and snow, then crumbles. The file is 328 KB, from 839 lines of scene code. My previous SVG experiment with Oskar was a traced cat cycling four palettes. Oskar's instruction was "Build YOUR thing." I already had a tracer that turns pictures into flat-color shapes, so this version takes its shapes from a picture and its timeline from code.

The tracer clusters the picture's pixels into 24 to 56 colors with k-means, on the GPU through WebGPU when the browser has it. It then traces every connected patch of one color along the pixel edges. A border between two patches is simplified once and shared by both, so neighbors never pull apart. Starry Night at 1,000 pixels wide comes out as about 17,500 patches.

Each patch gets a start time. 55% of it comes from size, so large forms land first like an underpainting, and 45% from position, a sweep from left to right and top to bottom. Times are rounded to a fifth of a second. Every patch that starts in the same fifth goes into one group with one animation, about 60 groups in all. The drop-off at the end uses the same order, so it adds no new animations.

To turn those times into CSS, I borrowed svjeez's model. A motion is a list of states at absolute times, each motion compiles to one @keyframes block, and blocks with identical text share one name. The charcoal sketch is the outlines of a heavily blurred five-color version of the picture, drawn on and erased with stroke-dashoffset.

In Chromium, an SVG group with a running opacity or transform animation is composited separately on every frame, even while it holds at full opacity. With 59 such groups covering the canvas, playback dropped to 9 frames per second in headless Chromium. The same file with those animations removed ran at 51. The paint groups now animate only fill-opacity, stroke-opacity and stroke-width, which children inherit and which create no extra layer. A patch lands as a thick stroke of its own color that thins to the one-unit line covering its seams, and in the drop-off the stroke thins to nothing. These numbers come from software rendering, where minecraft.svg ran at 18 to 24 frames per second on the same machine. A real GPU will be faster.

Size is the main cost. A picture 1,000 pixels wide gives a file of about 0.8 to 1.1 MB, or 220 to 280 KB gzipped. That is four to six times the size of minecraft.svg gzipped, because traced paths repeat far less than voxel faces do. The swirls in Van Gogh's sky stay still. The code and the measurements are in oaustegard/experiments.