One phone selfie, two rounds of image generation, an image-to-video model and a frame-by-frame vectorizer. What comes out is a 61-frame animated SVG that loops in 695 KB with no JavaScript, no SMIL and no player.

The source is a phone selfie: 2316 × 3088 pixels, shot under hard sunlight with a wooden ceiling behind. Bald head, stubble, navy fleece with a yellow stripe. Nothing in it was designed, which is the point — everything that follows has to be derived from this frame rather than drawn from scratch.
A photograph is the worst possible input for a vector pipeline. Sixteen million colors, no flat areas, soft shadows on every surface and sensor noise in the darks. Traced as-is it would produce thousands of overlapping paths and still read as a smudge. The photo carries the likeness, not the geometry.


ChatGPT's image model turned the selfie into a painted cartoon portrait: cream halo on a dark brown background, the navy jacket kept, the likeness intact. As an illustration it works. The shading is soft, the skin carries highlights, and light rolls gently along the shoulder of the jacket.
That shading is exactly the problem. A vectorizer has no way to keep a gradient: it slices the ramp into bands and emits a closed path for each one, so a single cheek becomes a dozen stacked shapes. Multiply that by 61 frames and every band jitters on its own.


The second pass keeps the subject and the composition — cream circle, dark brown background, navy jacket — but asks for flat illustration: solid fills, closed dark outlines, no gradients, no texture, no painted highlights. The likeness survives the constraint, and the drawing finally has the property everything downstream depends on.
Flatness here is a file-format decision taken early, not a style preference. Every region that reads as one color in the raster becomes one path in the SVG, so the constraint that simplifies the drawing is the same constraint that keeps the final file under a megabyte.
Seedance, ByteDance's image-to-video model, animates the flat portrait: a subtle head bob, one blink, a small smile that arrives and resolves. The output is 960 × 960 at 24 fps, 5.04 seconds, 1.35 MB of mp4. Fixed camera, fixed framing, nothing entering or leaving the shot.
Most of the brief is a list of things not to do. Every gradient, lighting change, motion blur or invented detail the model adds is paid for later in paths and colors, so the prompt spends its words defending the flatness the second pass established.
Animate this flat vector portrait in a seamless loop. Fixed camera and composition. Preserve the exact character design, proportions and color palette. Only flat solid colors, clean outlines and simple shapes. No gradients, textures, lighting changes, motion blur or new details. Expressive, polished 2D character animation.
ffmpeg pulls the loop apart into 61 frames, and from there every step exists to make the frames agree with each other. Colors are quantized once for the whole sequence rather than per frame, traced with identical settings, then reassembled into a single SVG whose only moving part is a CSS animation.


ffmpeg cuts the 5.04 s loop into 61 PNG frames at 12 fps and 512 px square. Halving the frame rate halves the final file; much below 12 fps the blink stops reading as a blink.
One k-means palette in CIELab is fitted on a pixel sample from every frame at once, over-clustered to 40 then merged below ΔE 6 so small but distinct colors like the eye whites survive: 20 colors. The median, spatial mode and 3-frame temporal mode filters all run on the palette index map — the first attempt filtered RGB channels instead and invented 41 colors.
VTracer traces every frame with identical parameters — color mode, stacked, spline, filter_speckle 16, color_precision 8, gradient_step 0, corner_threshold 60 — for about 44 paths per frame. Tuning a single frame to look better makes it pop in the loop.
Each frame becomes one group revealed by a CSS steps() animation with its own delay, so the file still plays inside an image tag. Fills are snapped back to the 20-color palette first: VTracer drifts them, emitting #2F1603 next to #301703 wherever removed speckles get averaged in.
Hoisting the cream circle and the brown background into one static layer would have removed a large share of the paths. It cannot be done: Seedance jitters every contour by about a pixel from frame to frame, so no path is identical twice and there is nothing stable left to hoist.
Encoding each frame as the previous one plus the pixels that changed was built and measured, not assumed. It is worse: the difference regions fragment into roughly 184 tiny paths per patch, and VTracer handles alpha poorly, so a patch ends up costing more than the whole frame it was meant to replace.
The finished file holds 61 frames, 2 670 paths and 695 KB, around 170 KB gzipped, looping in 5.08 seconds against the source's 5.04. It renders anywhere SVG renders: an image tag, a CSS background, resvg on the server. No canvas, no runtime, no video codec.
The honest limit: this is raster-style frame-by-frame animation, redrawn 61 times, not a rig. A Duolingo-grade character would start from six to ten key poses, cleaned and rebuilt as separate parts, then rigged and interpolated in Rive — lighter, retimable, editable. This pipeline buys the look without the rig.