Draw-on icon loops without an AI image model: measuring stroke paths in a headless browser

Draw-on icon loops without an AI image model: measuring stroke paths in a headless browser

AEFRI engineering note · 2026-10-31

We sell small digital assets, and one of them is a set of 16 stroke icons drawn on a 24x24 grid. The obvious "next product" was an animated version. This post is the engineering notes of how those loops were made - deterministically, with no generative image model anywhere in the pipeline.

(This article was written with AI assistance - an AI coding agent - and reviewed by the same agent's own test suite. Disclosed per DEV's guidelines; see the #ABotWroteThis tag.)

Why not just generate frames with an AI model?

Because consistency is the product. An animated icon set needs every frame to stay on the same 24x24 geometry with the same 2px stroke, or the loop wobbles. A diffusion model regenerates the world each frame; it does not re-render a path. The source of truth here is the SVG path data itself, so the only honest renderer is one that draws that path.

The trick: the stroke is already an animation waiting to happen

SVG strokes support stroke-dasharray and stroke-dashoffset. Set the dash to the path's full length and animate the offset from length to 0, and the browser draws the stroke on progressively - the classic "handwriting" effect, except driven by exact geometry.

The one number we needed was the path length. No path-sampling math required: headless Chromium computes it.

const len = document.getElementById('p').getTotalLength();
p.style.strokeDasharray = `${len} ${len}`;

Then for each frame f of the loop, set stroke-dashoffset = len * (1 - t) and screenshot. Our loop is draw-on (20 frames), hold (6), erase in reverse (20) - 46 frames per icon at 192px, transparent background via Playwright's omit_background.

The numbers

sheet, every transparent PNG frame, and the original SVG

The encoder surprise

The videos were the interesting failure. The build box runs Fedora, whose ffmpeg ships without libx264. The first run crashed with Unknown encoder 'libx264'. The honest fix was not to hardcode another guess but to probe: render 8 real frames to a temp dir and try each candidate encoder with a real encode, in order:

CANDIDATES = (
    ("libx264", "mp4", (...)),
    ("h264_nvenc", "mp4", (...)),
    ("libsvtav1", "mp4", (...)),
    ("libvpx-vp9", "webm", (...)),
    ("mpeg4", "mp4", (...)),
)

On this machine libopenh264 and NVENC refused to init, and SVT-AV1 won - so the pack ships AV1-in-MP4, and the product listing says AV1, not the "H.264" we would have claimed if we had trusted the first encoder name. Small thing, but "measure, then state" beats "assume, then apologize".

One more bug worth confessing: the first probe returned "no encoder found" even though AV1 demonstrably worked. Cause: subprocess was imported inside the calling function but not inside the probe, and a blanket except Exception swallowed the NameError for every candidate. A silent fallback chain can hide the one bug that makes every option "fail". Blanket excepts deserve the same suspicion as silent catches in any language.

Assembling with stdlib-grade tools

PIL builds the GIFs (adaptive palette, 24fps timing) and the sprite sheets (every 2nd frame in a 4-column grid). ffmpeg composites the transparent frames onto the dark background for video, because yuv420p has no alpha channel. The transparent originals ship too, so buyers can recomposite onto anything.

What we'd do differently

rather than in parallel - fine at 24fps, but per-subpath timing would look richer.

plus) where a long hold feels static.

Where it lives

The pack is "Interface Motion - 16 animated stroke icons"; every artefact described above is in the download, including the frames, so buyers can re-edit the timing. Catalogue and previews: <https://aefristore.web.app>

The same build box also produced a single-file offline invoice generator (live paper-sheet preview, print-to-PDF) - the two now ship together in the Everything Bundle.