music-video-gen/flow-state/.claude/skills/build-visualizer/SKILL.md
Dejvino 2806ef1386 Phase 10: variety, twelve scenes, and tooling to write the next one
Watching several finished tracks side by side turned up the problem neither
Phase 8 (too few cuts) nor Phase 9 (no through-line) addressed: the same
scene cast in two different videos looked like the same footage twice.
Section bias is nearly identical between two tracks' drops, so both sampled
their parameters around the same centre, and the library's own averageness
did the rest.

Three answers, none of them a new scene:

  Temperament — a per-track hand on every parameter dial: intensity, pace,
  detail, and an extremity that decides how far toward the ends of a range
  the track is willing to sample. Bias comes from the section and is shared
  between tracks; temperament comes from the track and is not.

  Overlays — sometimes a second full scene composited over the shot, from a
  different family, in a blend that preserves what is underneath and never
  above 0.6 opacity. Not always: a stack that always doubled up would read
  as permanently cluttered rather than as occasionally layered.

  A wider palette — hue now derives from SPECTRAL TILT, the log ratio of
  treble to body. The centroid is a number most masters sit in the middle
  of, and the plain body/(body+treble) fraction is worse: low frequencies
  carry most of the energy in all music, so it read 0.98-1.00 for
  everything and four different battery tracks came out within 0.02 of
  each other. The ratio is multiplicative, so its logarithm is what
  spreads — the same four measure -9.3, -5.0, -4.1, -3.8. Also both ways
  round the wheel (violet, magenta and pink were unreachable by
  construction), four new schemes, and seeded chroma profile and lightness
  curve. Closest battery pair went from 0.005 to 0.113.

Twelve scenes take the library to 36, six per family: Aurora Veil, Vortex
Drift, Tide Rings, Ink Bleed, Dust Chamber, Salt Flat, Cargo Belt, Gate
Corridor, Circuit Bloom, Truchet Fold, Signal Decay, Storm Rift. Weighted
toward the 'space' and 'shape' traits, which were thinnest and so the
signatures most likely to run a track out of cast — the Phase 9 casting
rule means the pool a track draws from is smaller than the library.

Also fixes a real one in shots.js: heavy LRU weighting was not enough to
make a section reach its whole roster, and a five-shot section still came
out 0,2,0,2,0 about a fifth of the time. An unseen companion now wins
outright; which one is still free, so only the coverage is guaranteed.

Block Mosh declared the camera trait, assigned sigCamera(p) to a p it then
never read, and passed the lint's evidence grep. The Phase 9 render gate
measured its response to the camera at exactly zero.

--- tooling ---

Adding a scene was mostly boilerplate and round-trips, which is expensive
in both senses. The irreducible cost is the shader body; everything around
it is now mechanical:

  npm run new:scene -- "Name" --family=... --traits=...

writes the module, registers it, and leaves a skeleton that already passes
every gate, with name-derived constants so two skeletons are not twins.

The lint grew the rules that previously needed a GPU to catch: the dead
camera above, prev() with no base image, and large loops with no early
break (with a `// lint: fixed-cost` opt-out for a genuinely fixed-cost
sampling loop). checks.html?scene=Name runs the per-scene acceptance
battery for one scene — ten lines and a verdict instead of rendering the
whole library to find out whether one shader is alive. The same procedure
is a repo skill under .claude/skills/build-visualizer/.

--- checks changed, with the measurements ---

P5 determinism compared two WebGL CONTEXTS, which is not what it is for.
Measured: one context is bit-exact over 40 frames with feedback at 0.6;
two contexts disagree by up to 2/255 whether feedback is on or off. It now
asserts generation is byte-identical (hard) and rendering within 2/255,
since feedback compounds single-level variance.

P10's cross-track comparison measures distance RELATIVE to how much image
there is. Most scenes are mostly dark, so two genuinely different renders
— 25 bars against 53 — scored under 0.02 absolute purely because the black
background agrees with itself.

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

100 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: build-visualizer
description: Add a new visualizer (scene) to flow-state, or fix one that is failing its gates. Use when asked to build/add/write a visualizer, scene, or shader for this project, when a scene fails lint or the phase gates, or when the scene library needs another entry in some family. Covers the scaffolder, the shader contract, the personality traits, and the single-scene gate loop.
---
# Build a visualizer
A scene is **a fragment shader plus a params block**. Uniform binding, UI sliders,
per-track sampling, arc drift and every gate are derived from the schema — there
is no per-scene wiring to write.
The full reference is [HOWTO-visualizers.md](../../../HOWTO-visualizers.md). Read
it once for the contract details. This file is the *procedure*, and following it
in order is what keeps a new scene from costing several rounds of trial and error.
## The loop
```bash
npm run new:scene -- "Salt Flat" --family=minimal --traits=shape,camera,space,style
```
That writes `src/scenes/shader/salt-flat.js`, registers it, and leaves a skeleton
that already passes every gate — live, animated, seeded, distinct from every
other scene, honouring exactly the traits it declares. Add `--feedback` if the
scene will read `prev()`.
Then, in order:
1. **Write the concept comment first.** Two lines: what it looks like, and what
makes it different from the scenes it sits beside. "No two scenes render the
same image" is a gate with a numeric floor, not a guideline. If you cannot
write the second line, the scene does not exist yet.
2. **Replace the `scene()` body.** Keep the skeleton's trait calls; they are
what the casting rule is checked against.
3. **Lint.** `npm run lint:scenes` — one second, no browser, catches schema and
shader disagreeing, undeclared uniforms, missing `rate: true`, a declared
trait with no evidence, a dead camera, `prev()` with no base image, and
unbounded loops.
4. **Gate the one scene.** Open
`http://localhost:5180/checks.html?scene=Salt%20Flat`.
Ten-ish lines: schema, renders, animates, deterministic, distinct, param
sweep, flash rate, and one line per declared trait. This is the same battery
Phases 2, 5 and 7 apply library-wide, filtered to your scene.
5. **Run the library gates** once at the end: `checks.html?slow=1`. Phases 2, 5
and 7 iterate the registry, so the new scene is covered automatically.
Do not skip 3 before 4, or 4 before 5. Each step is roughly ten times cheaper
than the next and catches a different class of mistake.
## Choosing family and traits
**Family** decides which section kinds can cast the scene — a breakdown never
lands on a strobing glitch scene. Aim for 4-6 scenes per family; check the
current spread with:
```bash
node -e "import('./src/scenes/registry.js').then(({scenes})=>{const b={};for(const m of scenes)(b[m.family]??=[]).push(m.name);console.log(b)})"
```
**Traits** are a contract, not a hint. Each track is built on a signature of one
or two traits and **a scene that does not honour all of them is never cast in
that track**. Declare only what the shader genuinely uses:
| trait | call | what it means for your scene |
|---|---|---|
| `shape` | `sigShape(p)` / `sigForm(p, at, size)` | every element you draw is the track's signature form, not your own circle or box |
| `camera` | `sigCamera(p)` | your coordinate is filmed by the track's operator. **Must feed the image** — assigning it to a `p` you then ignore is a dead camera, and both the lint and the render gate will say so |
| `space` | `sigHorizonY()`, `sigAir(col, p, d)` | your ground is at the track's horizon and your distance haze is the track's |
| `style` | `sigEdge(d)`, `sigGrain(uv)`, `sigFolded(p)`, `u_sigLine/Soft/Texture/Fold` | your lines are drawn in the track's weight |
Prefer thin traits. `space` and `shape` carry the most identity and have the
fewest scenes, so they are usually where another scene is worth most.
## The five mistakes that actually happen
1. **A rate param that is not flagged.** Anything multiplying `u_time` needs
`rate: true`, or reactivity jumps the phase by `elapsed × Δrate` and the scene
strobes. Add a bounded term instead: `u_time * u_speed + u_bandLow * 2.0`.
2. **Whole-frame luminance on the beat.** That is the WCAG 2.3.1 failure the
flash gate exists for. Pulse something local; quantise glitches onto
`floor(u_barPhase * n)` so they step with the music.
3. **A declared trait the image does not respond to.** Passes the eye, fails the
gate. Both cost the same to fix before you commit and much more after.
4. **Reading `prev()` with nothing underneath.** Black for the first frames,
different after a seek than after playback. Always draw a base field.
5. **A contract uniform name reused as a param** (`u_width`, `u_time`, `u_seed`).
GLSL redefinition; the only symptom is a black frame.
## When a gate fails
- **not distinct** — the closest scene is named in the output. Change the
structure, not the palette; colour comes from the track.
- **dead/blown in the param sweep** — the sweep pushes each param to its limits
alone. Usually a range that should not reach 0, or one that saturates.
- **flash rate over 3/s** — find the term that swings the whole frame and make it
local or smooth.
- **not deterministic** — something is reading wall-clock or unseeded randomness;
the lint greps for the usual suspects, but `fwidth`-style derivative tricks can
also differ. Everything must be a function of `u_time`/`u_frame` and `u_seed`.