music-video-gen/flow-state/HOWTO-variety.md
Dejvino 89e05459c0 Every shot stands on something, and the frame has two ends
A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.

Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.

The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.

The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.

Chasing the numbers turned up four real faults:

  * A scene that reads prev() cannot be a ground. It returns the whole
    composited frame including the layers above it, so a datamosh under a shot
    is eating it: the render stopped reproducing from a seek and two WebGL
    contexts diverged by 91/255 against a tolerance of 4.
  * Screen was the wrong operator for a shot over a bed. It lightens, so a
    median quarter of every frame clipped to paper and whole sections rendered
    100% white. Replaced by a lumakey — the shot's brightness is its alpha.
  * Feedback was an accumulator: a still image settled at 2.3x its own
    brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
    plus a highlight shoulder so the top rolls off instead of clipping.
  * useTrack never prewarmed, so a fresh Show's first frame differed from every
    later render of it — the export-breaking hazard Compositor.prime documents.

Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.

Rendered across twelve videos, middle of every section:

    painted        51% mean, darkest 0.3%  ->  87% mean, darkest 43%
    clipped white  24% median, worst 100%  ->   1% mean, worst 30%
    separation     0.10                    ->  0.44

Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:01 +02:00

15 KiB
Raw Blame History

How to build a visualizer that varies

HOWTO-visualizers.md covers making a scene that works. This one covers making a scene that looks different from one song to the next — a separate skill, and the one the library is currently worst at.

Everything here is a measurement rather than an opinion, and the numbers are quoted so a future change can contradict them. Several already contradict things believed earlier in the same week. Living document: add what you learn, and delete what stops being true.


The one-line version

A scene varies when the SONG can change what is on screen. It does not vary because its parameters moved.

Parameters mostly change the same picture. Content changes the picture.


How to know if you succeeded

gallery.html          your scene, six times, on six songs — with a score
checks.html?scene=X   the per-scene gate, including `consumes:` lines

The gallery score is the mean structural distance between a scene's own six frames, on the same descriptor the variety harness uses. High means the song changes this scene a lot; low means it looks like itself wherever it is cast.

There is no bar. There used to be one — 0.1, drawn across the gallery as a red line — and it was removed when composition arrived. A section is no longer one scene: it is a ground, a shot standing on it, and sometimes a pass over that, so what a viewer sees is a COMBINATION. A scene that is reliably itself is a perfectly good ingredient in one, and the old line failed it for being consistent.

What replaced it is a level up. The interesting quantity is how unalike the scenes in one stack are, and the generator now weights its choices by exactly that — see structuralDistance in scenes/surface.js. Your scene does not have to be varied on its own. It has to be unlike the things it will be stacked with, and that is measured for you.

Some reference points, measured across six songs:

0.324  Droste Feedback      varies a lot
0.082  Plasma Bloom
0.062  Voronoi Shatter      after a focus
0.062  Apollonian Gasket
0.047  Moiré Grid           after a subject; 0.031 before

The measured metadata

src/scenes/metadata.json is generated, tracked in git, and holds every measured fact about every scene: coverage, variety, per-block scores and the structural profile the distance above is computed from. Nothing in it is hand-written, and two decisions read it:

  • surface — canvas at 50% coverage or more, composable below. It used to be declared per scene and it drifted; nine scenes claimed canvas while painting under a third of the frame. Declaring surface: in a scene file is now an error.
  • which scenes may be a GROUND — a canvas that does not call prev(), fills the frame when sampled as a bed, and does not vanish under any of the six identities. Coverage is measured twice, once as a shot and once as a bed, because a scene paints wildly different amounts at different parameters: Metaballs is 69% as a drop's shot and 6% as an intro's ground.

Refresh it from gallery.html → refresh metadata after changing a shader or a metric. The file carries a fingerprint of the scenes, the shader contract, the identities and palettes, and the descriptor definitions; when that stops matching, phase 12 fails and tells you to re-measure. The write goes through a dev-only endpoint in vite.config.js, so it only works under npm run dev.


What the descriptor can and cannot see

Design against this, because half of "why is my score low" is here.

It is blind to:

  • Brightness and contrast. Frames are standardised to zero mean and unit variance before anything is measured. A scene that only gets brighter has not changed.
  • Colour. Measured, reported, and excluded from the score. Six palettes cannot disguise one image, which was the entire point.
  • Rotation, on the scale, orientation and texture blocks.
  • Quality. It measures change between songs, nothing else. Apollonian Gasket scores 0.039 and looks great. Both facts are true and neither implies the other.

It sees:

block what it measures how to move it
scale feature size — fine grain vs large forms let the song set element size (stageScale())
orient grid vs radial vs stripes, rotation-blind break or vary a regular lattice
layout where in the frame structure sits move the subject, change what is empty
texture element count, sparsity, mirror and radial symmetry vary how many things there are
region how differently the parts of the frame behave from each other give the frame a subject, so one region is unlike its neighbours
motion what moves and where, not how much vary the KIND of movement, not the rate

region is the block that rewards having something to look at. Layout says where the energy is, normalised, so a uniform field and a field with a subject in it can normalise to nearly the same answer. Region characterises each part of the frame in its own right — detail, direction, element count — and reports how far each deviates from the frame's average. A pattern spread evenly deviates by nothing everywhere, which is exactly why there is nothing to watch.

Measured when it was added: Apollonian Gasket 0.039 to 0.062 and Plasma Bloom to 0.082, both on region alone, because both have a subject the old blocks were not crediting. It is the strongest single block in the descriptor.

A known blind spot. layout is nearly useless for full-frame fields. Voronoi Shatter measures 0.004 there no matter what changes, because edge-to-edge cells occupy the same frame however they fall. That is honest — there genuinely is no arrangement — but it means a whole family is judged on four blocks instead of five. If your scene fills the frame corner to corner, expect to earn your score on orient and texture.


What has actually worked

Ordered by measured effect.

1. Draw the song's content instead of your own

The largest single lever. Take consumes: ['cast', 'ink', 'staging'] and draw castMain/castChorus where you would have drawn your own primitive. Recipe in MIGRATION.md.

Measured: identity is worth 117% of what the container is worth (checks.html?decompose=1) — swapping the song's cast under a fixed scene moves the picture more than swapping the scene under a fixed cast. A migrated library scene also carries the identity better than a stage written from scratch to carry it, which was a surprise and is why the whole library was migrated rather than replaced.

1b. Give the subject a third dimension — payoff still unmeasured

castSolid / castChorusSolid / castMarch, and consumes: ['form']. The song's protagonist as an assembly of solids instead of an outline, so its silhouette CHANGES as the shot moves rather than merely rotating. Recipe in MIGRATION.md, including the two guards that keep many instances affordable.

Three scenes carry it — Effigy, Floating Geometry, Swarm — which is 34.8% of videos containing at least one, against 11.9% when only Effigy had it. That is reach, not payoff.

The payoff is still unmeasured: the variety report has not been re-run against a library with these in it, so nothing here says the videos are more varied. What is measured is narrower — across four seeds, the lit area of Effigy's subject varies 14-113% over one turn against Soloist's 3-51% for the same rotation, which says the outline genuinely changes rather than merely spinning. Whether that reaches the variety blocks is the open question, and it is the next thing to run. Do not migrate a field scene onto it hoping for a win.

2. Let the song decide element SIZE

stageScale(). Not the size of your features relative to each other — the size of the whole vocabulary. A song of six huge forms and a song of four hundred tiny ones are different videos before anything else is decided.

Measured: adding elementScale to the identity moved stages from +0.0111 ±0.0043 to +0.0143 ±0.0008, and the scale block from 0.033 to 0.041.

3. Displace a regular lattice

If your scene is a grid, let the song push things off it. Bounded — past about half a cell the structure the grid was providing stops reading.

Measured: Isometric Blocks 0.034 → 0.049, with orient nearly doubling from 0.093 to 0.164 as the rigid lattice softened.

4. Give a full-frame field somewhere to be about

focusWarp(p, amount) and focusField(p). The song picks one to three focal points; tile in the warped coordinate so cells crowd toward them or pull away.

Measured: Voronoi Shatter 0.0463 → 0.0588. Note what moved: orient 0.140 → 0.194, while layout went 0.004 → 0.007, i.e. nowhere. Warping where cells sit changes what they look like without changing where the frame's energy is. The gain is real; the stated reason for it was wrong.


What has measurably NOT worked

Recorded because the failures were more informative than the wins, and because each of these looked obviously right beforehand.

Effects as parameters

Forty-nine scenes had their own glow; every one had its own grain. An effect knob sampled per song makes a scene look varied across parameter draws while its structure never moves — it inflates the score without changing the picture, and it fights the grade, which already does bloom, grain, chroma and vignette with an envelope the scene cannot see.

Do not add glow, bloom, grain, haze, chroma or trails to a scene. The post chain owns them. npm run lint:scenes does not catch this yet; reviewers should.

Concentrating disturbance instead of adding it

Isometric Blocks, twice. Spending the scatter budget near the focal points and calming the rest measured 0.049 → 0.0374 — most of the field went back onto the rigid lattice and took the orientation variety with it. Making the focus additive instead recovered nothing: 0.0365.

Two plausible diagnoses, both wrong. The lesson is narrow and worth keeping: a focal point has to be something the field gains, not something the rest of it pays for — and even that framing did not rescue it, so the real cause is still unknown.

Making the roster smaller

A whole afternoon went into the theory that a track drawing on fewer scenes would look more like itself. Swept directly across pool sizes 4, 8, 16 and 32 over twelve songs with three draws each: differences of 0.003 to 0.007 against a run-to-run noise of ±0.003 to ±0.005. Nothing. The theory came from an experiment whose arms differed in two ways at once.

Reading a form as a metric rather than drawing it

Spectrum Sculpture used sigShape as a radial distance rather than as a subject. Migrating it to castMain rendered pure black, because the cast carries notches and a hollow and an annulus used as a radius turns a sculpture inside out. If your scene consults a form's geometry rather than drawing it, it wants sigShape, and the cast is not for you.


Scene shapes, and how hard each is to vary

  • Element-placing (metaballs, floating-geometry, firefly-drift) — easiest. Take the cast, place on stageNode, keep your motion. Nearly all the wins above are this shape.
  • Single figure (slow-orb, eclipse-field) — take the cast and stageScale(). Composition is yours to vary; most such scenes centre their subject and never move it, which is free layout left on the table.
  • Full-frame field (voronoi-shatter, plasma-bloom, curl-flow) — hard. layout is unavailable to you. Earn it on orient and texture, and consider a focus.
  • Fixed geometry (truchet-fold, quasicrystal, apollonian-gasket) — hardest, and currently unsolved. The image is the maths, so the cast cannot be pasted on top of it. The open idea is to make the cast the repeating UNIT the tiling is built from, which is a real rewrite per scene rather than a recipe step. Nobody has tried it yet.
  • Treatment (analog-wow, scan-tear, halftone-misprint) — these are effects wearing a scene's clothes. They score 0.0310.040 and their whole migration was one posterisation. They probably belong in the identity's effects register rather than competing for screen time as subjects.

Trusting the numbers

This harness has two classes of measurement and only one is safe to steer by.

Direct render comparisons — the gallery score, checks.html?decompose=1, the per-scene gate — compare rendered frames to each other. Noise floor of exactly zero. Trust these.

Aggregate ratios — chiefly separation, which divides one small difference by another — swing wildly. Three runs of the same post-migration measurement gave 0.31, 0.56 and 0.07. Four conclusions were drawn and withdrawn during this epic for exactly that reason. Anything under about 0.01 of spread needs &repeats=3 before it is believed, and a difference that changes sign with the sample size is not a difference.

Two more traps, both of which have bitten:

  • A score of exactly 0.000 is a dead shader, not a boring scene. The gallery now says so outright — the row goes purple and carries the luminance and variance — but the underlying trap is general: a broken render produces the most boring possible numbers rather than an error.
  • A permanently-zero block looks like a property of your scene. The gallery built four of the descriptor's five blocks for weeks; motion came back 0.000 for every scene and depressed every score by a fifth. If a block is identical across many unrelated scenes, suspect the instrument.
  • Arms that differ in more than one way cannot be read as if they differed in one. This produced the roster-size result above, and it was believed for a day.

Checklist for a new or reworked scene

  1. consumes declared, and the consumes: lines pass in checks.html?scene=<name>. A declared artifact that is ignored is worse than one not declared, because the casting code believes it.
  2. No glow, bloom, grain, haze, chroma or trail parameters.
  3. Element size goes through stageScale().
  4. Placement goes through stageNode() unless the composition IS the scene.
  5. Gallery score above 0.1, checked before and after.
  6. The rest of the per-scene battery still passes — especially distinct. The more scenes share a cast, the easier it is to become one of your neighbours.

Open questions

  • Coupling is zero and has never moved. Whether a song looks different in proportion to how it sounds different measures -0.02 ±0.03. Every change in this epic left it there. Nobody has attacked it directly.
  • Fixed-geometry scenes have no known route to variety.
  • The layout blindness for full-frame fields would need a new descriptor block measuring cell statistics rather than where structure sits.
  • The bar is a target, not a description. Most of the library does not clear 0.1 — that is the point of it. It has moved from 0.04 to 0.05 to 0.1 as the descriptor gained a block and lost a bug, both of which raised every score. Expect to move it again whenever the descriptor changes, and re-read it off a fresh gallery rather than carrying the old number forward.