LAB / 2026 / IMAGE → VIDEO → SVG

Turning a selfie into an animated SVG

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.

Animated vector portrait of Rama Herbin, 61 frames
ANIM.SVG 61 FRAMES · 12 FPS · CSS ONLY
FRAMES
61
FPS
12
PATHS
2 670
WEIGHT
695 KB
COLORS
20
( 01 ) THE PHOTO
Phone selfie under hard sunlight, navy fleece with a yellow stripe, wooden ceiling
IMG_0449.JPEG IPHONE · 2316 × 3088

One phone selfie, hard light

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.

( 02 ) CARTOON, PASS ONE
The source selfie
First cartoon pass: a painterly, shaded portrait
PHOTO PASS ONE

Painterly, and the wrong kind of good

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.

( 03 ) CARTOON, PASS TWO
First cartoon pass: shaded and gradient-heavy
Second cartoon pass: flat fills and closed outlines
PASS ONE PASS TWO

Same portrait, re-prompted flat

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.

WHAT THE SECOND PROMPT ASKED FOR
Flat solid fillsClosed dark outlinesNo gradientsNo textureSame compositionSame palette
( 04 ) SEEDANCE
SEEDANCE.MP4 960² · 24 FPS · 5.04 S · 1.35 MB

One image in, five seconds of loop out

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.

THE BRIEF

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.

SOURCE
960 × 960
RATE
24 FPS
LENGTH
5.04 S
SIZE
1.35 MB
( 05 ) VECTORIZE

Sixty-one frames, one palette

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.

Frame 30 straight out of ffmpeg, full colour
FRAME_0030.PNG RAW · 16M COLORS
Frame 30 after quantization to the 20-colour global palette
FRAME_0030.PNG QUANTIZED · 20 COLORS
Frame 30 traced to vector paths by VTracer
FRAME_0030.SVG VTRACER · 43 PATHS
The assembled animated SVG, all 61 frames
ANIM.SVG 61 FRAMES · 695 KB
( GLOBAL PALETTE ) 20 COLORS
#301703 #cf8242 #f6d088 #101d34 #211104 #a75926 #162e54 #6b4a30 #0b0e17 #d07b61 #fbad0d #593f2b #714728 #b05f43 #b57846 #8f491d #93562c #e9a56c #faf8f7 #723614
( PIPELINE ) 4 STEPS
01

Extract

ffmpeg · fps=12 · 512 px

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.

02

Quantize

python · k-means CIELab · ΔE 6 · 20 colors

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.

03

Trace

vtracer · stacked · filter_speckle 16

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.

04

Assemble

node · steps() · svgo

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.

( WHAT DID NOT WORK )

A static background layer

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.

Delta patches

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.

( 06 ) RESULT & LIMITS
The final animated SVG, 61 frames driven by CSS steps()
SEEDANCE · MP4 · 1.35 MB ANIM.SVG · 695 KB

A 695 KB loop with no runtime

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.

( TOOLS )
ChatGPTSeedanceffmpegPythonPillowNumPyscikit-imageVTracerSVGOresvgSvelteKit