---
title: A Film as a Column of Color
date: '2026-07-05'
description: 'The third generative-art engine in the series. The problem of turning a seed into something that reads like a film reduced to color, a little of the maths behind it (seeded noise, OKLCH, a texture model), and a demo you can drive.'
author: Pablo Fernandez
tags:
  - Generative Art
  - SVG
  - OKLCH
  - Color
  - TypeScript
source: 'https://www.pablofr.com/blog/generative-colorfield-colors-of-motion'
---
There's a lovely project called [The Colors of Motion](https://thecolorsofmotion.com/) 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](/blog/generative-vasarely-in-code) and [the username avatars](/blog/generative-avatars-dither-halftone). Same rules as the others: a pure function of a seed string, no clock, no `Math.random`, rendered to [SVG](https://developer.mozilla.org/en-US/docs/Web/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.

```
                seed  "blade-runner"
                          │
                FNV-1a  →  mulberry32
                          ▼
         one deterministic stream of numbers
                          │
           walk the strip, scene by scene
                          ▼
           ┌────────────────────────────┐
           │ arc(depth) → base  L, C, H │
           │ cut · drift · black bar    │
           └──────────────┬─────────────┘
                          ▼
             for every line in the scene
                          │
       ┌────────────────────────────────────┐
       │ striation + grain on L, hue wobble │
       │ one event:  spike · fleck · dark   │
       └──────────────────┬─────────────────┘
                          ▼
                 OKLCH  →  sRGB hex
                          ▼
        run-length → color bands → one strip
```

*One pass down the strip: a seed becomes a deterministic number stream, which drives scene structure, then per-line color, then bands.*

Two pieces of maths do most of the work here. The first is the seeded stream. The string is hashed with [FNV-1a](https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function) into a 32-bit number, and that number seeds a [`mulberry32`](https://github.com/bryc/code/blob/master/jshash/PRNGs.md#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](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch), not RGB, because OKLCH is roughly [perceptually uniform](https://bottosson.github.io/posts/oklab/): 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:

```typescript:colorfield.ts /arc/ /lerpHue/
// 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;
```

```
  top    ┤ t 0.00   L 0.44  H 46    keyframe
         │
         │  eased between,
         │  hue the short way
         ┤ t 0.75   L 0.46  H 58    keyframe
         │
         │  eased between
  bottom ┤ t 1.00   L 0.50  H 72    keyframe
```

*A few keyframes pin lightness and hue at set depths down the strip, and between them both values are eased, hue the short way round the wheel.*

## 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.

```
      base color   (L, C, H from the arc)
          │
          ▼       striation + grain, hue wobble
  a perturbed line
          │
          ▼         roll one number in [0, 1)
     ┌────────────┬────────────┬────────────┐
     ▼            ▼            ▼            ▼
   keep         SPIKE        FLECK        DARK
   as is       L +0.3       off-hue      L ×0.3
                flash         pop         line
     │            │            │            │
     └────────────┴─────┬──────┴────────────┘
                        ▼
               OKLCH  →  sRGB hex
```

*Every line starts from the arc, gets striation and grain, then rolls once for a single event before it is converted to a color.*

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:

```typescript:colorfield.ts /sin/2-3 /signed/
// 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](https://thecolorsofmotion.com/)) 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.
