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 = `