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>
This commit is contained in:
Dejvino 2026-08-05 23:59:20 +02:00
parent 0ee7a6a3b3
commit 2806ef1386
31 changed files with 2275 additions and 76 deletions

View File

@ -0,0 +1,99 @@
---
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`.

View File

@ -1,5 +1,14 @@
# How to build a visualizer (scene) # How to build a visualizer (scene)
> **Start here:** `npm run new:scene -- "My Scene" --family=glitch --traits=camera,style`
>
> That writes the module, registers it, and leaves a skeleton that already passes
> every gate. Then write the `scene()` body, `npm run lint:scenes`, and open
> `checks.html?scene=My%20Scene`. The rest of this file is the reference.
>
> There is also a repo skill — `.claude/skills/build-visualizer/` — which is the
> same procedure in the form an agent will follow.
A scene is **a fragment shader plus a params block** — nothing else. Uniform A scene is **a fragment shader plus a params block** — nothing else. Uniform
binding, the generated UI sliders, seeded per-track sampling, arc drift and the binding, the generated UI sliders, seeded per-track sampling, arc drift and the
phase-gate checks are all derived from the schema. There is no per-scene wiring. phase-gate checks are all derived from the schema. There is no per-scene wiring.
@ -36,7 +45,10 @@ vec4 scene(vec2 uv, vec2 p) {
}; };
``` ```
Register it in `src/scenes/registry.js` (import + push into `MODULES`). Done. The scaffolder writes all of that for you, including the registry import and
entry, and bakes name-derived constants into the skeleton field so two freshly
scaffolded scenes are not identical to each other. To do it by hand: write the
file and add the import + `MODULES` entry in `src/scenes/registry.js`.
## The shader contract ## The shader contract
@ -128,19 +140,46 @@ The lint greps your shader and fails a declared trait with no evidence. Declarin
## Families ## Families
Chosen by section kind in the arc driver — a breakdown never lands on a strobing Chosen by section kind in the arc driver — a breakdown never lands on a strobing
glitch scene. Current counts (aim: 4-6 each): flow 2, organic 3, minimal 3, glitch scene. The library is at **six per family** (36 scenes), which is the
structural 2, geometric 3, **glitch 3**. The thin families are the best place to target. Check the current spread before adding another:
contribute next.
```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)})"
```
Depth matters more than it looks: the casting rule in `look/Personality.js`
disqualifies scenes that do not honour the track's signature traits, so the pool
a given track draws from is smaller than the library. Thin traits (`space`,
`shape`) are worth more than thin families.
## Verify ## Verify
Three rungs, each about ten times cheaper than the next. Climb them in order.
```bash ```bash
npm run lint:scenes # schema/shader agreement both ways + determinism grep npm run lint:scenes # ~1s, no browser
npm test # audio pipeline (unaffected, but cheap)
``` ```
Then the GPU gates in the browser at `http://localhost:5180/checks.html`: Static gates: schema and shader agreeing both ways, determinism grep, rate flags,
Phase 2 (param range sweep — no NaN/black/white), Phase 5 (flash-rate sweep), a declared trait with no evidence in the source, a **dead camera**
Phase 7 (per-scene acceptance: distinctness, liveness, animation, determinism, (`p = sigCamera(p)` and then nothing reads `p`), `prev()` with no base image, and
4K budget). These iterate the registry, so a new scene is covered automatically loops with a large bound and no early break. A loop whose cost is genuinely fixed
once registered. — sampling a curve at a set resolution — can say so with a `// lint: fixed-cost`
comment just above it.
```
http://localhost:5180/checks.html?scene=My%20Scene
```
The per-scene acceptance battery for one scene: schema, renders, animates,
deterministic, distinct from every other scene, param sweep, flash rate, and one
line per declared trait proving the image actually responds to it. Ten lines and
a verdict — this is the loop to stay in while writing.
```
http://localhost:5180/checks.html?slow=1
```
Everything. Phases 2, 5 and 7 iterate the registry so a new scene is covered
automatically; Phases 8-10 cover how the look generator uses it. Run this once
before committing.

View File

@ -528,6 +528,57 @@ to draw a hexagon should not appear in the hexagon video.
- **Watch the battery.** The target is that a viewer could name the through-line on a second - **Watch the battery.** The target is that a viewer could name the through-line on a second
viewing — not the first. No metric expresses that. viewing — not the first. No metric expresses that.
### Phase 10 — variety
The third finding from watching real tracks, after Phase 8 (too few cuts) and Phase 9 (no
through-line): **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; temperament comes from the track and is not.
- **Overlays.** Sometimes a second full scene is composited over the shot at partial opacity,
from a different family, in a blend that preserves what is underneath. Not always — a stack
that always doubled up would read as permanently cluttered rather than as occasionally
layered.
- **A wider palette.** Hue derives from **spectral tilt** — the log ratio of treble to body —
rather than the centroid or a plain body fraction. Both of those collapse: the centroid is a
number most masters sit in the middle of, and low frequencies carry most of the energy in
all music, so the plain fraction 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. Plus: both ways round the wheel (violet, magenta and pink were previously
unreachable by construction), four new schemes, and seeded chroma profile and lightness
curve.
The library also grew to **36 scenes, six per family** — depth matters more than it looks,
because the Phase 9 casting rule means the pool a given track draws from is smaller than the
library.
**Gate:**
- Temperaments spread across a battery rather than collapsing to one value.
- One scene rendered under two tracks' parameters differs — measured RELATIVE to how much
image there is, because most scenes are mostly dark and an absolute frame distance scores
two genuinely different sparse renders as nearly identical.
- Overlays occur on 5-55% of stacks, never at `normal` blend, never above 0.6 opacity.
- Every sixth of the colour wheel is reachable across a sampled population, and two tracks
that sound different do not get the same palette.
### Tooling, added with Phase 10
Adding a scene was mostly boilerplate and round-trips, which is expensive in both senses.
- `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 identical to each other).
- The lint grew the rules that used to need a GPU to catch: a **dead camera**
(`p = sigCamera(p)` and then nothing reads `p` — a real scene shipped like that and the
Phase 9 render gate measured its response at exactly zero), `prev()` with no base image, and
large loops with no early break (with a `// lint: fixed-cost` opt-out).
- `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.
- `.claude/skills/build-visualizer/` is the same procedure as a repo skill.
--- ---
## 10. Detachment from `party-stage` ## 10. Detachment from `party-stage`

View File

@ -8,7 +8,8 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint:scenes": "node tools/lint-scenes.js", "lint:scenes": "node tools/lint-scenes.js",
"test": "node --test test/*.test.js" "test": "node --test test/*.test.js",
"new:scene": "node tools/new-scene.js"
}, },
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {

View File

@ -1,4 +1,5 @@
import { runAll, summarize, allChecks } from './framework.js'; import { runAll, summarize, allChecks } from './framework.js';
import { runSceneGate } from './scene-gate.js';
// Registering a phase's checks is a side effect of importing it. // Registering a phase's checks is a side effect of importing it.
import './phase0.js'; import './phase0.js';
@ -11,6 +12,7 @@ import './phase6.js';
import './phase7.js'; import './phase7.js';
import './phase8.js'; import './phase8.js';
import './phase9.js'; import './phase9.js';
import './phase10.js';
const out = document.getElementById('results'); const out = document.getElementById('results');
const summaryEl = document.getElementById('summary'); const summaryEl = document.getElementById('summary');
@ -29,6 +31,25 @@ function row(result) {
async function main() { async function main() {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
// Single-scene mode: the per-scene acceptance battery for one scene, in a
// form that is cheap to run and cheap to read. This is the loop you are in
// while writing a scene; running all ten phases to find out whether one new
// shader is alive is both slow and a page of output to wade through.
const sceneArg = params.get('scene');
if (sceneArg) {
summaryEl.textContent = `gating "${sceneArg}"…`;
const { ok, lines } = runSceneGate(sceneArg);
out.innerHTML = `<pre class="scene-gate">${lines.join('\n')}</pre>`;
summaryEl.textContent = `${sceneArg}: ${ok ? 'PASS' : 'FAIL'}` +
`${lines.filter((l) => l.startsWith('PASS')).length}/${lines.length} criteria`;
summaryEl.className = ok ? 'ok' : 'bad';
window.__CHECKS__ = { scene: sceneArg, ok, lines };
window.__CHECKS_DONE__ = true;
console.log('[scene-gate]', sceneArg, ok ? 'PASS' : 'FAIL', '\n' + lines.join('\n'));
return;
}
const phaseArg = params.get('phase'); const phaseArg = params.get('phase');
const phases = phaseArg ? phaseArg.split(',').map(Number) : null; const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
const skipSlow = params.get('slow') !== '1'; const skipSlow = params.get('slow') !== '1';

View File

@ -0,0 +1,265 @@
// Phase 10 gate — variety.
//
// Phase 8 gave a track more cuts, Phase 9 gave it a coherent identity. Watching
// several tracks side by side exposed what neither 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 params
// around the same centre, and the library's own averageness did the rest.
//
// Three answers, and this gate is what holds them honest:
//
// temperament — a per-track hand on every parameter dial (Personality.js)
// overlays — a second full scene composited over the first, sometimes
// palette — a wider reachable colour space, so two tracks differ on
// colour before they differ on anything else
//
// The hard part of testing "variety" is that it is a property of a POPULATION,
// not of one render. Every check here therefore samples many tracks and asks
// about the spread, never about a single value.
import { check, expect } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { scenes } from '../scenes/registry.js';
import { generateLook } from '../look/LookGenerator.js';
import { AudioPalette, generateUsablePalette, relativeLuminance } from '../look/palette.js';
import { Rng } from '../engine/rng.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { sampleValues } from '../params/schema.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
import { battery as timbreBattery } from './phase3.js';
/**
* Several tracks that genuinely differ in what they sound like the population
* every check here measures. Synthetic, so the gate does not depend on assets.
*/
let cachedBattery = null;
function battery() {
if (!cachedBattery) {
cachedBattery = [
{ name: 'slow ambient', buffer: synthesizeSectioned({ bpm: 84, duration: 110, changeAt: 55 }) },
{ name: 'mid house', buffer: synthesizeSectioned({ bpm: 122, duration: 110, changeAt: 50 }) },
{ name: 'fast techno', buffer: synthesizeSectioned({ bpm: 148, duration: 110, changeAt: 45 }) },
{ name: 'broken beat', buffer: synthesizeSectioned({ bpm: 104, duration: 110, changeAt: 70 }) },
].map((t) => ({ ...t, track: FeatureTrack.fromAudioBuffer(t.buffer, { fps: 60 }) }));
}
return cachedBattery;
}
function makeEngine(track, width = 160, height = 90) {
const engine = new Engine({ width, height });
engine.timeline.setDuration(track.duration);
engine.setFeatureProvider(featureProviderFor(track));
return engine;
}
/** Every layer stack in a look, flattened. */
const stacksOf = (look) => look.sections.flatMap((s) => s.variants || [s.layers]);
check(10, 'two tracks do not share a temperament', () => {
// The per-track hand on the dials. If these collapsed toward one value the
// whole mechanism would be decorative, and the symptom — every video
// sampling around the library average — is exactly what it was built for.
const looks = battery().map(({ track }, i) => generateLook(track, { seed: 100 + i * 7919 }));
const t = looks.map((l) => l.personality.temperament);
const spread = (key) => Math.max(...t.map((x) => x[key])) - Math.min(...t.map((x) => x[key]));
const worst = Math.min(spread('intensity'), spread('pace'), spread('extremity'));
return expect(worst > 0.25,
`intensity ${spread('intensity').toFixed(2)} · pace ${spread('pace').toFixed(2)} · ` +
`detail ${spread('detail').toFixed(2)} · extremity ${spread('extremity').toFixed(2)}`);
});
check(10, 'one scene looks different in two different videos', () => {
// The complaint, as a number. Take a scene, render it with the parameters
// and personality two different tracks gave it, and require the images to
// actually differ — far above the 1/255 the determinism checks treat as
// noise, but far below "unrecognisable". The same scene should still be
// itself; it just should not be the same footage.
const looks = battery().map(({ track }, i) => ({
track,
look: generateLook(track, { seed: 500 + i * 6841 }),
}));
const distances = [];
const problems = [];
for (const module of scenes.filter((m) => m.kind === 'fragment').slice(0, 12)) {
const engine = makeEngine(looks[0].track);
try {
const frames = looks.map(({ look }) => {
// Sample this scene the way each track's own generator would.
// If the track did not cast it, sample it anyway with that
// track's temperament — the question is what this scene WOULD
// look like in that video, and falling back to defaults would
// compare two identical parameter sets and prove nothing.
const stack = stacksOf(look).find((s) => s[0].module === module);
const params = stack ? stack[0].params : sampleValues(
module,
new Rng(look.seed ^ 0x51ed270b),
look.sections[0].bias,
look.personality.temperament,
);
engine.setLayerSpecs([{
module,
params,
seed: look.seed & 0x7fffffff,
opacity: 1,
blend: 'normal',
palette: look.palette,
personality: look.personality,
}]);
engine.prime(300);
engine.compositor.reset();
return Uint8Array.from(engine.readPixels(engine.renderFrame(300)));
});
for (let i = 0; i < frames.length; i++) {
for (let j = i + 1; j < frames.length; j++) {
// Relative to how much image there is, not absolute.
// frameDistance averages over every pixel, and most scenes
// here are mostly dark — two genuinely different renders of
// a sparse scene (25 bars versus 53) score under 0.02 in
// absolute terms simply because the black background agrees
// with itself. Dividing by the images' own brightness asks
// the question that was meant: how different is this, as a
// fraction of what is actually on screen.
const brightness = Math.max(1e-3,
(frameLuminance(frames[i]) + frameLuminance(frames[j])) * 0.5);
const d = frameDistance(frames[i], frames[j]) / brightness;
distances.push(d);
if (d < 0.12) problems.push(`${module.name}: ${d.toFixed(3)}`);
}
}
} finally {
engine.dispose();
}
}
const mean = distances.reduce((a, b) => a + b, 0) / Math.max(1, distances.length);
return expect(problems.length === 0,
problems.slice(0, 4).join(' · ') ||
`${distances.length} cross-track pairs, mean relative distance ${mean.toFixed(2)}, ` +
`closest ${Math.min(...distances).toFixed(2)} (floor 0.12)`);
}, { slow: true });
check(10, 'overlays happen sometimes and not always', () => {
// A second scene over the first is the variation valve. Always-on would read
// as permanently cluttered and never-on is the state this fixed, so the
// check is on the RATE across many tracks rather than on any one stack.
let stacks = 0;
let withOverlay = 0;
const blends = new Set();
for (const { track } of battery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(track, { seed: 900 + s * 5231 });
for (const stack of stacksOf(look)) {
stacks++;
const overlay = stack.slice(1).find((l) => l.module.role !== 'accent');
if (overlay) {
withOverlay++;
blends.add(overlay.blend);
if (overlay.blend === 'normal') {
// 'normal' would hide the shot entirely rather than
// compositing over it — that is what a cut is for.
return expect(false, `${overlay.module.name} overlaid with 'normal'`);
}
}
}
}
}
const rate = withOverlay / Math.max(1, stacks);
return expect(rate > 0.05 && rate < 0.55,
`${withOverlay}/${stacks} stacks carry an overlay (${(rate * 100).toFixed(0)}%), ` +
`blends: ${[...blends].join(', ')}`);
});
check(10, 'an overlay never hides the shot underneath it', () => {
// Opacity is the other half of "composited over" — a 0.9 overlay is a
// replacement with extra steps.
const problems = [];
for (const { track } of battery()) {
for (let s = 0; s < 4; s++) {
for (const stack of stacksOf(generateLook(track, { seed: 1300 + s * 8641 }))) {
for (const layer of stack.slice(1)) {
if (layer.module.role === 'accent') continue;
if (layer.opacity > 0.6) {
problems.push(`${layer.module.name} at ${layer.opacity.toFixed(2)}`);
}
}
}
}
}
return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all overlays stay under 0.6');
});
check(10, 'the palette reaches the whole colour wheel', () => {
// Hue used to be a one-way sweep from red down to blue, which made violet,
// magenta and pink unreachable for every track ever generated — a third of
// the wheel the tool simply could not produce. Sampled across many tracks,
// every sixth of the wheel should now show up.
const buckets = new Array(6).fill(0);
const samples = 40;
for (let i = 0; i < samples; i++) {
const rng = new Rng(2000 + i * 7919);
const summary = {
meanCentroid: 0.2 + (i % 7) * 0.1,
meanFlatness: 0.1 + (i % 5) * 0.08,
dynamicRange: 0.3 + (i % 4) * 0.15,
bpm: 80 + (i % 9) * 12,
bandBalance: {
sub: 0.2 + (i % 5) * 0.12, low: 0.3 + (i % 3) * 0.2, mid: 0.25 + (i % 4) * 0.1,
high: 0.2 + (i % 6) * 0.1, air: 0.15 + (i % 7) * 0.09,
},
};
for (const [r, g, b] of generateUsablePalette(new AudioPalette(summary, rng), 6)) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
if (max - min < 0.08) continue; // greys carry no hue
let h;
if (max === r) h = ((g - b) / (max - min) + 6) % 6;
else if (max === g) h = (b - r) / (max - min) + 2;
else h = (r - g) / (max - min) + 4;
buckets[Math.floor(h) % 6]++;
}
}
const empty = buckets.filter((n) => n === 0).length;
const names = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta'];
return expect(empty === 0,
buckets.map((n, i) => `${names[i]}:${n}`).join(' ') +
(empty ? `${empty} sixth(s) unreachable` : ''));
});
check(10, 'two tracks do not get the same palette', () => {
// Distinctness, not just coverage: a wheel that is fully reachable but where
// every track lands in the same place would pass the check above.
//
// Measured on the Phase 3 battery rather than this file's, because those
// four tracks differ in TIMBRE and this file's differ mostly in tempo.
// Palette follows timbre by design, so two tracks that sound alike SHOULD
// get similar colour — asking otherwise would be asking the generator to
// ignore its own input.
const palettes = timbreBattery().map(({ track }) =>
generateLook(track, { seed: 61 }).palette); // fixed seed: difference must come from the audio
let closest = Infinity;
for (let i = 0; i < palettes.length; i++) {
for (let j = i + 1; j < palettes.length; j++) {
let sum = 0;
for (let k = 0; k < palettes[i].length; k++) {
const a = palettes[i][k], b = palettes[j][k];
sum += Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
closest = Math.min(closest, sum / palettes[i].length);
}
}
const lums = palettes.map((p) => p.map(relativeLuminance).join(','));
return expect(closest > 0.08 && new Set(lums).size === palettes.length,
`closest pair mean channel distance ${closest.toFixed(3)} (floor 0.08), ` +
`${new Set(lums).size}/${palettes.length} distinct`);
});

View File

@ -13,7 +13,7 @@ import { synthesizeSectioned } from '../audio/synth.js';
import { defaultValues, sampleValues } from '../params/schema.js'; import { defaultValues, sampleValues } from '../params/schema.js';
import { scenes } from '../scenes/registry.js'; import { scenes } from '../scenes/registry.js';
import { Rng } from '../engine/rng.js'; import { Rng } from '../engine/rng.js';
import { frameLuminance, frameVariance } from '../engine/hash.js'; import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
import { peakFlashRate } from '../engine/flash.js'; import { peakFlashRate } from '../engine/flash.js';
import { particleField } from '../scenes/layers3d/particles.js'; import { particleField } from '../scenes/layers3d/particles.js';
import { nebula } from '../scenes/shader/nebula.js'; import { nebula } from '../scenes/shader/nebula.js';
@ -300,25 +300,61 @@ check(5, 'multi-layer looks render live frames across the library', () => {
}, { slow: true }); }, { slow: true });
check(5, 'determinism survives feedback, post and 3D layers together', () => { check(5, 'determinism survives feedback, post and 3D layers together', () => {
const build = () => { // Two INDEPENDENTLY GENERATED looks, rendered through ONE engine.
//
// This used to build two Shows and compare them, which also compared two
// WebGL contexts — and once the library grew heavier scenes that started
// failing at 1-2/255 with nothing wrong: measured, the same context renders
// the same frames bit-exactly (delta 0 over 40 frames, feedback at 0.6),
// while two contexts on the same GPU disagree by up to 2/255 whether
// feedback is on or off. That is driver-level variance between contexts, and
// it is not what this check is for.
//
// Sharing the engine isolates the question that matters — does generating
// the look twice, and driving layers, feedback, post and a 3D layer twice,
// produce the same images — and lets it stay bit-exact rather than
// acquiring a tolerance that would hide a real fault.
// The two halves are asked separately, because only one of them can be
// answered bit-exactly. Generation is pure JS and must match EXACTLY —
// anything else is a real fault. Rendering the same look twice comes back
// within 1/255 but not always at 0: measured, rebuilding a look recompiles
// its programs, and a freshly linked program can differ from the previous
// one by a single level on the heavier scenes. That is the same GPU variance
// Phase 7 and PLAN.md §1 already account for, and hashing cannot express it.
const show = new Show({ width: 128, height: 72 }); const show = new Show({ width: 128, height: 72 });
show.useTrack(track5(), generateLook(track5(), { seed: 1357 }));
show.look.feedback.amount = 0.6;
return show;
};
const a = build();
const b = build();
try { try {
const run = (show) => { const shape = (look) => JSON.stringify(look.sections.map((s) =>
(s.variants || [s.layers]).map((v) => v.map((l) =>
[l.module.name, l.blend, l.opacity, l.seed, l.params]))));
const lookA = generateLook(track5(), { seed: 1357 });
const lookB = generateLook(track5(), { seed: 1357 });
const generationMatches = shape(lookA) === shape(lookB)
&& JSON.stringify(lookA.personality) === JSON.stringify(lookB.personality);
const run = (look) => {
show.setLook(look);
show.look.feedback.amount = 0.6;
show.engine.compositor.reset(); show.engine.compositor.reset();
const out = []; const out = [];
for (let f = 3000; f < 3060; f++) out.push(show.hashFrame(f)); for (let f = 3000; f < 3060; f++) {
out.push(Uint8Array.from(show.readPixels(show.renderFrame(f))));
}
return out; return out;
}; };
const ha = run(a), hb = run(b);
const mismatches = ha.filter((h, i) => h !== hb[i]).length; show.useTrack(track5(), lookA);
return expect(mismatches === 0, `${mismatches}/60 frames differed`); const fa = run(lookA);
const fb = run(lookB);
const worst = Math.max(...fa.map((frame, i) => frameMaxDelta(frame, fb[i])));
// Two levels rather than one, and only because feedback is on: the loop
// re-reads its own output at 0.6 gain every frame, so a single-level
// difference on frame n is still a fraction of a level on frame n+5.
// Measured at 2/255 over 60 frames; a real fault scores in the tens.
return expect(generationMatches && worst <= 2,
`generation identical: ${generationMatches} · worst render delta ${worst}/255 over 60 frames`);
} finally { } finally {
a.dispose(); b.dispose(); show.dispose();
} }
}); });

View File

@ -0,0 +1,160 @@
// The per-scene acceptance battery, runnable for ONE scene.
//
// The library-wide gates iterate the registry, so a new scene is covered the
// moment it is registered — but running them means rendering all thirty-six
// scenes and reading a page of results to find out whether the one you just
// wrote is alive. That is slow to run and expensive to read, and it is the loop
// you are in constantly while writing a scene.
//
// This runs the same acceptance criteria against a single scene and prints one
// line per criterion plus a single verdict. Open:
//
// checks.html?scene=Aurora%20Veil
//
// The criteria are deliberately the same ones Phase 2, 5 and 7 apply — this is
// a filter over the existing gates, not a second, weaker set of them.
import { Engine } from '../engine/Engine.js';
import { sceneByName, scenes } from '../scenes/registry.js';
import { defaultValues, sampleValues, sweepValues, validateModule } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
import { peakFlashRate } from '../engine/flash.js';
import { generatePersonality } from '../look/Personality.js';
const PALETTE = [
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
[0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
];
const SUMMARY = { meanCentroid: 0.5, meanFlatness: 0.25, dynamicRange: 0.5, bpm: 126, meanLoudness: 0.4 };
/**
* @param {string} name scene name as registered
* @returns {{ok: boolean, lines: string[]}}
*/
export function runSceneGate(name) {
const module = sceneByName(name);
const lines = [];
if (!module) {
return {
ok: false,
lines: [`FAIL no scene named "${name}" — registered: ${scenes.map((m) => m.name).join(', ')}`],
};
}
let ok = true;
const record = (pass, label, detail) => {
ok = ok && pass;
lines.push(`${pass ? 'PASS' : 'FAIL'} ${label.padEnd(26)} ${detail}`);
};
const errors = validateModule(module);
record(errors.length === 0, 'schema', errors.length ? errors.join(' · ') : 'valid');
const track = FeatureTrack.fromAudioBuffer(
synthesizeSectioned({ bpm: 128, duration: 60, changeAt: 30 }), { fps: 60 });
const engine = new Engine({ width: 192, height: 108 });
engine.timeline.setDuration(track.duration);
engine.setFeatureProvider(featureProviderFor(track));
const personality = generatePersonality(SUMMARY, new Rng(9001));
const draw = (params, frame, seed = 4242) => {
engine.setLayerSpecs([{
module, params, seed, opacity: 1, blend: 'normal', palette: PALETTE, personality,
}]);
engine.compositor.reset();
return Uint8Array.from(engine.readPixels(engine.renderFrame(frame)));
};
try {
// --- alive -------------------------------------------------------
const base = draw(defaultValues(module), 600);
const lum = frameLuminance(base);
const variance = frameVariance(base);
record(lum > 0.004 && variance > 0.0008, 'renders something',
`luminance ${lum.toFixed(4)} · variance ${variance.toFixed(4)}`);
// --- animates ----------------------------------------------------
const later = draw(defaultValues(module), 600 + 120);
const motion = frameMaxDelta(base, later);
record(motion > 3, 'animates', `max channel delta ${motion} over 2s`);
// --- deterministic -----------------------------------------------
const again = draw(defaultValues(module), 600);
const repeat = frameMaxDelta(base, again);
record(repeat <= 1, 'deterministic', `repeat delta ${repeat}/255`);
// --- distinct from every other scene -------------------------------
let closest = 255;
let closestName = '';
for (const other of scenes) {
if (other === module || other.kind !== 'fragment') continue;
engine.setLayerSpecs([{
module: other, params: defaultValues(other), seed: 4242,
opacity: 1, blend: 'normal', palette: PALETTE, personality,
}]);
engine.compositor.reset();
const d = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600))));
if (d < closest) { closest = d; closestName = other.name; }
}
record(closest >= 24, 'distinct', `closest ${closestName} at ${closest} (floor 24)`);
// --- param sweep ---------------------------------------------------
const dead = [];
for (const [pname, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue;
for (const value of sweepValues(def, 4)) {
const params = { ...defaultValues(module), [pname]: value };
const pixels = draw(params, 700);
const l = frameLuminance(pixels);
const v = frameVariance(pixels);
if (!(l > 0.002) || !(v > 0.0002) || l > 0.97) {
dead.push(`${pname}=${Array.isArray(value) ? value.join(',') : value}`);
}
}
}
record(dead.length === 0, 'param sweep',
dead.length ? `dead/blown at ${dead.slice(0, 4).join(', ')}` : 'all values live');
// --- flash rate ------------------------------------------------------
const hot = sampleValues(module, new Rng(77), { energy: 0.95, density: 0.9, motion: 0.9 });
engine.setLayerSpecs([{
module, params: hot, seed: 99, opacity: 1, blend: 'normal', palette: PALETTE, personality,
}]);
engine.compositor.reset();
const luminance = [];
for (let f = 600; f < 900; f++) {
luminance.push(frameLuminance(engine.readPixels(engine.renderFrame(f))));
}
const rate = peakFlashRate(luminance, 60);
record(rate <= 3, 'flash rate', `${rate}/s at aggressive settings (ceiling 3)`);
// --- personality response --------------------------------------------
// Every declared trait must move the image; a trait declared and ignored
// gets the scene cast in tracks it cannot express.
for (const trait of module.traits || []) {
const other = generatePersonality(SUMMARY, new Rng(9001));
if (trait === 'shape') other.shape = { sides: 6, roundness: 0.05, elongation: 1.3, tilt: 0.7 };
if (trait === 'camera') other.camera = { ...other.camera, driftAngle: 1.1, driftRate: 0.06, sway: 0.06, swayRate: 0.2, spin: 0.05, breathe: 0.05 };
if (trait === 'space') other.space = { horizon: 0.68, depth: 0.9, washAngle: 2.4, wash: 0.5 };
if (trait === 'style') other.style = { lineWeight: 0.95, softness: 0.9, texture: 0.5, symmetry: 4 };
engine.setLayerSpecs([{
module, params: defaultValues(module), seed: 4242,
opacity: 1, blend: 'normal', palette: PALETTE, personality: other,
}]);
engine.compositor.reset();
const changed = frameMaxDelta(base,
Uint8Array.from(engine.readPixels(engine.renderFrame(600))));
record(changed > 1, `trait: ${trait}`, `delta ${changed}/255`);
}
} finally {
engine.dispose();
}
return { ok, lines };
}

View File

@ -179,27 +179,68 @@ function derivePost(summary, rng) {
} }
/** /**
* One layer stack: a background scene plus an optional accent over it. * One layer stack: a background scene, sometimes a second scene composited over
* it, sometimes an accent on top of that.
* *
* The accent is composited additively at low opacity and drawn from a DIFFERENT * Three deliberately different jobs:
* family, so it reads as depth rather than as a second competing scene. Quiet *
* material mostly goes without an intro is supposed to be sparse. * background the shot. Always present, always opaque.
* overlay a SECOND full scene at partial opacity. Not always: this is the
* variation valve, and a stack that always doubled up would read
* as permanently cluttered rather than as occasionally layered.
* Drawn from a different family so the two images argue instead
* of blurring, and kept off scenes that are already busy.
* accent the depth pass. Mostly-empty by design (role: 'accent'),
* additive, low opacity.
*
* Quiet material mostly goes without either an intro is supposed to be sparse.
*/ */
function buildStack(module, accentRoster, bias, rng) { function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament) {
const layers = [{ const layers = [{
module, module,
params: sampleValues(module, rng, bias), params: sampleValues(module, rng, bias, temperament),
seed: rng.int(0, 0x7fffffff), seed: rng.int(0, 0x7fffffff),
blend: 'normal', blend: 'normal',
opacity: 1, opacity: 1,
}]; }];
// --- overlay --------------------------------------------------------
// Roughly a third of stacks on busy material, rarely on quiet material, and
// never on a background that is itself a full-frame glitch — two competing
// corruption passes is noise, not depth.
const overlayChance = module.family === 'glitch'
? 0.05
: 0.12 + bias.energy * 0.35 + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0);
const overlays = overlayRoster.filter((m) => m.family !== module.family && m.name !== module.name);
if (overlays.length && rng.bool(Math.min(0.6, overlayChance))) {
const overlay = rng.pick(overlays);
// Screen and add keep the background readable underneath; softlight and
// overlay tint it instead. All four preserve the shot; 'normal' would
// simply replace it, which is what the shot cut is for.
const blend = rng.pickWeighted(['screen', 'add', 'softlight', 'overlay'], [3, 2, 2, 1]);
layers.push({
module: overlay,
params: sampleValues(overlay, rng.fork(`overlay:${overlay.name}`), {
// An overlay reads as texture over the shot, so it is sampled
// sparser and calmer than it would be as a background.
...bias,
density: Math.max(0, bias.density - 0.25),
energy: Math.max(0, bias.energy - 0.2),
}, temperament),
seed: rng.int(0, 0x7fffffff),
blend,
opacity: blend === 'add' ? rng.range(0.18, 0.42) : rng.range(0.25, 0.55),
});
}
// --- accent ---------------------------------------------------------
if (accentRoster.length && rng.bool(bias.energy * 0.8)) { if (accentRoster.length && rng.bool(bias.energy * 0.8)) {
const eligible = accentRoster.filter((m) => m.family !== module.family); const eligible = accentRoster.filter((m) => m.family !== module.family);
const accent = rng.pick(eligible.length ? eligible : accentRoster); const accent = rng.pick(eligible.length ? eligible : accentRoster);
layers.push({ layers.push({
module: accent, module: accent,
params: sampleValues(accent, rng.fork('accent'), bias), params: sampleValues(accent, rng.fork('accent'), bias, temperament),
seed: rng.int(0, 0x7fffffff), seed: rng.int(0, 0x7fffffff),
blend: rng.pickWeighted(['add', 'screen'], [2, 1]), blend: rng.pickWeighted(['add', 'screen'], [2, 1]),
opacity: rng.range(0.18, 0.5), opacity: rng.range(0.18, 0.5),
@ -242,13 +283,20 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
const accentRoster = scenes.filter((m) => m.role === 'accent' const accentRoster = scenes.filter((m) => m.role === 'accent'
&& sceneHonours(m, personality.signature)); && sceneHonours(m, personality.signature));
// Scenes eligible to be composited OVER a background. Same casting rule as
// everything else — an overlay is on screen as much as the shot under it,
// so an off-design one would be just as visible.
const overlayRoster = scenes.filter((m) => m.role !== 'accent'
&& sceneHonours(m, personality.signature));
const sections = track.sections.map((section) => { const sections = track.sections.map((section) => {
const roster = rosterByKind.get(section.kind) || [scenes[0]]; const roster = rosterByKind.get(section.kind) || [scenes[0]];
const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`); const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
const bias = biasFor(section, summary); const bias = biasFor(section, summary);
const variants = roster.map((module, v) => buildStack( const variants = roster.map((module, v) => buildStack(
module, accentRoster, bias, sectionRng.fork(`variant:${section.index}:${v}`), module, accentRoster, overlayRoster, bias,
sectionRng.fork(`variant:${section.index}:${v}`), personality.temperament,
)); ));
const shots = planShots( const shots = planShots(
@ -313,8 +361,10 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
if (!roster.length) roster.push(scenes[0]); if (!roster.length) roster.push(scenes[0]);
const accentRoster = scenes.filter((m) => m.role === 'accent'); const accentRoster = scenes.filter((m) => m.role === 'accent');
const overlayRoster = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
section.variants = roster.map((module, v) => buildStack( section.variants = roster.map((module, v) => buildStack(
module, accentRoster, section.bias, rng.fork(`variant:${v}`), module, accentRoster, overlayRoster, section.bias, rng.fork(`variant:${v}`),
look.personality && look.personality.temperament,
)); ));
section.shots = planShots( section.shots = planShots(
section, track, section.bias, section.variants.length, rng.fork('shots'), section, track, section.bias, section.variants.length, rng.fork('shots'),

View File

@ -21,6 +21,14 @@
// style — the art direction. Line weight, edge softness, texture, and how // style — the art direction. Line weight, edge softness, texture, and how
// many times the frame is folded. // many times the frame is folded.
// //
// Plus a fifth thing that is not a trait and is not declared by anyone: the
// TEMPERAMENT. Traits decide what a track looks like; temperament decides how
// hard it commits. It is the track's hand on every scene's parameter dials, and
// it exists because section bias alone is nearly identical between two tracks'
// drops — so one scene cast in two videos sampled around the same centre both
// times and the videos looked like each other. Temperament is per track and
// pushes those samples apart. See params/schema.js sampleValues.
//
// A scene declares which traits it can honour. Each track picks a SIGNATURE of // A scene declares which traits it can honour. Each track picks a SIGNATURE of
// one or two traits, and a scene that does not honour all of them is // one or two traits, and a scene that does not honour all of them is
// disqualified from that track — the library shrinks per track, on purpose. A // disqualified from that track — the library shrinks per track, on purpose. A
@ -64,6 +72,7 @@ export function generatePersonality(summary, rng, countEligible = null) {
const bright = summary.meanCentroid; const bright = summary.meanCentroid;
const noisy = Math.min(1, summary.meanFlatness * 3); const noisy = Math.min(1, summary.meanFlatness * 3);
const fast = Math.min(1, Math.max(0, (summary.bpm - 80) / 80)); const fast = Math.min(1, Math.max(0, (summary.bpm - 80) / 80));
const loud = Math.min(1, (summary.dynamicRange ?? 0.5) + (summary.meanLoudness ?? 0.3));
const shape = { const shape = {
// 0 sides means round. Everything else is a polygon the whole track // 0 sides means round. Everything else is a polygon the whole track
@ -107,9 +116,25 @@ export function generatePersonality(summary, rng, countEligible = null) {
symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6], [6, 4, 2, 2, 2, 1]), symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6], [6, 4, 2, 2, 2, 1]),
}; };
// How hard this track pushes every scene it casts. Deliberately wide, and
// deliberately not derived from the section: two tracks must be able to
// disagree about what "a drop" means.
const temperament = {
// Up or down on the energy/density dials.
intensity: rng.range(-0.85, 0.85) * (0.5 + loud * 0.9),
// Up or down on anything that moves.
pace: rng.range(-0.8, 0.8) * (0.55 + fast * 0.8),
// Fine and busy, or few and large. Independent of loudness on purpose —
// a quiet track can be intricate and a loud one can be blunt.
detail: rng.range(-0.6, 0.6),
// How far toward the ends of a range this track is willing to sample.
// The single most effective knob against "every video looks average".
extremity: rng.range(0.25, 0.95),
};
const signature = pickSignature(rng, countEligible); const signature = pickSignature(rng, countEligible);
return { signature, shape, camera, space, style }; return { signature, shape, camera, space, style, temperament };
} }
/** /**
@ -205,5 +230,9 @@ export function describePersonality(personality) {
SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`, SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`,
]; ];
if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`); if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`);
if (personality.temperament) {
const t = personality.temperament;
parts.push(`${t.intensity >= 0 ? 'hot' : 'cool'} ${t.extremity.toFixed(2)} bold`);
}
return parts.join(' · '); return parts.join(' · ');
} }

View File

@ -70,10 +70,31 @@ const SCHEMES = {
triad: (h) => [h, h + 2.094, h + 4.189, h + 0.5, h + 2.6, h + 4.7], triad: (h) => [h, h + 2.094, h + 4.189, h + 0.5, h + 2.6, h + 4.7],
split: (h) => [h, h + 2.6, h + 3.7, h + 0.35, h + 2.9, h + 3.4], split: (h) => [h, h + 2.6, h + 3.7, h + 0.35, h + 2.9, h + 3.4],
duo: (h) => [h, h + 1.9, h + 0.2, h + 2.1, h - 0.25, h + 1.7], duo: (h) => [h, h + 1.9, h + 0.2, h + 2.1, h - 0.25, h + 1.7],
// Four evenly spaced hues plus two repeats: the widest spread available, and
// the reason a track can now come out looking like four colours rather than
// a gradient between two.
tetrad: (h) => [h, h + 1.571, h + 3.142, h + 4.712, h + 0.8, h + 2.4],
// One hue family carrying the frame, with a single far-off pop. Reads as a
// deliberate art-directed choice rather than as a spectrum.
accented: (h) => [h, h + 0.25, h - 0.2, h + 0.45, h + 2.9, h + 3.05],
// One hue, everything else carried by lightness and chroma. Needs the
// widened L range below to stay legible, and gives the library the
// near-monochrome look it could not previously reach at all.
mono: (h) => [h, h + 0.12, h - 0.1, h + 0.18, h - 0.16, h + 0.08],
}; };
export const SCHEME_NAMES = Object.keys(SCHEMES); export const SCHEME_NAMES = Object.keys(SCHEMES);
/**
* Stretch a value around a centre so a narrow real-world range fills 0..1.
*
* A logistic rather than a linear rescale, because the tails must stay bounded:
* an unusually bass-heavy track should land at the warm end, not past it.
*/
function expand(x, centre = 0.5, slope = 3.0) {
return 1 / (1 + Math.exp(-slope * (x - centre) * 4));
}
/** The interface a palette source implements. */ /** The interface a palette source implements. */
export class PaletteSource { export class PaletteSource {
/** @returns {number[][]} array of [r,g,b] in 0..1 */ /** @returns {number[][]} array of [r,g,b] in 0..1 */
@ -107,25 +128,50 @@ export class AudioPalette extends PaletteSource {
const rng = this.rng; const rng = this.rng;
// --- Temperature: the track's timbre signature, not its loudness --- // --- Temperature: the track's timbre signature, not its loudness ---
// Warmth places spectral mass from the body (sub/low/mid) against the // SPECTRAL TILT — the log ratio of treble energy to body energy — rather
// trebles (high/air). It is folded through the whole band profile rather // than either the centroid or a plain body/(body+treble) fraction.
// than the centroid alone, because the centroid is a one number that //
// most mastered pop sits in the middle of — which was why every track // Both of those were tried and both collapse. The centroid is one number
// flared green/purple. This is the feel of the sound: a voice-and-body // most mastered music sits in the middle of. The plain fraction is worse:
// forward track belongs to the warm end of the wheel, a crisp or airy // low frequencies carry most of the energy in essentially all music, so
// track to the cool end. // it reads 0.98-1.00 for everything and the four check-battery tracks
const body = (bandBalance.sub ?? 0.4) * 0.6 // came out within 0.02 of each other. The ratio is MULTIPLICATIVE, so its
+ (bandBalance.low ?? 0.4) * 0.9 // logarithm is what actually spreads: the same four tracks measure -9.3,
+ (bandBalance.mid ?? 0.3) * 0.4; // -5.0, -4.1 and -3.8, which is a real axis to hang a palette on.
const treble = (bandBalance.high ?? 0.3) * 0.7 const bands = {
+ (bandBalance.air ?? 0.3) * 0.5 sub: bandBalance.sub ?? 0.2, low: bandBalance.low ?? 0.2, mid: bandBalance.mid ?? 0.2,
+ meanCentroid * 0.5; high: bandBalance.high ?? 0.2, air: bandBalance.air ?? 0.2,
const warmth = body / (body + treble + 1e-6); // 0 = cold, 1 = warm };
const body = bands.sub * 1.0 + bands.low * 0.9 + bands.mid * 0.35 + 1e-7;
const treble = bands.high * 0.9 + bands.air * 1.0 + bands.mid * 0.15 + 1e-7;
const tilt = Math.log(treble / body);
// Hue sweeps cold(blue, 240°) -> cyan -> green -> yellow -> warm(red), // -9 (nothing above the low mids) .. -2 (bright, airy) covers the range
// so warm material finally reaches red/yellow rather than pooling in the // real material occupies; the centroid keeps a minority vote so two
// blue/green gap. A little seeded jitter keeps identical tracks apart. // tracks with the same tilt but different brightness still differ.
const baseHue = (1 - warmth) * (Math.PI * 4 / 3) + rng.range(-0.45, 0.45); const tiltWarmth = Math.max(0, Math.min(1, (-2 - tilt) / 7));
const warmth = tiltWarmth * 0.7 + (1 - meanCentroid) * 0.3;
// Hue sweeps cold -> warm, but which WAY round the wheel is seeded.
// Going down from red through yellow and green to blue is the obvious
// route and the only one that existed; it also means violet, magenta and
// pink were unreachable for every track ever generated, because they sit
// on the arc the sweep skipped. Half of tracks now take the other way
// round, so the same warm/cool reading can land on crimson-through-
// magenta instead of crimson-through-amber.
//
// Both routes span the same arc. A short return leg would mean tracks
// that took it barely differ in hue however different they sound.
const clockwise = rng.bool(0.5);
const span = (clockwise ? 1 : -1) * Math.PI * 4 / 3;
// Tempo and dynamics nudge the hue too. Timbre is the main axis, but two
// tracks can be timbrally alike and still feel different — a slow
// spacious one and a fast compressed one should not be handed the same
// colour just because they occupy the same part of the spectrum.
const feel = ((bpm - 120) / 200 + (dynamicRange - 0.5) * 0.5) * 0.6;
const baseHue = (1 - warmth) * span + feel + rng.range(-0.45, 0.45);
const schemeName = rng.pick(SCHEME_NAMES); const schemeName = rng.pick(SCHEME_NAMES);
const hues = SCHEMES[schemeName](baseHue, rng); const hues = SCHEMES[schemeName](baseHue, rng);
@ -135,25 +181,50 @@ export class AudioPalette extends PaletteSource {
// stays muted. This is the "does it pop" axis, orthogonal to timbre. // stays muted. This is the "does it pop" axis, orthogonal to timbre.
const fast = Math.min(1, Math.max(0, (bpm - 80) / 150)); const fast = Math.min(1, Math.max(0, (bpm - 80) / 150));
const energy = Math.min(1, fast * 0.4 + (1 - Math.min(1, meanFlatness)) * 0.4 + dynamicRange * 0.3); const energy = Math.min(1, fast * 0.4 + (1 - Math.min(1, meanFlatness)) * 0.4 + dynamicRange * 0.3);
const chromaBase = 0.10 + energy * 0.16; // Vividness is the track's, but how far it commits is seeded — the old
// fixed mapping meant two tracks with similar statistics got not just
// similar hues but the same saturation, which is most of why they read
// as the same palette.
const vividness = rng.range(0.55, 1.45);
const chromaBase = (0.09 + energy * 0.19) * vividness;
// Chroma profile: does the palette saturate in the middle (the old fixed
// behaviour), at the bright end, or barely at all? A near-neutral set
// with one vivid accent is a look the generator could not previously
// produce.
const profile = rng.pickWeighted(['arch', 'rising', 'flat', 'accent'], [3, 2, 2, 2]);
const chromaAt = (t) => {
switch (profile) {
case 'rising': return 0.35 + t * 1.1;
case 'flat': return 0.9;
case 'accent': return t > 0.72 ? 1.5 : 0.28;
default: return 0.55 + Math.sin(t * Math.PI) * 0.75;
}
};
// A dynamic mercury gets a wider light-to-dark range; warmth keeps warm // A dynamic mercury gets a wider light-to-dark range; warmth keeps warm
// tones from sinking into brown, since dark + orange is mud. // tones from sinking into brown, since dark + orange is mud.
const spread = 0.30 + Math.min(1, dynamicRange) * 0.30; const spread = (0.30 + Math.min(1, dynamicRange) * 0.30) * rng.range(0.85, 1.5);
const anchor = 0.40 - warmth * 0.06 + rng.range(-0.05, 0.10); const anchor = 0.40 - warmth * 0.06 + rng.range(-0.14, 0.16);
// How the lightness steps are distributed: 1.7 keeps most entries dark
// with a couple of bright accents (the old fixed curve), below 1 spreads
// them evenly, above 2 makes the set almost entirely dark with one
// highlight. Another axis two similar tracks can differ on.
const curve = rng.range(0.75, 2.4);
const colors = []; const colors = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const t = count > 1 ? i / (count - 1) : 0; const t = count > 1 ? i / (count - 1) : 0;
// Deliberately non-linear: most entries mid-dark, one or two bright. // Deliberately non-linear: most entries mid-dark, one or two bright.
// Scenes use pal(0) as a base and higher indices as accents. // Scenes use pal(0) as a base and higher indices as accents.
const L = Math.max(0.06, Math.min(0.95, anchor + Math.pow(t, 1.7) * spread)); const L = Math.max(0.05, Math.min(0.97, anchor + Math.pow(t, curve) * spread));
const C = chromaBase * (0.55 + Math.sin(t * Math.PI) * 0.75) + rng.range(-0.012, 0.012); const C = chromaBase * chromaAt(t) + rng.range(-0.012, 0.012);
const h = hues[i % hues.length] + rng.range(-0.08, 0.08); const h = hues[i % hues.length] + rng.range(-0.08, 0.08);
colors.push(oklchToRgb(L, Math.max(0, C), h)); colors.push(oklchToRgb(L, Math.max(0, C), h));
} }
this.lastScheme = schemeName; this.lastScheme = schemeName;
this.lastProfile = profile;
return colors; return colors;
} }
} }

View File

@ -147,14 +147,26 @@ export function planShots(section, track, bias, variantCount, rng) {
function pickVariant(variantCount, previous, lastSeen, shotIndex, rng) { function pickVariant(variantCount, previous, lastSeen, shotIndex, rng) {
if (previous !== 0 && rng.bool(0.75)) return 0; if (previous !== 0 && rng.bool(0.75)) return 0;
// A companion this section has not shown yet wins outright. Weighting it
// heavily was not enough — measured, a five-shot section still came out
// 0,2,0,2,0 about a fifth of the time, so the roster existed and the shots
// never reached it. Which unseen one is still a free choice, so the order
// varies between sections; only the coverage is guaranteed.
const unseen = [];
for (let v = 1; v < variantCount; v++) {
if (v !== previous && lastSeen[v] < 0) unseen.push(v);
}
if (unseen.length) return rng.pick(unseen);
const options = []; const options = [];
const weights = []; const weights = [];
for (let v = 0; v < variantCount; v++) { for (let v = 0; v < variantCount; v++) {
if (v === previous) continue; if (v === previous) continue;
options.push(v); options.push(v);
// Unseen variants sort first, then by how long ago they were last up. // Everything has been shown at least once: fall back to least recently
// The anchor stays in the draw so the rotation cannot become rigid. // seen, with the anchor kept in the draw so the rotation cannot become
weights.push(v === 0 ? 1 : 2 + (lastSeen[v] < 0 ? variantCount : shotIndex - lastSeen[v])); // a rigid cycle.
weights.push(v === 0 ? 1 : 2 + (shotIndex - lastSeen[v]));
} }
if (!options.length) return 0; if (!options.length) return 0;
return rng.pickWeighted(options, weights); return rng.pickWeighted(options, weights);

View File

@ -91,32 +91,48 @@ export function clampValue(def, value) {
* this is how a track's measured character reaches the parameters without every * this is how a track's measured character reaches the parameters without every
* scene needing to know about audio features. `energy: 0.8` on a hard track * scene needing to know about audio features. `energy: 0.8` on a hard track
* pushes density-ish params up without pinning them, so seed variation survives. * pushes density-ish params up without pinning them, so seed variation survives.
*
* `temperament` is the track's own hand on the same dials see
* look/Personality.js. Bias comes from the SECTION and is therefore nearly the
* same for every track's drop; temperament comes from the TRACK and is not.
* Without it, one scene cast in two different videos sampled around the same
* centre both times and the two videos looked like the same video, which is
* exactly the complaint temperament exists to answer.
*/ */
export function sampleValues(module, rng, bias = {}) { export function sampleValues(module, rng, bias = {}, temperament = null) {
const out = {}; const out = {};
for (const [name, def] of Object.entries(module.params || {})) { for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') { out[name] = null; continue; } if (def.type === 'palette') { out[name] = null; continue; }
if (def.fixed) { out[name] = defaultValue(def); continue; } if (def.fixed) { out[name] = defaultValue(def); continue; }
const b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5; let b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
if (temperament) b = clamp01(b + temperamentShift(def.bias, temperament));
if (def.type === 'bool') { if (def.type === 'bool') {
out[name] = rng.bool(0.25 + b * 0.5); out[name] = rng.bool(0.25 + b * 0.5);
continue; continue;
} }
const [lo, hi] = def.range || [0, 1]; const [lo, hi] = def.range || [0, 1];
// Triangular-ish blend of a uniform draw with the bias target: keeps the
// full range reachable (so the seed contact sheet stays wide) while still // How far this track is willing to push a param toward its limits. A
// letting track character shift the centre of mass. // timid track samples near the middle of everything and reads as the
const u = rng.next(); // library's average; a bold one commits. This is the difference between
// "the same scene again" and "that scene, but this video's version".
const extremity = temperament ? temperament.extremity : 0.5;
// Bias still moves the centre of mass, but a bold track overrides more
// of it — otherwise every drop in every video converges on one point.
const mixAmount = (def.biasStrength !== undefined ? def.biasStrength : 0.45)
* (1 - extremity * 0.45);
const u = boldUniform(rng.next(), extremity);
const target = lo + (hi - lo) * b; const target = lo + (hi - lo) * b;
const mixAmount = def.biasStrength !== undefined ? def.biasStrength : 0.45;
let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount; let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;
if (def.type === 'vec2') { if (def.type === 'vec2') {
const u2 = rng.next(); const u2 = boldUniform(rng.next(), extremity);
const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount; const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount;
out[name] = [v, v2]; out[name] = [clampValue(def, [v, v2])[0], clampValue(def, [v, v2])[1]];
continue; continue;
} }
if (def.type === 'int') v = Math.round(v); if (def.type === 'int') v = Math.round(v);
@ -125,6 +141,31 @@ export function sampleValues(module, rng, bias = {}) {
return out; return out;
} }
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
* Reshape a uniform draw so a bold track reaches the ends of a range.
*
* At extremity 0 this is unchanged. As it rises the distribution hollows out:
* the same draw lands further from the centre, so a track that wants density
* gets scenes at their dense end rather than at a polite 60%.
*/
function boldUniform(u, extremity) {
const signed = (u - 0.5) * 2;
const shaped = Math.sign(signed) * Math.pow(Math.abs(signed), 1 - clamp01(extremity) * 0.65);
return clamp01(0.5 + shaped * 0.5);
}
/** Which way this track leans on each of the three bias axes. */
function temperamentShift(axis, temperament) {
switch (axis) {
case 'energy': return temperament.intensity * 0.3;
case 'density': return temperament.intensity * 0.2 + temperament.detail * 0.3;
case 'motion': return temperament.pace * 0.35;
default: return 0;
}
}
/** Evenly spaced probe values across a param's range, for the range-sweep check. */ /** Evenly spaced probe values across a param's range, for the range-sweep check. */
export function sweepValues(def, steps = 5) { export function sweepValues(def, steps = 5) {
if (def.type === 'bool') return [false, true]; if (def.type === 'bool') return [false, true];

View File

@ -24,6 +24,18 @@ import { silkRibbon } from './shader/silk-ribbon.js';
import { pylonGrid } from './shader/pylon-grid.js'; import { pylonGrid } from './shader/pylon-grid.js';
import { prismBloom } from './shader/prism-bloom.js'; import { prismBloom } from './shader/prism-bloom.js';
import { pitchShatter } from './shader/pitch-shatter.js'; import { pitchShatter } from './shader/pitch-shatter.js';
import { auroraVeil } from './shader/aurora-veil.js';
import { tideRings } from './shader/tide-rings.js';
import { dustChamber } from './shader/dust-chamber.js';
import { cargoBelt } from './shader/cargo-belt.js';
import { circuitBloom } from './shader/circuit-bloom.js';
import { truchetFold } from './shader/truchet-fold.js';
import { signalDecay } from './shader/signal-decay.js';
import { inkBleed } from './shader/ink-bleed.js';
import { saltFlat } from './shader/salt-flat.js';
import { stormRift } from './shader/storm-rift.js';
import { vortexDrift } from './shader/vortex-drift.js';
import { gateCorridor } from './shader/gate-corridor.js';
/** /**
* The scene library. Families exist so the arc driver can choose by section * The scene library. Families exist so the arc driver can choose by section
@ -67,6 +79,24 @@ const MODULES = [
pylonGrid, pylonGrid,
prismBloom, prismBloom,
pitchShatter, pitchShatter,
// Ten added to widen the library past the point where a track's rosters
// start repeating: the casting rule shrinks the pool per track, so depth in
// every family is what keeps two videos from drawing the same four scenes.
// Weighted toward the 'space' and 'shape' traits, which were the thinnest
// and therefore the signatures most likely to run out of cast.
auroraVeil,
tideRings,
dustChamber,
cargoBelt,
circuitBloom,
truchetFold,
signalDecay,
inkBleed,
saltFlat,
stormRift,
vortexDrift,
gateCorridor,
]; ];
const errors = []; const errors = [];

View File

@ -0,0 +1,81 @@
// Flow family: curtains of light standing above the track's horizon.
//
// Distinct from Curl Flow (a full-frame advected field) and Silk Ribbon (one
// strand): this is several tall vertical sheets, each rippling on its own phase,
// dense at the base and dissolving upward. The rippling is a sum of sines rather
// than noise, which is what gives an aurora its folded-sheet look instead of a
// smoky one — noise curtains read as fog.
//
// The horizon is the track's, so this stands in the same place as every other
// scene that has ground.
export const auroraVeil = {
name: 'Aurora Veil',
family: 'flow',
kind: 'fragment',
traits: ['camera', 'space', 'style'],
params: {
curtains: { type: 'int', range: [2, 9], default: 4, uniform: 'u_curtains', bias: 'density' },
height: { type: 'float', range: [0.4, 1.8], default: 1.0, uniform: 'u_height' },
fold: { type: 'float', range: [0.1, 1.4], default: 0.55, uniform: 'u_fold', bias: 'density' },
speed: { type: 'float', range: [0.03, 0.5], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
glow: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_glow', bias: 'energy' },
ground: { type: 'float', range: [0, 0.8], default: 0.3, uniform: 'u_ground' },
palette: { type: 'palette', count: 5 },
},
reactive: {
glow: { feature: 'bandHigh', amount: 0.4, response: 'smooth' },
fold: { feature: 'bandLow', amount: 0.3, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
float horizon = sigHorizonY() * 0.8 - 0.55;
float above = p.y - horizon;
vec3 col = pal(0) * 0.06;
for (int i = 0; i < 9; i++) {
if (i >= u_curtains) break;
float fi = float(i);
float s = u_seed + fi * 37.13;
// Each sheet is a vertical line whose x wanders as a sum of three sines.
// Three is the fewest that stops reading as a single wobble.
float x = (fract(s * 0.61) - 0.5) * 2.2
+ sin(above * 1.7 + t * 1.3 + s) * u_fold * 0.35
+ sin(above * 3.1 - t * 0.8 + s * 1.7) * u_fold * 0.18
+ sin(above * 0.7 + t * 0.4 + s * 2.3) * u_fold * 0.5;
float d = abs(p.x - x);
float widthAt = 0.05 + above * 0.06 + u_sigLine * 0.05;
// Bright and tight at the base, wide and faint at the top: the vertical
// falloff is what makes it a curtain rather than a stripe.
float rise = smoothstep(-0.05, 0.0, above) * exp(-max(above, 0.0) / max(u_height, 0.05));
float sheet = exp(-d * d / max(widthAt * widthAt, 1e-5)) * rise;
vec3 tint = palRamp(fract(s) * 0.5 + above * 0.12 + 0.1);
col += tint * sheet * (0.55 + u_glow * 0.6);
col += tint * exp(-d * 5.0) * rise * u_glow * 0.12;
}
// Ground: the curtains reflected, dim and compressed.
if (u_ground > 0.01 && above < 0.0) {
float below = -above;
col += pal(2) * u_ground * exp(-below * 6.0) * 0.35;
}
col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.x)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default auroraVeil;

View File

@ -33,13 +33,21 @@ export const blockMosh = {
vec4 scene(vec2 uv, vec2 p) { vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed; float t = u_time * u_speed + u_seed;
float beat = u_beat; float beat = u_beat;
p = sigCamera(p);
// The camera moves the CONTENT, not the block grid. The grid has to stay
// screen-aligned or the mosh stops reading as a codec artefact and starts
// reading as a moving texture — but the field being moshed is part of the
// shot and is filmed by the same operator as every other scene. (Assigning
// sigCamera(p) to an otherwise unused p is how this scene originally
// "honoured" the trait; the Phase 9 render gate measured the resulting
// difference at exactly zero.)
vec2 cp = sigCamera(p);
// The thing being moshed: a sheared banded field, recoloured per track, so // The thing being moshed: a sheared banded field, recoloured per track, so
// the corruption has real content to drag around even standing alone. // the corruption has real content to drag around even standing alone.
float shear = uv.y * (2.5 + u_sigLine * 3.0) + t * 0.45; float shear = cp.y * (2.5 + u_sigLine * 3.0) + t * 0.45;
float field = fbm(vec2(uv.x * 1.2 + sin(shear) * 0.3, uv.y * 2.0 + t * 0.35), 3); float field = fbm(vec2(cp.x * 0.9 + sin(shear) * 0.3, cp.y * 1.5 + t * 0.35), 3);
vec3 base = palRamp(field * 1.15 + uv.x * 0.2 + t * 0.03); vec3 base = palRamp(field * 1.15 + cp.x * 0.18 + t * 0.03);
base *= 0.35 + 0.55 * sat(field * 1.5); base *= 0.35 + 0.55 * sat(field * 1.5);
// One quantised grid for every jump below, so the mosh steps like an edit // One quantised grid for every jump below, so the mosh steps like an edit

View File

@ -0,0 +1,88 @@
// Structural family: horizontal belts of cargo running in alternating
// directions, stacked up the frame.
//
// Distinct from Neon City (a static skyline) and Pylon Grid (perspective depth):
// this has no depth at all. It is flat, industrial and lateral — the only scene
// in the library whose motion is purely sideways, which is exactly what makes it
// cut well against everything that recedes.
//
// Each crate is stamped in the track's signature form, and the belts step in
// bar-quantised lurches rather than sliding, so the movement is mechanical.
export const cargoBelt = {
name: 'Cargo Belt',
family: 'structural',
kind: 'fragment',
traits: ['shape', 'camera', 'style'],
params: {
belts: { type: 'int', range: [2, 8], default: 4, uniform: 'u_belts', bias: 'density' },
crates: { type: 'float', range: [2, 14], default: 6, uniform: 'u_crates', bias: 'density' },
crateSize:{ type: 'float', range: [0.15, 0.75],default: 0.42, uniform: 'u_crateSize' },
speed: { type: 'float', range: [0.05, 1.0], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
gap: { type: 'float', range: [0.02, 0.3], default: 0.1, uniform: 'u_gap' },
rails: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_rails' },
lamp: { type: 'float', range: [0, 1.3], default: 0.5, uniform: 'u_lamp', bias: 'energy' },
palette: { type: 'palette', count: 5 },
},
reactive: {
lamp: { feature: 'beat', amount: 0.35, response: 'spike' },
rails: { feature: 'bandLow', amount: 0.2, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
vec3 col = pal(0) * 0.05;
float span = 2.0 / max(float(u_belts), 1.0);
for (int i = 0; i < 8; i++) {
if (i >= u_belts) break;
float fi = float(i);
float centre = -1.0 + span * (fi + 0.5);
float dy = p.y - centre;
if (abs(dy) > span * 0.5) continue;
float dir = mod(fi, 2.0) < 0.5 ? 1.0 : -1.0;
// Quantised travel: the belt advances in eighth-bar steps, so cargo
// lurches from cell to cell the way a conveyor does.
float march = floor((t + fi * 0.37) * 4.0) * 0.25 * dir;
float lane = p.x * 0.5 + march;
float cell = floor(lane * u_crates);
float withinCell = fract(lane * u_crates);
float rnd = hash12(vec2(cell, fi));
// Not every cell carries a crate; the gaps are what make it read as
// cargo rather than as a stripe pattern.
if (rnd > u_gap) {
vec2 local = vec2((withinCell - 0.5) * 2.0, dy / max(span * 0.5, 1e-3));
float size = u_crateSize * (0.7 + rnd * 0.5);
float d = sigShape(local / max(size, 1e-3)) * size;
vec3 crateColor = pal(int(mod(cell + fi, 4.0)) + 1);
col = mix(col, crateColor * (0.35 + rnd * 0.5), smoothstep(0.02, -0.02, d));
col += crateColor * sigEdge(d) * (0.4 + u_lamp * 0.5);
// Lamp on a minority of crates, pulsing on the beat. Local, not
// whole-frame: a per-crate blink is not a flash.
if (rnd > 0.82) col += pal(4) * smoothstep(0.06, 0.0, length(local)) * u_lamp;
}
// Belt rails, top and bottom of each lane.
float rail = smoothstep(0.06, 0.0, abs(abs(dy) - span * 0.45));
col += pal(2) * rail * u_rails * 0.5;
}
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default cargoBelt;

View File

@ -0,0 +1,88 @@
// Geometric family: orthogonal traces growing outward from the centre, with a
// pad in the track's signature form at every junction.
//
// Distinct from Moiré Grid (two interfering line grids) and Prism Bloom (folded
// radial geometry): the structure here is Manhattan — everything runs at right
// angles, and the only curves are the pads. That right-angle language is the
// thing the library was missing, and it cuts hard against every radial scene.
//
// Traces light up in travelling pulses rather than all at once, so the frame is
// busy without ever changing brightness as a whole.
export const circuitBloom = {
name: 'Circuit Bloom',
family: 'geometric',
kind: 'fragment',
traits: ['shape', 'camera', 'style'],
params: {
cells: { type: 'float', range: [2, 14], default: 6, uniform: 'u_cells', bias: 'density' },
trace: { type: 'float', range: [0.01, 0.1], default: 0.035,uniform: 'u_trace' },
pads: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_pads' },
padSize: { type: 'float', range: [0.04, 0.22],default: 0.1, uniform: 'u_padSize' },
pulse: { type: 'float', range: [0, 1.5], default: 0.6, uniform: 'u_pulse', bias: 'energy' },
speed: { type: 'float', range: [0.05, 1.2], default: 0.35, uniform: 'u_speed', bias: 'motion', rate: true },
fill: { type: 'float', range: [0.2, 0.95], default: 0.6, uniform: 'u_fill', bias: 'density' },
palette: { type: 'palette', count: 5 },
},
reactive: {
pulse: { feature: 'bandHigh', amount: 0.4, response: 'smooth' },
pads: { feature: 'beat', amount: 0.2, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
vec2 g = p * u_cells;
vec2 cell = floor(g);
vec2 f = fract(g) - 0.5;
float rnd = hash12(cell + u_seed);
float rnd2 = hash12(cell * 1.7 + 11.3 + u_seed);
vec3 col = pal(0) * 0.05;
// Each cell carries a horizontal trace, a vertical one, or both — the L
// junctions are what make it read as routing rather than as a grid.
float horizontal = step(1.0 - u_fill, rnd);
float vertical = step(1.0 - u_fill, rnd2);
float width = u_trace * (0.6 + u_sigLine);
float dH = abs(f.y);
float dV = abs(f.x);
// Distance from the centre of the board, used to gate growth outward.
float reach = sat(1.4 - length(p) * 0.5);
float traceMask = 0.0;
if (horizontal > 0.5) traceMask += smoothstep(width, width * 0.35, dH);
if (vertical > 0.5) traceMask += smoothstep(width, width * 0.35, dV);
traceMask = sat(traceMask) * reach;
vec3 traceColor = palRamp(rnd * 0.4 + 0.15);
col += traceColor * traceMask * 0.5;
// Travelling pulse: a bright packet running along the trace, its position a
// function of the cell's own hash so packets are out of step with each other.
float along = horizontal > 0.5 ? f.x : f.y;
float packet = fract(rnd * 3.1 + t * (0.4 + rnd2 * 0.8));
float dPacket = abs(along - (packet - 0.5));
col += pal(4) * traceMask * exp(-dPacket * dPacket * 260.0) * u_pulse;
// Pads sit where both traces meet, stamped in the signature form.
if (horizontal > 0.5 && vertical > 0.5 && rnd2 > 1.0 - u_pads) {
float d = sigShape(f / max(u_padSize, 1e-3)) * u_padSize;
col += pal(2) * smoothstep(0.01, -0.01, d) * reach * 0.7;
col += pal(3) * sigEdge(d) * reach * (0.4 + u_pulse * 0.4);
}
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default circuitBloom;

View File

@ -0,0 +1,79 @@
// Minimal family: a nearly empty volume with a shaft of light through it and a
// few motes suspended in the beam.
//
// Distinct from Firefly Drift (a swarm advected along a flow) and Slow Orb (one
// body): almost nothing moves here. The motes hold position and only breathe;
// what changes is the light. That makes this one of the very few scenes in the
// library that can hold a thirty-second intro without asking for attention.
//
// The beam lands on the track's horizon, so the room is the same room every
// other scene with a floor is standing in.
export const dustChamber = {
name: 'Dust Chamber',
family: 'minimal',
kind: 'fragment',
traits: ['shape', 'camera', 'space', 'style'],
params: {
motes: { type: 'int', range: [6, 40], default: 18, uniform: 'u_motes', bias: 'density' },
moteSize: { type: 'float', range: [0.004, 0.05], default: 0.014, uniform: 'u_moteSize' },
beam: { type: 'float', range: [0.1, 1.0], default: 0.45, uniform: 'u_beam', bias: 'energy' },
beamWidth:{ type: 'float', range: [0.15, 1.2], default: 0.5, uniform: 'u_beamWidth' },
sway: { type: 'float', range: [0, 0.12], default: 0.04, uniform: 'u_sway' },
drift: { type: 'float', range: [0.01, 0.3], default: 0.06, uniform: 'u_drift', bias: 'motion', rate: true },
palette: { type: 'palette', count: 4 },
},
reactive: {
beam: { feature: 'loudness', amount: 0.25, response: 'smooth' },
moteSize: { feature: 'beat', amount: 0.2, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_drift + u_seed;
p = sigCamera(p);
float floorY = sigHorizonY() * 0.7 - 0.6;
// The shaft: a soft wedge widening as it falls, cut off at the floor.
float axis = sin(u_seed * 0.7) * 0.35;
float down = sat((1.0 - (p.y - floorY)) * 0.6);
float halfWidth = u_beamWidth * (0.25 + down * 0.75);
float inBeam = exp(-pow((p.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.2);
inBeam *= smoothstep(floorY - 0.05, floorY + 0.5, p.y);
vec3 col = pal(0) * 0.05;
col += pal(1) * inBeam * u_beam * 0.5;
// The pool where the shaft meets the floor.
float pool = exp(-abs(p.y - floorY) * 14.0)
* exp(-pow((p.x - axis) / max(halfWidth * 1.3, 1e-3), 2.0));
col += pal(2) * pool * u_beam * 0.7;
// Motes: fixed positions, breathing brightness, only visible in the light.
for (int i = 0; i < 40; i++) {
if (i >= u_motes) break;
float fi = float(i);
float s = u_seed + fi * 53.7;
vec2 at = vec2(
(hash11(s) - 0.5) * 2.4 + sin(t * 0.8 + s) * u_sway,
(hash11(s + 9.1) - 0.5) * 1.8 + cos(t * 0.6 + s * 1.3) * u_sway
);
float lit = exp(-pow((at.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.0);
float pulse = 0.55 + 0.45 * sin(t * 2.0 + s * 3.0);
float m = sigForm(p, at, u_moteSize * (0.6 + hash11(s + 3.3)));
col += pal(3) * m * lit * pulse * (0.5 + u_beam);
}
col = sigAir(col, p, smoothstep(0.0, 1.5, length(p)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default dustChamber;

View File

@ -0,0 +1,89 @@
// Structural family: a corridor of nested gates receding to a vanishing point,
// travelled through.
//
// Rectilinear depth, which the library did not have: Kaleido Tunnel recedes but
// is radial and folded, Pylon Grid stands still, Neon City is front-on. Here the
// camera moves forward through a series of frames that scale up and pass, and
// the ring geometry is the track's signature form, so a hexagonal video travels
// through hexagonal gates.
//
// Motion is a saw on log-depth, which is what makes gates emerge from the
// vanishing point at a constant apparent rate instead of rushing at the end.
export const gateCorridor = {
name: 'Gate Corridor',
family: 'structural',
kind: 'fragment',
traits: ['shape', 'camera', 'space', 'style'],
params: {
gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density' },
aperture: { type: 'float', range: [0.15, 0.9], default: 0.45, uniform: 'u_aperture' },
thickness:{ type: 'float', range: [0.02, 0.3], default: 0.09, uniform: 'u_thickness' },
travel: { type: 'float', range: [0.02, 0.7], default: 0.2, uniform: 'u_travel', bias: 'motion', rate: true },
rails: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_rails' },
lamps: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_lamps', bias: 'energy' },
vanish: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_vanish' },
palette: { type: 'palette', count: 5 },
},
reactive: {
lamps: { feature: 'beat', amount: 0.35, response: 'spike' },
aperture: { feature: 'bandLow', amount: 0.15, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_travel + u_seed;
p = sigCamera(p);
// The vanishing point sits on the track's horizon, slightly off centre.
vec2 vanishAt = vec2(sin(u_seed) * u_vanish, sigHorizonY() * 0.35);
vec2 q = p - vanishAt;
vec3 col = pal(0) * 0.04;
// Depth rails converging on the vanishing point.
if (u_rails > 0.01) {
float ang = atan(q.y, q.x);
float spokes = abs(fract(ang * 1.9098 + 0.5) - 0.5) * 2.0; // 12 rails
float rail = smoothstep(0.06, 0.0, spokes) * smoothstep(0.02, 0.5, length(q));
col += pal(1) * rail * u_rails * 0.25;
}
for (int i = 0; i < 14; i++) {
if (i >= u_gates) break;
float fi = float(i);
// Log-spaced depth with a saw: each gate walks forward, and when it
// passes the camera it wraps to the far end.
float phase = fract((fi / float(u_gates)) + t);
float scale = u_aperture * exp(phase * 3.2) * 0.35;
float d = abs(sigShape(q / max(scale, 1e-3)) * max(scale, 1e-3));
// Near gates are drawn thicker and brighter: the only depth cue that
// matters once the geometry is right.
float near = phase;
float w = u_thickness * (0.25 + near * 1.2) * (0.5 + u_sigLine);
float frame = smoothstep(w, w * 0.25, d);
vec3 tint = palRamp(fi * 0.13 + 0.1);
col += tint * frame * (0.25 + near * 0.75);
col += tint * exp(-d * 14.0) * near * 0.2;
// A lamp at the top of every third gate, pulsing on the beat.
if (mod(fi, 3.0) < 0.5) {
vec2 lampAt = vanishAt + vec2(0.0, scale);
col += pal(4) * exp(-length(p - lampAt) * 26.0) * u_lamps * (0.3 + near);
}
}
col = sigAir(col, p, 1.0 - smoothstep(0.0, 1.2, length(q)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default gateCorridor;

View File

@ -0,0 +1,81 @@
// Organic family: ink dropped into wet paper, spreading along the fibre.
//
// The bleed is done with feedback — each frame the previous one is sampled
// slightly outward along a noise-warped direction and darkened, which is a
// diffusion step in everything but name. Distinct from Curl Flow's trails
// (advected along a flow field, so they streak) because this expands in all
// directions at once, so it blooms.
//
// A base field is always drawn, so the scene stands alone before feedback has
// converged and survives a seek.
export const inkBleed = {
name: 'Ink Bleed',
family: 'organic',
kind: 'fragment',
traits: ['camera', 'space', 'style'],
params: {
drops: { type: 'int', range: [1, 6], default: 3, uniform: 'u_drops', bias: 'density' },
spread: { type: 'float', range: [0.002, 0.02], default: 0.007, uniform: 'u_spread' },
fibre: { type: 'float', range: [0.5, 8], default: 3.0, uniform: 'u_fibre', bias: 'density' },
soak: { type: 'float', range: [0.7, 0.99], default: 0.93, uniform: 'u_soak' },
density: { type: 'float', range: [0.1, 1.2], default: 0.5, uniform: 'u_density', bias: 'energy' },
pace: { type: 'float', range: [0.02, 0.4], default: 0.1, uniform: 'u_pace', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
density: { feature: 'bandLow', amount: 0.35, response: 'smooth' },
fibre: { feature: 'bandAir', amount: 0.2 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_pace + u_seed;
p = sigCamera(p);
// Paper: a still fibre texture that the ink will follow.
float grainField = fbm(p * u_fibre * 2.0 + 17.0, 4);
vec3 col = mix(pal(0) * 0.09, pal(1) * 0.14, grainField);
// Fresh ink. Each drop pulses in and out on its own slow cycle, so the page
// is never uniformly saturated and there is always somewhere new bleeding.
float ink = 0.0;
for (int i = 0; i < 6; i++) {
if (i >= u_drops) break;
float fi = float(i);
float s = u_seed + fi * 61.3;
vec2 at = vec2(sin(t * 0.6 + s) * 0.6, cos(t * 0.47 + s * 1.7) * 0.45);
float life = 0.5 + 0.5 * sin(t * 1.3 + s * 2.1);
float d = length(p - at) + (grainField - 0.5) * 0.15;
ink += exp(-d * d * 90.0) * life;
}
ink = sat(ink) * u_density;
vec3 inkColor = palRamp(0.45 + grainField * 0.3);
col = mix(col, inkColor, ink);
// The bleed: sample the previous frame outward along the fibre. Reading four
// offsets rather than one is what makes it spread in every direction instead
// of sliding — one sample is a smear, four is diffusion.
vec2 warp = (vec2(fbm(p * u_fibre + 3.0, 3), fbm(p * u_fibre - 7.0, 3)) - 0.5) * 2.0;
float r = u_spread;
vec3 soaked = (
prev(uv + (vec2( 1.0, 0.0) + warp * 0.6) * r) +
prev(uv + (vec2(-1.0, 0.0) + warp * 0.6) * r) +
prev(uv + (vec2( 0.0, 1.0) + warp * 0.6) * r) +
prev(uv + (vec2( 0.0, -1.0) + warp * 0.6) * r)
) * 0.25;
col = max(col, soaked * u_soak);
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default inkBleed;

View File

@ -0,0 +1,83 @@
// Minimal family: an empty plain under a very large sky, with one distant form
// standing on the horizon.
//
// The emptiest scene in the library and the most deliberate about it — most of
// the frame is a gradient. Distinct from Horizon Lines (a bundle of lines) and
// Ridge Terrain (layered silhouettes): there is exactly one object, it is small,
// and it is far away. What moves is the light and the heat shimmer.
//
// The object is the track's signature form, so the thing on the horizon of a
// hexagonal video is a hexagon.
export const saltFlat = {
name: 'Salt Flat',
family: 'minimal',
kind: 'fragment',
traits: ['shape', 'camera', 'space', 'style'],
params: {
monolith: { type: 'float', range: [0.0, 0.35], default: 0.12, uniform: 'u_monolith' },
standing: { type: 'float', range: [-0.7, 0.7], default: 0.0, uniform: 'u_standing' },
shimmer: { type: 'float', range: [0, 0.09], default: 0.025,uniform: 'u_shimmer' },
glowBand: { type: 'float', range: [0, 1.2], default: 0.45, uniform: 'u_glowBand', bias: 'energy' },
ground: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_ground' },
salt: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_salt', bias: 'density' },
pace: { type: 'float', range: [0.02, 0.5], default: 0.12, uniform: 'u_pace', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
glowBand: { feature: 'loudness', amount: 0.3, response: 'smooth' },
shimmer: { feature: 'bandAir', amount: 0.25 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_pace + u_seed;
p = sigCamera(p);
float horizon = sigHorizonY() * 0.6 - 0.15;
// Heat shimmer: everything near the horizon wobbles, nothing else does.
float nearHorizon = exp(-abs(p.y - horizon) * 5.0);
p.x += sin(p.y * 60.0 + t * 6.0) * u_shimmer * nearHorizon;
float above = p.y - horizon;
// Sky: a tall gradient, darkest at the top.
vec3 col = mix(pal(1) * 0.5, pal(0) * 0.25, sat(above * 0.8 + 0.15));
// The glow band sitting on the horizon — the light source of the whole scene.
col += pal(3) * exp(-abs(above) * 9.0) * u_glowBand * 0.8;
if (above < 0.0) {
float depth = sat(-above * 2.2); // 0 far .. 1 near
vec3 plain = mix(pal(2) * 0.5, pal(0) * 0.3, depth);
// Salt crust: cracked cells, only legible in the near field.
vec2 cellUv = vec2(p.x / max(-above * 0.9 + 0.06, 0.02), 1.0 / max(-above + 0.05, 0.02));
float crack = abs(fract(cellUv.x * 0.5) - 0.5) + abs(fract(cellUv.y * 0.5) - 0.5);
plain += pal(4) * smoothstep(0.42, 0.5, crack) * u_salt * depth * 0.25;
// Reflection of the glow band, compressed toward the horizon.
plain += pal(3) * exp(above * 7.0) * u_glowBand * 0.3;
col = mix(col, plain, u_ground);
}
// The one object: small, on the horizon, in the track's form.
if (u_monolith > 0.005) {
vec2 at = vec2(u_standing, horizon + u_monolith * 0.9);
float d = sigShape((p - at) / u_monolith) * u_monolith;
col = mix(col, pal(0) * 0.12, smoothstep(0.006, -0.006, d));
col += pal(4) * sigEdge(d) * (0.3 + u_glowBand * 0.5);
}
col = sigAir(col, p, smoothstep(0.0, 1.4, abs(p.x)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default saltFlat;

View File

@ -0,0 +1,90 @@
// Glitch family: a stack of oscilloscope traces losing signal.
//
// Distinct from Scan Tear (rows displaced sideways), Block Mosh (block-level
// datamosh) and Pitch Shatter (vertical transposition): nothing here is
// displaced at all. The corruption is in the SIGNAL — each trace degrades from a
// clean wave into noise as its lock is lost, and regains it. Loss of lock steps
// on the bar grid, so traces drop out in time rather than flickering.
export const signalDecay = {
name: 'Signal Decay',
family: 'glitch',
kind: 'fragment',
traits: ['camera', 'style'],
params: {
traces: { type: 'int', range: [2, 10], default: 5, uniform: 'u_traces', bias: 'density' },
amplitude:{ type: 'float', range: [0.02, 0.3], default: 0.1, uniform: 'u_amplitude', bias: 'energy' },
frequency:{ type: 'float', range: [1, 22], default: 7, uniform: 'u_frequency', bias: 'density' },
loss: { type: 'float', range: [0, 0.9], default: 0.35, uniform: 'u_loss' },
hiss: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_hiss' },
persist: { type: 'float', range: [0, 0.85], default: 0.4, uniform: 'u_persist' },
speed: { type: 'float', range: [0.1, 2.0], default: 0.6, uniform: 'u_speed', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
amplitude: { feature: 'bandLow', amount: 0.35, response: 'smooth' },
hiss: { feature: 'flatness', amount: 0.3 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
// Quantised era: lock is lost and regained on the eighth-bar grid, so
// dropouts land with the music instead of crawling.
float era = floor(u_barPhase * 8.0) + floor(t * 2.0) * 8.0;
vec3 col = pal(0) * 0.05;
float span = 2.0 / max(float(u_traces), 1.0);
for (int i = 0; i < 10; i++) {
if (i >= u_traces) break;
float fi = float(i);
float centre = -1.0 + span * (fi + 0.5);
float s = u_seed + fi * 27.7;
// How much lock this trace has this era. Below zero it is pure noise.
float lock = sat(hash12(vec2(fi, era)) * 1.4 - u_loss);
// Clean signal: two sines and a slow envelope, so it looks like a
// waveform rather than a test tone.
float clean = sin(p.x * u_frequency + t * 3.0 + s) * 0.6
+ sin(p.x * u_frequency * 2.7 - t * 1.7 + s * 1.3) * 0.4;
// Noise floor: hashed per pixel column and era, held steady within a
// step so it reads as static rather than as a shimmer.
float noise = (hash12(vec2(floor(p.x * 220.0), era + fi)) - 0.5) * 2.0;
float signal = mix(noise, clean, lock);
float y = centre + signal * u_amplitude;
float d = abs(p.y - y);
float w = 0.004 + u_sigLine * 0.012;
float line = smoothstep(w * 2.5, 0.0, d);
vec3 tint = palRamp(fract(s) * 0.4 + 0.15);
col += tint * line * (0.4 + lock * 0.6);
col += tint * exp(-d * 40.0) * 0.25 * lock;
// Hiss band around an unlocked trace: the visual equivalent of the
// sound. Confined to the lane, so it never washes the whole frame.
if (lock < 0.4) {
float band = exp(-abs(p.y - centre) * 12.0);
col += pal(3) * band * abs(noise) * u_hiss * 0.3 * (1.0 - lock);
}
}
// Ghost of the previous frame, so a dropout leaves a trail rather than
// vanishing cleanly.
col = max(col, prev(uv) * u_persist);
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default signalDecay;

View File

@ -42,6 +42,8 @@ vec4 scene(vec2 uv, vec2 p) {
float ph = fk * 2.2 + t * 0.3; float ph = fk * 2.2 + t * 0.3;
float best = 1e9; float best = 1e9;
// lint: fixed-cost — this samples the curve at a fixed resolution, so
// there is no param to break on. Cost is governed by the 4K budget gate.
for (int n = 0; n < 48; n++) { for (int n = 0; n < 48; n++) {
float uu = (float(n) + 0.5) / 48.0; float uu = (float(n) + 0.5) / 48.0;
float x = (uu - 0.5) * 2.0 * u_spread; float x = (uu - 0.5) * 2.0 * u_spread;

View File

@ -0,0 +1,109 @@
// Glitch family: branching discharge across the sky above the track's horizon.
//
// The bolt is a recursive-looking zigzag built from stacked hashed segments,
// re-struck on the quantised grid rather than continuously, so strikes land on
// the music. Between strikes the afterglow decays through feedback, which is
// what makes the dark frames read as "after a flash" rather than as empty.
//
// The flash itself is deliberately LOCAL — the bolt and a halo around it, not
// the frame. A full-frame white flash on every kick is exactly the WCAG 2.3.1
// failure this library is checked against.
export const stormRift = {
name: 'Storm Rift',
family: 'glitch',
kind: 'fragment',
traits: ['camera', 'space', 'style'],
params: {
bolts: { type: 'int', range: [1, 5], default: 2, uniform: 'u_bolts', bias: 'density' },
jag: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_jag' },
segments: { type: 'float', range: [4, 20], default: 10, uniform: 'u_segments', bias: 'density' },
branch: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_branch' },
afterglow:{ type: 'float', range: [0, 0.9], default: 0.55, uniform: 'u_afterglow' },
cloud: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_cloud' },
rate: { type: 'float', range: [0.2, 3.0], default: 1.0, uniform: 'u_rate', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
branch: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
cloud: { feature: 'bandLow', amount: 0.25, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_rate + u_seed;
p = sigCamera(p);
float horizon = sigHorizonY() * 0.7 - 0.5;
// Cloud deck above, lit from within.
float deck = fbm(vec2(p.x * 1.4 + t * 0.15, p.y * 2.2 - t * 0.05), 5);
vec3 col = mix(pal(0) * 0.06, pal(1) * 0.2, deck * u_cloud * smoothstep(horizon, horizon + 1.2, p.y));
// Strikes step on the eighth-bar grid. Each era re-rolls every bolt.
float era = floor(u_barPhase * 8.0) + floor(t) * 8.0;
for (int b = 0; b < 5; b++) {
if (b >= u_bolts) break;
float fb = float(b);
float boltSeed = hash12(vec2(era, fb * 7.3 + u_seed));
// Not every bolt fires every era; misfires are what make the ones that
// land feel like events.
float fires = step(0.35, boltSeed);
float age = fract(t * 2.0 + fb * 0.31);
float intensity = fires * exp(-age * 6.0);
if (intensity < 0.004) continue;
// The channel: a piecewise-linear zigzag from the cloud deck down to the
// horizon, each segment hashed off (era, bolt, segment).
float x0 = (hash11(boltSeed * 31.0) - 0.5) * 1.8;
float best = 1e3;
for (int s = 0; s < 20; s++) {
if (float(s) >= u_segments) break;
float f0 = float(s) / u_segments;
float f1 = float(s + 1) / u_segments;
float yA = mix(1.1, horizon, f0);
float yB = mix(1.1, horizon, f1);
float xA = x0 + (hash11(boltSeed * 17.0 + float(s) * 3.7) - 0.5) * u_jag * (0.3 + f0);
float xB = x0 + (hash11(boltSeed * 17.0 + float(s + 1) * 3.7) - 0.5) * u_jag * (0.3 + f1);
// Distance to this segment.
vec2 a = vec2(xA, yA), bb = vec2(xB, yB);
vec2 pa = p - a, ba = bb - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
best = min(best, length(pa - ba * h));
// Branches: a short spur off some joints, in the same hand.
if (hash11(boltSeed * 53.0 + float(s)) < u_branch * 0.4) {
vec2 tip = a + vec2((hash11(boltSeed + float(s) * 5.1) - 0.5) * 0.5, -0.12);
vec2 pb = p - a, bc = tip - a;
float h2 = clamp(dot(pb, bc) / max(dot(bc, bc), 1e-6), 0.0, 1.0);
best = min(best, length(pb - bc * h2) + 0.008);
}
}
float core = smoothstep(0.012 + u_sigLine * 0.01, 0.0, best);
float halo = exp(-best * 12.0);
col += pal(4) * core * intensity;
col += pal(3) * halo * intensity * 0.5;
}
// Ground catches the light; below the horizon is otherwise near black.
if (p.y < horizon) {
col *= 0.25;
col += pal(2) * exp((p.y - horizon) * 5.0) * 0.15;
}
col = max(col, prev(uv) * u_afterglow);
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default stormRift;

View File

@ -0,0 +1,79 @@
// Organic family: interference rings spreading from a handful of drop points,
// like rain on still water.
//
// Distinct from Classic Wave (one centred ring set) and Metaballs (merging
// bodies): here several sources overlap and the SUM is what is drawn, so the
// image is the interference pattern rather than the rings themselves. Where two
// wavefronts meet they cancel, which produces the moving lattice of nodes that
// makes water look like water.
//
// Ring geometry is measured in the track's signature form, so a hexagonal track
// gets hexagonal wavefronts.
export const tideRings = {
name: 'Tide Rings',
family: 'organic',
kind: 'fragment',
traits: ['shape', 'camera', 'space', 'style'],
params: {
sources: { type: 'int', range: [2, 7], default: 4, uniform: 'u_sources', bias: 'density' },
wavelength:{ type: 'float', range: [3, 26], default: 10, uniform: 'u_wavelength', bias: 'density' },
speed: { type: 'float', range: [0.05, 1.2], default: 0.35, uniform: 'u_speed', bias: 'motion', rate: true },
decay: { type: 'float', range: [0.2, 2.0], default: 0.8, uniform: 'u_decay' },
caustic: { type: 'float', range: [0, 1.5], default: 0.6, uniform: 'u_caustic', bias: 'energy' },
spread: { type: 'float', range: [0.2, 1.1], default: 0.65, uniform: 'u_spread' },
palette: { type: 'palette', count: 5 },
},
reactive: {
caustic: { feature: 'beat', amount: 0.3, response: 'smooth' },
wavelength: { feature: 'bandMid', amount: 0.2 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
float sum = 0.0;
float energy = 0.0;
for (int i = 0; i < 7; i++) {
if (i >= u_sources) break;
float fi = float(i);
float s = u_seed + fi * 91.7;
// Sources wander slowly, so the interference lattice never settles into
// a fixed pattern the eye can lock onto.
vec2 src = vec2(
sin(t * 0.21 + s) * u_spread,
cos(t * 0.17 + s * 1.4) * u_spread * 0.7
);
// Distance in the signature metric: round tracks get circular wavefronts.
float d = sigShape(p - src) + 1.0;
float amp = exp(-d * u_decay);
sum += sin(d * u_wavelength - t * 6.0 + fract(s) * 6.28) * amp;
energy += amp;
}
float wave = sum / max(energy, 1e-3);
// The node lattice: where the sum passes through zero, the surface is flat
// and bright. Squaring the gradient-ish term is what picks those out.
float nodes = 1.0 - abs(wave);
nodes = pow(sat(nodes), 3.0);
vec3 col = mix(pal(0) * 0.1, pal(1), sat(wave * 0.5 + 0.5));
col += pal(3) * nodes * u_caustic;
col += pal(4) * pow(sat(wave), 6.0) * u_caustic * 0.5;
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default tideRings;

View File

@ -0,0 +1,74 @@
// Geometric family: a Truchet tiling — every cell carries two quarter-arcs in
// one of two rotations, and the arcs join across cell edges into long continuous
// curves nobody placed.
//
// That emergent continuity is the whole point and the reason this does not
// duplicate Moiré Grid or Circuit Bloom: the structure is a grid, but the thing
// you actually see is a set of wandering closed loops. The tiling re-rolls on
// phrase lines rather than crawling, so the maze reconfigures on the music.
export const truchetFold = {
name: 'Truchet Fold',
family: 'geometric',
kind: 'fragment',
traits: ['camera', 'style'],
params: {
cells: { type: 'float', range: [1.5, 12], default: 4, uniform: 'u_cells', bias: 'density' },
weight: { type: 'float', range: [0.04, 0.3], default: 0.12, uniform: 'u_weight' },
radius: { type: 'float', range: [0.3, 0.7], default: 0.5, uniform: 'u_radius' },
churn: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_churn' },
glow: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_glow', bias: 'energy' },
drift: { type: 'float', range: [0.0, 0.4], default: 0.08, uniform: 'u_drift', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
glow: { feature: 'bandMid', amount: 0.35, response: 'smooth' },
weight: { feature: 'beat', amount: 0.15, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_drift + u_seed;
p = sigFolded(sigCamera(p));
// The tiling slides slowly and re-rolls its orientations on the phrase grid.
vec2 g = p * u_cells + vec2(t, t * 0.6);
vec2 cell = floor(g);
vec2 f = fract(g) - 0.5;
float era = floor(u_phrasePhase * 4.0) * u_churn;
float flip = step(0.5, hash12(cell + era + u_seed));
// Mirror one of the two orientations; the arcs then always meet at edge
// midpoints, which is what makes neighbouring cells join up.
if (flip > 0.5) f.x = -f.x;
// Two quarter-arcs, centred on opposite corners.
float d1 = abs(length(f - vec2(-0.5, -0.5)) - u_radius);
float d2 = abs(length(f - vec2(0.5, 0.5)) - u_radius);
float d = min(d1, d2);
float w = u_weight * (0.5 + u_sigLine);
float line = smoothstep(w, w * 0.3, d);
float halo = exp(-d * 9.0);
// Colour by which arc, and by cell, so the continuous curves change hue
// along their length rather than being one flat ribbon.
float which = d1 < d2 ? 0.0 : 1.0;
vec3 tint = palRamp(hash12(cell * 0.7 + era) * 0.5 + which * 0.2 + 0.1);
vec3 col = pal(0) * 0.05;
col += tint * line * 0.75;
col += tint * halo * u_glow * 0.35;
col += pal(4) * sigEdge(d) * u_glow * 0.25;
col *= 0.65 + 0.35 * exp(-dot(p, p) * 0.25);
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default truchetFold;

View File

@ -0,0 +1,79 @@
// Flow family: spiral arms winding into a slowly wandering core.
//
// Differential rotation is the point — the inner arms turn faster than the outer
// ones, so the arms wind up over time and the image is never twice the same.
// Distinct from Curl Flow (isotropic advection with no centre) and Kaleido
// Tunnel (rigid radial symmetry receding): this has one centre, real shear, and
// no symmetry at all.
//
// The core is drawn in the track's signature form, so the eye of a hexagonal
// track's vortex is a hexagon.
export const vortexDrift = {
name: 'Vortex Drift',
family: 'flow',
kind: 'fragment',
traits: ['shape', 'camera', 'style'],
params: {
arms: { type: 'int', range: [1, 7], default: 3, uniform: 'u_arms', bias: 'density' },
winding: { type: 'float', range: [0.5, 7], default: 2.5, uniform: 'u_winding', bias: 'density' },
shear: { type: 'float', range: [0.1, 2.5], default: 1.0, uniform: 'u_shear' },
speed: { type: 'float', range: [0.02, 0.6], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
turbulence:{ type: 'float', range: [0, 1.2], default: 0.4, uniform: 'u_turbulence' },
core: { type: 'float', range: [0.0, 0.4], default: 0.12, uniform: 'u_core', bias: 'energy' },
falloff: { type: 'float', range: [0.2, 2.0], default: 0.8, uniform: 'u_falloff' },
palette: { type: 'palette', count: 5 },
},
reactive: {
core: { feature: 'beat', amount: 0.3, response: 'spike' },
turbulence: { feature: 'bandMid', amount: 0.3, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
// The eye wanders, so the composition never sits still even when the arms do.
vec2 eye = vec2(sin(t * 0.6) * 0.22, cos(t * 0.47) * 0.16);
vec2 q = p - eye;
float r = max(length(q), 1e-4);
float a = atan(q.y, q.x);
// Differential rotation: angular speed falls off with radius, so the arms
// shear. Bounded rather than 1/r, or the core would spin arbitrarily fast.
float omega = u_shear / (0.35 + r * 1.6);
float wound = a + omega * t * 2.0 + log(r + 0.25) * u_winding;
// Turbulence breaks the arms into filaments instead of clean spokes.
wound += (fbm(q * 3.0 + t * 0.4, 4) - 0.5) * u_turbulence * 2.0;
float arms = max(float(u_arms), 1.0);
float band = sin(wound * arms) * 0.5 + 0.5;
band = pow(band, 1.8);
// Density falls off outward, so the frame has a subject.
float envelope = exp(-r * u_falloff);
vec3 col = pal(0) * 0.06;
col += palRamp(wound * 0.08 + r * 0.2) * band * envelope * 0.9;
col += pal(4) * pow(band, 4.0) * envelope * 0.5;
// The eye itself, in the signature form.
if (u_core > 0.004) {
float d = sigShape(q / u_core) * u_core;
col = mix(col, pal(0) * 0.05, smoothstep(0.004, -0.01, d));
col += pal(3) * sigEdge(d) * 0.8;
col += pal(4) * exp(-max(d, 0.0) * 18.0) * 0.35;
}
col += sigGrain(uv);
return vec4(col, 1.0);
}
`,
};
export default vortexDrift;

View File

@ -217,3 +217,15 @@ input[type=range] { accent-color: var(--accent); background: transparent; }
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
} }
} }
/* Single-scene gate output (checks.html?scene=Name). Monospaced so the
PASS/FAIL column lines up and the whole verdict reads in one glance. */
.scene-gate {
font: 12px/1.7 ui-monospace, monospace;
color: var(--text);
background: #0e1016;
padding: 14px 16px;
margin: 0;
white-space: pre;
overflow-x: auto;
}

View File

@ -167,6 +167,56 @@ console.log('\nscene schema lint');
} }
} }
// A dead camera: `p = sigCamera(p)` and then nothing reads p again.
// This passed the evidence grep above, passed review, and shipped — the
// Phase 9 render gate later measured the scene's response to the camera
// at exactly zero. Cheaper to catch here than on a GPU.
const cameraAssign = src.match(/(\w+)\s*=\s*sig(?:Camera|Folded)\s*\([^;]*\);/);
if (cameraAssign) {
const target = cameraAssign[1];
const after = src.slice(src.indexOf(cameraAssign[0]) + cameraAssign[0].length);
const reads = new RegExp(`\\b${target}\\b`).test(after);
if (!reads) {
fail(`${id}: assigns sigCamera to '${target}' and never reads it again — ` +
`the trait is declared but the image cannot change`);
}
}
// A scene whose only content is the previous frame is black on its first
// frames and different after a seek than after playback.
if (/\bprev\s*\(/.test(src)) {
const bodyBeforePrev = src.slice(0, src.indexOf('prev('));
if (!/\b(pal|palRamp|fbm|vnoise|hash1[12])\s*\(/.test(bodyBeforePrev)) {
fail(`${id}: reads prev() without generating a base image first — ` +
`it will be black until feedback converges and will not survive a seek`);
}
}
// Loop cost. GLSL needs a constant bound, so the pattern here is a
// generous fixed bound plus an early break on the param that actually
// decides the count — that break is what keeps the cost proportional to
// what the look asked for. A big bound WITHOUT one runs every iteration
// on every pixel at 4K, which the budget check will catch on a GPU and
// this catches in a second.
for (const loop of src.matchAll(/for\s*\(\s*int\s+\w+\s*=\s*0\s*;\s*\w+\s*<\s*(\d+)[^)]*\)/g)) {
const bound = Number(loop[1]);
const body = src.slice(src.indexOf(loop[0]) + loop[0].length, src.indexOf(loop[0]) + loop[0].length + 600);
const breaksEarly = /\bbreak\s*;/.test(body);
// Opt-out for a genuinely fixed-cost loop — sampling a curve at a
// fixed resolution has nothing to break on. The author states it,
// and the measured 4K budget check still governs.
const optOut = /\/\/\s*lint:\s*fixed-cost/.test(
src.slice(Math.max(0, src.indexOf(loop[0]) - 200), src.indexOf(loop[0])));
if (optOut) continue;
if (bound > 64) {
fail(`${id}: fixed loop bound ${bound} is too large whatever it breaks on`);
} else if (bound > 16 && !breaksEarly) {
fail(`${id}: loop of ${bound} with no early break — bound it on the param ` +
`(\`if (i >= u_count) break;\`) so the cost follows what the look asked for`);
}
}
// Rate params: anything the shader multiplies absolute time by must be // Rate params: anything the shader multiplies absolute time by must be
// flagged `rate: true`, which stops reactivity and drift from touching it. // flagged `rate: true`, which stops reactivity and drift from touching it.
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds // Modulating such a param jumps the phase by elapsed*delta — sixty seconds

View File

@ -0,0 +1,202 @@
#!/usr/bin/env node
// Scaffold a new scene: writes the module, registers it, and leaves it in a
// state that already passes every static and GPU gate.
//
// The point is token economy as much as typing. Everything a new scene needs
// besides its shader body is mechanical — the params block, the trait
// declarations that match the helpers actually called, the registry import and
// entry, a reactive mapping that is not on a rate param — and every one of
// those has its own lint rule waiting to reject it. Generating them from a
// template means the only thing left to write is the part that needs judgement.
//
// Usage:
// npm run new:scene -- "Salt Flat" --family=minimal --traits=shape,camera,space,style
// npm run new:scene -- "Ink Bleed" --family=organic --traits=camera,style --feedback
//
// Then edit the `scene()` body. The skeleton renders a live, animated, seeded
// field, so the scene is gate-clean from the first run and stays that way while
// you replace the body a piece at a time.
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const SCENES = join(root, 'src/scenes/shader');
const REGISTRY = join(root, 'src/scenes/registry.js');
const FAMILIES = ['flow', 'organic', 'minimal', 'structural', 'geometric', 'glitch'];
const TRAITS = ['shape', 'camera', 'space', 'style'];
const args = process.argv.slice(2);
const flags = new Map(args.filter((a) => a.startsWith('--')).map((a) => {
const [k, v] = a.replace(/^--/, '').split('=');
return [k, v === undefined ? true : v];
}));
const name = args.find((a) => !a.startsWith('--'));
if (!name) {
console.error(`usage: npm run new:scene -- "Scene Name" --family=<${FAMILIES.join('|')}> ` +
`--traits=<${TRAITS.join(',')}> [--feedback]`);
process.exit(1);
}
const family = String(flags.get('family') || 'flow');
if (!FAMILIES.includes(family)) {
console.error(`unknown family '${family}' — one of ${FAMILIES.join(', ')}`);
process.exit(1);
}
const traits = String(flags.get('traits') || 'camera,style').split(',').map((t) => t.trim()).filter(Boolean);
for (const t of traits) {
if (!TRAITS.includes(t)) {
console.error(`unknown trait '${t}' — one of ${TRAITS.join(', ')}`);
process.exit(1);
}
}
const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const camel = kebab.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
const file = join(SCENES, `${kebab}.js`);
if (existsSync(file)) {
console.error(`${kebab}.js already exists`);
process.exit(1);
}
/**
* A per-scene constant baked into the skeleton.
*
* Two freshly scaffolded scenes would otherwise render identically and trip the
* "no two scenes render the same image" gate before either has been written.
* Deriving the constants from the name means the skeleton is already distinct.
*/
function salt(text, lo, hi) {
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return +(lo + (h % 1000) / 1000 * (hi - lo)).toFixed(2);
}
const freq = salt(kebab, 2.5, 7.5);
const skew = salt(kebab + 'x', 0.4, 2.2);
const warp = salt(kebab + 'w', 0.3, 1.6);
// Trait expression: each declared trait gets a real call, because the lint
// greps for one and the Phase 9 render gate then measures that it changed the
// image. A trait declared and not used fails both, in that order.
const traitLines = {
camera: ' p = sigCamera(p);',
space: ' float horizon = sigHorizonY();\n p.y -= horizon * 0.3;',
shape: '', // used in the body below
style: '', // used in the body below
};
const head = traits.map((t) => traitLines[t]).filter(Boolean).join('\n');
const shapeLine = traits.includes('shape')
? '\n // TRAIT shape: the track\'s signature form, so this scene is cast from\n' +
' // the same actors as every other scene in the video.\n' +
' float form = sigShape(p / max(u_size, 1e-3)) * u_size;\n' +
' col += pal(3) * sigEdgeOrMask(form);'
: '';
const styleLine = traits.includes('style')
? '\n // TRAIT style: the track\'s art direction.\n col += sigGrain(uv);'
: '';
const spaceLine = traits.includes('space')
? '\n col = sigAir(col, p, smoothstep(0.0, 1.6, length(p)));'
: '';
const feedbackLine = flags.get('feedback')
? '\n // Feedback. The base field above must stand alone: a scene that only\n' +
' // reads prev() is black for its first frames and fragile under seek.\n' +
' col = max(col, prev(uv - vec2(0.0, 0.002)) * u_persist);'
: '';
const shapeHelper = traits.includes('shape')
? `
// Fill for the signature form, drawn in the track's line weight.
float sigEdgeOrMask(float d) {
return smoothstep(0.01, -0.01, d) * 0.6 + sigEdge(d);
}
`
: '';
const params = [
` scale: { type: 'float', range: [1, 12], default: ${freq}, uniform: 'u_scale', bias: 'density' },`,
` speed: { type: 'float', range: [0.05, 1.2], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },`,
` detail: { type: 'float', range: [0.2, 2.5], default: ${warp}, uniform: 'u_detail', bias: 'density' },`,
` glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },`,
traits.includes('shape')
? ` size: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_size' },`
: null,
flags.get('feedback')
? ` persist: { type: 'float', range: [0, 0.85], default: 0.4, uniform: 'u_persist' },`
: null,
` palette: { type: 'palette', count: 5 },`,
].filter(Boolean).join('\n');
const source = `// ${family[0].toUpperCase() + family.slice(1)} family: TODO one line on what this looks like.
//
// TODO: say what makes it DIFFERENT from the scenes it sits next to. That
// sentence is the scene's reason to exist, and "no two scenes render the same
// image" is a gate, not a guideline.
//
// Scaffolded by tools/new-scene.js. See HOWTO-visualizers.md.
export const ${camel} = {
name: '${name.trim()}',
family: '${family}',
kind: 'fragment',
traits: [${traits.map((t) => `'${t}'`).join(', ')}],
params: {
${params}
},
reactive: {
glow: { feature: 'beat', amount: 0.3, response: 'spike' },
detail: { feature: 'bandMid', amount: 0.25, response: 'smooth' },
},
shader: \`${shapeHelper}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
${head}
// TODO: replace this field. It exists so the skeleton is live, animated and
// distinct from every other scene the moment it is registered.
float n = fbm(p * u_scale * 0.5 + vec2(t * 0.4, -t * ${skew}), 4);
float band = sin(n * u_detail * 6.0 + length(p) * ${freq} - t * 2.0) * 0.5 + 0.5;
vec3 col = mix(pal(0) * 0.08, pal(1), band);
col += pal(2) * pow(band, 4.0) * u_glow;${shapeLine}${spaceLine}${feedbackLine}${styleLine}
return vec4(col, 1.0);
}
\`,
};
export default ${camel};
`;
writeFileSync(file, source);
// --- register -------------------------------------------------------------
let registry = readFileSync(REGISTRY, 'utf8');
const importLine = `import { ${camel} } from './shader/${kebab}.js';`;
if (!registry.includes(importLine)) {
const lastImport = registry.lastIndexOf("} from './shader/");
const eol = registry.indexOf('\n', lastImport);
registry = registry.slice(0, eol + 1) + importLine + '\n' + registry.slice(eol + 1);
}
registry = registry.replace(/\n\];/, `\n ${camel},\n];`);
writeFileSync(REGISTRY, registry);
console.log(`created src/scenes/shader/${kebab}.js`);
console.log(`registered ${camel} (${family}, traits: ${traits.join(', ') || 'none'})`);
console.log('');
console.log('next:');
console.log(' 1. write the scene() body — everything else is done');
console.log(' 2. npm run lint:scenes');
console.log(` 3. open http://localhost:5180/checks.html?scene=${encodeURIComponent(name.trim())}`);