music-video-gen/flow-state
Dejvino 7d151be6e1 Pylon Grid stands on the chorus instead of on a strut
The legs were the last thing in this scene it had invented for itself: a
fixed-width vertical capsule, the same object in every video, with only the
palette separating one track's structure from another's. Against migrated crowns
they read as scaffolding left up around a finished building — and structure is
half this frame, so half of it was still saying what the SCENE is rather than
what the song is.

A leg is now a stack of the song's chorus. How many members make up a column is
the song's element size deciding it — a track of few huge forms stands on three,
a track of many small ones on a ladder of nine — and how wide the column is comes
from the chorus's own proportions. Each member turns at its own angle, so a
column is one form seen many ways rather than one form printed nine times. That
makes `staging` a third declared artifact, which the lint caught before the gate
had to.

Measured on the crop that contains the change, since the frame-level score
cannot see it: over six identities the bottom third of the frame moves 13.4/255
with the old strut and 22.2/255 with the chorus stacked. The whole-frame variety
score goes 0.1322 to 0.1348, and the header now explains why the two disagree
and why both are right — the crowns dominate a descriptor averaged over the
whole image.

Cost is unchanged in practice: 7.7ms/frame at 4K, 12.4ms with every param at the
top of its range, against a 60ms ceiling. The column's bounding box is two
comparisons for a pixel outside it, which with a hundred and sixty pylons is
most of the budget.

The crown is resolved before the leg now. Resolved the other way round, the
first-wins rule let the column paint over the object standing on it and every
subject was cut off at the waist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:49:29 +02:00
..
.claude Make grain a treatment, tempo a governor, and params commit harder 2026-08-06 07:04:27 +02:00
src Pylon Grid stands on the chorus instead of on a strut 2026-08-19 17:49:29 +02:00
test Phase 1: offline audio pipeline 2026-08-05 11:10:01 +02:00
tools The song brings a body, not only an outline 2026-08-19 17:15:49 +02:00
.gitignore Finish the song bank: axis ranges that are real, and a collinearity check 2026-08-17 07:03:02 +02:00
checks.html Phase 0: determinism spine 2026-08-05 10:59:30 +02:00
debug.html One scene, wide: songs against seeds 2026-08-19 17:16:15 +02:00
EPIC-2.md Epic 2.5.2: make the framing actually reach the image 2026-08-06 20:18:08 +02:00
EPIC-3.md Measure where visual difference comes from, and correct the epic's framing 2026-08-17 21:42:04 +02:00
EPIC-4.md Epic 4: record why direction was negative, and what is left 2026-08-19 06:58:27 +02:00
filmstrip.html Let scenes layer on each other, and give the camera somewhere to look 2026-08-19 06:48:13 +02:00
gallery.html One scene, wide: songs against seeds 2026-08-19 17:16:15 +02:00
HOWTO-variety.md The song brings a body, not only an outline 2026-08-19 17:15:49 +02:00
HOWTO-visualizers.md A guidance document for building visualizers that vary 2026-08-18 16:45:38 +02:00
index.html Rename clicktrack.js — a content blocker was eating it and killing the app 2026-08-18 07:01:23 +02:00
LIBRARY-20.md Close out the twenty: what the gates taught 2026-08-17 07:00:45 +02:00
MIGRATION.md The song brings a body, not only an outline 2026-08-19 17:15:49 +02:00
package-lock.json Phase 6 WIP 2026-08-05 11:56:52 +02:00
package.json Finish the song bank: axis ranges that are real, and a collinearity check 2026-08-17 07:03:02 +02:00
PLAN.md Make grain a treatment, tempo a governor, and params commit harder 2026-08-06 07:04:27 +02:00
README.md Let scenes layer on each other, and give the camera somewhere to look 2026-08-19 06:48:13 +02:00
SIDE-QUESTS.md Write up the three side quests Epic 2 left behind 2026-08-06 16:37:12 +02:00
vite.config.js Let scenes layer on each other, and give the camera somewhere to look 2026-08-19 06:48:13 +02:00

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.

The story

Everything above happens at a moment. On top of it the track gets a story: a plot chosen per song, four moments found in the audio (arrival, turn, climax, resolution), and a position for every section — which of the four drops this is, not just that it is a drop.

That position moves what the video does. The kind's anchor scene is reserved for the climax rather than opening every section of its kind; the dials ratchet with each recurrence; the cutting rate follows tension and the resolution holds; the song's cast arrives over the video instead of being fully stated in the first shot; and under a return plot the outro re-casts what the intro opened on.

The story never overrides the song. Its moments are read off the section energies, and its effect on parameter bias is bounded so it can decide which drop this is and never whether a breakdown is one. D shows the current plot, act and journey; the timeline marks the moments. EPIC-4.md has the design.

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 metronome 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/filmstrip.html renders every song in the bank as one frame every 30 seconds, so a video that is busy and going nowhere is visible as a row of interchangeable stills. http://localhost:5180/checks.html runs the GPU gates for every phase. Add ?slow=1 for the full suite, ?phase=5 for one phase.

Seed variety test

Every other phase asks whether one video is correct. Phase 12 asks whether two videos are different — the one failure the rest of the suite cannot see, since a generator that ignores its seed passes determinism and liveness perfectly.

http://localhost:5180/checks.html?variety=1

Frames are reduced to a structural descriptor that is deliberately blind to brightness, contrast, hue and rotation, and sensitive to feature scale, orientation structure, composition, texture statistics and motion. Colour is measured but never counted — its job is to expose the case where two seeds differ only in palette. The score sits between two references the same instrument produced: the floor is how far one video travels from itself across its own sections, the ceiling is the same pipeline with every layer recast at random. Two checks gate the instrument itself before any number from it is trusted.

The report also prints direction: the rank correlation between how far apart two probes are in time and how far apart they look. drift cannot tell a video that travels from one that merely keeps changing, and a video with a story raises it on purpose — so read the two together. A floor that rises with direction is an arc; a floor that rises without one is a shuffle.

The report also sweeps all visualizations in the library, every pair, and names the structural twins — different scenes that are the same look in different colours, which the per-scene distinct gate cannot catch because it compares raw pixels.

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_time must be rate: true. Phase is elapsed × rate, so modulating a rate jumps the phase by elapsed × 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, metronome
  engine/     Timeline, Renderer, Layer, Compositor, passes, seeded rng, flash safety
  look/       palette (OKLCH), LookGenerator, ArcDriver, Story
  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.