A Film as a Column of Color
There's a lovely project called The Colors of Motion that takes a film, averages every frame down to a single horizontal line, and stacks those lines into one tall image. A whole movie becomes a barcode: you can read the cuts, the moods, the moment it goes to night. I kept coming back to it and eventually wanted the inverse, not to reduce a film I had, but to generate strips that feel like that from nothing but a seed.
This is the third engine in the same series as the Vasarely spheres and the username avatars. Same rules as the others: a pure function of a seed string, no clock, no Math.random, rendered to SVG. Same seed, same strip. I call it colorfield.
The actual problem#
A strip like that looks simple, and that is exactly what makes it hard. There are two obvious ways to build one, and both fail.
The first is to pick colors at random. You get confetti: bands that have no relationship to their neighbours, no sense of a scene holding for a while and then cutting. Real film strips are not noise. They move in runs.
The second is to draw a smooth gradient. Now it is too calm. A gradient is a single idea stretched over the whole height, and it reads as a color-picker swatch, not a film. Real strips are busy. Every band is slightly out of register with the one above it, there are flashes and dark threads, and every so often the whole thing cuts to something else.
So the problem is to sit in the narrow space between those two, structured enough to read as scenes, noisy enough to read as film. Everything below is about controlling that one tension.
How a strip is built#
The whole engine is one pass down the image. A seed becomes a stream of numbers, that stream drives a scene structure, each scene fills its lines with color, and identical colors are merged into bands at the end.
Two pieces of maths do most of the work here. The first is the seeded stream. The string is hashed with FNV-1a into a 32-bit number, and that number seeds a mulberry32 generator, a tiny function that returns a reproducible sequence in [0, 1). Nothing else is random, so the same string always produces the same numbers, which is what makes a strip cacheable and repeatable.
The second is the color space. All the color maths happens in OKLCH, not RGB, because OKLCH is roughly perceptually uniform: lightness L, chroma C, and hue H are separate axes, and a step of the same size looks like the same size to your eye. That matters when you interpolate. Fading between two colors in RGB drifts through muddy greys; in OKLCH it stays clean, which is why the gradients inside a scene look like light changing rather than paint mixing.
The trend of a scene comes from an arc: a few keyframes of "at this depth, sit around this lightness and this hue", interpolated in between. Hue is interpolated the short way round the wheel, then wrapped:
// the palette's vertical trend, top (t:0) → bottom (t:1)
const arc = [{ t: 0, l: 0.44, h: 46 }, { t: 0.75, l: 0.46, h: 58 }, { t: 1, l: 0.5, h: 72 }];
// interpolate hue along the shortest arc, then wrap into [0, 360)
const lerpHue = (a, b, f) => (((a + shortestArc(a, b) * f) % 360) + 360) % 360;The texture that sells it#
The arc alone gives you smooth blocks, which is the gradient failure again. What turns a block into something filmic is a per-line perturbation applied to every line, plus one rare punctuating event.
The striation is just two sine waves at different phases and frequencies, summed with different weights, so the lightness of a scene rolls slowly instead of sitting flat. On top of that goes white-noise grain:
// low-frequency striation (two sines) + a little white-noise grain
const stri = 0.6 * Math.sin(k * f1 + p1) + 0.4 * Math.sin(k * f2 + p2);
L += lineJitter * activity * (stri + signed(0.7));Then each line rolls a single number. Most of the time nothing happens. Occasionally it lands in one of three thin windows and becomes an event: a spike (a near-white flash, the way a bright frame blows out), a fleck (a jump to a saturated off-hue color), or a dark line (a thin near-black thread). Their probabilities are tiny and scaled by a per-scene "activity" level, so some stretches are calm and some are dense. Those three events are the entire difference between a chart of colors and a frame of film that is slightly out of register.
Moods, measured against real films#
The invented side of the engine is four palette families, each an arc plus a set of dials for chroma, contrast, how often it cuts, and how often it drops to black. To stop them looking like guesses, I sampled 257 real Colors of Motion strips and looked at the distributions. A few things jumped out: films are mostly muted, with a median chroma far lower than I had assumed and only rare saturated pops; they lean strongly warm, orange and gold dominating the wheel with blue as the main cool accent; and they sit mostly in the mid lightnesses. So I pulled the palettes toward that, muted by default with the occasional pop, warm where warmth belongs, while keeping each mood's own character.
Because it is all one seeded stream, changing the seed reshuffles everything at once, the scene lengths, the cuts, where the flecks land:
The other mode: sampled palettes#
The moods are invented. The other way to drive a strip is to sample a real palette (the six at the top of this page are made this way, as an homage to Colors of Motion) and let its tone curve drive the base color of every line, with the same texture model on top. A vary knob decides how faithful it stays: at 0 it reproduces the palette, and as it climbs it lets the texture reinvent it.
Try it#
Type a seed and pick a palette: a seeded pick, one of the four moods, or one of the sampled films.
Where this goes#
Colorfield is the most "just for looking" of the three engines. There is no username to encode and no op-art illusion to pull off, only a seed turned into something that feels like a film you cannot quite name. But it is the same trick underneath all of them: a string becomes a stream of numbers, the numbers become color in a space that was built for eyes rather than screens, and because the input is the only source of entropy, the output is yours for good. A whole movie in one column, except there was never a movie, only the seed.
Related articles
A follow-up to the Vasarely piece. How I generate deterministic avatars from a username, first as ordered-dithered identicons, then as halftone dot fields where the dot size does the drawing.
A design decision, not a demo. Why I encoded a whole VPS as one Go CLI, pfrctl, instead of renting a PaaS or carrying a heavy self-hosted panel, and where that trade-off pays off.
A defender's-eye tour of how modern anti-bot systems actually work in 2026, from passive TLS and HTTP fingerprints to an obfuscated client that probes your browser, server-side ML scoring, and tokens that expire in seconds. Plus an honest split of what a small team can build and what it has to rent.