The palette was generated once and pushed to every layer of every section for
the whole runtime. Five minutes, one scheme, no movement — and colour is the
strongest perceptual variable the system has, so freezing it wasted the
biggest lever available for making a long video feel like it is going
somewhere.
Deliberately not "a new palette per section". A track has one identity and
the palette is most of it; replacing it mid-video reads as a different video.
What moves is the palette ITSELF — rotated, warmed, opened up — so at four
minutes the image is somewhere the first minute implied. Four modes: static
(one in six or so, a held colour is a legitimate choice), drift (slow hue
travel across the whole track), sections (each kind gets its own offset, so
the colour tells you where you are), and lift (saturation and lightness
rising into a drop).
Movement happens in OKLCH, which needed the inverse of the existing
conversion: rotating hue in RGB changes brightness as a side effect, and that
artefact is the reason this project picked OKLCH in the first place. The
rotation is applied to every colour equally, so the scheme and the spread
that made the palette a palette survive the move.
Bounded on purpose at ±34° hue, ±35% saturation, ±0.07 lightness. A full
rotation would destroy the identity as surely as a new palette; the movement
has to be the kind you notice on a rewatch, not the kind you notice as an
effect.
Four new Phase 11 checks, two of which are the counter-checks that keep this
honest. Colour must actually move (weakest 0.232 channel distance across 18
moving tracks) AND must stay inside its identity. The contrast floor is
asserted at 1620 sampled points across the movement rather than at the two
ends, because a saturation lift can flatten a perfectly good palette
somewhere in the middle. The moved palette is memoised on a rounded shift,
and a fourth check proves a seeked frame gets bit-identical colours to a
played one rather than merely similar — that would have been an export-only
determinism fault.
47/48 on phases 7-11 slow; the one failure is the known load-dependent
Horizon Lines flake already recorded in
|
||
|---|---|---|
| .. | ||
| .claude | ||
| src | ||
| test | ||
| tools | ||
| .gitignore | ||
| checks.html | ||
| EPIC-2.md | ||
| HOWTO-visualizers.md | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| PLAN.md | ||
| README.md | ||
| vite.config.js | ||
flow-state
Ambient/EDM music video generator. Drop in a track, get a full-length, non-story, music-reactive video. No sourced footage — every frame is generated, and the whole look is derived from the audio.
npm install
npm run dev # http://localhost:5180
Drop an audio file onto the page (mp3, flac, wav, ogg). Analysis takes a second or two, then the video is ready to preview and export.
How it works
The track is decoded and analysed before the first frame renders, into a table with one row per video frame: band energies, onset flux, spectral centroid and flatness, a phase-locked beat grid, section boundaries, and lookahead fields.
Nothing reads a live AnalyserNode. Realtime preview maps audio.currentTime to a
frame index; export counts frames. Both read the same rows, so what you preview is
what you export — the exporter has no render path of its own.
Analysing the whole track up front also buys the thing a causal analyser cannot do: a build can anticipate its drop and arrive at the transition already at full tension, instead of reacting once the drop has landed.
Working with it
space |
play / pause |
← → |
previous / next section boundary |
L |
loop the current section |
D |
debug HUD |
O |
toggle the corner title plate |
, . |
step one frame |
test render exports 20 seconds around the playhead at full export quality. Use it before committing to a full render.
reroll re-seeds the whole track; reroll section changes only the section under the playhead; lock protects a section from further rerolls. Every parameter the generator chose is exposed under the scene tab and can be edited live.
The click track button (look tab) mixes an audible click onto the detected beat grid. If the clicks don't sit on the beat, tempo detection is wrong and everything downstream inherits it — check this first when a track looks off.
Checks
npm test # audio pipeline against synthetic ground truth
npm run lint:scenes # determinism grep + scene schema/shader agreement
http://localhost:5180/checks.html runs the GPU gates for every phase. Add
?slow=1 for the full suite, ?phase=5 for one phase.
Adding a scene
Quick walkthrough: HOWTO-visualizers.md.
A scene is a shader plus a params block. Everything else — uniform binding, UI controls, seeded per-track sampling, arc automation — is derived from the schema.
export const myScene = {
name: 'My Scene',
family: 'organic', // flow organic minimal structural geometric glitch
kind: 'fragment',
params: {
density: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_density', bias: 'density' },
speed: { type: 'float', range: [0.1, 2], default: 0.5, uniform: 'u_speed', rate: true },
palette: { type: 'palette', count: 4 },
},
reactive: {
density: { feature: 'bandLow', amount: 0.3 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
return vec4(palRamp(fbm(p * 4.0 + t, 4)), 1.0);
}
`,
};
Register it in src/scenes/registry.js, then run npm run lint:scenes and the
Phase 7 checks. Three rules the linter enforces, each of which has already caused a
real bug here:
- Anything multiplying
u_timemust berate: true. Phase iselapsed × rate, so modulating a rate jumps the phase byelapsed × delta— a minute in, a small wobble throws the image several whole units between frames. It measured as strobing at twice the accessibility limit. - Don't reuse a contract uniform name (
u_width,u_time,u_seed, …). It's a GLSL redefinition error, and the only symptom is a black frame. - Use
pal()/palRamp(), not hardcoded colours, or the look generator can't recolour the scene.
Scenes that composite over a background rather than being one declare
role: 'accent'.
Layout
src/
audio/ decode, STFT analysis, tempo, segmentation, FeatureTrack, click track
engine/ Timeline, Renderer, Layer, Compositor, passes, seeded rng, flash safety
look/ palette (OKLCH), LookGenerator, ArcDriver
params/ declarative schema, validation, serialisation
scenes/ the library — shader/ and layers3d/
export/ WebCodecs exporter
ui/ preview surface
checks/ phase gates, run from checks.html
PLAN.md has the full design and the reasoning behind each gate.
Forked from party-stage by copying what was useful, then fully detached — there
are no imports across the directory boundary in either direction.