diff --git a/flow-state/debug.html b/flow-state/debug.html new file mode 100644 index 0000000..e2175a2 --- /dev/null +++ b/flow-state/debug.html @@ -0,0 +1,183 @@ + + + + +flow-state · debug + + + +

flow-state · debug

+

+ Everything here answers a question the app itself cannot. The app shows you + one video; these show you whether the generator is doing its job across + many. +

+ +

Looking at output

+
+
+

gallery

+

Every visualizer, six times, on six different songs' content — cast, + ink, lattice, palette and parameters all varying. Sorted least-varied + first, so the scenes that always look the same come to the top.

+
~3 min · the one to open when a scene feels familiar
+
+
+

the app

+

Drop a track and watch it. test/songs/*.wav holds the + synthetic bank if you want something with known properties.

+
build the bank with npm run build:songs
+
+
+ +

Is it correct?

+
+
+

phase gates

+

Every acceptance gate in PLAN.md, run for real. This is + the one to check before committing.

+
+ ?slow=1 full suite · + ?phase=12 one phase +
+
+
+

single scene

+

The whole per-scene battery for one visualizer: renders, animates, + deterministic, distinct, param sweep, flash rate, and every declared + trait and artifact.

+
checks.html?scene=Metaballs — the loop you + are in while writing or migrating one
+
+
+ +

Is it varied?

+

+ These measure whether two videos differ, which no other gate can see — a + generator that ignores its input passes determinism and liveness perfectly. + Read the caution below before trusting a single run. +

+
+
+

decompose

+

Identity against container, measured apart: hold the scene fixed and + vary the song's content, then the reverse. The most + trustworthy number here — it compares renders directly, and + its noise floor is exactly zero.

+
~1 min
+
+
+

song variety

+

Twelve songs from the bank, each with its own audio-derived seed. Also + reports coupling — whether songs that sound different come + out looking different, which is still unsolved.

+
~2 min · ?variety=1 + for the seed version, one song and many seeds
+
+
+

arms, with error bars

+

Three pools compared over repeated runs. The template for any A/B + here: it prints the difference against its own noise band and says + outright when the two are indistinguishable.

+
~5 min
+
+
+

pool sweep

+

How many scenes a track should draw on. Swept rather than guessed — + and the answer turned out to be that it does not matter between 4 and + 32.

+
~5 min
+
+
+ +

Command line

+
+
+

migration status

+

Which scenes draw the song's content, which still draw their own, and + what each remaining one needs. Recipe in MIGRATION.md.

+
node tools/migration-status.js
+
+
+

cast census

+

Which visualizers actually get cast, across songs and seeds — and for + the ones that do not, which of the four gates killed them.

+
node tools/cast-census.js 12 40
+
+
+

identity census

+

How far apart the songs' identities are before anything is rendered. + No GPU. Run this before blaming the visualizers.

+
node tools/identity-census.js 12
+
+
+

song bank

+

Rebuild the test songs and verify they still span every feature the + generator reads. Fails loudly if an axis has collapsed.

+
npm run build:songs
+npm run check:songs
+
+
+

static gates

+

Determinism grep, schema/shader agreement both ways, and the backtick + check that guards the shader template literals.

+
npm run lint:scenes
+npm test
+
+
+ +

Reading the variety numbers

+

+ This harness has two classes of measurement and only one of them is safe to + steer by. Direct render comparisons — the gallery score, + decompose, the per-scene gate — have a noise floor of zero and + mean what they say. Aggregate ratios — chiefly + separation, which divides one small difference by another — + swing wildly between runs: three runs of the same measurement gave 0.31, + 0.56 and 0.07. Four conclusions were drawn and withdrawn during this epic + for exactly that reason. Anything under about 0.01 of spread needs + &repeats=3 before it is believed, and a difference that + changes sign with the sample is not a difference. +

+ +

If a page is dead

+

+ A module that fails to load takes the whole graph with it: the page renders, + every control is present, and nothing is wired to anything. The app carries a + boot guard that says so. The usual causes are a content blocker — a file + named like tracking will be treated as tracking, which cost this project a + session over a file called clicktrack.js — or a dev server + caught mid-restart. +

+ + diff --git a/flow-state/gallery.html b/flow-state/gallery.html new file mode 100644 index 0000000..de2cb35 --- /dev/null +++ b/flow-state/gallery.html @@ -0,0 +1,161 @@ + + + + +flow-state · gallery + + + +

flow-state · gallery

+

+ Every visualizer rendered six times, once per song, each with that song's + complete identity — its cast, ink, lattice, palette and sampled parameters. + A scene whose six frames are interchangeable is one the song cannot change. + Sorted least-varied first, scored on the same structural descriptor the + variety harness uses, with colour excluded so six palettes cannot disguise + one image. ← all debug tools +

+ +
+ + + idle +
+
+ +
+ + + + diff --git a/flow-state/src/checks/gallery.js b/flow-state/src/checks/gallery.js new file mode 100644 index 0000000..da0d487 --- /dev/null +++ b/flow-state/src/checks/gallery.js @@ -0,0 +1,156 @@ +// The gallery: every visualizer, six times, on six different songs' content. +// +// The complaint this answers is one a metric could not raise — watching a few +// videos, certain scenes announce themselves. You have seen that one before. +// You cannot tell which ones from a number, because the harness measures whole +// videos and a scene that always looks like itself is averaged in with +// everything around it. +// +// So this renders each scene under six complete identities — six casts, six ink +// treatments, six lattices, six palettes, six parameter draws — and puts them +// side by side. A scene whose six thumbnails are interchangeable is a scene the +// song cannot change, and that is the definition of the problem. +// +// Then it scores them, using the same structural descriptor the variety harness +// runs on, so the gallery can be sorted worst-first. Browsing sixty-six scenes +// looking for the repetitive ones is exactly the job a sort order should do. + +import { Engine } from '../engine/Engine.js'; +import { scenes } from '../scenes/registry.js'; +import { sampleValues } from '../params/schema.js'; +import { Rng, hashString } from '../engine/rng.js'; +import { featureProviderFor } from '../audio/FeatureTrack.js'; +import { songBank } from '../audio/songbank.js'; +import { generateLook } from '../look/LookGenerator.js'; +import { describeIdentity } from '../look/Identity.js'; +import { frameDescriptor } from './variety/descriptors.js'; +import { descriptorDistance, STRUCTURAL } from './variety/signature.js'; + +const THUMB = { width: 256, height: 144 }; + +/** + * Six contexts, one per song: everything a scene is handed when it is cast. + * + * Built from real bank entries rather than invented, so what the gallery shows + * is what the generator would actually produce — the same identities, palettes + * and section biases, only with the scene held fixed instead of chosen. + */ +export function galleryContexts(count = 6) { + return songBank({ count }).map((entry) => { + const look = generateLook(entry.track, { seed: hashString(entry.name) }); + // The busiest section, because that is where a scene is asked for the + // most and where two scenes are most likely to converge. + const section = look.sections.reduce( + (best, s) => (s.bias.energy > best.bias.energy ? s : best), look.sections[0]); + return { + name: entry.name, + track: entry.track, + palette: look.palette, + personality: look.personality, + bias: section.bias, + frame: section.startFrame + Math.floor((section.endFrame - section.startFrame) * 0.5), + identity: describeIdentity(look.personality.identity), + }; + }); +} + +/** Bottom-up WebGL pixels into a canvas the right way up. */ +function blit(canvas, pixels, width, height) { + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(width, height); + const row = width * 4; + for (let y = 0; y < height; y++) { + const src = (height - 1 - y) * row; + image.data.set(pixels.subarray(src, src + row), y * row); + } + ctx.putImageData(image, 0, 0); +} + +/** + * Render one scene across every context. + * + * @returns {{thumbs: Uint8Array[], variety: number, byBlock: object}} + */ +export function renderScene(engine, module, contexts) { + const thumbs = []; + const descriptors = []; + + for (const ctx of contexts) { + engine.timeline.setDuration(ctx.track.duration); + engine.setFeatureProvider(featureProviderFor(ctx.track)); + + const rng = new Rng(hashString(`${module.name}:${ctx.name}`)); + const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament); + + engine.setLayerSpecs([{ + module, + params, + seed: rng.int(0, 0x7fffffff), + opacity: 1, + blend: 'normal', + palette: ctx.palette, + personality: ctx.personality, + }]); + engine.compositor.reset(); + // A few frames of warm-up so anything with state is past its first frame. + for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f); + const pixels = Uint8Array.from(engine.readPixels(engine.renderFrame(ctx.frame))); + + thumbs.push(pixels); + descriptors.push(frameDescriptor(pixels, THUMB.width, THUMB.height)); + } + + // How different this scene's six outputs are from each other, on the same + // structural descriptor the variety harness uses — colour excluded, because + // six palettes would otherwise make every scene look varied. + const byBlock = {}; + let total = 0, pairs = 0; + for (let i = 0; i < descriptors.length; i++) { + for (let j = i + 1; j < descriptors.length; j++) { + const d = descriptorDistance(descriptors[i], descriptors[j]); + for (const b of [...STRUCTURAL, 'colour']) byBlock[b] = (byBlock[b] || 0) + (d[b] || 0); + total += STRUCTURAL.reduce((a, b) => a + (d[b] || 0), 0) / STRUCTURAL.length; + pairs++; + } + } + for (const b of Object.keys(byBlock)) byBlock[b] /= pairs || 1; + + return { thumbs, variety: pairs ? total / pairs : 0, byBlock }; +} + +/** + * Build the whole gallery, reporting progress as it goes. + * + * @param {(done:number, of:number, name:string, row:object) => void} onScene + */ +export async function buildGallery({ contexts, onScene, only = null } = {}) { + const list = only + ? scenes.filter((m) => only.includes(m.name)) + : scenes.filter((m) => m.kind === 'fragment'); + + const engine = new Engine({ ...THUMB }); + const rows = []; + try { + for (let i = 0; i < list.length; i++) { + const module = list[i]; + let row; + try { + row = { module, ...renderScene(engine, module, contexts) }; + } catch (err) { + row = { module, thumbs: [], variety: 0, byBlock: {}, error: err.message }; + } + rows.push(row); + if (onScene) onScene(i + 1, list.length, module.name, row); + // Yield so the page can paint each row as it lands rather than + // freezing for a minute and then showing everything at once. + await new Promise((r) => setTimeout(r, 0)); + } + } finally { + engine.dispose(); + } + return rows; +} + +export { blit, THUMB }; diff --git a/flow-state/vite.config.js b/flow-state/vite.config.js index ed51d1c..226e567 100644 --- a/flow-state/vite.config.js +++ b/flow-state/vite.config.js @@ -11,6 +11,8 @@ export default defineConfig({ input: { main: resolve(process.cwd(), 'index.html'), checks: resolve(process.cwd(), 'checks.html'), + debug: resolve(process.cwd(), 'debug.html'), + gallery: resolve(process.cwd(), 'gallery.html'), }, }, },