videoskilletdecisions Open the app ↗

0006 — A take is a seed plus its resolved picks, and never Math.random

Status: accepted, 2026-08-11.

Context#

The premise of the offline render (../EDITOR.md › Fixed-framerate export) is that you perform a piece at whatever rate the GPU gives you and re-render it at quality afterwards. That only means anything if the second render is the first one again.

Two things stood in the way, and only the first was on anybody's list.

The strip rolls on purpose. A rundown's rows can name a pool rather than a file (wiki-random, ia-random) and can shake the live look, so a take is unreproducible by construction unless the draws come from somewhere a record can point at. Record four good minutes with unseeded rolls and there is no way back to them.

The signal path rolls too, which was not on the list. MixState and TapeState each own a Wow — the capstan's quasi-periodic wander, which draws three times a frame — and both reached for Math.random from inside the frame. LineState draws once a line and ModState's random walk and sample-hold once a frame each; both took a rand already, and the engine was passing neither. So a vhs board could not have re-rendered identically however clean frame zero was, and the 3% spread the old harness measured between two takes was never only the feedback buffers it was attributed to.

A seed alone is also not enough, and src/sources/pool.ts says why: a url is a rendering. Commons rolls with gsrsort=random, so which candidates come back is the server's choice; archive.org's within-page ordering is upstream's; and the url that worked today 404s when a transcode ladder is rebuilt. A seed reproduces this app's decisions — which pool, which page, which of the candidates — and only a recorded PoolRef (origin, title, kind) reproduces the file.

Decision#

Everything that rolls takes a trailing rand argument defaulting to Math.random. The generator lives in src/core/rng.ts (rngFor, mulberry32) and the convention is one line: a caller that has a seed passes it, and a caller that does not keeps the behaviour it had. mutate, randomPresetMix, ModState, LineState, MixState, TapeState, Wow, StickSlip and both pool rolls follow it.

The engine holds the dice for a take. Engine.startTake({fps, seed}) points every per-frame modulator at rngFor(seed) for the length of the take; endTake() puts them back on Math.random. Live is deliberately unseeded — a session nobody is recording should not walk one fixed sequence from page load.

A take records the seed and the resolved picks, never either alone, and the picks are stored as identity (PoolRef) rather than as urls. The seed half is built — the rundown carries one, seedFor derives a per-row generator from it, and renderTake hands it to the engine. The picks half has nothing to record yet, because nothing rolls during a render until the strip's offline walk lands; this record is here first so that walk is built against the rule rather than discovering it.

Consequences#