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'); +}