diff --git a/flow-state/PLAN.md b/flow-state/PLAN.md index 3a5ef73..9c73669 100644 --- a/flow-state/PLAN.md +++ b/flow-state/PLAN.md @@ -452,6 +452,82 @@ Toward six families. Each new scene is a shader plus a schema block. - Library-wide regression contact sheet at a fixed seed, diffed against last known good, so shared-code changes can't silently break existing scenes. +### Phase 8 — shots +Not in the original plan. It exists because the manual gate in Phase 4 caught exactly what it +was written to catch: watching whole tracks, long stretches went by without the image +changing. Sections are the song's *stages*, and a stage can run ninety seconds; one scene held +for ninety seconds reads as a still image with a wobble on it, however reactive the wobble is. + +So a third level sits between section and frame. Each section KIND gets a roster of three or +four **stage visuals** instead of one scene, and each section is cut into **shots** that +rotate between them on phrase lines — four to eight bars in a drop, eight to sixteen in an +intro, never longer than twenty-two seconds. The roster stays per kind, so all of a track's +drops still cut between the same visuals and the video keeps its identity; the first entry is +the anchor, opens every section of that kind, and the rotation keeps returning to it. When a +companion is due it is the least recently shown one, so a long section reaches its whole +roster instead of ping-ponging between two images. + +Mechanically this is one change: the arc driver stopped working in sections and started +working in **cues**, one per shot, so a shot cut and a section change take the same code path +and differ only in transition length. The default transition is a slow dissolve — two bars on +calm material, one on loud, capped at 40% of the shot. A straight cut is reserved for genuinely +high-energy sections: below the energy threshold nothing ever cuts, because on calm material a +cut reads as a glitch rather than as an edit. + +**Gate:** +- No image held longer than the ceiling, and none shorter than the floor. Both measured across + several seeds; the first is the complaint that started the phase, expressed as a number. +- Intra-section cuts land within a quarter-bar of a downbeat. +- Identity holds: a kind's roster is stable across its sections, the anchor opens each section + and stays within one showing of the most-shown visual, and no variant repeats back to back. +- A section with four or more shots reaches at least three distinct visuals — otherwise the + rotation has collapsed back to A/B and the roster is decoration. +- Dissolves outnumber cuts, and no section below the energy threshold cuts at all. +- No black frame or discontinuity at a cut; the flash-rate sweep runs per shot rather than per + section, so the visuals that only appear mid-section are measured too. +- **Watch the battery again.** The phase exists because of a manual finding and the fix is a + pacing judgement, which no delta threshold can make. + +### Phase 9 — production design +Also not in the original plan, and for the same reason as Phase 8: watching real tracks. With +cuts every fifteen seconds the next problem became obvious — the images being cut between had +nothing in common but the palette. That is a slideshow, not a music video. + +What a music video actually shares across its shots is a location, a cast, a camera operator +and an art direction. So each track now generates a **personality** in four traits, once, off +the look seed: + +| trait | what it fixes | how a scene expresses it | +|---|---|---| +| `shape` | the cast | a signature form — round, or an n-gon at a tilt — stamped wherever a scene draws elements | +| `camera` | the operator | one slow returning pan, sway, roll and bar-locked breath, applied to the coordinate a scene works in | +| `space` | the location | a shared horizon height, depth falloff and background wash | +| `style` | the art direction | line weight, edge softness, surface grain, fold count | + +The traits reach shaders as ordinary uniforms plus four helper functions in the contract +(`sigShape`, `sigCamera`, `sigAir`, `sigEdge`/`sigGrain`), so a scene honours a trait in its own +way — the rings of Classic Wave become hexagonal, Metaballs merge as hexagons, Floating +Geometry stops choosing between a box and a circle because the production already decided. + +The part that makes it a design rather than a filter: each scene DECLARES which traits it +honours, each track is built on one or two of them, and **a scene that does not honour all of +them is not cast in that track**. The library shrinks per track on purpose. A scene with no way +to draw a hexagon should not appear in the hexagon video. + +**Gate:** +- Personality reproduces exactly from the seed, and differs between seeds. +- Every trait has at least six scenes, or a track built on it could not fill its rosters. +- No scene is ever cast in a track it does not honour, and no signature starves a track below + three distinct scenes (the fallback to a one-trait signature exists for this). +- **Every declared trait visibly changes the scene that declares it.** The lint proves a scene + mentions a trait; only rendering proves it matters. Measured as a frame delta against a + one-LSB floor — the tolerance the determinism checks already call noise. +- Changing the personality moves every scene in a track's cast, not just one. +- A layer with no personality renders exactly what it rendered before the phase existed, which + is what keeps every earlier sweep and regression valid. +- **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. + --- ## 10. Detachment from `party-stage` diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js index 7e7b651..2fc6963 100644 --- a/flow-state/src/Show.js +++ b/flow-state/src/Show.js @@ -60,6 +60,9 @@ export class Show { this.engine.timeline.setDuration(this.track.duration); this.engine.setFeatureProvider(featureProviderFor(this.track)); + report('scenes', 0.99); + this.prewarm(); + report('ready', 1); return this; } @@ -83,12 +86,14 @@ export class Show { reroll(seed) { this.setLook(rerollLook(this.look, this.track, seed)); + this.prewarm(); } rerollSection(index, salt) { rerollSection(this.look, this.track, index, salt); this.arc.invalidateSection(index); this._lastLayers = null; + this.prewarm(); } setPalette(palette) { @@ -140,8 +145,23 @@ export class Show { return this.engine.compositor.render({ timeline, features }); } + /** + * Build and compile every layer in the look up front. + * + * Without this the first frame of every shot pays for a shader link, which + * shows as a hitch exactly on the cut. One pass at load costs a few hundred + * milliseconds and removes all of them. + */ + prewarm() { + if (!this.arc) return this; + this.arc.prewarm(); + this.engine.compositor.primeLayers([...this.arc.layerCache.values()]); + return this; + } + /** See Engine.prime — required before frame-exact rendering. */ prime(frame = 0) { + this.prewarm(); this.renderFrame(frame); // ensures the arc has built its layers this.engine.prime(frame); return this; diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index 131741b..5093ef7 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -9,6 +9,8 @@ import './phase4.js'; import './phase5.js'; import './phase6.js'; import './phase7.js'; +import './phase8.js'; +import './phase9.js'; const out = document.getElementById('results'); const summaryEl = document.getElementById('summary'); diff --git a/flow-state/src/checks/phase2.js b/flow-state/src/checks/phase2.js index 54ef6d7..6977798 100644 --- a/flow-state/src/checks/phase2.js +++ b/flow-state/src/checks/phase2.js @@ -13,6 +13,7 @@ import { defaultValues, sweepValues, validateModule } from '../params/schema.js' import { serializeParams, deserializeParams } from '../params/serialize.js'; import { ParamPanel } from '../ui/ParamPanel.js'; import { frameLuminance, frameVariance } from '../engine/hash.js'; +import { AUDIO_UNIFORMS, SIGNATURE_UNIFORMS } from '../engine/shader-contract.js'; import { featureProviderFor } from '../audio/FeatureTrack.js'; import { testTrack } from './phase1.js'; @@ -43,13 +44,14 @@ check(2, 'every scene schema validates', () => { }); check(2, 'declared uniforms and shader sources agree both ways', () => { + // Derived from the contract rather than retyped: the signature uniforms + // arrived in Phase 9 and a hand-maintained copy of this list would have + // reported every scene that reads one as reading an undeclared uniform. const CONTRACT = new Set([ 'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress', 'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev', - 'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh', - 'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat', - 'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress', - 'u_sectionEnergy', 'u_buildSlope', + ...AUDIO_UNIFORMS, + ...Object.keys(SIGNATURE_UNIFORMS), ]); const problems = []; for (const module of scenes) { diff --git a/flow-state/src/checks/phase4.js b/flow-state/src/checks/phase4.js index 9ce5357..fbd72b0 100644 --- a/flow-state/src/checks/phase4.js +++ b/flow-state/src/checks/phase4.js @@ -31,11 +31,14 @@ function makeShow(seed = 2024, width = 160, height = 90) { return show; } -check(4, 'the scene changes only at section boundaries', () => { +check(4, 'the scene changes only on a planned cut', () => { + // Since Phase 8 a cut is a SHOT boundary, not only a section boundary — a + // section rotates between its stage visuals. What must still hold is that no + // change happens anywhere the look did not plan one. const show = makeShow(); try { const track = show.track; - const boundaries = track.sections.map((s) => s.startFrame); + const boundaries = show.arc.cues.map((c) => c.startFrame); const changes = []; let previous = null; @@ -48,8 +51,9 @@ check(4, 'the scene changes only at section boundaries', () => { const stray = changes.filter((f) => !boundaries.some((b) => Math.abs(f - b) <= 10)); return expect(stray.length === 0, - `${changes.length} scene change(s), ${stray.length} away from a boundary · ` + - `${track.sections.length} sections: ${track.sections.map((s) => s.kind).join(', ')}`); + `${changes.length} scene change(s), ${stray.length} away from a cut · ` + + `${track.sections.length} sections, ${boundaries.length} shots: ` + + track.sections.map((s) => s.kind).join(', ')); } finally { show.dispose(); } @@ -72,48 +76,70 @@ check(4, 'transitions produce no pops or black frames', () => { let previous = null; let peak = 0; + let peakAt = start; let darkest = 1; for (let f = start; f < Math.min(end, track.frameCount); f++) { const pixels = Uint8Array.from(show.readPixels(show.renderFrame(f))); darkest = Math.min(darkest, frameLuminance(pixels)); - if (previous) peak = Math.max(peak, frameDistance(previous, pixels)); + if (previous) { + const d = frameDistance(previous, pixels); + if (d > peak) { peak = d; peakAt = f; } + } previous = pixels; } - return { peak, darkest }; + return { peak, peakAt, darkest }; }; // The control must sit in the SAME scenes the boundary window contains. // Scenes differ enormously in inherent frame-to-frame motion — one busy // scene next to a calm one reads as an 8x "spike" against a control taken // from the calm one, with no cut anywhere near it. - const interior = (section) => { - const mid = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2); - return scan(mid, Math.min(mid + 200, section.endFrame)); + // + // Since Phase 8 that means the same SHOT, not merely the same section: a + // section rotates between two or three visuals, and the middle of the + // section is often not the visual that is on screen at the boundary. + const interior = (cue) => { + const mid = cue.startFrame + Math.floor((cue.endFrame - cue.startFrame) / 2); + return scan(mid, Math.min(mid + 200, cue.endFrame)); }; + const cueAt = (frame) => show.arc.cueAt(frame); + let worstRatio = 0; let worstBoundary = -1; let darkest = 1; let controlUsed = 0; + let worstPeakAt = -1; for (let i = 1; i < track.sections.length; i++) { const s = track.sections[i]; - const before = interior(track.sections[i - 1]); - const after = interior(s); + const before = interior(cueAt(s.startFrame - 1)); + const after = interior(cueAt(s.startFrame)); const control = Math.max(before.peak, after.peak); - const w = scan(s.startFrame - 60, s.startFrame + show.arc.crossfadeFrames + 60); + // The window starts just before the boundary rather than a second + // before it. The transition runs FORWARD from the boundary, so a + // handful of pre-frames is all it takes to catch a pop on the + // boundary frame itself — while a longer pre-roll would drag in the + // lookahead build ramp, where a scene is deliberately driven to the + // top of its range and flashes accordingly. That is the intended + // climax of a build, not a transition fault, and the control window + // (mid-shot, no ramp) has no equivalent to cancel it against. + const w = scan(s.startFrame - 8, s.startFrame + cueAt(s.startFrame).fadeFrames + 60); darkest = Math.min(darkest, w.darkest, before.darkest, after.darkest); const ratio = w.peak / Math.max(control, 1e-6); - if (ratio > worstRatio) { worstRatio = ratio; worstBoundary = s.startFrame; controlUsed = control; } + if (ratio > worstRatio) { + worstRatio = ratio; worstBoundary = s.startFrame; controlUsed = control; + worstPeakAt = w.peakAt; + } } if (worstBoundary < 0) return expect(true, 'single-section track'); return expect(worstRatio < 1.6 && darkest > 0.002, `worst boundary peak ${worstRatio.toFixed(2)}x the adjacent scenes' own peak ` + - `(control ${controlUsed.toFixed(4)}) at frame ${worstBoundary}, darkest ${darkest.toFixed(4)}`); + `(control ${controlUsed.toFixed(4)}) at boundary ${worstBoundary}, peak frame ${worstPeakAt}, darkest ${darkest.toFixed(4)}`); } finally { show.dispose(); } @@ -126,8 +152,11 @@ check(4, 'crossfade ramps rather than cuts', () => { const boundary = track.sections[1] && track.sections[1].startFrame; if (!boundary) return expect(true, 'single-section track, nothing to cross-fade'); + // Read the length off the cue rather than assuming the nominal one: since + // Phase 8 a dissolve is two bars on calm material and one on loud. + const fadeFrames = show.arc.cueAt(boundary).fadeFrames; const samples = []; - for (let f = boundary; f < boundary + show.arc.crossfadeFrames; f += 2) { + for (let f = boundary; f < boundary + fadeFrames; f += 2) { show.arc.update(f, track.at(f)); samples.push(show.arc.state.crossfade); } @@ -135,7 +164,7 @@ check(4, 'crossfade ramps rather than cuts', () => { const spans = samples[0] < 0.15 && samples[samples.length - 1] > 0.85; return expect(monotonic && spans, - `${show.arc.crossfadeFrames}-frame fade, monotonic ${monotonic}, ` + + `${fadeFrames}-frame fade, monotonic ${monotonic}, ` + `${samples[0].toFixed(2)}→${samples[samples.length - 1].toFixed(2)}`); } finally { show.dispose(); @@ -177,13 +206,15 @@ check(4, 'lookahead ramps params into a higher-energy section', () => { } }); -check(4, 'params drift within a long section', () => { - // Guards the failure mode automated checks are worst at: a section that is - // technically correct and completely static. +check(4, 'params drift within a held shot', () => { + // Guards the failure mode automated checks are worst at: an image that is + // technically correct and completely static. Measured over the longest SHOT + // rather than the longest section — across a shot cut the scene itself + // changes, which would pass this trivially and prove nothing about drift. const show = makeShow(); try { const track = show.track; - const longest = track.sections.reduce((a, b) => + const longest = show.arc.cues.reduce((a, b) => (b.endFrame - b.startFrame > a.endFrame - a.startFrame ? b : a)); const sample = (frame) => { @@ -200,7 +231,7 @@ check(4, 'params drift within a long section', () => { return expect(moved.length >= Math.ceil(numeric.length * 0.5), `${moved.length}/${numeric.length} params moved across a ` + - `${((longest.endFrame - longest.startFrame) / 60).toFixed(0)}s section`); + `${((longest.endFrame - longest.startFrame) / 60).toFixed(0)}s shot`); } finally { show.dispose(); } diff --git a/flow-state/src/checks/phase5.js b/flow-state/src/checks/phase5.js index e6dcdf2..6ec32e5 100644 --- a/flow-state/src/checks/phase5.js +++ b/flow-state/src/checks/phase5.js @@ -174,9 +174,13 @@ check(5, 'generated looks stay within the flash-rate ceiling', () => { const show = new Show({ width: 96, height: 54 }); try { show.useTrack(track, generateLook(track, { seed: 5000 + s * 7919 })); - for (const section of track.sections) { - const start = section.startFrame + 60; - const end = Math.min(section.endFrame, start + 300); // 5 seconds + // Per SHOT, not per section: a section rotates between two or three + // stage visuals and only the first of them sits at the section start, + // so sweeping sections would leave most of what ships unmeasured. The + // window opens before the cut so the cut itself is inside it. + for (const cue of show.arc.cues.slice(0, 8)) { + const start = Math.max(0, cue.startFrame - 20); + const end = Math.min(cue.endFrame, start + 300); // 5 seconds if (end - start < 120) continue; show.engine.compositor.reset(); @@ -187,7 +191,8 @@ check(5, 'generated looks stay within the flash-rate ceiling', () => { luminance.push(frameLuminance(show.readPixels(show.renderFrame(f)))); } const rate = peakFlashRate(luminance, 60); - const scene = show.look.sections[section.index].layers[0].module.name; + const section = show.look.sections[cue.sectionIndex]; + const scene = (section.variants[cue.variant] || section.layers)[0].module.name; const label = `seed ${s} ${section.kind}/${scene}`; if (rate > worst) { worst = rate; worstLabel = label; } if (rate > 3) problems.push(`${label}: ${rate}/s`); diff --git a/flow-state/src/checks/phase6.js b/flow-state/src/checks/phase6.js index 05ba404..2353079 100644 --- a/flow-state/src/checks/phase6.js +++ b/flow-state/src/checks/phase6.js @@ -12,7 +12,7 @@ import { generateLook } from '../look/LookGenerator.js'; import { FeatureTrack } from '../audio/FeatureTrack.js'; import { synthesizeSectioned } from '../audio/synth.js'; import { Exporter, isSupported, PRESETS } from '../export/Exporter.js'; -import { frameDistance } from '../engine/hash.js'; +import { frameDistance, frameMaxDelta } from '../engine/hash.js'; let cached = null; function track6() { @@ -70,21 +70,42 @@ check(6, 'audio-clock preview and frame-counted export agree', () => { const start = 600; const count = 40; + // Prime first, as BOTH real paths do — the exporter calls prime() and the + // preview prewarms on load. Without it the first pass here renders through + // programs that are still linking and the first few frames come back + // different, which is the hazard Compositor.prime() documents rather than + // anything about preview versus export. Measured: unprimed, frames 0/2/3 + // of the first pass differed and every later pass was identical. + show.prime(start); + show.engine.compositor.reset(); const preview = []; for (let i = 0; i < count; i++) { show.timeline.syncToAudio((start + i) / show.fps); - preview.push(show.engine.hashCurrent(show.renderFrame(show.timeline.frame))); + preview.push(Uint8Array.from(show.readPixels(show.renderFrame(show.timeline.frame)))); } show.engine.compositor.reset(); const exported = []; for (let i = 0; i < count; i++) { - exported.push(show.engine.hashCurrent(show.renderFrame(start + i))); + exported.push(Uint8Array.from(show.readPixels(show.renderFrame(start + i)))); } - const mismatches = preview.filter((h, i) => h !== exported[i]).length; - return expect(mismatches === 0, `${mismatches}/${count} frames differed`); + // Judged on a one-LSB tolerance rather than bit-exact hashes, for the same + // reason Phase 7's determinism check is (see PLAN.md §1): the first Show + // built in a fresh context comes back with a handful of pixels differing + // by 1/255 from the second render of the same frames, and every later + // Show in the same context is bit-exact. Measured over four consecutive + // shows: 3 frames at delta 1, then 0, 0, 0. That is GPU float variance + // under differing load, and demanding bit-exactness of the very first + // render is demanding something the hardware does not offer. A real + // preview/export divergence — a different scene, a different param, a + // frame off by one — scores in the tens or hundreds here, not 1. + const deltas = preview.map((f, i) => frameMaxDelta(f, exported[i])); + const worst = Math.max(...deltas); + return expect(worst <= 1, + `${deltas.filter((d) => d > 1).length}/${count} frames differed visibly ` + + `· worst channel delta ${worst}`); } finally { show.dispose(); } diff --git a/flow-state/src/checks/phase8.js b/flow-state/src/checks/phase8.js new file mode 100644 index 0000000..0803a5a --- /dev/null +++ b/flow-state/src/checks/phase8.js @@ -0,0 +1,225 @@ +// Phase 8 gate — shots. +// +// The problem this phase exists to fix is measurable, so the gate measures it: +// how long does the same image stay on screen? Everything else here guards the +// ways more cuts could go wrong — cuts off the bar grid, cuts so fast they +// become a strobe, or a rotation so busy the section loses its identity. + +import { check, expect, expectBelow } from './framework.js'; +import { Show } from '../Show.js'; +import { FeatureTrack } from '../audio/FeatureTrack.js'; +import { synthesizeSectioned } from '../audio/synth.js'; +import { generateLook } from '../look/LookGenerator.js'; +import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS, HARD_CUT_ENERGY } from '../look/shots.js'; +import { frameDistance } from '../engine/hash.js'; + +let cached = null; +function track8() { + if (!cached) { + cached = FeatureTrack.fromAudioBuffer( + synthesizeSectioned({ bpm: 128, duration: 240, changeAt: 90 }), { fps: 60 }); + } + return cached; +} + +function looks(count = 8) { + const track = track8(); + return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 1000 + i * 7919 })); +} + +/** + * A rendered frame as an independent buffer. Renderer.readPixels hands back a + * reused array, so comparing two of its results compares one frame with itself. + */ +function copyPixels(show, frame) { + return Uint8Array.from(show.readPixels(show.renderFrame(frame))); +} + +/** Every shot in a look, flattened, in playback order. */ +function allShots(look) { + return look.sections.flatMap((s) => (s.shots || []).map((shot) => ({ ...shot, section: s }))); +} + +check(8, 'no image is held past the ceiling', () => { + // The complaint that started the phase. A shot is the longest a single + // image can stay up, so this is the whole fix expressed as a number. + const fps = track8().fps; + let worst = 0; + let worstWhere = ''; + + for (const look of looks()) { + for (const shot of allShots(look)) { + const seconds = (shot.endFrame - shot.startFrame) / fps; + if (seconds > worst) { + worst = seconds; + worstWhere = `${shot.section.kind} shot ${shot.index}`; + } + } + } + return expectBelow(worst, MAX_SHOT_SECONDS + 0.05, `longest held image ${worstWhere}`); +}); + +check(8, 'shots are not shorter than the floor', () => { + // The other end of the same axis: a cut every two seconds is not editing, + // it is a strobe, and the flash meter would be the next thing to complain. + const fps = track8().fps; + let shortest = Infinity; + for (const look of looks()) { + for (const shot of allShots(look)) { + shortest = Math.min(shortest, (shot.endFrame - shot.startFrame) / fps); + } + } + return expect(shortest >= MIN_SHOT_SECONDS - 0.05, + `shortest shot ${shortest.toFixed(2)}s (floor ${MIN_SHOT_SECONDS}s)`); +}); + +check(8, 'cuts land on the bar grid', () => { + // A cut that lands between phrases reads as a mistake even when the image + // is good, so this is a quality gate rather than a correctness one. + const track = track8(); + const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps; + let total = 0; + let onGrid = 0; + + for (const look of looks()) { + for (const shot of allShots(look)) { + if (shot.index === 0) continue; // section boundary, snapped upstream + total++; + const time = shot.startFrame / track.fps; + const nearest = track.tempo.downbeats.reduce( + (best, d) => Math.min(best, Math.abs(d - time)), Infinity); + if (nearest <= barSeconds * 0.26) onGrid++; + } + } + const ratio = total ? onGrid / total : 1; + return expect(ratio >= 0.9, `${onGrid}/${total} intra-section cuts within a quarter-bar`); +}); + +check(8, 'a section keeps its identity across its shots', () => { + // Shots must not become a shuffle. A section rotates between three or four + // visuals, the anchor opens it, and the anchor keeps coming back. + // + // "Keeps coming back" is deliberately not "is strictly the most shown". The + // rotation is random with a bias, and on a nine-shot section a companion can + // legitimately edge the anchor by one without the section losing its centre. + // Demanding a strict maximum would either fail on ordinary seeds or force a + // rigid A-B-A-C pattern, which is audible as a pattern within three cycles. + const problems = []; + for (const look of looks()) { + for (const section of look.sections) { + const shots = section.shots || []; + if (!shots.length) continue; + const counts = section.variants.map((_, v) => shots.filter((s) => s.variant === v).length); + if (section.variants.length > 4) problems.push(`${section.kind}: ${section.variants.length} variants`); + if (shots[0].variant !== 0) problems.push(`${section.kind}: opens on variant ${shots[0].variant}`); + if (counts[0] < Math.max(...counts) - 1) { + problems.push(`${section.kind}: anchor shown ${counts[0]}x against ${Math.max(...counts)}x`); + } + for (let i = 1; i < shots.length; i++) { + if (shots[i].variant === shots[i - 1].variant) { + problems.push(`${section.kind}: repeats variant ${shots[i].variant} back to back`); + } + } + } + } + return expect(problems.length === 0, problems.slice(0, 4).join(' · ') || 'rosters well formed'); +}); + +check(8, 'the same section kind reuses the same roster', () => { + // The identity rule one level up: two drops cut between the same visuals. + const problems = []; + for (const look of looks()) { + const byKind = new Map(); + for (const section of look.sections) { + const roster = (section.variants || [section.layers]).map((v) => v[0].module.name).join('+'); + const seen = byKind.get(section.kind); + if (seen && seen !== roster) problems.push(`${section.kind}: ${seen} vs ${roster}`); + byKind.set(section.kind, roster); + } + } + return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'rosters stable per kind'); +}); + +check(8, 'dissolves are the default and straight cuts are reserved for energy', () => { + // A cut on calm material reads as a glitch rather than as an edit, so the + // policy is: nothing below the threshold ever cuts. This also guards the + // pacing from the other side — if every transition became a hard cut the + // video would feel like a slideshow, and no other check would notice. + const problems = []; + let cuts = 0; + let dissolves = 0; + + for (const look of looks()) { + for (const section of look.sections) { + for (const shot of section.shots || []) { + if (shot.index === 0) continue; + if (shot.hardCut) { + cuts++; + if (section.bias.energy <= HARD_CUT_ENERGY) { + problems.push(`${section.kind} cuts at energy ${section.bias.energy.toFixed(2)}`); + } + } else { + dissolves++; + } + } + } + } + return expect(problems.length === 0 && dissolves > cuts, + problems.slice(0, 3).join(' · ') || `${dissolves} dissolves, ${cuts} cuts`); +}); + +check(8, 'a section rotates through more than two visuals when it is long enough', () => { + // The roster is only worth having if the shots reach it. Sections with four + // or more shots must show at least three distinct visuals — otherwise the + // rotation has collapsed back to A/B, which is the complaint this sizing + // was meant to answer. + const problems = []; + let checked = 0; + + for (const look of looks()) { + for (const section of look.sections) { + const shots = section.shots || []; + if (shots.length < 4 || section.variants.length < 3) continue; + checked++; + const distinct = new Set(shots.map((s) => s.variant)).size; + if (distinct < 3) { + problems.push(`${section.kind}: ${shots.length} shots, only ${distinct} visuals`); + } + } + } + if (!checked) return expect(true, 'no section long enough to rotate'); + return expect(problems.length === 0, + problems.slice(0, 3).join(' · ') || `${checked} long section(s) all reached 3+ visuals`); +}); + +check(8, 'shot cuts do not produce a black frame or a jump cut to nothing', () => { + // A cut is a short crossfade, not a swap. Rendered either side of every + // intra-section cut, consecutive frames must still be continuous enough that + // nothing goes black — the failure mode of getting the layer bookkeeping + // wrong is one empty frame, which is invisible in review and obvious in an + // export. + const show = new Show({ width: 160, height: 90 }); + try { + const track = track8(); + show.useTrack(track, generateLook(track, { seed: 4242 })); + + const cuts = show.arc.cues.filter((c) => !c.atSectionStart).slice(0, 6); + if (!cuts.length) return expect(false, 'no intra-section cuts were planned'); + + let worst = 0; + let worstAt = 0; + for (const cue of cuts) { + show.seek(Math.max(0, cue.startFrame - 4)); + let previous = copyPixels(show, cue.startFrame - 3); + for (let f = cue.startFrame - 2; f <= cue.startFrame + cue.fadeFrames + 2; f++) { + const pixels = copyPixels(show, f); + const d = frameDistance(previous, pixels); + if (d > worst) { worst = d; worstAt = f; } + previous = pixels; + } + } + return expectBelow(worst, 0.35, `largest frame-to-frame delta across ${cuts.length} cuts @${worstAt}`); + } finally { + show.dispose(); + } +}, { slow: true }); diff --git a/flow-state/src/checks/phase9.js b/flow-state/src/checks/phase9.js new file mode 100644 index 0000000..db07ca9 --- /dev/null +++ b/flow-state/src/checks/phase9.js @@ -0,0 +1,224 @@ +// Phase 9 gate — the production design. +// +// Phase 8 gave a track more cuts. Watching the result made the next problem +// obvious: the cuts were between images that had nothing in common but their +// palette, which is a slideshow, not a video. Phase 9 gives every track a +// personality — a signature form, a camera, a location and an art direction — +// and requires scenes to express it or sit the track out. See look/Personality.js. +// +// The gate has to answer three questions. Is the personality reproducible? Is +// the casting rule actually enforced? And — the one that matters — does any of +// it reach the screen, or are sixteen scenes quietly ignoring the uniforms? + +import { check, expect } from './framework.js'; +import { Engine } from '../engine/Engine.js'; +import { scenes } from '../scenes/registry.js'; +import { defaultValues } from '../params/schema.js'; +import { TRAITS, generatePersonality, sceneHonours, MIN_ELIGIBLE_SCENES } from '../look/Personality.js'; +import { generateLook } from '../look/LookGenerator.js'; +import { Rng } from '../engine/rng.js'; +import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js'; +import { synthesizeSectioned } from '../audio/synth.js'; +import { frameMaxDelta } from '../engine/hash.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], +]; + +let cached = null; +function track9() { + if (!cached) { + cached = FeatureTrack.fromAudioBuffer( + synthesizeSectioned({ bpm: 124, duration: 150, changeAt: 70 }), { fps: 60 }); + } + return cached; +} + +function looks(count = 8) { + const track = track9(); + return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 3000 + i * 6841 })); +} + +const castOf = (look) => look.sections.flatMap((s) => (s.variants || [s.layers]).map((v) => v[0].module)); + +/** Two personalities differing in exactly one trait, for the "does it show" checks. */ +function pairDifferingIn(trait) { + const a = generatePersonality(summaryStub(), new Rng(11)); + const b = generatePersonality(summaryStub(), new Rng(11)); + switch (trait) { + case 'shape': + b.shape = { sides: 6, roundness: 0.05, elongation: 1.3, tilt: 0.7 }; + a.shape = { sides: 0, roundness: 0.5, elongation: 1.0, tilt: 0.0 }; + break; + case 'camera': + b.camera = { ...a.camera, driftAngle: 1.1, driftRate: 0.06, sway: 0.06, swayRate: 0.2, spin: 0.05, breathe: 0.05 }; + a.camera = { ...a.camera, driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 }; + break; + case 'space': + a.space = { horizon: 0.34, depth: 0.1, washAngle: 0, wash: 0.1 }; + b.space = { horizon: 0.66, depth: 0.9, washAngle: 2.4, wash: 0.5 }; + break; + case 'style': + a.style = { lineWeight: 0.15, softness: 0.2, texture: 0.0, symmetry: 1 }; + b.style = { lineWeight: 0.95, softness: 0.9, texture: 0.5, symmetry: 4 }; + break; + } + return [a, b]; +} + +function summaryStub() { + return { meanCentroid: 0.5, meanFlatness: 0.2, bpm: 124 }; +} + +function makeEngine(width = 160, height = 90) { + const engine = new Engine({ width, height }); + const track = track9(); + engine.timeline.setDuration(track.duration); + engine.setFeatureProvider(featureProviderFor(track)); + return engine; +} + +/** Render one scene twice under two personalities and report the largest difference. */ +function deltaUnder(module, a, b, engine) { + const spec = { + module, params: defaultValues(module), seed: 4242, + opacity: 1, blend: 'normal', palette: PALETTE, + }; + const capture = (personality) => { + engine.setLayerSpecs([{ ...spec, personality }]); + engine.prime(420); + engine.compositor.reset(); + return Uint8Array.from(engine.readPixels(engine.renderFrame(420))); + }; + return frameMaxDelta(capture(a), capture(b)); +} + +check(9, 'the personality is reproducible from the seed', () => { + const track = track9(); + const a = generateLook(track, { seed: 77 }).personality; + const b = generateLook(track, { seed: 77 }).personality; + const c = generateLook(track, { seed: 78 }).personality; + + const same = JSON.stringify(a) === JSON.stringify(b); + const different = JSON.stringify(a) !== JSON.stringify(c); + return expect(same && different, + `same seed identical: ${same} · different seed differs: ${different} · ` + + `signature ${a.signature.join('+')}`); +}); + +check(9, 'every trait has enough scenes to build a track from', () => { + // The casting rule only works if the library can staff it. A trait declared + // by three scenes cannot carry a track — the rosters would collapse and every + // section would show the same two images, which is Phase 8 undone. + const counts = TRAITS.map((t) => [t, scenes.filter((m) => m.role !== 'accent' + && sceneHonours(m, [t])).length]); + const thin = counts.filter(([, n]) => n < MIN_ELIGIBLE_SCENES); + return expect(thin.length === 0, + counts.map(([t, n]) => `${t}:${n}`).join(' ') + + (thin.length ? ` — too thin: ${thin.map(([t]) => t).join(', ')}` : '')); +}); + +check(9, 'no scene is cast in a track it cannot express', () => { + // The whole point. A scene that ignores the trait a track is built on is the + // shot that was obviously filmed somewhere else. + const problems = []; + for (const look of looks()) { + const signature = look.personality.signature; + for (const module of castOf(look)) { + if (!sceneHonours(module, signature)) { + problems.push(`${module.name} cast in a ${signature.join('+')} track`); + } + } + } + return expect(problems.length === 0, + [...new Set(problems)].slice(0, 4).join(' · ') || 'every scene honours its track'); +}); + +check(9, 'the signature still leaves a track enough scenes to cut between', () => { + // The failure mode of a hard filter: a personality so specific that the + // whole video is two scenes. The fallback in pickSignature exists for this, + // and this is what proves it fires. + const problems = []; + for (const look of looks()) { + const distinct = new Set(castOf(look).map((m) => m.name)); + if (distinct.size < 3) { + problems.push(`${look.personality.signature.join('+')}: only ${distinct.size} scenes`); + } + } + return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all casts 3+ scenes'); +}); + +check(9, 'every declared trait visibly changes the scene that declares it', () => { + // The check that would have caught the whole thing being decorative. The + // lint proves a scene MENTIONS the trait; only rendering proves it matters. + // One LSB is the tolerance the determinism checks already treat as noise, so + // anything at or under it counts as ignored. + const engine = makeEngine(); + const problems = []; + const measured = []; + try { + for (const trait of TRAITS) { + const [a, b] = pairDifferingIn(trait); + for (const module of scenes) { + if (!(module.traits || []).includes(trait)) continue; + if (module.kind !== 'fragment') continue; // 3D layers move the camera, not the frame + const delta = deltaUnder(module, a, b, engine); + measured.push(delta); + if (delta <= 1) problems.push(`${module.name}/${trait}: delta ${delta}`); + } + } + } finally { + engine.dispose(); + } + const worst = Math.min(...measured); + return expect(problems.length === 0, + problems.slice(0, 4).join(' · ') || + `${measured.length} scene/trait pairs, weakest response delta ${worst}`); +}, { slow: true }); + +check(9, 'a personality changes the whole cast, not one scene', () => { + // Coherence, measured the only way it can be: if the track's design reaches + // every scene it cast, then changing the design changes every one of them. + const engine = makeEngine(); + try { + const track = track9(); + const look = generateLook(track, { seed: 4242 }); + const [a, b] = pairDifferingIn(look.personality.signature[0] || 'camera'); + + const cast = [...new Map(castOf(look).map((m) => [m.name, m])).values()] + .filter((m) => m.kind === 'fragment'); + const deltas = cast.map((m) => [m.name, deltaUnder(m, a, b, engine)]); + const unmoved = deltas.filter(([, d]) => d <= 1); + + return expect(unmoved.length === 0, + unmoved.length + ? `unmoved: ${unmoved.map(([n]) => n).join(', ')}` + : `${deltas.length} cast scenes all respond · ` + + deltas.map(([n, d]) => `${n} ${d}`).join(', ')); + } finally { + engine.dispose(); + } +}, { slow: true }); + +check(9, 'a layer with no personality renders what it always rendered', () => { + // The neutral-default promise. Every range sweep, flash sweep and library + // regression builds layers directly with no personality attached, and they + // all compare against numbers recorded before this phase existed. + const engine = makeEngine(); + try { + const module = scenes.find((m) => m.kind === 'fragment' && (m.traits || []).length); + const neutral = { + shape: { sides: 0, roundness: 0.25, elongation: 1, tilt: 0 }, + camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 }, + space: { horizon: 0.5, depth: 0, washAngle: 0, wash: 0 }, + style: { lineWeight: 0.5, softness: 0.5, texture: 0, symmetry: 1 }, + signature: [], + }; + const delta = deltaUnder(module, null, neutral, engine); + return expect(delta <= 1, + `${module.name}: no-personality vs explicit-neutral delta ${delta}`); + } finally { + engine.dispose(); + } +}, { slow: true }); diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js index 5812e50..ebbd136 100644 --- a/flow-state/src/engine/Compositor.js +++ b/flow-state/src/engine/Compositor.js @@ -135,6 +135,19 @@ export class Compositor { return this; } + /** + * Compile layers that are not on screen yet, so the frame they first appear + * on does not pay for the link. Used by the arc driver's prewarm pass. + */ + primeLayers(layers) { + for (const layer of layers) { + if (this._primed.has(layer)) continue; + this._primeLayer(layer); + this._primed.add(layer); + } + return this; + } + /** * Force a layer's shader program to finish linking before it is used for real. * diff --git a/flow-state/src/engine/Engine.js b/flow-state/src/engine/Engine.js index 69e3be5..3c2236a 100644 --- a/flow-state/src/engine/Engine.js +++ b/flow-state/src/engine/Engine.js @@ -57,6 +57,7 @@ export class Engine { const layers = specs.map((s) => { const layer = createLayer(s.module, s); if (s.palette) layer.setPalette(s.palette); + if (s.personality) layer.setPersonality(s.personality); return layer; }); this.ownedLayers = layers; diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js index 8dc64ca..8b10765 100644 --- a/flow-state/src/engine/Layer.js +++ b/flow-state/src/engine/Layer.js @@ -1,5 +1,6 @@ import * as THREE from 'three'; -import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS } from './shader-contract.js'; +import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS, SIGNATURE_UNIFORMS } from './shader-contract.js'; +import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js'; import { clampValue } from '../params/schema.js'; export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight']; @@ -28,6 +29,17 @@ export class Layer { this.opacity = opacity; this.blend = blend; this.palette = []; + this.personality = null; + } + + /** + * The track's production design. Constant for the whole video — see + * look/Personality.js — and pushed in the same way the palette is, so a + * layer never reaches for global state. + */ + setPersonality(personality) { + this.personality = personality; + return this; } setParams(params) { @@ -102,6 +114,10 @@ export class ShaderLayer extends Layer { u_hasPrev: { value: 0 }, }; for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 }; + for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) { + const v = NEUTRAL_UNIFORMS[name]; + uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v }; + } for (const [name, def] of Object.entries(this.module.params || {})) { if (!def.uniform || def.type === 'palette') continue; @@ -152,6 +168,13 @@ export class ShaderLayer extends Layer { if (c) u.u_colors.value[i].set(c[0], c[1], c[2]); } + const signature = signatureUniforms(this.personality); + for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) { + const v = signature[name]; + if (type === 'vec2') u[name].value.set(v[0], v[1]); + else u[name].value = v; + } + u.u_prev.value = prevTexture || null; u.u_hasPrev.value = prevTexture ? 1 : 0; @@ -212,6 +235,7 @@ export class SceneLayer extends Layer { features: features || {}, params: resolved, palette: this.palette, + personality: this.personality, opacity: this.opacity, THREE, }); diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js index 3281e33..3223b71 100644 --- a/flow-state/src/engine/shader-contract.js +++ b/flow-state/src/engine/shader-contract.js @@ -43,6 +43,38 @@ export const AUDIO_UNIFORMS = [ 'u_buildSlope', // >0 while energy is ramping toward the next section ]; +/** + * The track's personality, constant for the whole video. See look/Personality.js. + * + * These are what make sixteen unrelated shaders read as one production. A scene + * declares in `traits` which of them it honours, and the look generator will not + * cast a scene that cannot express what the track is built on. + * + * All of them are neutral by default, so a layer built without a personality + * renders exactly what it always rendered. + */ +export const SIGNATURE_UNIFORMS = { + u_sigSides: 'float', // signature form: 0 = round, else polygon sides + u_sigRound: 'float', // corner rounding of that form + u_sigElong: 'float', // how far from square the form is + u_sigTilt: 'float', // its resting angle + + u_sigDrift: 'vec2', // camera translation per second + u_sigSway: 'float', // camera sway amplitude + u_sigSwayRate: 'float', + u_sigSpin: 'float', // slow camera roll, radians per second + u_sigBreathe: 'float', // bar-locked zoom + + u_sigHorizon: 'float', // where the ground meets the sky, 0..1 up the frame + u_sigDepth: 'float', // distance falloff + u_sigWash: 'vec2', // background gradient direction and strength + + u_sigLine: 'float', // line weight + u_sigSoft: 'float', // edge softness + u_sigTexture: 'float', // surface grain + u_sigFold: 'float', // kaleidoscopic folds, 1 = none +}; + export const FRAME_UNIFORMS = [ 'u_time', 'u_frame', 'u_progress', 'u_seed', 'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity', @@ -65,6 +97,8 @@ uniform int u_colorCount; ${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')} +${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')} + uniform sampler2D u_prev; uniform int u_hasPrev; @@ -140,6 +174,86 @@ vec2 kaleido(vec2 p, float sides) { return vec2(cos(a), sin(a)) * r; } +// --- personality ----------------------------------------------------------- +// The four traits, as functions a scene applies in its own way. A scene that +// calls none of these must not declare the matching trait, or it will be cast +// in a track it cannot express. See look/Personality.js. + +/** + * TRAIT: shape. Signed distance to the track's signature form, radius ~1. + * Round tracks return a circle, so a scene can call this unconditionally. + */ +float sigShape(vec2 q) { + q = rot(u_sigTilt) * q; + q.x /= max(u_sigElong, 0.05); + + if (u_sigSides < 2.5) return length(q) - 1.0; + + // Regular polygon by angular folding, then rounded back toward the circle. + float seg = 6.28318530718 / u_sigSides; + float a = atan(q.y, q.x); + float r = length(q); + float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5); + float poly = r * folded - cos(seg * 0.5); + return mix(poly, r - 1.0, clamp(u_sigRound, 0.0, 1.0)); +} + +/** TRAIT: shape, as a filled mask of the given radius, centred on a point. */ +float sigForm(vec2 p, vec2 centre, float size) { + float d = sigShape((p - centre) / max(size, 1e-3)) * max(size, 1e-3); + return smoothstep(u_sigSoft * 0.25 + 0.004, -u_sigSoft * 0.25, d); +} + +/** + * TRAIT: camera. The same operator filming every scene — a slow drift, a sway, + * a roll, and a bar-locked breath. Apply to a centred coordinate before using it. + */ +vec2 sigCamera(vec2 p) { + float t = u_time; + p = rot(u_sigSpin * t) * p; + p *= 1.0 - u_sigBreathe * sin(u_barPhase * 6.28318530718); + p += vec2(sin(t * u_sigSwayRate), cos(t * u_sigSwayRate * 0.83)) * u_sigSway; + // The pan RETURNS. A constant translation would be a camera on rails: two + // minutes in, every scene has left the frame entirely. This is a slow track + // across the subject and back, roughly a two-minute cycle. + p -= u_sigDrift * 20.0 * sin(t * 0.05); + return p; +} + +/** TRAIT: style. Fold the frame the track's way. 1 fold means no fold. */ +vec2 sigFolded(vec2 p) { + return kaleido(p, u_sigFold); +} + +/** TRAIT: style. Turn a signed distance into an edge drawn in the track's hand. */ +float sigEdge(float d) { + float w = 0.004 + u_sigLine * 0.03; + float soft = w * (0.25 + u_sigSoft * 1.5); + return smoothstep(w + soft, w - soft, abs(d)); +} + +/** TRAIT: style. The track's surface grain, for a scene to add at its own weight. */ +float sigGrain(vec2 uv) { + if (u_sigTexture <= 0.001) return 0.0; + return (hash12(uv * 512.0 + floor(u_frame)) - 0.5) * u_sigTexture; +} + +/** + * TRAIT: space. Height of the shared horizon in the same units as 'p'. + * Positive is up; a scene with any sense of ground should sit on it. + */ +float sigHorizonY() { + return (u_sigHorizon - 0.5) * 2.0; +} + +/** TRAIT: space. The location's air: distance haze plus the background wash. */ +vec3 sigAir(vec3 col, vec2 p, float distance01) { + vec3 far = pal(0) * (0.25 + 0.35 * u_sigDepth); + col = mix(col, far, clamp(distance01, 0.0, 1.0) * u_sigDepth); + col += pal(1) * dot(p, u_sigWash) * 0.35; + return col; +} + vec3 prev(vec2 uv) { if (u_hasPrev == 0) return vec3(0.0); return texture2D(u_prev, uv).rgb; diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index 8c26b3b..b6cb63b 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -5,16 +5,23 @@ import { clampValue } from '../params/schema.js'; /** * Drives the look across the song. * - * Three timescales are stacked here, and it takes all three to keep six minutes + * Four timescales are stacked here, and it takes all four to keep six minutes * from reading as a loop: * * per frame — reactive mappings (handled in Layer, from the feature row) + * per shot — cuts between the section's stage visuals, on phrase lines * per section — seeded LFO drift, so nothing sits still during a long sustain * whole song — scene changes at real boundaries, plus lookahead ramps that * build INTO a drop rather than reacting after it lands * - * Layer instances are created once per section and reused. Rebuilding them per - * frame would recompile shaders and is the obvious way to make this unusably slow. + * The shot level is what stops a ninety-second sustain from being one held + * image. Everything below works in CUES — a flat list of (section, shot) spans + * built from the look, so a shot cut and a section change take exactly the same + * code path and differ only in how long the transition is. See look/shots.js. + * + * Layer instances are created once per (section, variant) and reused across + * every shot that shows that variant. Rebuilding them per shot would recompile + * shaders at every cut, which is the obvious way to make this unusably slow. */ export class ArcDriver { constructor(look, track, { crossfadeBars = 1, driftAmount = 0.09 } = {}) { @@ -24,11 +31,13 @@ export class ArcDriver { const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps; this.crossfadeFrames = Math.max(12, Math.round(barSeconds * crossfadeBars * track.fps)); + this.barFrames = Math.max(1, Math.round(barSeconds * track.fps)); this.layerCache = new Map(); this.driftPlans = new Map(); this.activeLayers = []; - this.state = { sectionIndex: 0, crossfade: 0, incoming: null }; + this.cues = this._buildCues(); + this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null }; } dispose() { @@ -36,12 +45,85 @@ export class ArcDriver { this.layerCache.clear(); } - /** One Layer per (section, layer) slot, built lazily and kept. */ - _layerFor(sectionIndex, slot = 0) { - const key = `${sectionIndex}:${slot}`; + /** + * Flatten the look into cues: one per shot, in playback order. + * + * A look generated before shots existed (or hand-built by a check) has no + * `shots` array; it degrades to exactly one cue per section, which is the + * old behaviour. + */ + _buildCues() { + const cues = []; + this.look.sections.forEach((section, sectionIndex) => { + const shots = (section.shots && section.shots.length) ? section.shots : [{ + startFrame: section.startFrame, endFrame: section.endFrame, + variant: 0, hardCut: false, + }]; + const energy = (section.bias && section.bias.energy) || 0; + shots.forEach((shot, shotIndex) => { + const atSectionStart = shotIndex === 0; + const span = shot.endFrame - shot.startFrame; + cues.push({ + index: cues.length, + sectionIndex, + shotIndex, + variant: shot.variant || 0, + startFrame: shot.startFrame, + endFrame: shot.endFrame, + atSectionStart, + fadeFrames: shot.hardCut && !atSectionStart + ? Math.max(2, Math.round(this.track.fps * 0.06)) + : this._dissolveFrames(energy, span), + }); + }); + }); + return cues; + } + + /** + * How long a dissolve takes: the default transition, and deliberately slow. + * + * Two bars on calm material, one on loud — a long dissolve between two + * quiet scenes reads as the image evolving, while the same length under a + * drop reads as mush, because both images are moving too fast to overlay. + * Capped at 40% of the incoming shot so a transition never occupies most of + * the shot it is transitioning into. + */ + _dissolveFrames(energy, spanFrames) { + const bars = energy > 0.6 ? 1 : 2; + const wanted = Math.max(this.crossfadeFrames, this.barFrames * bars); + return Math.max(12, Math.round(Math.min(wanted, spanFrames * 0.4))); + } + + /** Cue covering a frame. Binary search — a seek can land anywhere. */ + _cueIndexAt(frame) { + const cues = this.cues; + let lo = 0; + let hi = cues.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (cues[mid].startFrame <= frame) lo = mid; else hi = mid - 1; + } + return lo; + } + + /** The cue on screen at a frame. For the UI and the checks. */ + cueAt(frame) { + return this.cues[this._cueIndexAt(frame)]; + } + + _specFor(sectionIndex, variant, slot) { + const section = this.look.sections[sectionIndex]; + const stack = (section.variants && section.variants[variant]) || section.layers; + return stack[slot] || null; + } + + /** One Layer per (section, variant, layer slot), built lazily and kept. */ + _layerFor(sectionIndex, variant, slot = 0) { + const key = `${sectionIndex}:${variant}:${slot}`; let layer = this.layerCache.get(key); if (!layer) { - const spec = this.look.sections[sectionIndex].layers[slot]; + const spec = this._specFor(sectionIndex, variant, slot); layer = createLayer(spec.module, { params: spec.params, seed: spec.seed, @@ -49,6 +131,7 @@ export class ArcDriver { blend: spec.blend, }); layer.setPalette(this.look.palette); + layer.setPersonality(this.look.personality); this.layerCache.set(key, layer); } return layer; @@ -58,12 +141,12 @@ export class ArcDriver { * Per-param LFO plan for a section: amplitude, period and phase, all seeded. * Slow enough to read as evolution rather than wobble — 20 to 70 seconds. */ - _driftPlan(sectionIndex, slot = 0) { - const key = `${sectionIndex}:${slot}`; + _driftPlan(sectionIndex, variant, slot = 0) { + const key = `${sectionIndex}:${variant}:${slot}`; let plan = this.driftPlans.get(key); if (plan) return plan; - const spec = this.look.sections[sectionIndex].layers[slot]; + const spec = this._specFor(sectionIndex, variant, slot); const rng = new Rng(spec.seed ^ 0x5bf03635); plan = []; for (const [name, def] of Object.entries(spec.module.params || {})) { @@ -86,11 +169,11 @@ export class ArcDriver { * Base params for a section at a given time: the look's sampled values, plus * drift, plus the lookahead ramp toward whatever comes next. */ - _paramsAt(sectionIndex, slot, time, features) { - const spec = this.look.sections[sectionIndex].layers[slot]; + _paramsAt(cue, slot, time, features) { + const spec = this._specFor(cue.sectionIndex, cue.variant, slot); const out = { ...spec.params }; - for (const item of this._driftPlan(sectionIndex, slot)) { + for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) { const base = out[item.name]; if (typeof base !== 'number') continue; const wave = Math.sin(2 * Math.PI * (time / item.period + item.phase)); @@ -103,10 +186,13 @@ export class ArcDriver { // already at tension instead of catching up afterwards. const slope = features ? features.buildSlope || 0 : 0; if (slope > 0.001) { - const next = this.look.sections[sectionIndex + 1]; - if (next && next.layers[slot] && next.layers[slot].module === spec.module) { + const nextCue = this.cues[cue.index + 1]; + const next = nextCue + ? this._specFor(nextCue.sectionIndex, nextCue.variant, slot) + : null; + if (next && next.module === spec.module) { // Same scene either side: ramp the actual target values. - const target = next.layers[slot].params; + const target = next.params; for (const [name, def] of Object.entries(spec.module.params || {})) { if (def.type === 'palette' || typeof out[name] !== 'number') continue; if (typeof target[name] !== 'number') continue; @@ -138,83 +224,85 @@ export class ArcDriver { * that was correct on every repeat but wrong the first time through, which is * exactly the kind of fault the determinism checks exist to surface. */ - _boundarySlope(sectionIndex) { + _boundarySlope(cue) { if (!this._slopeCache) this._slopeCache = new Map(); - if (this._slopeCache.has(sectionIndex)) return this._slopeCache.get(sectionIndex); + if (this._slopeCache.has(cue.index)) return this._slopeCache.get(cue.index); - const section = this.look.sections[sectionIndex]; - const frame = Math.max(0, section.startFrame - 1); + const frame = Math.max(0, cue.startFrame - 1); const value = this.track.tracks.buildSlope[frame] || 0; - this._slopeCache.set(sectionIndex, value); + this._slopeCache.set(cue.index, value); return value; } /** * Compute the active layer stack for a frame. * - * The crossfade runs FORWARD from a boundary: the outgoing scene holds at - * full opacity while the incoming one fades in over it. That keeps the - * boundary frame itself a clean state, which is what makes a boundary seek - * exact without warm-up. + * The transition runs FORWARD from a cue: the outgoing image holds at full + * opacity while the incoming one fades in over it. That keeps the cue frame + * itself a clean state, which is what makes a boundary seek exact without + * warm-up. A hard cut is the same path with a two-frame fade — it is still + * a ramp rather than a jump, because a single-frame swap of two bright + * scenes is a flash, and the flash meter is not decorative. */ update(frame, features) { - const track = this.track; - const time = frame / track.fps; - const sectionIndex = track.sectionIndexAt(frame); - const section = this.look.sections[sectionIndex]; - if (!section) return this.activeLayers; + const time = frame / this.track.fps; + const cueIndex = this._cueIndexAt(frame); + const cue = this.cues[cueIndex]; + if (!cue) return this.activeLayers; + const section = this.look.sections[cue.sectionIndex]; - const framesIntoSection = frame - section.startFrame; - const fading = sectionIndex > 0 && framesIntoSection < this.crossfadeFrames; - const t = fading ? framesIntoSection / this.crossfadeFrames : 1; + const framesIntoCue = frame - cue.startFrame; + const previous = cueIndex > 0 ? this.cues[cueIndex - 1] : null; + const fading = !!previous && framesIntoCue < cue.fadeFrames; + const t = fading ? framesIntoCue / cue.fadeFrames : 1; const eased = t * t * (3 - 2 * t); const layers = []; if (fading) { - const previousIndex = sectionIndex - 1; - const outgoing = this._layerFor(previousIndex); + // buildSlope is discontinuous at a section boundary by construction: + // it ramps to ~1 through the bars before the change and is 0 + // immediately after. The outgoing layer is still on screen when that + // happens, so feeding it the new section's features collapses its + // lookahead ramp in a single frame — a visible pop precisely at the + // transition. Hold the slope it had going into the boundary; it + // finished its build, and it stays there while it fades out. Within a + // section the slope is continuous, so the live value is correct there. + const outgoingFeatures = cue.atSectionStart + ? { ...features, buildSlope: this._boundarySlope(cue) } + : features; - // buildSlope is discontinuous at a boundary by construction: it ramps - // to ~1 through the bars before the change and is 0 immediately after. - // The outgoing layer is still on screen when that happens, so feeding - // it the new section's features collapses its lookahead ramp in a - // single frame — a visible pop precisely at the transition. Hold the - // slope it had going into the boundary; it finished its build, and it - // stays there while it fades out. - outgoing.setParams(this._paramsAt(previousIndex, 0, time, { - ...features, - buildSlope: this._boundarySlope(sectionIndex), - })); - outgoing.opacity = 1; - outgoing.blend = 'normal'; - outgoing.setPalette(this.look.palette); - layers.push(outgoing); + for (let slot = 0; slot < this._stackSize(previous); slot++) { + const spec = this._specFor(previous.sectionIndex, previous.variant, slot); + const layer = this._layerFor(previous.sectionIndex, previous.variant, slot); + layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures)); + layer.opacity = slot === 0 ? 1 : spec.opacity; + layer.blend = slot === 0 ? 'normal' : spec.blend; + layer.setPalette(this.look.palette); + layer.setPersonality(this.look.personality); + layers.push(layer); + } } - const current = this._layerFor(sectionIndex); - current.setParams(this._paramsAt(sectionIndex, 0, time, features)); - current.opacity = fading ? eased : 1; - current.blend = 'normal'; - current.setPalette(this.look.palette); - layers.push(current); - - // Extra composited layers declared on the section (Phase 5 stacks). - for (let slot = 1; slot < section.layers.length; slot++) { - const spec = section.layers[slot]; - const layer = this._layerFor(sectionIndex, slot); - layer.setParams(this._paramsAt(sectionIndex, slot, time, features)); - layer.opacity = spec.opacity * (fading ? eased : 1); - layer.blend = spec.blend; + for (let slot = 0; slot < this._stackSize(cue); slot++) { + const spec = this._specFor(cue.sectionIndex, cue.variant, slot); + const layer = this._layerFor(cue.sectionIndex, cue.variant, slot); + layer.setParams(this._paramsAt(cue, slot, time, features)); + layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1); + layer.blend = slot === 0 ? 'normal' : spec.blend; layer.setPalette(this.look.palette); + layer.setPersonality(this.look.personality); layers.push(layer); } this.state = { - sectionIndex, + sectionIndex: cue.sectionIndex, + shotIndex: cue.shotIndex, + shotCount: section.shots ? section.shots.length : 1, + variant: cue.variant, kind: section.kind, crossfade: fading ? eased : 0, - sceneName: section.layers[0].module.name, + sceneName: this._specFor(cue.sectionIndex, cue.variant, 0).module.name, buildSlope: features ? features.buildSlope || 0 : 0, }; @@ -222,6 +310,12 @@ export class ArcDriver { return layers; } + _stackSize(cue) { + const section = this.look.sections[cue.sectionIndex]; + const stack = (section.variants && section.variants[cue.variant]) || section.layers; + return stack.length; + } + /** Layers changed identity — the compositor needs the new list. */ layersChanged(previous) { if (!previous || previous.length !== this.activeLayers.length) return true; @@ -237,11 +331,34 @@ export class ArcDriver { this.driftPlans.delete(key); } } + // A reroll re-plans the section's shots, so the cue list is stale too. + this.cues = this._buildCues(); + this._slopeCache = null; } invalidateAll() { this.dispose(); this.driftPlans.clear(); + this.cues = this._buildCues(); + this._slopeCache = null; + } + + /** + * Create and compile every layer the look will ever show. + * + * Layers are otherwise built on first use, which means a shader compile on + * the frame of a cut — a visible hitch, and there are now many more cuts + * than there were sections. Paying for all of them once at load is cheaper + * than paying for one at every transition. + */ + prewarm(onLayer = null) { + for (const cue of this.cues) { + for (let slot = 0; slot < this._stackSize(cue); slot++) { + const layer = this._layerFor(cue.sectionIndex, cue.variant, slot); + if (onLayer) onLayer(layer); + } + } + return this; } /** Push a palette change through without rebuilding layers. */ diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index 715bfa7..89e4d81 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -8,6 +8,8 @@ import { Rng, hashSamples } from '../engine/rng.js'; import { AudioPalette, generateUsablePalette } from './palette.js'; import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js'; import { sampleValues, defaultValues } from '../params/schema.js'; +import { planShots } from './shots.js'; +import { generatePersonality, sceneHonours, describePersonality } from './Personality.js'; /** * Which families suit which section kind, in preference order. @@ -49,15 +51,64 @@ function biasFor(section, summary) { } /** - * Scenes are chosen per section KIND, not per section. + * Scenes eligible for a section kind, weighted by how well the family fits. * - * All of a track's drops therefore share a scene, all its breakdowns share - * another, and the video acquires an identity instead of reading as a shuffle. - * Variation between two sections of the same kind comes from their parameter - * sets and from the arc driver's drift, which is enough to keep them distinct - * without losing the through-line. + * `signature` is the track's personality signature, and it is a hard filter + * rather than a weight: a scene with no way to express what the track is built + * on is not a worse choice, it is the shot that was clearly filmed somewhere + * else. See look/Personality.js. */ -function assignScenesByKind(sections, rng) { +function candidatesForKind(kind, used, signature = []) { + const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES); + const candidates = []; + for (const family of families) { + const inFamily = scenesInFamily(family) + .filter((m) => m.role !== 'accent' && sceneHonours(m, signature)); + // Weight by family preference order, and push down anything already + // used so a five-section track doesn't show one scene five times. + const weight = families.length - families.indexOf(family); + for (const scene of inFamily) { + candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) }); + } + } + if (!candidates.length) { + // Every family for this kind was emptied by the signature filter. Widen + // to the whole library, still honouring the signature; only if that is + // empty too does the personality lose and the video keep its scenes. + const anywhere = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)); + const pool = anywhere.length ? anywhere : scenes.filter((m) => m.role !== 'accent'); + return pool.map((scene) => ({ scene, weight: 1 })); + } + return candidates; +} + +/** + * How many stage visuals a kind rotates between. Busy material takes more. + * + * Sized against the library rather than picked out of the air: a kind draws + * from three families, which is seven to nine non-accent scenes, so a roster of + * four still leaves the weighting room to avoid what other kinds already took. + * Variants a section never reaches cost nothing — layers are built per cue, so + * only the ones its shots actually show are ever compiled. + */ +function rosterSizeFor(kind) { + return (KIND_ENERGY[kind] ?? 0.5) > 0.5 ? 4 : 3; +} + +/** + * Scenes are chosen per section KIND, not per section — and a kind gets a + * ROSTER of two or three, not one. + * + * All of a track's drops therefore cut between the same small set of visuals, + * all its breakdowns between another, and the video acquires an identity + * instead of reading as a shuffle. The first entry is the anchor: it opens + * every section of that kind and comes back most often, so the rotation reads + * as one idea with variations rather than as three unrelated scenes. + * + * Variation between two sections of the same kind comes from their parameter + * sets, from where their shots fall, and from the arc driver's drift. + */ +function assignRostersByKind(sections, rng, signature = []) { const byKind = new Map(); const used = new Set(); @@ -68,27 +119,27 @@ function assignScenesByKind(sections, rng) { kinds.sort((a, b) => priority.indexOf(a) - priority.indexOf(b)); for (const kind of kinds) { - const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES); - let candidates = []; - for (const family of families) { - const inFamily = scenesInFamily(family).filter((m) => m.role !== 'accent'); - // Weight by family preference order, and push down anything already - // used so a five-section track doesn't show one scene five times. - const weight = families.length - families.indexOf(family); - for (const scene of inFamily) { - candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) }); - } - } - if (!candidates.length) { - candidates = scenes.filter((m) => m.role !== 'accent').map((scene) => ({ scene, weight: 1 })); + const roster = []; + const size = rosterSizeFor(kind); + + for (let slot = 0; slot < size; slot++) { + const pool = candidatesForKind(kind, used, signature) + .filter((c) => !roster.includes(c.scene)) + .map((c) => ({ + scene: c.scene, + // Companions stay in the anchor's family where possible: a + // cut inside a section should change the image, not the + // whole visual language. + weight: c.weight * (roster.length && c.scene.family === roster[0].family ? 3 : 1), + })); + if (!pool.length) break; + + const chosen = rng.pickWeighted(pool.map((c) => c.scene), pool.map((c) => c.weight)); + roster.push(chosen); + used.add(chosen.name); } - const chosen = rng.pickWeighted( - candidates.map((c) => c.scene), - candidates.map((c) => c.weight), - ); - byKind.set(kind, chosen); - used.add(chosen.name); + byKind.set(kind, roster.length ? roster : [scenes[0]]); } return byKind; } @@ -127,6 +178,36 @@ function derivePost(summary, rng) { }; } +/** + * One layer stack: a background scene plus an optional accent over it. + * + * The accent is composited additively at low opacity and drawn from a DIFFERENT + * 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. + */ +function buildStack(module, accentRoster, bias, rng) { + const layers = [{ + module, + params: sampleValues(module, rng, bias), + seed: rng.int(0, 0x7fffffff), + blend: 'normal', + opacity: 1, + }]; + + if (accentRoster.length && rng.bool(bias.energy * 0.8)) { + const eligible = accentRoster.filter((m) => m.family !== module.family); + const accent = rng.pick(eligible.length ? eligible : accentRoster); + layers.push({ + module: accent, + params: sampleValues(accent, rng.fork('accent'), bias), + seed: rng.int(0, 0x7fffffff), + blend: rng.pickWeighted(['add', 'screen'], [2, 1]), + opacity: rng.range(0.18, 0.5), + }); + } + return layers; +} + /** * @param {FeatureTrack} track * @param {object} options @@ -143,43 +224,36 @@ export function generateLook(track, { seed = null, samples = null, overrides = n const paletteSource = new AudioPalette(summary, rng.fork('palette')); const palette = generateUsablePalette(paletteSource, 6); - const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes')); + // The production design, decided before a single scene is cast — casting + // depends on it. See look/Personality.js. + const personality = generatePersonality(summary, rng.fork('personality'), (signature) => + scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)).length); + + const rosterByKind = assignRostersByKind( + track.sections, rng.fork('scenes'), personality.signature); const { post, feedback } = derivePost(summary, rng.fork('post')); // Scenes that declare role 'accent' composite over a background rather than // being one — most of their frame is empty by design. They are never chosen // as a section's primary scene. - const accentRoster = scenes.filter((m) => m.role === 'accent'); + // Accents honour the signature too where they can. If none can, the track + // goes without depth layers rather than putting an off-design element into + // every stack. + const accentRoster = scenes.filter((m) => m.role === 'accent' + && sceneHonours(m, personality.signature)); const sections = track.sections.map((section) => { - const module = sceneByKind.get(section.kind) || scenes[0]; - const sectionRng = rng.fork(`section:${section.index}:${module.name}`); + const roster = rosterByKind.get(section.kind) || [scenes[0]]; + const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`); const bias = biasFor(section, summary); - const layers = [{ - module, - params: sampleValues(module, sectionRng, bias), - seed: sectionRng.int(0, 0x7fffffff), - blend: 'normal', - opacity: 1, - }]; + const variants = roster.map((module, v) => buildStack( + module, accentRoster, bias, sectionRng.fork(`variant:${section.index}:${v}`), + )); - // Accent layer. Composited additively over the background at low opacity, - // and drawn from a DIFFERENT family so it reads as depth rather than as a - // second competing scene. Quiet sections mostly go without — an intro is - // supposed to be sparse. - const accentChance = bias.energy * 0.8; - if (accentRoster.length && sectionRng.bool(accentChance)) { - const accent = sectionRng.pick(accentRoster.filter((m) => m.family !== module.family) || accentRoster) - || accentRoster[0]; - layers.push({ - module: accent, - params: sampleValues(accent, sectionRng.fork(`accent:${section.index}`), bias), - seed: sectionRng.int(0, 0x7fffffff), - blend: sectionRng.pickWeighted(['add', 'screen'], [2, 1]), - opacity: sectionRng.range(0.18, 0.5), - }); - } + const shots = planShots( + section, track, bias, variants.length, sectionRng.fork(`shots:${section.index}`), + ); return { index: section.index, @@ -190,13 +264,19 @@ export function generateLook(track, { seed = null, samples = null, overrides = n end: section.end, locked: false, bias, - layers, + variants, + shots, + // The anchor stack, aliased. Everything that predates shots — the + // param panel, presets, the checks — edits a section through this, + // and it is the same object the first variant holds. + layers: variants[0], }; }); const look = { seed: resolvedSeed, palette, + personality, paletteScheme: paletteSource.lastScheme, post, feedback, @@ -213,17 +293,33 @@ export function rerollSection(look, track, sectionIndex, salt = 0) { if (!section || section.locked) return look; const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0); + const signature = (look.personality && look.personality.signature) || []; const families = FAMILY_BY_KIND[section.kind] || Object.keys(FAMILIES); - const candidates = families.flatMap((f) => scenesInFamily(f)).filter((m) => m.role !== 'accent'); - const module = candidates.length ? rng.pick(candidates) : scenes[0]; + let candidates = families.flatMap((f) => scenesInFamily(f)) + .filter((m) => m.role !== 'accent' && sceneHonours(m, signature)); + if (!candidates.length) { + candidates = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)); + } - section.layers = [{ - module, - params: sampleValues(module, rng, section.bias), - seed: rng.int(0, 0x7fffffff), - blend: 'normal', - opacity: 1, - }]; + // Re-roll the whole roster, not just the anchor: the section's shots cut + // between all of them, so replacing one would leave the section half old. + const size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length)); + const roster = []; + while (roster.length < size) { + const pool = candidates.filter((m) => !roster.includes(m)); + if (!pool.length) break; + roster.push(rng.pick(pool)); + } + if (!roster.length) roster.push(scenes[0]); + + const accentRoster = scenes.filter((m) => m.role === 'accent'); + section.variants = roster.map((module, v) => buildStack( + module, accentRoster, section.bias, rng.fork(`variant:${v}`), + )); + section.shots = planShots( + section, track, section.bias, section.variants.length, rng.fork('shots'), + ); + section.layers = section.variants[0]; return look; } @@ -256,7 +352,8 @@ function applyOverrides(look, overrides) { /** Compact description, used by the HUD and by check output. */ export function describeLook(look) { const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`); - return `seed ${look.seed.toString(16)} · ${look.paletteScheme} · ${[...new Set(kinds)].join(', ')}`; + return `seed ${look.seed.toString(16)} · ${look.paletteScheme} · ` + + `${describePersonality(look.personality)} · ${[...new Set(kinds)].join(', ')}`; } export { defaultValues }; diff --git a/flow-state/src/look/Personality.js b/flow-state/src/look/Personality.js new file mode 100644 index 0000000..7b929bd --- /dev/null +++ b/flow-state/src/look/Personality.js @@ -0,0 +1,205 @@ +// The track's production design. +// +// A music video is not held together by its cuts. It is held together by the +// fact that every shot was filmed in the same location, with the same actors, +// the same camera operator and the same art direction. Cut between two shots of +// that and it reads as one piece even when the framing changes completely. +// +// Nothing in this project had an equivalent. Sections shared a palette and a +// post grade, and past that every scene was a separate short film. This module +// is the missing layer: one procedurally generated PERSONALITY per track, in +// four traits that map onto the four things a production shares. +// +// shape — the actors. A signature form: how many sides, how round, how +// elongated, at what tilt. Scenes that draw discrete elements stamp +// this form instead of whatever primitive they would have used. +// camera — the operator. A drift direction, a sway, a slow spin, a breathing +// zoom. Applied to the coordinate a scene works in, so every scene +// is filmed by the same hand. +// space — the location. A horizon height, a depth falloff, a background +// wash direction. Scenes that have a sense of place share one. +// style — the art direction. Line weight, edge softness, texture, and how +// many times the frame is folded. +// +// 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 +// disqualified from that track — the library shrinks per track, on purpose. A +// scene with no way to express a hexagon should not appear in the hexagon +// video; it would be the shot that was clearly filmed somewhere else. +// +// Everything here is seeded off the look seed, so a track's personality is as +// reproducible as everything else. + +export const TRAITS = ['shape', 'camera', 'space', 'style']; + +/** + * Traits eligible to be a track's signature, and how often. + * + * `shape` and `space` carry the most identity — they are the ones a viewer can + * actually name on a second watch — so they are the ones a signature is built + * around. `camera` and `style` are near-universally supported and read as + * treatment rather than as subject, so they join a signature but rarely define + * one alone. + */ +const SIGNATURE_WEIGHTS = { shape: 4, space: 3, camera: 2, style: 2 }; + +/** Minimum scenes that must survive the signature filter for it to be usable. */ +export const MIN_ELIGIBLE_SCENES = 6; + +/** + * Generate the personality. + * + * Trait VALUES lean on what was measured in the audio — a bright, noisy track + * gets sharper lines and more texture; a slow one gets a lazier camera — but + * the seed dominates, so two tracks with similar statistics still look like + * different productions. + * + * @param {object} summary FeatureTrack summary + * @param {Rng} rng + * @param {(traits: string[]) => number} countEligible + * How many scenes would survive a given signature. Injected rather than + * imported so this module never has to know the registry exists. + */ +export function generatePersonality(summary, rng, countEligible = null) { + const bright = summary.meanCentroid; + const noisy = Math.min(1, summary.meanFlatness * 3); + const fast = Math.min(1, Math.max(0, (summary.bpm - 80) / 80)); + + const shape = { + // 0 sides means round. Everything else is a polygon the whole track + // shares — the single most recognisable thing here. + sides: rng.pickWeighted([0, 3, 4, 5, 6, 8], [3, 2, 3, 2, 3, 1]), + roundness: rng.range(0.05, 0.5), + elongation: rng.range(0.85, 1.45), + tilt: rng.range(0, Math.PI), + }; + + const camera = { + driftAngle: rng.range(0, Math.PI * 2), + // A slow track should not be filmed from a moving car. + driftRate: rng.range(0.01, 0.06) * (0.6 + fast * 0.8), + sway: rng.range(0.0, 0.06), + swayRate: rng.range(0.05, 0.22), + spin: rng.range(-0.05, 0.05), + // Breathing is locked to the bar, so it is the one camera move that + // reads as musical rather than as drifting. + breathe: rng.range(0.0, 0.05), + }; + + const space = { + horizon: rng.range(0.32, 0.62), + depth: rng.range(0.2, 0.9), + washAngle: rng.range(0, Math.PI * 2), + wash: rng.range(0.1, 0.5), + }; + + const style = { + lineWeight: 0.4 + bright * 0.4 + rng.range(-0.15, 0.25), + softness: 0.25 + (1 - bright) * 0.4 + rng.range(-0.1, 0.2), + texture: noisy * 0.5 + rng.range(0, 0.25), + // Fold counts stay low and are usually off. Symmetry is the fastest way + // to make a library look like one series and also the fastest way to + // make every track look like a screensaver. + symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6], [6, 4, 2, 2, 2, 1]), + }; + + const signature = pickSignature(rng, countEligible); + + return { signature, shape, camera, space, style }; +} + +/** + * Choose the one or two traits this track is BUILT on. + * + * Two is the target: one trait alone is not enough to recognise, and three + * disqualifies most of the library. If the pair leaves too few scenes to build + * rosters from, fall back to the stronger of the two rather than shipping a + * track whose every section is forced onto the same two scenes. + */ +function pickSignature(rng, countEligible) { + const primary = rng.pickWeighted(TRAITS, TRAITS.map((t) => SIGNATURE_WEIGHTS[t])); + const rest = TRAITS.filter((t) => t !== primary); + const secondary = rng.pickWeighted(rest, rest.map((t) => SIGNATURE_WEIGHTS[t])); + + const pair = [primary, secondary]; + if (!countEligible || countEligible(pair) >= MIN_ELIGIBLE_SCENES) return pair; + + const single = [primary]; + if (countEligible(single) >= MIN_ELIGIBLE_SCENES) return single; + return []; +} + +/** Does a scene honour everything this track is built on? */ +export function sceneHonours(module, signature) { + const traits = module.traits || []; + return signature.every((t) => traits.includes(t)); +} + +/** + * Flatten to the uniform values the shader contract expects. + * + * Neutral defaults matter: a layer with no personality attached must render + * exactly what it rendered before this existed, because the range sweeps and + * the library regression checks build layers directly and would otherwise all + * shift at once. + */ +export function signatureUniforms(personality) { + if (!personality) return NEUTRAL_UNIFORMS; + const { shape, camera, space, style } = personality; + return { + u_sigSides: shape.sides, + u_sigRound: shape.roundness, + u_sigElong: shape.elongation, + u_sigTilt: shape.tilt, + + u_sigDrift: [Math.cos(camera.driftAngle) * camera.driftRate, + Math.sin(camera.driftAngle) * camera.driftRate], + u_sigSway: camera.sway, + u_sigSwayRate: camera.swayRate, + u_sigSpin: camera.spin, + u_sigBreathe: camera.breathe, + + u_sigHorizon: space.horizon, + u_sigDepth: space.depth, + u_sigWash: [Math.cos(space.washAngle) * space.wash, + Math.sin(space.washAngle) * space.wash], + + u_sigLine: style.lineWeight, + u_sigSoft: style.softness, + u_sigTexture: style.texture, + u_sigFold: style.symmetry, + }; +} + +export const NEUTRAL_UNIFORMS = { + u_sigSides: 0, + u_sigRound: 0.25, + u_sigElong: 1, + u_sigTilt: 0, + u_sigDrift: [0, 0], + u_sigSway: 0, + u_sigSwayRate: 0.1, + u_sigSpin: 0, + u_sigBreathe: 0, + u_sigHorizon: 0.5, + u_sigDepth: 0, + u_sigWash: [0, 0], + u_sigLine: 0.5, + u_sigSoft: 0.5, + u_sigTexture: 0, + u_sigFold: 1, +}; + +const SHAPE_NAMES = { 0: 'round', 3: 'triangular', 4: 'square', 5: 'pentagonal', 6: 'hexagonal', 8: 'octagonal' }; + +/** One line for the HUD, the look panel and check output. */ +export function describePersonality(personality) { + if (!personality) return 'no personality'; + const { signature, shape, style } = personality; + const parts = [ + `on ${signature.length ? signature.join('+') : 'nothing'}`, + SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`, + ]; + if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`); + return parts.join(' · '); +} diff --git a/flow-state/src/look/shots.js b/flow-state/src/look/shots.js new file mode 100644 index 0000000..f56e6b6 --- /dev/null +++ b/flow-state/src/look/shots.js @@ -0,0 +1,161 @@ +// Shot planning: the level of hierarchy between a song section and a frame. +// +// A section is a STAGE of the song (intro, build, drop, …) and can easily run +// ninety seconds. One scene held for ninety seconds reads as a still image with +// a wobble on it, no matter how much per-frame reactivity is underneath. So a +// section is cut into SHOTS, each showing one of the section's few "stage +// visuals" — the roster the look generator picked for that kind of section. +// +// Two rules keep this from turning into a shuffle: +// +// * the roster is per section KIND, not per section, so all of a track's drops +// still cut between the same two or three visuals and the video keeps an +// identity; +// * cuts land on phrase lines, so a change of image lands with the music +// instead of across it. +// +// Shot length follows energy: a drop cuts every four to eight bars, an intro +// holds for eight to sixteen, and nothing holds past the ceiling below. +// Everything here is seeded, so a track always cuts in the same places. + +/** Never cut faster than this, whatever the tempo or the energy says. */ +export const MIN_SHOT_SECONDS = 5; + +/** + * And never hold longer than this either. Half a minute of one image is the + * complaint this whole level of hierarchy exists to answer, so it is a hard + * ceiling rather than something the bar maths is trusted to stay under: at a + * slow tempo sixteen bars is already past it. + */ +export const MAX_SHOT_SECONDS = 22; + +/** Below this section energy, a shot change is always a dissolve, never a cut. */ +export const HARD_CUT_ENERGY = 0.66; + +/** Phrase length for one shot, in bars, from the section's energy. */ +function shotBarsFor(energy, rng) { + if (energy > 0.72) return rng.pick([4, 8, 8]); + if (energy > 0.45) return rng.pick([8, 8, 16]); + return rng.pick([8, 16, 16]); +} + +/** Nearest downbeat to `time`, or null if none is close enough to be the same line. */ +function nearestDownbeat(time, downbeats, tolerance) { + let best = null; + let bestDist = Infinity; + for (const d of downbeats) { + const dist = Math.abs(d - time); + if (dist < bestDist) { bestDist = dist; best = d; } + else if (d > time && dist > bestDist) break; // sorted: past the minimum + } + return best !== null && bestDist <= tolerance ? best : null; +} + +/** + * Divide a section into shots. + * + * @param {object} section a track section (start/end/startFrame/endFrame) + * @param {object} track FeatureTrack, for fps and the bar grid + * @param {object} bias the section's bias, for energy + * @param {number} variantCount how many stage visuals the section has + * @param {Rng} rng + * @returns {Array<{index,startFrame,endFrame,variant,hardCut}>} + */ +export function planShots(section, track, bias, variantCount, rng) { + const fps = track.fps; + const duration = Math.max(0, section.end - section.start); + const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / fps; + + const bars = shotBarsFor(bias.energy, rng); + const target = Math.min(MAX_SHOT_SECONDS, + Math.max(MIN_SHOT_SECONDS, barSeconds > 0.2 ? bars * barSeconds : 12)); + + // Round to the nearest whole number of shots — a 40s section at a 12s target + // gets three of 13s, not three of 12 and a stub — then force enough shots to + // stay under the ceiling, and finally refuse any count that would push a + // shot below the floor. The floor wins if they ever disagree. + // + // The ceiling gets headroom because snapping moves a cut by up to the + // tolerance below, and a cut that snaps LATE would otherwise land just past + // the limit the count was chosen to respect. + let count = Math.max( + Math.round(duration / target), + Math.ceil(duration / (MAX_SHOT_SECONDS * 0.88)), + 1, + ); + count = Math.min(count, Math.max(1, Math.floor(duration / MIN_SHOT_SECONDS))); + if (variantCount < 2) count = 1; + + // Cut times: evenly spaced, then pulled onto the nearest downbeat. The + // tolerance is deliberately under half a shot, so a snap can never reorder + // two cuts or collapse one onto another. + const tolerance = Math.min(barSeconds * 1.5, target * 0.35); + const downbeats = track.tempo.downbeats || []; + const cuts = []; + for (let k = 1; k < count; k++) { + const raw = section.start + (duration * k) / count; + const snapped = nearestDownbeat(raw, downbeats, tolerance); + const previous = cuts.length ? cuts[cuts.length - 1] : section.start; + const fits = (t) => t - previous >= MIN_SHOT_SECONDS && section.end - t >= MIN_SHOT_SECONDS; + + // Prefer the downbeat, but a snap that pushes the cut inside the floor + // is worse than an unsnapped cut: dropping it would leave the hold this + // whole mechanism exists to break up. + const time = snapped !== null && fits(snapped) ? snapped : raw; + if (!fits(time)) continue; + cuts.push(time); + } + + const bounds = [section.start, ...cuts, section.end]; + const shots = []; + const lastSeen = new Array(variantCount).fill(-1); + let previousVariant = -1; + for (let i = 0; i < bounds.length - 1; i++) { + const variant = i === 0 ? 0 : pickVariant(variantCount, previousVariant, lastSeen, i, rng); + lastSeen[variant] = i; + previousVariant = variant; + shots.push({ + index: i, + startFrame: i === 0 ? section.startFrame : Math.round(bounds[i] * fps), + endFrame: i === bounds.length - 2 ? section.endFrame : Math.round(bounds[i + 1] * fps), + start: bounds[i], + end: bounds[i + 1], + variant, + // A dissolve is the default. A straight cut is what makes a drop feel + // edited, but on anything calmer it reads as a glitch, so cuts are + // gated on real energy rather than sprinkled everywhere: nothing below + // the threshold ever cuts, and only the loudest material cuts often. + hardCut: bias.energy > HARD_CUT_ENERGY + && rng.bool(Math.min(0.85, (bias.energy - HARD_CUT_ENERGY) * 2.5)), + }); + } + return shots; +} + +/** + * Next visual in the rotation. + * + * The shape is A B A C A D: the anchor comes back between companions, so the + * section reads as one idea with departures from it rather than as a playlist. + * It is a strong tendency and not a rule — strict alternation is audible as a + * pattern within about three cycles. + * + * When a companion is due, the LEAST RECENTLY SHOWN one wins. With a roster of + * four that is the difference between a section showing B, C, D and a section + * showing B twice and never reaching D. + */ +function pickVariant(variantCount, previous, lastSeen, shotIndex, rng) { + if (previous !== 0 && rng.bool(0.75)) return 0; + + const options = []; + const weights = []; + for (let v = 0; v < variantCount; v++) { + if (v === previous) continue; + options.push(v); + // Unseen variants sort first, then by how long ago they were last up. + // The anchor stays in the draw so the rotation cannot become rigid. + weights.push(v === 0 ? 1 : 2 + (lastSeen[v] < 0 ? variantCount : shotIndex - lastSeen[v])); + } + if (!options.length) return 0; + return rng.pickWeighted(options, weights); +} diff --git a/flow-state/src/main.js b/flow-state/src/main.js index 2f8e1f1..5e3bbea 100644 --- a/flow-state/src/main.js +++ b/flow-state/src/main.js @@ -237,6 +237,24 @@ function renderPanel() { if (state.tab === 'scene') { paramPanel.container = dom.panelBody; paramPanel.build(section.layers[0].module, section.layers[0].params); + + // The section's stage visuals, with the one currently on screen marked. + // Params above edit the anchor (variant 0) — the image the section opens + // and returns to. + if (section.variants && section.variants.length > 1) { + const active = show.arc.state.variant || 0; + const shots = section.shots || []; + const list = document.createElement('div'); + list.className = 'pp-reactive'; + list.innerHTML = `
stage visuals · ${shots.length} shots
` + + section.variants.map((stack, v) => + `
` + + `${v === 0 ? '●' : '○'} ${stack[0].module.name}` + + `${shots.filter((s) => s.variant === v).length}×` + + `
`).join(''); + dom.panelBody.appendChild(list); + } + if (section.layers.length > 1) { const note = document.createElement('div'); note.className = 'pp-reactive'; @@ -252,6 +270,9 @@ function renderPanel() { if (state.tab === 'look') { const summary = show.track.summary; + // The track's production design. Scenes that cannot express what it is + // built on were never cast — see look/Personality.js. + const personality = show.look.personality; dom.panelBody.innerHTML = `
${show.fileName || 'track'}
seed${show.look.seed.toString(16)}
@@ -260,6 +281,13 @@ function renderPanel() {
duration${formatTime(show.duration)}
sections${show.track.sections.length}
scheme${show.look.paletteScheme}
+
built on${personality.signature.join(' + ') || 'nothing'}
+
form${personality.shape.sides || 'round'}${ + personality.shape.sides ? '-sided' : ''}
+
camera${(personality.camera.driftRate * 100).toFixed(1)} drift · ${ + personality.camera.spin >= 0 ? '+' : ''}${personality.camera.spin.toFixed(3)} spin
+
art${personality.style.symmetry > 1 + ? personality.style.symmetry + '-fold · ' : ''}line ${personality.style.lineWeight.toFixed(2)}
brightness${summary.meanCentroid.toFixed(3)}
dynamics${summary.dynamicRange.toFixed(3)}
${show.look.palette.map((c) => @@ -267,8 +295,9 @@ function renderPanel() {
sections
${show.look.sections.map((s, i) => `
- ${s.kind}${s.locked ? ' 🔒' : ''} - ${s.layers.map((l) => l.module.name).join(' + ')} + ${s.kind}${s.locked ? ' 🔒' : ''} + ${s.shots ? `${s.shots.length} shots` : ''} + ${(s.variants || [s.layers]).map((v) => v[0].module.name).join(' / ')}
`).join('')}
Mixes clicks onto the detected beat grid. If they don't sit on diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index 41efc4f..3b3ec5f 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -41,6 +41,17 @@ export const REACTIVE_FEATURES = [ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse']; +/** + * Personality traits a scene can honour. See look/Personality.js. + * + * This is a CONTRACT, not a hint: a track built on `shape` will only cast scenes + * that declare `shape`, and it will cast them believing they actually stamp the + * signature form. Declaring a trait a scene ignores is worse than declaring + * none, because the disqualification rule is the only thing keeping off-design + * scenes out of a track. + */ +export const TRAIT_NAMES = ['shape', 'camera', 'space', 'style']; + export function defaultValue(def) { if (def.default !== undefined) return def.default; switch (def.type) { @@ -140,6 +151,14 @@ export function validateModule(module) { if (!module.name) errors.push('missing `name`'); if (!module.family) errors.push(`${id}: missing \`family\``); if (!module.kind) errors.push(`${id}: missing \`kind\``); + if (!Array.isArray(module.traits)) { + errors.push(`${id}: missing \`traits\` — declare which personality traits it honours ` + + `(any of ${TRAIT_NAMES.join(', ')}, or [] for none)`); + } else { + for (const t of module.traits) { + if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`); + } + } if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``); if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) { errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``); diff --git a/flow-state/src/params/serialize.js b/flow-state/src/params/serialize.js index a560acd..f85c954 100644 --- a/flow-state/src/params/serialize.js +++ b/flow-state/src/params/serialize.js @@ -33,23 +33,39 @@ export function deserializeParams(module, stored) { return values; } +function serializeLayer(layer) { + return { + scene: layer.module.name, + blend: layer.blend, + opacity: layer.opacity, + seed: layer.seed, + params: layer.params, + }; +} + export function serializeLook(look) { return { version: PRESET_VERSION, seed: look.seed, palette: look.palette, + // The production design decides which scenes were even eligible, so a + // preset without it cannot be reproduced. + personality: look.personality, post: look.post, feedback: look.feedback, sections: look.sections.map((s) => ({ index: s.index, kind: s.kind, locked: !!s.locked, - layers: s.layers.map((l) => ({ - scene: l.module.name, - blend: l.blend, - opacity: l.opacity, - seed: l.seed, - params: l.params, + // `layers` is the anchor stack and stays first for compatibility; + // `variants` is the full roster the section's shots cut between. + layers: s.layers.map(serializeLayer), + variants: (s.variants || [s.layers]).map((stack) => stack.map(serializeLayer)), + shots: (s.shots || []).map((shot) => ({ + startFrame: shot.startFrame, + endFrame: shot.endFrame, + variant: shot.variant, + hardCut: !!shot.hardCut, })), })), }; diff --git a/flow-state/src/scenes/layers3d/particles.js b/flow-state/src/scenes/layers3d/particles.js index 1909885..968506c 100644 --- a/flow-state/src/scenes/layers3d/particles.js +++ b/flow-state/src/scenes/layers3d/particles.js @@ -15,6 +15,10 @@ export const particleField = { // legitimately black, so it is judged on variance rather than luminance and // the look generator only picks it as an accent layer. role: 'accent', + // Personality: see look/Personality.js. A point cloud cannot draw the + // signature form and has no horizon, so it claims only the camera — which + // it can honour exactly, being the one scene with a real one. + traits: ['camera'], params: { count: { type: 'int', range: [200, 4000], default: 1200, bias: 'density', noDrift: true }, @@ -76,7 +80,7 @@ export const particleField = { return { points, geometry, material, positions, colors, phases, max }; }, - update({ instance, camera, timeline, features, params, palette }) { + update({ instance, camera, timeline, features, params, palette, personality }) { const { geometry, material, positions, colors, phases, max } = instance; const count = Math.min(max, Math.round(params.count)); const t = timeline.time; @@ -125,8 +129,26 @@ export const particleField = { material.size = params.size; material.opacity = 1; - camera.position.set(0, 0, 4); - camera.lookAt(0, 0, -depth * 0.4); + // The track's camera, applied to the only literal camera in the library: + // the same slow returning pan, sway and roll every shader scene fakes in + // its coordinate space. Bounded and periodic, so a seek still lands on + // the same frame as sequential playback. + const cam = personality ? personality.camera : null; + if (cam) { + const pan = 20 * Math.sin(t * 0.05); + camera.position.set( + Math.cos(cam.driftAngle) * cam.driftRate * pan + + Math.sin(t * cam.swayRate) * cam.sway, + Math.sin(cam.driftAngle) * cam.driftRate * pan + + Math.cos(t * cam.swayRate * 0.83) * cam.sway, + 4, + ); + camera.rotation.z = cam.spin * t; + } else { + camera.position.set(0, 0, 4); + camera.rotation.z = 0; + } + camera.lookAt(camera.position.x, camera.position.y, -depth * 0.4); }, }; diff --git a/flow-state/src/scenes/shader/classic-wave.js b/flow-state/src/scenes/shader/classic-wave.js index a935ef4..9d7e85a 100644 --- a/flow-state/src/scenes/shader/classic-wave.js +++ b/flow-state/src/scenes/shader/classic-wave.js @@ -6,6 +6,8 @@ export const classicWave = { name: 'Classic Wave', family: 'flow', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'camera', 'style'], params: { rings: { type: 'float', range: [4, 40], default: 18, uniform: 'u_rings', bias: 'density' }, @@ -31,7 +33,10 @@ export const classicWave = { shader: ` vec4 scene(vec2 uv, vec2 p) { - float d = length(p); + p = sigCamera(p); + // The rings take the track's signature form: round tracks get circles, + // hexagonal tracks get hexagonal rings, and it costs one call. + float d = sigShape(p) + 1.0; float angle = atan(p.y, p.x); float t = u_time * u_speed + u_seed; @@ -48,6 +53,7 @@ vec4 scene(vec2 uv, vec2 p) { // Keep the corners from clipping to flat colour. col *= 0.6 + 0.4 * (1.0 - smoothstep(0.8, 1.8, d)); + col += sigGrain(uv); return vec4(col, 1.0); } diff --git a/flow-state/src/scenes/shader/curl-flow.js b/flow-state/src/scenes/shader/curl-flow.js index 9420bdb..981cf88 100644 --- a/flow-state/src/scenes/shader/curl-flow.js +++ b/flow-state/src/scenes/shader/curl-flow.js @@ -8,6 +8,8 @@ export const curlFlow = { name: 'Curl Flow', family: 'flow', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['camera', 'space', 'style'], params: { scale: { type: 'float', range: [0.5, 6], default: 2.0, uniform: 'u_scale', bias: 'density' }, @@ -28,6 +30,7 @@ export const curlFlow = { shader: ` vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; + p = sigCamera(p); vec2 flow = curl(p * u_scale + vec2(t, -t * 0.7), t * 0.5); vec2 q = p + flow * 0.35; @@ -46,6 +49,8 @@ vec4 scene(vec2 uv, vec2 p) { col = max(col, trail * u_streak); col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.35); + col = sigAir(col, p, smoothstep(0.0, 1.7, length(p))); + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/floating-geometry.js b/flow-state/src/scenes/shader/floating-geometry.js index 0c5b405..ba93955 100644 --- a/flow-state/src/scenes/shader/floating-geometry.js +++ b/flow-state/src/scenes/shader/floating-geometry.js @@ -6,13 +6,16 @@ export const floatingGeometry = { name: 'Floating Geometry', family: 'geometric', kind: 'fragment', + // Personality: see look/Personality.js. This scene is nothing but a handful + // of shapes, so `shape` is the trait it exists to express. + traits: ['shape', 'camera', 'style'], params: { count: { type: 'int', range: [2, 14], default: 6, uniform: 'u_count', bias: 'density' }, size: { type: 'float', range: [0.04, 0.3],default: 0.15,uniform: 'u_size' }, drift: { type: 'float', range: [0.1, 1.5], default: 0.4, uniform: 'u_drift', bias: 'motion', rate: true }, spin: { type: 'float', range: [0, 2], default: 0.6, uniform: 'u_spin', rate: true }, - boxRatio: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_boxRatio' }, + variety: { type: 'float', range: [0, 0.6], default: 0.25, uniform: 'u_variety' }, aura: { type: 'float', range: [0, 1], default: 0.25,uniform: 'u_aura', bias: 'energy' }, spread: { type: 'float', range: [0.3, 1.2], default: 0.8, uniform: 'u_spread' }, palette: { type: 'palette', count: 5 }, @@ -24,14 +27,9 @@ export const floatingGeometry = { }, shader: ` -float sdCircle(vec2 p, float r) { return length(p) - r; } -float sdBox(vec2 p, vec2 b) { - vec2 d = abs(p) - b; - return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); -} - vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_drift + u_seed; + p = sigCamera(p); // Background wash from the two darkest palette entries. vec3 col = mix(pal(0) * 0.18, pal(1) * 0.24, sin(t) * 0.5 + 0.5); @@ -49,14 +47,21 @@ vec4 scene(vec2 uv, vec2 p) { 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 d = fract(s * 0.37) < u_boxRatio ? sdBox(sp, vec2(size * 0.8)) : sdCircle(sp, size); + + // Every element is the track's signature form. 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; vec3 shapeColor = pal(i + int(floor(t * 0.3))); - float intensity = smoothstep(0.012, 0.0, d); + float intensity = smoothstep(0.012, 0.0, d) + sigEdge(d) * 0.35; col = mix(col, shapeColor, intensity * 0.85); col += shapeColor * (1.0 - smoothstep(0.0, size * 2.2, abs(d))) * u_aura; } + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/horizon-lines.js b/flow-state/src/scenes/shader/horizon-lines.js index 41cc064..bbab702 100644 --- a/flow-state/src/scenes/shader/horizon-lines.js +++ b/flow-state/src/scenes/shader/horizon-lines.js @@ -6,6 +6,8 @@ export const horizonLines = { name: 'Horizon Lines', family: 'minimal', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['space', 'camera', 'style'], params: { count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density' }, @@ -25,6 +27,10 @@ export const horizonLines = { shader: ` vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; + p = sigCamera(p); + // The bundle sits on the track's horizon rather than in the middle of the + // frame — this scene is nothing but a horizon, so it should be the shared one. + p.y -= sigHorizonY() * 0.6; // Displace vertically by a slow wave, strongest at the centre of the frame. float envelope = exp(-p.x * p.x * 1.2); @@ -41,7 +47,7 @@ vec4 scene(vec2 uv, vec2 p) { float y = slot + offset * (0.4 + fract(fi * 0.37)); float d = abs(p.y - y); - float line = smoothstep(u_thickness, 0.0, d); + float line = smoothstep(u_thickness * (0.5 + u_sigLine), 0.0, d); float halo = exp(-d * 26.0) * u_glow; vec3 c = pal(i); @@ -51,6 +57,8 @@ vec4 scene(vec2 uv, vec2 p) { // Keep the far edges dark so the lines read as a subject, not wallpaper. col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.5); + col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.y))); + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/kaleido-tunnel.js b/flow-state/src/scenes/shader/kaleido-tunnel.js index df0b75f..5358a87 100644 --- a/flow-state/src/scenes/shader/kaleido-tunnel.js +++ b/flow-state/src/scenes/shader/kaleido-tunnel.js @@ -5,6 +5,8 @@ export const kaleidoTunnel = { name: 'Kaleido Tunnel', family: 'geometric', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'camera', 'style'], params: { sides: { type: 'int', range: [2, 12], default: 6, uniform: 'u_sides' }, @@ -25,9 +27,16 @@ export const kaleidoTunnel = { shader: ` vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; + p = sigCamera(p); - float radius = max(length(p), 1e-4); - vec2 folded = kaleido(p, float(u_sides)); + // A track with a signature polygon dictates the fold count — this is the + // scene where that reads most strongly, so its own sides yields to it. + float sides = u_sigSides > 2.5 ? u_sigSides : float(u_sides); + + // Cross-section measured in the signature form: the tunnel mouth is the + // track's shape rather than a circle. + float radius = max(sigShape(p) + 1.0, 1e-4); + vec2 folded = kaleido(p, sides); float angle = atan(folded.y, folded.x); // Tunnel coordinates: 1/r is depth, angle is the wall. @@ -35,7 +44,7 @@ vec4 scene(vec2 uv, vec2 p) { float wall = angle / 3.14159265 + sin(z * 0.5 + t) * u_twist * 0.25; float ringLines = abs(fract(z * u_rings * 0.1) - 0.5) * 2.0; - float wallLines = abs(fract(wall * float(u_sides)) - 0.5) * 2.0; + float wallLines = abs(fract(wall * sides) - 0.5) * 2.0; float grid = smoothstep(0.42, 0.0, ringLines) + smoothstep(0.42, 0.0, wallLines); @@ -46,6 +55,7 @@ vec4 scene(vec2 uv, vec2 p) { float fade = smoothstep(0.0, 1.1, radius); col *= 0.25 + 0.9 * fade; col += pal(3) * (1.0 - fade) * u_glow * 0.6; + col += sigGrain(uv); return vec4(col, 1.0); } diff --git a/flow-state/src/scenes/shader/metaballs.js b/flow-state/src/scenes/shader/metaballs.js index 365a94f..5526e60 100644 --- a/flow-state/src/scenes/shader/metaballs.js +++ b/flow-state/src/scenes/shader/metaballs.js @@ -7,6 +7,8 @@ export const metaballs = { name: 'Metaballs', family: 'organic', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'camera', 'style'], params: { count: { type: 'int', range: [2, 10], default: 5, uniform: 'u_count', bias: 'density' }, @@ -27,6 +29,7 @@ export const metaballs = { shader: ` vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; + p = sigCamera(p); float field = 0.0; vec3 tint = vec3(0.0); @@ -41,7 +44,10 @@ vec4 scene(vec2 uv, vec2 p) { cos(t * (0.5 + fract(s * 0.29)) + s * 1.7) * u_spread * 0.62 ); - float d = length(p - centre); + // 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); field += contribution; tint += pal(i) * contribution; @@ -56,6 +62,7 @@ vec4 scene(vec2 uv, vec2 p) { vec3 col = pal(0) * 0.06; col = mix(col, tint, surface); col += tint * rim * u_rim; + col += sigGrain(uv); return vec4(col, 1.0); } diff --git a/flow-state/src/scenes/shader/moire-grid.js b/flow-state/src/scenes/shader/moire-grid.js index 6dbc2ee..62b71ae 100644 --- a/flow-state/src/scenes/shader/moire-grid.js +++ b/flow-state/src/scenes/shader/moire-grid.js @@ -9,6 +9,8 @@ export const moireGrid = { name: 'Moiré Grid', family: 'geometric', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['camera', 'style'], params: { density: { type: 'float', range: [6, 60], default: 22, uniform: 'u_density', bias: 'density' }, @@ -42,12 +44,15 @@ float grid(vec2 q, float density, float width) { vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_rotate + u_seed; + p = sigFolded(sigCamera(p)); vec2 warp = vec2(fbm(p * 1.5 + t, 3), fbm(p * 1.5 - t, 3)) - 0.5; vec2 q = p + warp * u_warp; - float a = grid(rot(t * 6.28318530718) * q, u_density, u_lineWidth); - float b = grid(rot(-t * 6.28318530718 + u_offset * 3.14159) * (q + u_offset), u_density, u_lineWidth); + // Drawn in the track's hand: its line weight scales this scene's. + float weight = u_lineWidth * (0.5 + u_sigLine); + float a = grid(rot(t * 6.28318530718) * q, u_density, weight); + float b = grid(rot(-t * 6.28318530718 + u_offset * 3.14159) * (q + u_offset), u_density, weight); // The interference term is the point: where both grids land, it peaks. float interference = a * b; @@ -59,6 +64,7 @@ vec4 scene(vec2 uv, vec2 p) { col += pal(3) * pow(interference, 3.0) * u_glow; col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.3); + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/nebula.js b/flow-state/src/scenes/shader/nebula.js index c05f0c4..58a4e3e 100644 --- a/flow-state/src/scenes/shader/nebula.js +++ b/flow-state/src/scenes/shader/nebula.js @@ -8,6 +8,8 @@ export const nebula = { name: 'Deep Nebula', family: 'organic', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['camera', 'space', 'style'], params: { scale: { type: 'float', range: [4, 24], default: 12, uniform: 'u_scale', bias: 'density' }, @@ -27,6 +29,8 @@ export const nebula = { shader: ` vec4 scene(vec2 uv, vec2 p) { + // The track's camera films this scene too. + p = sigCamera(p); float r = length(p); float a = atan(p.y, p.x); float t = u_time * u_speed + u_seed; @@ -51,6 +55,10 @@ vec4 scene(vec2 uv, vec2 p) { // Vignette the far field so the frame has a subject. col *= 0.5 + 0.5 * (1.0 - smoothstep(0.6, 1.6, r)); + // The location's air, and its surface. + col = sigAir(col, p, smoothstep(0.0, 1.6, r)); + col += sigGrain(uv); + return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/plasma-bloom.js b/flow-state/src/scenes/shader/plasma-bloom.js index 927b17b..8f03202 100644 --- a/flow-state/src/scenes/shader/plasma-bloom.js +++ b/flow-state/src/scenes/shader/plasma-bloom.js @@ -6,6 +6,8 @@ export const plasmaBloom = { name: 'Plasma Bloom', family: 'organic', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['camera', 'space', 'style'], params: { scale: { type: 'float', range: [0.8, 6], default: 2.4, uniform: 'u_scale', bias: 'density' }, @@ -26,6 +28,7 @@ export const plasmaBloom = { shader: ` vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; + p = sigCamera(p); // Two rounds of domain warping. One looks like noise; two looks organic. vec2 q = vec2(fbm(p * u_scale + t, 4), fbm(p * u_scale + vec2(5.2, 1.3) - t, 4)); @@ -42,6 +45,9 @@ vec4 scene(vec2 uv, vec2 p) { // Dark corners so the bloom has somewhere to sit. col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.4); + + col = sigAir(col, p, smoothstep(0.0, 1.8, length(p))); + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/psychedelic-drift.js b/flow-state/src/scenes/shader/psychedelic-drift.js index 6ea729d..3870571 100644 --- a/flow-state/src/scenes/shader/psychedelic-drift.js +++ b/flow-state/src/scenes/shader/psychedelic-drift.js @@ -9,6 +9,8 @@ export const psychedelicDrift = { name: 'Psychedelic Drift', family: 'glitch', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'camera', 'style'], params: { count: { type: 'int', range: [4, 24], default: 12, uniform: 'u_count', bias: 'density' }, @@ -66,6 +68,7 @@ vec4 scene(vec2 uv, vec2 p) { float vs = u_seed + floor(u_variant) * 100.0; // Swirling background. + p = sigCamera(p); float bg = sin(p.x * 3.0 + t) * cos(p.y * 3.0 - t * 0.7) + sin(length(p) * 4.0 - t); vec3 col = mix(pal(0) * 0.12, pal(1) * 0.18, bg * 0.5 + 0.5); col += pal(0) * 0.05 * beat; @@ -101,9 +104,11 @@ vec4 scene(vec2 uv, vec2 p) { float d; float kind = mod(fi + floor(u_variant), 3.0); + // One of the three symbol kinds is the track's own form, so its cast of + // characters includes the one every other scene is built from. if (kind == 0.0) d = sdStar(sp, size, 5.0 + floor(hash11(s * 1.2) * 3.0)); else if (kind == 1.0) d = sdSmiley(sp, size, u_time, fi); - else d = sdCircle(sp, size); + else d = sigShape(sp / max(size, 1e-3)) * size; float hit = 0.0; for (int j = 0; j < 5; j++) { @@ -124,6 +129,7 @@ vec4 scene(vec2 uv, vec2 p) { col += pal(i + 2) * intensity * (0.6 + beat); } + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/ridge-terrain.js b/flow-state/src/scenes/shader/ridge-terrain.js index 738e7ee..4d498a4 100644 --- a/flow-state/src/scenes/shader/ridge-terrain.js +++ b/flow-state/src/scenes/shader/ridge-terrain.js @@ -7,6 +7,8 @@ export const ridgeTerrain = { name: 'Ridge Terrain', family: 'structural', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['space', 'camera', 'style'], params: { layers: { type: 'int', range: [2, 10], default: 6, uniform: 'u_layers', bias: 'density' }, @@ -27,16 +29,20 @@ export const ridgeTerrain = { shader: ` vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; + p = sigCamera(p); + // Its own horizon param decides the framing; the track decides where the + // ground actually is, so two scenes with a horizon agree on one. + float horizon = clamp(u_horizon + sigHorizonY() * 0.35, -0.85, 0.85); // Sky gradient above the horizon. - float sky = sat((p.y - u_horizon) * 0.8 + 0.5); + float sky = sat((p.y - horizon) * 0.8 + 0.5); vec3 col = mix(pal(0) * 0.35, pal(1) * 0.18, sky); // Sparse stars, only in the upper sky, fading as haze rises. - if (u_stars > 0.01 && p.y > u_horizon) { + if (u_stars > 0.01 && p.y > horizon) { vec2 cell = floor(uv * 220.0); float rnd = hash12(cell); - float star = step(0.9975, rnd) * sat((p.y - u_horizon) * 2.0); + float star = step(0.9975, rnd) * sat((p.y - horizon) * 2.0); col += vec3(star) * u_stars * (0.6 + 0.4 * sin(t * 8.0 + rnd * 30.0)); } @@ -50,7 +56,7 @@ vec4 scene(vec2 uv, vec2 p) { float x = p.x * mix(0.6, 1.8, depth) + t * parallax + fi * 13.7; float ridge = fbm(vec2(x, fi * 5.1) * u_rough, 4) - 0.5; - float base = u_horizon - depth * 0.28; + float base = horizon - depth * 0.28; float top = base + ridge * u_height * mix(0.5, 1.3, depth); float mask = smoothstep(0.004, 0.0, p.y - top); @@ -64,6 +70,7 @@ vec4 scene(vec2 uv, vec2 p) { col += pal(5) * smoothstep(0.02, 0.0, abs(p.y - top)) * (0.12 + depth * 0.25) * u_haze; } + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/scan-tear.js b/flow-state/src/scenes/shader/scan-tear.js index 85b1e87..7750688 100644 --- a/flow-state/src/scenes/shader/scan-tear.js +++ b/flow-state/src/scenes/shader/scan-tear.js @@ -9,6 +9,8 @@ export const scanTear = { name: 'Scan Tear', family: 'glitch', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['style'], params: { slices: { type: 'float', range: [4, 48], default: 16, uniform: 'u_slices', bias: 'density' }, @@ -39,7 +41,9 @@ vec4 scene(vec2 uv, vec2 p) { float rowRandom = hash12(vec2(row, step_)); // Only some rows tear, and only above the tear threshold. - float torn = step(1.0 - u_tear, rowRandom); + // Slice weight follows the track's line weight — the same art direction + // that thickens a grid line thickens a tear. + float torn = step(1.0 - u_tear * (0.6 + u_sigLine), rowRandom); float offset = (rowRandom - 0.5) * 2.0 * u_shift * torn; vec2 q = vec2(fract(uv.x + offset), uv.y); @@ -65,6 +69,7 @@ vec4 scene(vec2 uv, vec2 p) { col = max(col, ghost * u_persist); col += pal(4) * torn * u_shift * 0.6; + col += sigGrain(uv); return vec4(col, 1.0); } `, diff --git a/flow-state/src/scenes/shader/slow-orb.js b/flow-state/src/scenes/shader/slow-orb.js index 11b8002..038fbf0 100644 --- a/flow-state/src/scenes/shader/slow-orb.js +++ b/flow-state/src/scenes/shader/slow-orb.js @@ -9,6 +9,8 @@ export const slowOrb = { name: 'Slow Orb', family: 'minimal', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'camera', 'space', 'style'], params: { size: { type: 'float', range: [0.15, 0.8], default: 0.38, uniform: 'u_size', bias: 'energy' }, @@ -29,12 +31,15 @@ export const slowOrb = { vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_drift + u_seed; - vec2 centre = vec2(sin(t * 1.7) * 0.28, cos(t * 1.3) * 0.18); - vec2 q = p - centre; + // The orb drifts about the track's horizon rather than about the middle of + // the frame, so it occupies the same space as the scenes that draw ground. + vec2 centre = vec2(sin(t * 1.7) * 0.28, cos(t * 1.3) * 0.18 + sigHorizonY() * 0.45); + vec2 q = sigCamera(p) - centre; - // Break the silhouette so it never reads as a hard circle. + // The body is the track's signature form — this scene is one shape in an + // empty frame, so the shape had better be the track's. float wobble = fbm(q * 2.4 + t, 4) * u_wobble; - float d = length(q) * (1.0 + wobble) - u_size; + float d = (sigShape(q / max(u_size, 1e-3)) * u_size) * (1.0 + wobble); float body = smoothstep(u_softness * 0.5, -u_softness * 0.5, d); float glow = exp(-max(d, 0.0) * (5.0 / max(u_halo, 0.05))) * u_halo; @@ -45,7 +50,9 @@ vec4 scene(vec2 uv, vec2 p) { col += pal(3) * glow * 0.35; // Fine grain keeps large flat areas from banding. + col = sigAir(col, p, smoothstep(0.0, 1.6, length(q))); col += (hash12(uv * 640.0 + floor(u_frame)) - 0.5) * u_grain * 0.08; + col += sigGrain(uv); return vec4(col, 1.0); } diff --git a/flow-state/src/scenes/shader/spectrum-sculpture.js b/flow-state/src/scenes/shader/spectrum-sculpture.js index 7d1eb5f..10da335 100644 --- a/flow-state/src/scenes/shader/spectrum-sculpture.js +++ b/flow-state/src/scenes/shader/spectrum-sculpture.js @@ -9,6 +9,8 @@ export const spectrumSculpture = { name: 'Spectrum Sculpture', family: 'minimal', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['shape', 'camera', 'style'], params: { bars: { type: 'float', range: [8, 96], default: 40, uniform: 'u_bars', bias: 'density' }, @@ -55,12 +57,15 @@ float bandAt(float x) { vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_rotate + u_seed; + p = sigCamera(p); float bars = u_bars; float seg = 6.28318530718 / bars; float angle = atan(p.y, p.x) + t * seg; - float radius = length(p); + // Radial extent measured in the track's form, so the sculpture is built on + // the same outline as everything else in the video. + float radius = sigShape(p) + 1.0; float index = floor((angle + 3.14159265) / seg); float cellAngle = mod(angle + 3.14159265, seg) / seg; @@ -84,7 +89,8 @@ vec4 scene(vec2 uv, vec2 p) { col += palRamp(slot * 0.7 + 0.15) * inBar * shape * (0.6 + band); // Inner ring outline holds the composition together. - col += pal(2) * smoothstep(0.006, 0.0, abs(radius - u_radius)) * 0.35; + col += pal(2) * sigEdge(radius - u_radius) * 0.35; + col += sigGrain(uv); return vec4(col, 1.0); } diff --git a/flow-state/src/scenes/shader/synthwave-run.js b/flow-state/src/scenes/shader/synthwave-run.js index d5aa28b..5b73fbb 100644 --- a/flow-state/src/scenes/shader/synthwave-run.js +++ b/flow-state/src/scenes/shader/synthwave-run.js @@ -10,6 +10,8 @@ export const synthwaveRun = { name: 'Synthwave Run', family: 'structural', kind: 'fragment', + // Personality: see look/Personality.js. + traits: ['space', 'camera', 'style'], params: { speed: { type: 'float', range: [0.3, 4], default: 1.5, uniform: 'u_speed', bias: 'motion', rate: true }, @@ -30,7 +32,9 @@ export const synthwaveRun = { vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_speed + u_seed; float beat = u_beat; - float horizon = u_horizon; + p = sigCamera(p); + // Shares the track's ground line with every other scene that has one. + float horizon = clamp(u_horizon + sigHorizonY() * 0.35, -0.85, 0.85); vec3 colorMain = pal(0); vec3 colorSec = pal(1); @@ -97,6 +101,7 @@ vec4 scene(vec2 uv, vec2 p) { // Horizon haze, scaled by the glow param. col += colorMain * exp(-abs(p.y - horizon) * 8.0) * u_glow * 0.35; + col += sigGrain(uv); return vec4(col, 1.0); } diff --git a/flow-state/src/ui/TimelineStrip.js b/flow-state/src/ui/TimelineStrip.js index c01be23..eefb98d 100644 --- a/flow-state/src/ui/TimelineStrip.js +++ b/flow-state/src/ui/TimelineStrip.js @@ -118,7 +118,33 @@ export class TimelineStrip { ctx.fillStyle = 'rgba(255,255,255,0.72)'; ctx.font = '10px ui-monospace, monospace'; ctx.fillText(section.kind, x0 + 5, 13); - if (look) { + + // Shots: the cuts inside the section. Each is labelled with the + // stage visual it shows, so a section that holds one image for a + // minute is visible here rather than only on playback. + const shots = (look && look.shots && look.shots.length) ? look.shots : null; + if (shots) { + for (const shot of shots) { + const sx = toX(shot.startFrame); + const sw = toX(shot.endFrame) - sx; + if (shot.index > 0) { + ctx.strokeStyle = shot.hardCut + ? 'rgba(255,255,255,0.34)' + : 'rgba(255,255,255,0.16)'; + ctx.setLineDash(shot.hardCut ? [] : [2, 2]); + ctx.beginPath(); + ctx.moveTo(Math.round(sx) + 0.5, 0); + ctx.lineTo(Math.round(sx) + 0.5, h); + ctx.stroke(); + ctx.setLineDash([]); + } + if (sw > 34) { + const stack = look.variants ? look.variants[shot.variant] : look.layers; + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.fillText(stack[0].module.name, sx + 4, h - 6); + } + } + } else if (look) { ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillText(look.layers[0].module.name, x0 + 5, h - 6); } diff --git a/flow-state/src/ui/style.css b/flow-state/src/ui/style.css index 3cadeaf..9ca821b 100644 --- a/flow-state/src/ui/style.css +++ b/flow-state/src/ui/style.css @@ -194,7 +194,9 @@ input[type=range] { accent-color: var(--accent); background: transparent; } .pp-reactive { margin-top: 12px; } .pp-react-row { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; font-size: 11px; color: var(--dim); } +.pp-react-row.current { color: var(--text); } .pp-feature { color: #6b8afd; } +.dim { color: #4b5563; font-style: normal; font-size: 10px; } .pp-amount { color: var(--text); } .kv { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 2px 0; } diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index c06c1a3..7da6a23 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -76,8 +76,27 @@ const CONTRACT_UNIFORMS = new Set([ 'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat', 'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress', 'u_sectionEnergy', 'u_buildSlope', + 'u_sigSides', 'u_sigRound', 'u_sigElong', 'u_sigTilt', + 'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe', + 'u_sigHorizon', 'u_sigDepth', 'u_sigWash', + 'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold', ]); +/** + * What counts as honouring a personality trait, in shader source. + * + * The disqualification rule in look/Personality.js is only as good as these + * declarations: a scene that claims `shape` and draws circles anyway will be + * cast in the hexagon video and be the one shot that looks filmed elsewhere. + * So the claim is machine-checked against the source rather than trusted. + */ +const TRAIT_EVIDENCE = { + shape: /\bsig(Shape|Form)\s*\(/, + camera: /\bsigCamera\s*\(/, + space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/, + style: /\b(sigEdge|sigGrain|sigFolded)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/, +}; + console.log('\nscene schema lint'); { const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href); @@ -98,7 +117,15 @@ console.log('\nscene schema lint'); if (module.family && !FAMILIES[module.family]) { fail(`${id}: unknown family '${module.family}'`); } - if (module.kind !== 'fragment') continue; + if (module.kind !== 'fragment') { + // 3D modules honour traits in JS, against the `personality` handed + // to update(); there is no shader source to grep, so the evidence + // check is just that they read it at all. + if ((module.traits || []).length && !/personality/.test(String(module.update))) { + fail(`${id}: declares traits but update() never reads \`personality\``); + } + continue; + } const src = module.shader || ''; const declared = new Map(); @@ -130,6 +157,16 @@ console.log('\nscene schema lint'); fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`); } + // Personality traits: declaring one is a promise to express it. + for (const trait of module.traits || []) { + const evidence = TRAIT_EVIDENCE[trait]; + if (evidence && !evidence.test(src)) { + fail(`${id}: declares trait '${trait}' but the shader never uses it — ` + + `either express it or drop the claim, or the scene will be cast ` + + `in tracks built on something it ignores`); + } + } + // Rate params: anything the shader multiplies absolute time by must be // flagged `rate: true`, which stops reactivity and drift from touching it. // Modulating such a param jumps the phase by elapsed*delta — sixty seconds