From e5bb7d77e0b895825af4ab32097edd06e14160dd Mon Sep 17 00:00:00 2001 From: Dejvino Date: Mon, 17 Aug 2026 21:59:24 +0200 Subject: [PATCH] A reproducible migration, and the first eighteen scenes through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library is sixty-one scenes, which is too many to convert from memory or from taste, so the migration is a queue with a gate rather than a judgement call per file. Three pieces. A classifier reads each shader and assigns a tier from evidence in the source — drawn, figure, field, treatment — so two passes over the library reach the same answer and the work has an order. MIGRATION.md is the recipe per tier, written to be followed mechanically. And a gate makes the result verifiable: `consumes` is now a schema field, the lint enforces it in both directions, and the per-scene battery renders each scene under two deliberately distant identities and requires the picture to change. That gate is the part that matters. Without it `consumes` is a comment, and the whole inversion becomes unverifiable at exactly the point where it stops being checkable by eye. With it, a scene that declares the cast and ignores it fails. Eighteen scenes migrated. Four by hand at the drawn tier — Firefly Drift, Metaballs, Floating Geometry, Prism Bloom — and ten at the field tier by script, which is one declaration and one wrapped return. All eighteen pass. The field tier is honestly marginal and the gate says so: every one of the ten moves by 37 to 39 of 255, against 173 to 255 for the drawn tier, and the uniformity across ten unrelated scenes is the tell. That is one global posterisation applying, not ten scenes expressing anything. Cheap, real, shallow. The decomposition moved from identity being worth 54% of the container to 158%, but the stage set changed underneath the measurement and part of that is Metaballs expressing a cast better than Constellation did. What survives the caveat is the useful finding: a migrated library scene carries the identity better than a stage written from scratch to carry it. The four bespoke stages were the wrong shape of effort. Co-Authored-By: Claude Opus 5 --- flow-state/MIGRATION.md | 219 ++++++++++++++++++ flow-state/src/checks/scene-gate.js | 36 +++ flow-state/src/checks/variety/print.js | 2 +- flow-state/src/checks/variety/report.js | 13 +- flow-state/src/params/schema.js | 18 ++ flow-state/src/scenes/shader/contour-map.js | 3 +- flow-state/src/scenes/shader/firefly-drift.js | 17 +- .../src/scenes/shader/floating-geometry.js | 20 +- .../src/scenes/shader/halftone-misprint.js | 3 +- flow-state/src/scenes/shader/metaballs.js | 23 +- flow-state/src/scenes/shader/moire-grid.js | 3 +- flow-state/src/scenes/shader/mycelium-web.js | 3 +- flow-state/src/scenes/shader/nebula.js | 3 +- flow-state/src/scenes/shader/plasma-bloom.js | 3 +- flow-state/src/scenes/shader/prism-bloom.js | 16 +- flow-state/src/scenes/shader/rain-column.js | 3 +- flow-state/src/scenes/shader/ridge-terrain.js | 3 +- flow-state/src/scenes/shader/smoke-column.js | 3 +- flow-state/src/scenes/shader/synthwave-run.js | 3 +- flow-state/tools/lint-scenes.js | 40 ++++ flow-state/tools/migration-status.js | 130 +++++++++++ 21 files changed, 517 insertions(+), 47 deletions(-) create mode 100644 flow-state/MIGRATION.md create mode 100644 flow-state/tools/migration-status.js diff --git a/flow-state/MIGRATION.md b/flow-state/MIGRATION.md new file mode 100644 index 0000000..2e0cf44 --- /dev/null +++ b/flow-state/MIGRATION.md @@ -0,0 +1,219 @@ +# Migrating a scene onto the identity artifacts + +The recipe `tools/migration-status.js` reports progress against. Sixty-one +scenes is too many to convert from taste, so this is written to be followed +mechanically and to produce the same result twice. + +Background is EPIC-3.md. The one-line version: a scene draws its own content, a +migrated scene draws the *song's* content — its cast, in its ink, on its lattice +— and the measured payoff is additive with everything the scene already did. + +```bash +node tools/migration-status.js # the queue, and what each scene needs +node tools/migration-status.js --all # every scene and its tier +``` + +--- + +## The rule that decides everything + +> **An artifact is content a scene could not have invented for itself.** + +If the scene still looks right with the identity switched off, it has used the +artifact as a modifier and the migration has not happened. That is not a style +preference — it is the exact failure mode of `u_sigSides`, which thirty scenes +declare and most quietly ignore, and it is why the gate below exists. + +--- + +## The four tiers + +The classifier assigns these from the shader source. Check its answer, don't +trust it: it reads patterns, and a scene that places elements in an unusual way +will be misfiled. + +| tier | what it looks like | consumes | +|---|---|---| +| **drawn** | loops over discrete elements at computed positions | `cast, ink, staging` | +| **figure** | one or a few forms, its own composition | `cast, ink` | +| **field** | a continuous surface — noise, flow, terrain | `ink` | +| **treatment** | an effect over an image rather than an image | `ink` | + +Accents are excluded. They are mostly-empty depth passes, not subjects. + +--- + +## Recipe: `drawn` + +The highest payoff and the least invention. Four edits. + +**1. Declare it.** Add to the module, above `params`: + +```js +consumes: ['cast', 'ink', 'staging'], +``` + +**2. Replace the primitive with the cast.** Whatever the loop was drawing — +a circle, a box, `sigShape`, a bespoke SDF — becomes one of: + +```glsl +float d = castMain(q) * size; // the protagonist: large, few +float d = castChorus(q) * size; // the chorus: small, many +``` + +`q` is the element-local coordinate, `(p - pos) / size`. Multiplying the result +back by `size` restores it to scene units, which is what the ink expects. + +Use `castMain` when the scene draws a handful of things and `castChorus` when it +draws a crowd. A scene that draws both should use both — that is what the two +members are for. + +**3. Replace the placement with the lattice.** + +```glsl +vec3 node = stageNode(fi, float(u_count)); // xy position, z scale multiplier +vec2 pos = node.xy; +float size = u_size * node.z; +``` + +Keep whatever the scene did that was *motion* — a drift, an orbit, a wander, a +march. Give up what was *composition*. The split is the point: identity owns +where things are, the scene owns what they do. + +A scene whose composition IS its identity — a spiral that must be a spiral — can +keep it and take `stageScale()` alone, which is the song's element size. Declare +`staging` either way. + +**4. Replace the edge with the ink.** + +```glsl +col = mix(col, pal(i + 1), inkMask(d, uv)); +``` + +`inkMask` does fill, fill treatment (hatch, stipple, halftone), outline and edge +hardness in one call. Delete the scene's own `smoothstep(soft, -soft, d)` and +its `sigEdge` — the ink supersedes both. + +Then wrap the return: + +```glsl +return vec4(inkValue(col), 1.0); +``` + +**Do not** delete `sigCamera`, `sigGrain`, `sigHorizonY` or `sigAir`. Traits and +artifacts are different layers and both still apply. + +--- + +## Recipe: `figure` + +Steps 1, 2 and 4, skipping the lattice. Declare `consumes: ['cast', 'ink']`. +Take `stageScale()` if the figure has a size worth scaling and add `staging` if +you do. + +--- + +## Recipe: `field` and `treatment` + +There are no elements to replace, so this is one edit plus a judgement. + +```js +consumes: ['ink'], +``` + +```glsl +return vec4(inkValue(col), 1.0); +``` + +If the field already dithers, hatches or posterises internally, replace that +with `inkPattern(uv)` so the treatment is the song's rather than the scene's. +If it does not, `inkValue` alone is the whole migration — a value structure +shared across every scene in a video is worth having and costs one line. + +Be honest about `treatment` scenes. Most of them are effects wearing a scene's +clothes, and EPIC-3 §8 argues they should move into the identity's EFFECTS +register rather than compete for screen time as subjects. Migrating one is a +holding action, not the answer. + +--- + +## Verifying — the part that makes this reproducible + +A migration is not done when the code looks right. It is done when the gate +passes: + +``` +checks.html?scene= +``` + +Every artifact in `consumes` must show a passing line: + +``` +PASS consumes: cast delta 255/255 (floor 24) +PASS consumes: ink delta 255/255 (floor 24) +PASS consumes: staging delta 255/255 (floor 24) +``` + +That check renders the scene twice under two deliberately distant identities and +requires the picture to change. A scene that declares `cast` and ignores it +fails here, which is the only reason `consumes` can be trusted at library scale. + +`npm run lint:scenes` enforces the other half in both directions: declaring an +artifact without calling it, and calling one without declaring it. The second +matters more than it looks — an undeclared artifact hides the scene from this +report and from anything that later selects scenes on capability. + +Then the usual battery still applies. `renders something`, `animates`, +`deterministic`, `distinct`, `param sweep`, `flash rate` and every declared +trait must all still pass. A migration that breaks `distinct` has made the scene +into one of its neighbours, which is a real risk here: the more scenes share a +cast, the more two weakly-composed ones converge. + +--- + +## Measuring the payoff + +Per scene, the gate. Across the library, two numbers: + +``` +checks.html?decompose=1 identity against container, measured apart +checks.html?experiment=1 stages against legacy, with error bars +``` + +`decompose` is the one to watch. Before the migration, over the four purpose- +built stages: + +``` +identity only 0.0299 container only 0.0557 identity = 54% of container +``` + +After the first eighteen scenes, over the eight that consume the cast: + +``` +identity only 0.1287 container only 0.0815 identity = 158% of container +``` + +Read that with its caveat: the stage set changed underneath the measurement, so +part of the jump is that Metaballs expresses a cast more strongly than +Constellation did rather than that the migration itself moved anything. What it +does establish is the thing worth knowing — a migrated LIBRARY scene carries the +identity better than a stage written from scratch to carry it. The bespoke +stages were the wrong shape of effort. As scenes migrate, the +`identity only` number should climb while `container only` holds — because the +whole point of the correction in EPIC-3 §9b is that these add rather than trade. +If `container only` falls as scenes migrate, the migration is homogenising the +library and should stop. + +--- + +## A caution learned the expensive way + +This harness's noise floor is large enough to invent findings. Three claims in +this epic were made from single runs and withdrawn after repeats: that roster +size was the dominant lever, that stages beat legacy by 14%, and that the +identity's range was the bottleneck. Anything under about 0.01 of spread needs +`&repeats=3` before it is believed, and a difference that changes sign with the +sample size is not a difference. + +Migrate in batches, measure after each batch, and expect the per-batch effect to +be inside the noise. The trend across batches is the signal. diff --git a/flow-state/src/checks/scene-gate.js b/flow-state/src/checks/scene-gate.js index 83a1f85..d58cd49 100644 --- a/flow-state/src/checks/scene-gate.js +++ b/flow-state/src/checks/scene-gate.js @@ -23,6 +23,7 @@ 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'; +import { generateIdentity } from '../look/Identity.js'; const PALETTE = [ [0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95], @@ -133,6 +134,41 @@ export function runSceneGate(name) { const rate = peakFlashRate(luminance, 60); record(rate <= 3, 'flash rate', `${rate}/s at aggressive settings (ceiling 3)`); + // --- identity response ----------------------------------------------- + // The migration gate. A scene that declares it consumes the cast must + // produce a DIFFERENT PICTURE when the song's cast changes — otherwise + // `consumes` is a comment and the whole inversion is unverifiable at + // library scale. Deliberately a much larger threshold than the trait + // check: a trait may be honoured subtly, but content is the subject. + for (const artifact of module.consumes || []) { + const other = generatePersonality(SUMMARY, new Rng(9001)); + // Two identities as far apart as the generator can make them. + const alt = generateIdentity( + { ...SUMMARY, meanFlatness: 0.6, meanCentroid: 0.85, bpm: 168 }, + new Rng(31337), 6); + // Force the always-visible ink decisions on. A field scene's whole + // migration may be `inkValue`, and posterisation is off for most + // identities — without pinning it the probe would sometimes hand the + // scene two identities that ask it for the same picture and then + // fail it for complying. + alt.ink = { ...alt.ink, posterize: 4, fill: 'hatch', outline: 0.8, weight: 0.8 }; + other.identity = alt; + if (artifact === 'cast') { + // The protagonist's geometry is read from the signature form. + other.shape = { sides: 8, roundness: 0.02, elongation: 1.4, tilt: 0.9 }; + } + + 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 > 24, `consumes: ${artifact}`, + `delta ${changed}/255 (floor 24)`); + } + // --- personality response -------------------------------------------- // Every declared trait must move the image; a trait declared and ignored // gets the scene cast in tracks it cannot express. diff --git a/flow-state/src/checks/variety/print.js b/flow-state/src/checks/variety/print.js index 668d88e..c3f714c 100644 --- a/flow-state/src/checks/variety/print.js +++ b/flow-state/src/checks/variety/print.js @@ -381,7 +381,7 @@ export async function decomposeReportLines({ songs = 6, probes = 3 } = {}) { const lines = []; lines.push('IDENTITY vs CONTAINER — where visual difference actually comes from'); lines.push(''); - lines.push(` ${d.songs} songs · one fixed track · stage held or varied as labelled`); + lines.push(` ${d.songs} songs · one fixed track · ${d.stageCount} cast-consuming scenes`); lines.push(''); lines.push(` identity only ${bar(Math.min(1, d.identityOnly * 5))} ${d.identityOnly.toFixed(4)}`); lines.push(` one stage (${d.stage}), each song's cast, ink and lattice`); diff --git a/flow-state/src/checks/variety/report.js b/flow-state/src/checks/variety/report.js index d7f84c9..1462447 100644 --- a/flow-state/src/checks/variety/report.js +++ b/flow-state/src/checks/variety/report.js @@ -622,8 +622,12 @@ export function measureFingerprint({ songs = 6, probes = 4, refScenes = 5 } = {} export function measureDecomposition({ songs = 6, probes = 3, stageNames = null } = {}) { const bank = songBank({ count: songs }); const track = bank[0].track; - const names = stageNames || ['Procession', 'Constellation', 'Soloist', 'Swarm']; - const stages = names.map((n) => scenes.find((m) => m.name === n)).filter(Boolean); + // Every scene that actually draws the cast, not a hardcoded list — so this + // number tracks the migration instead of being pinned to the four scenes + // that happened to be written first. + const stages = stageNames + ? stageNames.map((n) => scenes.find((m) => m.name === n)).filter(Boolean) + : scenes.filter((m) => (m.consumes || []).includes('cast') && m.role !== 'accent'); // Each song's identity, lifted off its own look so it can be transplanted. const looks = bank.map((e) => generateLook(e.track, { seed: hashString(e.name) })); @@ -673,5 +677,8 @@ export function measureDecomposition({ songs = 6, probes = 3, stageNames = null render(oneStage, looks[0], 4242), ]); - return { identityOnly, containerOnly, both, sameBoth, stage: oneStage.name, songs: bank.length }; + return { + identityOnly, containerOnly, both, sameBoth, + stage: oneStage.name, songs: bank.length, stageCount: stages.length, + }; } diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index 426ea40..38ca0da 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -53,6 +53,15 @@ export const REACTIVE_FEATURES = [ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse']; +/** + * Identity artifacts a scene can consume. See look/Identity.js and EPIC-3.md. + * + * A trait is a modifier a scene may honour; an artifact is CONTENT the scene + * draws. Declaring one is a commitment the gate enforces: swap the song's + * identity and a scene that claims `cast` must produce a different picture. + */ +export const ARTIFACT_NAMES = ['cast', 'ink', 'staging']; + /** * Personality traits a scene can honour. See look/Personality.js. * @@ -231,6 +240,15 @@ export function validateModule(module) { if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`); } } + if (module.consumes !== undefined) { + if (!Array.isArray(module.consumes)) { + errors.push(`${id}: \`consumes\` must be an array of ${ARTIFACT_NAMES.join('/')}`); + } else { + for (const a of module.consumes) { + if (!ARTIFACT_NAMES.includes(a)) errors.push(`${id}: unknown artifact '${a}'`); + } + } + } if (module.texture !== undefined && (typeof module.texture !== 'number' || module.texture < 0 || module.texture > 2)) { errors.push(`${id}: \`texture\` must be a number 0..2 — how much of the track's ` + diff --git a/flow-state/src/scenes/shader/contour-map.js b/flow-state/src/scenes/shader/contour-map.js index c748b21..8305f06 100644 --- a/flow-state/src/scenes/shader/contour-map.js +++ b/flow-state/src/scenes/shader/contour-map.js @@ -14,6 +14,7 @@ export const contourMap = { kind: 'fragment', // Drafting, not photography. texture: 0.3, + consumes: ['ink'], traits: ['camera', 'space', 'style'], params: { @@ -88,7 +89,7 @@ vec4 scene(vec2 uv, vec2 p) { col = sigAir(col, p, sat(1.0 - below * 0.9)); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/firefly-drift.js b/flow-state/src/scenes/shader/firefly-drift.js index c304b31..fde2575 100644 --- a/flow-state/src/scenes/shader/firefly-drift.js +++ b/flow-state/src/scenes/shader/firefly-drift.js @@ -10,6 +10,7 @@ export const fireflyDrift = { family: 'flow', kind: 'fragment', // Personality: see look/Personality.js. + consumes: ['cast', 'ink', 'staging'], traits: ['camera', 'style'], params: { @@ -38,25 +39,29 @@ vec4 scene(vec2 uv, vec2 p) { if (float(i) >= u_count) break; float fi = float(i); - // Seed the mote to a stable place in the frame (deterministic per i). - vec2 grid = vec2(hash12(vec2(fi, 1.7)), hash12(vec2(fi, 9.1))); - vec2 base = (grid - 0.5) * 2.0 * u_spread * vec2(1.0, 0.7); + // The mote's home is the song's lattice; the drift is this scene's own. + vec3 node = stageNode(fi, u_count); + vec2 base = node.xy * u_spread * vec2(1.0, 0.7); // Advect along the curl field plus a bounded wander term on top. - vec2 flow = curl(grid * 3.0 + vec2(0.0, t * 0.15), t * 0.5); + vec2 flow = curl(node.xy * 3.0 + vec2(0.0, t * 0.15), t * 0.5); vec2 pos = base + flow * 1.3 + vec2(sin(t * (0.4 + fract(fi * 0.13))), cos(t * (0.5 + fract(fi * 0.29)))) * u_jitter; + // A mote is one of the chorus, drawn small — the glow is the scene's + // contribution, the form is the song's. + float size = 0.045 * node.z; + float sd = castChorus((p - pos) / max(size, 1e-3)) * size; float d = length(p - pos); vec3 hc = pal(int(mod(fi, 4.0))); + col = mix(col, hc, inkMask(sd, uv)); col += hc * (exp(-d * d / (0.015 + u_glow * 0.04)) * (1.0 + u_glow * 0.6)); - col += hc * exp(-d * d * 60.0); } col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; \ No newline at end of file diff --git a/flow-state/src/scenes/shader/floating-geometry.js b/flow-state/src/scenes/shader/floating-geometry.js index 5968e2c..fc581b0 100644 --- a/flow-state/src/scenes/shader/floating-geometry.js +++ b/flow-state/src/scenes/shader/floating-geometry.js @@ -10,6 +10,7 @@ export const floatingGeometry = { // of shapes, so `shape` is the trait it exists to express. // Takes the track's surface grain, but lightly — this is drawn, not filmed. texture: 0.4, + consumes: ['cast', 'ink', 'staging'], traits: ['shape', 'camera', 'style'], params: { @@ -41,30 +42,29 @@ vec4 scene(vec2 uv, vec2 p) { float fi = float(i); float s = u_seed + fi * 123.456; - vec2 pos = vec2( - sin(t * 0.5 + s) * u_spread, - cos(t * 0.3 + s * 1.1) * u_spread * 0.55 - ); + // The song's lattice sets where they hang; this scene keeps the float. + vec3 node = stageNode(fi, float(u_count)); + vec2 pos = node.xy * u_spread * vec2(1.0, 0.55) + + vec2(sin(t * 0.5 + s), cos(t * 0.3 + s * 1.1)) * 0.18; vec2 sp = rot(t * (0.2 + fract(s) * u_spin)) * (p - pos); - float size = u_size * (0.6 + fract(s * 0.7) * 0.8); + float size = u_size * node.z * (0.6 + fract(s * 0.7) * 0.8); - // Every element is the track's signature form. This scene used to pick + // Every element is the song's protagonist. This scene used to pick // between a box and a circle per element, which is precisely the choice // the production design should be making — one video, one cast. // The variety param only scales them apart; it never changes what they are. float scale = size * (1.0 + (fract(s * 0.37) - 0.5) * u_variety); - float d = sigShape(sp / max(scale, 1e-3)) * scale; + float d = castMain(sp / max(scale, 1e-3)) * scale; vec3 shapeColor = pal(i + int(floor(t * 0.3))); - float intensity = smoothstep(0.012, 0.0, d) + sigEdge(d) * 0.35; - col = mix(col, shapeColor, intensity * 0.85); + col = mix(col, shapeColor, inkMask(d, uv) * 0.85); col += shapeColor * (1.0 - smoothstep(0.0, size * 2.2, abs(d))) * u_aura; } col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/halftone-misprint.js b/flow-state/src/scenes/shader/halftone-misprint.js index d945b64..94b56b4 100644 --- a/flow-state/src/scenes/shader/halftone-misprint.js +++ b/flow-state/src/scenes/shader/halftone-misprint.js @@ -13,6 +13,7 @@ export const halftoneMisprint = { kind: 'fragment', // The paper's tooth is the point; take the track's grain in full. texture: 1.2, + consumes: ['ink'], traits: ['camera', 'style'], params: { @@ -94,7 +95,7 @@ vec4 scene(vec2 uv, vec2 p) { col = max(col, vec3(0.0)); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/metaballs.js b/flow-state/src/scenes/shader/metaballs.js index 5526e60..eff6f2d 100644 --- a/flow-state/src/scenes/shader/metaballs.js +++ b/flow-state/src/scenes/shader/metaballs.js @@ -8,6 +8,7 @@ export const metaballs = { family: 'organic', kind: 'fragment', // Personality: see look/Personality.js. + consumes: ['cast', 'ink', 'staging'], traits: ['shape', 'camera', 'style'], params: { @@ -39,16 +40,18 @@ vec4 scene(vec2 uv, vec2 p) { float fi = float(i); float s = u_seed + fi * 71.3; - vec2 centre = vec2( - sin(t * (0.7 + fract(s * 0.13)) + s) * u_spread, - cos(t * (0.5 + fract(s * 0.29)) + s * 1.7) * u_spread * 0.62 - ); + // Homes on the song's lattice, orbited by this scene's own motion. + vec3 node = stageNode(fi, float(u_count)); + vec2 centre = node.xy * u_spread * vec2(1.0, 0.62) + + vec2(sin(t * (0.7 + fract(s * 0.13)) + s), + cos(t * (0.5 + fract(s * 0.29)) + s * 1.7)) * 0.25; - // Distance measured in the track's form rather than as a circle: for a - // round personality this is exactly length(p - centre), and for a - // hexagonal one the blobs merge as hexagons. - float d = sigShape((p - centre) / max(u_radius, 1e-3)) * u_radius + u_radius; - float contribution = (u_radius * u_radius) / max(d * d, 1e-4); + // Distance measured in the song's FORM rather than as a circle: for a + // round cast this is exactly length(p - centre), and for a hexagonal + // one the blobs merge as hexagons. + float radius = u_radius * node.z; + float d = castMain((p - centre) / max(radius, 1e-3)) * radius + radius; + float contribution = (radius * radius) / max(d * d, 1e-4); field += contribution; tint += pal(i) * contribution; } @@ -64,7 +67,7 @@ vec4 scene(vec2 uv, vec2 p) { col += tint * rim * u_rim; col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/moire-grid.js b/flow-state/src/scenes/shader/moire-grid.js index 8182410..aa67644 100644 --- a/flow-state/src/scenes/shader/moire-grid.js +++ b/flow-state/src/scenes/shader/moire-grid.js @@ -12,6 +12,7 @@ export const moireGrid = { // Personality: see look/Personality.js. // Crisp line work: the track's surface grain would only fur the edges. texture: 0, + consumes: ['ink'], traits: ['camera', 'style'], params: { @@ -67,7 +68,7 @@ vec4 scene(vec2 uv, vec2 p) { col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.3); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/mycelium-web.js b/flow-state/src/scenes/shader/mycelium-web.js index d8bcc74..422c205 100644 --- a/flow-state/src/scenes/shader/mycelium-web.js +++ b/flow-state/src/scenes/shader/mycelium-web.js @@ -12,6 +12,7 @@ export const myceliumWeb = { name: 'Mycelium Web', family: 'organic', kind: 'fragment', + consumes: ['ink'], traits: ['camera', 'space', 'style'], params: { @@ -91,7 +92,7 @@ vec4 scene(vec2 uv, vec2 p) { col = sigAir(col, p, sat(1.0 - below * 1.1)); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/nebula.js b/flow-state/src/scenes/shader/nebula.js index 58a4e3e..fde4441 100644 --- a/flow-state/src/scenes/shader/nebula.js +++ b/flow-state/src/scenes/shader/nebula.js @@ -9,6 +9,7 @@ export const nebula = { family: 'organic', kind: 'fragment', // Personality: see look/Personality.js. + consumes: ['ink'], traits: ['camera', 'space', 'style'], params: { @@ -59,7 +60,7 @@ vec4 scene(vec2 uv, vec2 p) { col = sigAir(col, p, smoothstep(0.0, 1.6, r)); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/plasma-bloom.js b/flow-state/src/scenes/shader/plasma-bloom.js index 0628318..1e89856 100644 --- a/flow-state/src/scenes/shader/plasma-bloom.js +++ b/flow-state/src/scenes/shader/plasma-bloom.js @@ -7,6 +7,7 @@ export const plasmaBloom = { family: 'organic', kind: 'fragment', // Personality: see look/Personality.js. + consumes: ['ink'], traits: ['camera', 'space', 'style'], params: { @@ -48,7 +49,7 @@ vec4 scene(vec2 uv, vec2 p) { col = sigAir(col, p, smoothstep(0.0, 1.8, length(p))); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/prism-bloom.js b/flow-state/src/scenes/shader/prism-bloom.js index 8cb3527..beeeb7c 100644 --- a/flow-state/src/scenes/shader/prism-bloom.js +++ b/flow-state/src/scenes/shader/prism-bloom.js @@ -10,6 +10,7 @@ export const prismBloom = { // Personality: see look/Personality.js. // Crisp line work: the track's surface grain would only fur the edges. texture: 0, + consumes: ['cast', 'ink', 'staging'], traits: ['shape', 'camera', 'style'], params: { @@ -45,8 +46,8 @@ vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; p = sigFolded(sigCamera(p)); - // Facet count yields to the track's signature form when it names one. - float sites = u_sigSides > 2.5 ? u_sigSides : float(u_sites); + // Facet count yields to the song's cast when it names a form. + float sites = u_castSides > 2.5 ? u_castSides : float(u_sites); vec3 col = pal(0) * 0.04; for (int i = 0; i < 8; i++) { @@ -54,7 +55,7 @@ vec4 scene(vec2 uv, vec2 p) { float fi = float(i); // Layers counter-rotate and breathe independently; all closed-form. - float layerRadius = u_radius * (0.5 + fi * 0.24); + float layerRadius = u_radius * stageScale() * (0.5 + fi * 0.24); float breathe = 0.78 + 0.22 * sin(u_time * 0.9 + fi * 1.7); vec2 lp = rot(fi * 0.7 + t * (fi * 0.5 + 0.3) * u_spin) * p; @@ -65,13 +66,14 @@ vec4 scene(vec2 uv, vec2 p) { col += pal(0) * halo; } - // The heart of the bloom is the track's own form. - float core = sigForm(p, vec2(0.0), 0.09 + u_beat * 0.06); - col = mix(col, pal(3), core); + // The heart of the bloom is the song's protagonist, drawn in its ink. + float coreSize = (0.09 + u_beat * 0.06) * stageScale(); + float core = castMain(p / max(coreSize, 1e-3)) * coreSize; + col = mix(col, pal(3), inkMask(core, uv)); col += pal(3) * exp(-dot(p, p) * 8.0) * (0.5 + u_glow); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; \ No newline at end of file diff --git a/flow-state/src/scenes/shader/rain-column.js b/flow-state/src/scenes/shader/rain-column.js index bc81f9d..83522ca 100644 --- a/flow-state/src/scenes/shader/rain-column.js +++ b/flow-state/src/scenes/shader/rain-column.js @@ -12,6 +12,7 @@ export const rainColumn = { name: 'Rain Column', family: 'flow', kind: 'fragment', + consumes: ['ink'], traits: ['camera', 'space', 'style'], params: { @@ -101,7 +102,7 @@ vec4 scene(vec2 uv, vec2 p) { col = sigAir(col, p, smoothstep(0.2, 1.5, abs(p.x) * 0.6 + sat(above))); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/ridge-terrain.js b/flow-state/src/scenes/shader/ridge-terrain.js index 4d498a4..de54491 100644 --- a/flow-state/src/scenes/shader/ridge-terrain.js +++ b/flow-state/src/scenes/shader/ridge-terrain.js @@ -8,6 +8,7 @@ export const ridgeTerrain = { family: 'structural', kind: 'fragment', // Personality: see look/Personality.js. + consumes: ['ink'], traits: ['space', 'camera', 'style'], params: { @@ -71,7 +72,7 @@ vec4 scene(vec2 uv, vec2 p) { } col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/smoke-column.js b/flow-state/src/scenes/shader/smoke-column.js index 8d1a0f9..72054bb 100644 --- a/flow-state/src/scenes/shader/smoke-column.js +++ b/flow-state/src/scenes/shader/smoke-column.js @@ -12,6 +12,7 @@ export const smokeColumn = { name: 'Smoke Column', family: 'flow', kind: 'fragment', + consumes: ['ink'], traits: ['camera', 'space', 'style'], params: { @@ -81,7 +82,7 @@ vec4 scene(vec2 uv, vec2 p) { col = sigAir(col, p, smoothstep(0.0, 1.8, length(p))); col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/src/scenes/shader/synthwave-run.js b/flow-state/src/scenes/shader/synthwave-run.js index 5b73fbb..44d81be 100644 --- a/flow-state/src/scenes/shader/synthwave-run.js +++ b/flow-state/src/scenes/shader/synthwave-run.js @@ -11,6 +11,7 @@ export const synthwaveRun = { family: 'structural', kind: 'fragment', // Personality: see look/Personality.js. + consumes: ['ink'], traits: ['space', 'camera', 'style'], params: { @@ -103,7 +104,7 @@ vec4 scene(vec2 uv, vec2 p) { col += colorMain * exp(-abs(p.y - horizon) * 8.0) * u_glow * 0.35; col += sigGrain(uv); - return vec4(col, 1.0); + return vec4(inkValue(col), 1.0); } `, }; diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index a3d899d..31b60cf 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -80,6 +80,19 @@ const CONTRACT_UNIFORMS = new Set([ 'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe', 'u_sigHorizon', 'u_sigDepth', 'u_sigWash', 'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold', + 'u_sigFrameScale', 'u_sigFrameShift', + // The identity artifacts. See look/Identity.js. A scene reads these through + // castMain/inkMask/stageNode rather than directly, but a migrated scene may + // legitimately branch on one — Prism Bloom takes its facet count from the + // cast's side count — so they belong in the contract set. + 'u_castSides', 'u_castRound', 'u_castElong', 'u_castTilt', + 'u_castNotchN', 'u_castNotchD', 'u_castHollow', + 'u_chorusSides', 'u_chorusRound', 'u_chorusElong', 'u_chorusTilt', + 'u_chorusNotchN', 'u_chorusNotchD', 'u_chorusHollow', + 'u_inkWeight', 'u_inkEdge', 'u_inkFill', 'u_inkHatchAngle', + 'u_inkHatchScale', 'u_inkOutline', 'u_inkPosterize', + 'u_latKind', 'u_latJitter', 'u_latSpread', + 'u_latScaleSpread', 'u_latScaleBias', 'u_latScale', ]); /** @@ -94,6 +107,16 @@ const CONTRACT_UNIFORMS = new Set([ // sigShape ever did — the form is the subject rather than a hint applied to one // — so castMain/castChorus count as evidence. Likewise inkMask/inkValue are the // style trait carried out in full. See scenes/stage/README.md. +// The same idea as TRAIT_EVIDENCE, for the identity artifacts: a scene that +// declares it consumes the cast has to actually draw it. Without this, +// `consumes` is a comment, and the migration becomes unverifiable the moment it +// is more than a handful of files. +const ARTIFACT_EVIDENCE = { + cast: /\bcast(Main|Chorus|SDF)\s*\(/, + ink: /\bink(Mask|Value|Pattern)\s*\(/, + staging: /\b(stageNode|stageScale)\s*\(/, +}; + const TRAIT_EVIDENCE = { shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF))\s*\(/, camera: /\bsigCamera\s*\(/, @@ -193,6 +216,23 @@ console.log('\nscene schema lint'); } } + for (const artifact of module.consumes || []) { + const evidence = ARTIFACT_EVIDENCE[artifact]; + if (evidence && !evidence.test(src)) { + fail(`${id}: declares it consumes '${artifact}' but the shader never calls it — ` + + `an artifact that is declared and ignored is worse than one that is ` + + `not declared, because the casting code will believe it`); + } + } + // The reverse: using an artifact without declaring it hides the scene + // from the migration status report and from anything that selects on + // capability later. + for (const [artifact, evidence] of Object.entries(ARTIFACT_EVIDENCE)) { + if (evidence.test(src) && !(module.consumes || []).includes(artifact)) { + fail(`${id}: uses the '${artifact}' artifact but does not declare it in \`consumes\``); + } + } + // 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 diff --git a/flow-state/tools/migration-status.js b/flow-state/tools/migration-status.js new file mode 100644 index 0000000..a22dd1a --- /dev/null +++ b/flow-state/tools/migration-status.js @@ -0,0 +1,130 @@ +// Which scenes have migrated to the identity artifacts, and what each of the +// rest needs. See MIGRATION.md for the recipe this reports progress against. +// +// Sixty-one scenes is too many to migrate from memory or from taste. The +// classifier below reads each shader and proposes a TIER from evidence in the +// source, so the work is a queue rather than a judgement call each time, and so +// two passes over the library reach the same answer. +// +// It is deliberately conservative: it proposes, the gate disposes. A scene is +// only migrated when `consumes` is declared AND checks.html?scene= shows +// the matching `consumes:` line passing, which no amount of pattern matching +// can fake. +// +// node tools/migration-status.js progress plus the next ten +// node tools/migration-status.js --all every scene +// node tools/migration-status.js --tier drawn + +import { scenes } from '../src/scenes/registry.js'; + +const has = (n) => process.argv.includes(`--${n}`); +const argOf = (n) => { + const i = process.argv.indexOf(`--${n}`); + return i >= 0 ? process.argv[i + 1] : null; +}; + +/** + * Tiers, in migration order. The order is by payoff per unit of risk: scenes + * that already loop over discrete elements are a near-mechanical change and are + * where the cast actually shows, while a full-frame field has no elements to + * replace and can only take the ink. + */ +const TIERS = { + drawn: { + consumes: ['cast', 'ink', 'staging'], + why: 'loops over discrete elements — replace the primitive with the cast, ' + + 'the placement with stageNode, the edge with inkMask', + }, + figure: { + consumes: ['cast', 'ink'], + why: 'draws one or a few forms without a lattice — take the cast and the ink, ' + + 'keep its own composition', + }, + field: { + consumes: ['ink'], + why: 'a continuous surface with no elements — inkValue on the way out, ' + + 'inkPattern where it already dithers or hatches', + }, + treatment: { + consumes: ['ink'], + why: 'an effect over an image rather than an image — takes the value ' + + 'structure only, and should probably become an EFFECT rather than a scene', + }, +}; + +const RX = { + migrated: /\b(castMain|castChorus|castSDF|inkMask|inkValue|stageNode)\s*\(/, + // A loop whose body places something at a computed position: the signature + // of an element-placing scene. + elementLoop: /for\s*\(\s*int\s+\w+\s*=\s*0[\s\S]{0,900}?(length\s*\(\s*p\s*-|p\s*-\s*(pos|centre|center|c)\b|sigForm\s*\(|smoothstep\s*\([^)]*\bd\b)/, + anyLoop: /for\s*\(\s*int\s+\w+\s*=\s*0/, + // A single distance-field subject, no loop needed. + figure: /\bsig(Shape|Form)\s*\(|\bsdf\w*\s*\(|length\s*\(\s*p\s*\)\s*-/, + // Screen-space corruption: it operates on the frame, not on a world. + treatment: /\bprev\s*\(|\bvUv\b[\s\S]{0,200}(tear|glitch|shift)|u_prev/, + field: /\b(fbm|vnoise|curl|voronoi|noise)\s*\(/, +}; + +function classify(module) { + const src = module.shader || ''; + if (RX.migrated.test(src) || (module.consumes || []).length) return 'migrated'; + if (module.role === 'accent') return 'accent'; + if (RX.elementLoop.test(src)) return 'drawn'; + if (RX.treatment.test(src)) return 'treatment'; + if (RX.figure.test(src) && !RX.anyLoop.test(src)) return 'figure'; + if (RX.field.test(src)) return 'field'; + return 'figure'; +} + +const rows = scenes.map((m) => ({ + name: m.name, + family: m.family, + role: m.role || 'stage', + tier: classify(m), + consumes: m.consumes || [], + lines: (m.shader || '').split('\n').length, +})); + +const migrated = rows.filter((r) => r.tier === 'migrated'); +const pending = rows.filter((r) => r.tier !== 'migrated' && r.tier !== 'accent'); +const accents = rows.filter((r) => r.tier === 'accent'); + +const bar = (v, w = 28) => '█'.repeat(Math.round(v * w)) + '·'.repeat(w - Math.round(v * w)); + +console.log(`\nMIGRATION STATUS — ${migrated.length}/${migrated.length + pending.length} scenes on the identity artifacts\n`); +console.log(` ${bar(migrated.length / Math.max(1, migrated.length + pending.length))} ` + + `${(migrated.length / Math.max(1, migrated.length + pending.length) * 100).toFixed(0)}%` + + ` (${accents.length} accents excluded — they are depth passes, not subjects)\n`); + +console.log(' REMAINING BY TIER\n'); +for (const [tier, spec] of Object.entries(TIERS)) { + const group = pending.filter((r) => r.tier === tier); + if (!group.length) continue; + console.log(` ${tier} — ${group.length} scenes → consumes: [${spec.consumes.join(', ')}]`); + console.log(` ${spec.why}`); + console.log(` ${group.map((r) => r.name).join(', ')}`); + console.log(''); +} + +if (has('all') || argOf('tier')) { + const want = argOf('tier'); + console.log(' PER SCENE\n'); + for (const r of rows.filter((x) => !want || x.tier === want)) { + console.log(` ${r.name.padEnd(24)} ${r.family.padEnd(11)} ${r.tier.padEnd(10)}` + + `${r.consumes.length ? `[${r.consumes.join(',')}]` : ''}`); + } + console.log(''); +} else if (pending.length) { + // The queue: drawn first, biggest payoff and least invention required. + const order = ['drawn', 'figure', 'field', 'treatment']; + const next = pending.slice().sort( + (a, b) => order.indexOf(a.tier) - order.indexOf(b.tier) || a.lines - b.lines); + console.log(' NEXT TEN\n'); + for (const r of next.slice(0, 10)) { + console.log(` ${r.name.padEnd(24)} ${r.tier.padEnd(10)} ${r.family.padEnd(11)} ${r.lines} shader lines`); + } + console.log(''); + console.log(' Recipe: MIGRATION.md. Verify one with:'); + console.log(` checks.html?scene=${encodeURIComponent(next[0].name)}`); + console.log(' and look for a passing "consumes:" line for every artifact declared.\n'); +}