Phase 0: determinism spine

Engine core: Timeline (fixed dt, audio-mastered in realtime), seeded Rng,
Renderer, Layer/ShaderLayer/SceneLayer, Compositor with blend modes,
feedback and post chain.

Shader scenes are compiled against a fixed uniform contract and define only
`vec4 scene(vec2 uv, vec2 p)`, so adding a scene costs a shader plus a
params block. Deep Nebula ported from party-stage as the first one.

Gate passes, 7/7 in checks.html:
- 300 frames rendered twice are bit-identical
- a fresh Engine reproduces the same frames
- simulated dropped frames change nothing (proves dt is fixed)
- seek matches sequential playback
- 320x180 vs 1280x720 agree within 0.010 (limit 0.06)
- seeded rng reproducible, forked streams independent
- compositor reset clears feedback history

Static gates: no wall-clock or unseeded randomness in deterministic
directories; scene schemas and shader sources agree in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-05 10:59:30 +02:00
parent e8fb647f11
commit 7e31c19d6e
31 changed files with 3408 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "flow-state",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "flow-state", "run", "dev"],
"port": 5180
}
]
}

4
flow-state/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.vite
out

42
flow-state/checks.html Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>flow-state · checks</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; padding: 24px;
background: #0b0d12; color: #d6dae3;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
}
h1 { font-size: 15px; letter-spacing: .12em; text-transform: uppercase; color: #7d8698; margin: 0 0 4px; }
#summary { margin: 0 0 20px; font-size: 14px; }
#summary.ok { color: #4ade80; }
#summary.bad { color: #f87171; }
.row {
display: grid; grid-template-columns: 52px 34px 1fr 60px;
gap: 10px; align-items: baseline;
padding: 7px 10px; border-left: 3px solid transparent; margin-bottom: 2px;
background: #11141b;
}
.row.pass { border-color: #22c55e; }
.row.fail { border-color: #ef4444; background: #1c1214; }
.row.manual { border-color: #eab308; }
.badge { font-weight: 700; font-size: 11px; }
.pass .badge { color: #4ade80; }
.fail .badge { color: #f87171; }
.manual .badge { color: #facc15; }
.phase { color: #6b7280; }
.ms { color: #4b5563; text-align: right; }
.detail { grid-column: 3 / 5; color: #8b93a5; font-size: 12px; }
.detail:empty { display: none; }
</style>
</head>
<body>
<h1>flow-state — phase gates</h1>
<div id="summary">starting…</div>
<div id="results"></div>
<script type="module" src="/src/checks/main.js"></script>
</body>
</html>

73
flow-state/index.html Normal file
View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>flow-state</title>
<link rel="stylesheet" href="/src/ui/style.css">
</head>
<body>
<div id="app">
<div id="stage">
<canvas id="canvas"></canvas>
<div id="overlay">
<div id="dropzone">
<div class="dz-inner">
<div class="dz-title">flow-state</div>
<div class="dz-sub">drop an audio file, or click to choose</div>
<div class="dz-hint">mp3 · flac · wav · ogg</div>
</div>
</div>
<div id="analysing" hidden>
<div class="an-title">analysing</div>
<div class="an-step"></div>
<div class="an-bar"><div class="an-fill"></div></div>
</div>
</div>
<div id="hud" hidden></div>
</div>
<div id="transport">
<div id="timeline">
<canvas id="timeline-canvas"></canvas>
</div>
<div id="controls">
<button id="btn-play" title="Play / pause (space)"></button>
<button id="btn-prev-section" title="Previous section (←)"></button>
<button id="btn-next-section" title="Next section (→)"></button>
<button id="btn-loop" title="Loop current section (L)"></button>
<span id="time-display">0:00 / 0:00</span>
<span class="spacer"></span>
<span id="section-display"></span>
<span class="spacer"></span>
<label class="ctl">quality
<select id="sel-quality">
<option value="draft">draft</option>
<option value="full" selected>full</option>
</select>
</label>
<button id="btn-reroll" title="New seed for the whole track">reroll</button>
<button id="btn-reroll-section" title="New scene for this section only">reroll section</button>
<button id="btn-lock" title="Lock this section against rerolls">lock</button>
<button id="btn-hud" title="Toggle debug HUD (D)">hud</button>
<button id="btn-segment" title="Render 20s around the playhead at export quality">test render</button>
<button id="btn-export" class="primary" title="Export the full video">export</button>
</div>
</div>
<aside id="panel">
<div id="panel-tabs">
<button data-tab="look" class="active">look</button>
<button data-tab="scene">scene</button>
<button data-tab="post">post</button>
<button data-tab="export">export</button>
</div>
<div id="panel-body"></div>
</aside>
</div>
<input type="file" id="file-input" accept="audio/*" hidden>
<audio id="audio" hidden></audio>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1132
flow-state/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
flow-state/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "flow-state",
"version": "1.0.0",
"description": "Ambient/EDM music video generator with a deterministic render core",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint:scenes": "node tools/lint-scenes.js",
"test": "node --test test/"
},
"license": "ISC",
"devDependencies": {
"vite": "^7.2.2"
},
"dependencies": {
"three": "^0.181.1"
}
}

View File

@ -0,0 +1,81 @@
// Minimal check harness. Every phase gate in PLAN.md is a check registered here
// and run from checks.html, so "the gate passes" is something you execute rather
// than something you assert in a commit message.
const registry = [];
export function check(phase, name, fn, options = {}) {
registry.push({ phase, name, fn, manual: !!options.manual, slow: !!options.slow });
}
export function allChecks() {
return registry;
}
export async function runAll({ phases = null, onResult = null, skipSlow = false } = {}) {
const results = [];
for (const entry of registry) {
if (phases && !phases.includes(entry.phase)) continue;
if (skipSlow && entry.slow) continue;
const started = Date.now();
let result;
try {
const r = await entry.fn();
result = {
phase: entry.phase,
name: entry.name,
pass: r === true || (r && r.pass !== false),
detail: (r && r.detail) || '',
manual: entry.manual,
ms: Date.now() - started,
};
} catch (err) {
result = {
phase: entry.phase,
name: entry.name,
pass: false,
detail: `threw: ${err && err.message ? err.message : String(err)}`,
manual: entry.manual,
ms: Date.now() - started,
};
}
results.push(result);
if (onResult) onResult(result);
}
return results;
}
export function summarize(results) {
const total = results.length;
const failed = results.filter((r) => !r.pass && !r.manual);
const manual = results.filter((r) => r.manual);
return {
total,
passed: results.filter((r) => r.pass).length,
failed: failed.length,
manual: manual.length,
ok: failed.length === 0,
failures: failed.map((r) => `[P${r.phase}] ${r.name}: ${r.detail}`),
};
}
/** Assertion helpers that produce useful detail strings rather than bare booleans. */
export function expect(condition, detail) {
return { pass: !!condition, detail };
}
export function expectClose(actual, expected, tolerance, label) {
const diff = Math.abs(actual - expected);
return {
pass: diff <= tolerance,
detail: `${label}: ${actual.toFixed(6)} vs ${expected.toFixed(6)}${diff.toFixed(6)}, tol ${tolerance})`,
};
}
export function expectBelow(actual, limit, label) {
return {
pass: actual <= limit,
detail: `${label}: ${typeof actual === 'number' ? actual.toFixed(6) : actual} (limit ${limit})`,
};
}

View File

@ -0,0 +1,48 @@
import { runAll, summarize, allChecks } from './framework.js';
// Registering a phase's checks is a side effect of importing it.
import './phase0.js';
import './phase1.js';
import './phase2.js';
import './phase3.js';
import './phase4.js';
import './phase5.js';
import './phase6.js';
const out = document.getElementById('results');
const summaryEl = document.getElementById('summary');
function row(result) {
const el = document.createElement('div');
el.className = 'row ' + (result.pass ? 'pass' : result.manual ? 'manual' : 'fail');
el.innerHTML = `
<span class="badge">${result.pass ? 'PASS' : 'FAIL'}</span>
<span class="phase">P${result.phase}</span>
<span class="name">${result.name}</span>
<span class="ms">${result.ms}ms</span>
<div class="detail">${result.detail || ''}</div>`;
out.appendChild(el);
}
async function main() {
const params = new URLSearchParams(location.search);
const phaseArg = params.get('phase');
const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
const skipSlow = params.get('slow') !== '1';
summaryEl.textContent = `running ${allChecks().length} checks…`;
const results = await runAll({ phases, skipSlow, onResult: row });
const s = summarize(results);
summaryEl.textContent =
`${s.passed}/${s.total} passed · ${s.failed} failed` + (skipSlow ? ' · slow checks skipped (?slow=1 to include)' : '');
summaryEl.className = s.ok ? 'ok' : 'bad';
// Read by the browser automation that drives these gates.
window.__CHECKS__ = { results, summary: s };
window.__CHECKS_DONE__ = true;
console.log('[checks]', JSON.stringify(s, null, 2));
}
main();

View File

@ -0,0 +1,163 @@
// Phase 0 gate — the determinism spine.
//
// These are cheap now and impossible to retrofit later: every phase after this
// one assumes that frame N is a pure function of (seed, params, frame index).
import { check, expect, expectBelow } from './framework.js';
import { Engine } from '../engine/Engine.js';
import { frameDistance, downsample } from '../engine/hash.js';
import { Rng } from '../engine/rng.js';
import { nebula } from '../scenes/shader/nebula.js';
import { defaultValues } from '../params/schema.js';
const TEST_PALETTE = [
[0.05, 0.02, 0.15],
[0.85, 0.15, 0.55],
[0.15, 0.75, 0.95],
[0.98, 0.85, 0.35],
];
function makeEngine(width = 320, height = 180) {
const engine = new Engine({ width, height });
engine.timeline.setDuration(60);
engine.setLayerSpecs([{
module: nebula,
params: defaultValues(nebula),
seed: 12345,
opacity: 1,
blend: 'normal',
palette: TEST_PALETTE,
}]);
return engine;
}
check(0, 'repeat run produces identical frames', () => {
const engine = makeEngine();
try {
const a = engine.hashRun(0, 300);
const b = engine.hashRun(0, 300);
const mismatches = a.filter((h, i) => h !== b[i]).length;
return expect(mismatches === 0, `${mismatches}/300 frames differed between runs`);
} finally {
engine.dispose();
}
});
check(0, 'fresh engine reproduces the same frames', () => {
// A second Engine instance must agree with the first: proves nothing is
// carried in module-level or GPU state between constructions.
const a = makeEngine();
const b = makeEngine();
try {
const ha = a.hashRun(0, 60);
const hb = b.hashRun(0, 60);
const mismatches = ha.filter((h, i) => h !== hb[i]).length;
return expect(mismatches === 0, `${mismatches}/60 frames differed between engines`);
} finally {
a.dispose(); b.dispose();
}
});
check(0, 'simulated dropped frames change nothing', () => {
// Renders the same frame indices but with irregular gaps in between, the way
// a stuttering browser would. Because dt is fixed and features are indexed by
// frame, output must be byte-identical to the smooth run.
const engine = makeEngine();
try {
const smooth = engine.hashRun(0, 120);
engine.compositor.reset();
const stuttered = [];
for (let i = 0; i < 120; i++) {
// Burn some GPU work between frames without advancing the timeline.
if (i % 7 === 0) engine.renderFrame(i);
const target = engine.renderFrame(i);
stuttered.push(engine.hashCurrent(target));
}
const mismatches = smooth.filter((h, i) => h !== stuttered[i]).length;
return expect(mismatches === 0, `${mismatches}/120 frames differed under simulated stutter`);
} finally {
engine.dispose();
}
});
check(0, 'seek equals sequential playback', () => {
const engine = makeEngine();
try {
const sequential = engine.hashRun(0, 100);
engine.compositor.reset();
const direct = engine.hashCurrent(engine.renderFrame(99));
// With feedback off (Phase 0 default) a direct seek must match exactly.
return expect(direct === sequential[99],
`seek→99 ${direct} vs sequential ${sequential[99]}`);
} finally {
engine.dispose();
}
});
check(0, 'resolution independence (320x180 vs 1280x720)', () => {
const small = new Engine({ width: 320, height: 180 });
const large = new Engine({ width: 1280, height: 720 });
try {
for (const e of [small, large]) {
e.timeline.setDuration(60);
e.setLayerSpecs([{
module: nebula, params: defaultValues(nebula), seed: 12345,
opacity: 1, blend: 'normal', palette: TEST_PALETTE,
}]);
}
const frames = [10, 90, 200];
let worst = 0;
for (const f of frames) {
small.compositor.reset(); large.compositor.reset();
const st = small.renderFrame(f);
const lt = large.renderFrame(f);
const sp = Uint8Array.from(small.readPixels(st));
const lp = Uint8Array.from(large.readPixels(lt));
const reduced = downsample(lp, 1280, 720, 4);
worst = Math.max(worst, frameDistance(sp, reduced.pixels));
}
// 4x downsampling of a noise-bearing shader will never be exact; this
// threshold catches genuine pixel-space dependence, not filtering error.
return expectBelow(worst, 0.06, 'worst mean channel distance');
} finally {
small.dispose(); large.dispose();
}
});
check(0, 'seeded rng is reproducible and independent per stream', () => {
const a = new Rng(42);
const b = new Rng(42);
const seqA = Array.from({ length: 500 }, () => a.next());
const seqB = Array.from({ length: 500 }, () => b.next());
if (seqA.some((v, i) => v !== seqB[i])) return expect(false, 'same seed diverged');
const parent = new Rng(7);
const c1 = parent.fork('layer:0').next();
const parent2 = new Rng(7);
const c2 = parent2.fork('layer:0').next();
const c3 = parent2.fork('layer:1').next();
if (c1 !== c2) return expect(false, 'fork(label) not stable');
if (c1 === c3) return expect(false, 'different fork labels collided');
const inRange = seqA.every((v) => v >= 0 && v < 1);
return expect(inRange, `500 draws reproducible, forks independent, range ok=${inRange}`);
});
check(0, 'compositor reset clears all history', () => {
const engine = makeEngine();
try {
engine.compositor.setFeedback({ amount: 0.8, decay: 0.95 });
engine.hashRun(0, 30);
const afterHistory = engine.hashRun(0, 30); // hashRun resets first
const fresh = makeEngine();
fresh.compositor.setFeedback({ amount: 0.8, decay: 0.95 });
const freshHashes = fresh.hashRun(0, 30);
fresh.dispose();
const mismatches = afterHistory.filter((h, i) => h !== freshHashes[i]).length;
return expect(mismatches === 0,
`${mismatches}/30 frames differed after reset — feedback history leaked`);
} finally {
engine.dispose();
}
});

View File

@ -0,0 +1 @@
// Phase 1 gate — filled in when the phase lands.

View File

@ -0,0 +1 @@
// Phase 2 gate — filled in when the phase lands.

View File

@ -0,0 +1 @@
// Phase 3 gate — filled in when the phase lands.

View File

@ -0,0 +1 @@
// Phase 4 gate — filled in when the phase lands.

View File

@ -0,0 +1 @@
// Phase 5 gate — filled in when the phase lands.

View File

@ -0,0 +1 @@
// Phase 6 gate — filled in when the phase lands.

View File

@ -0,0 +1,267 @@
import * as THREE from 'three';
import { makePassMaterial } from './Renderer.js';
import {
BLEND_FRAG, FEEDBACK_FRAG, BRIGHT_FRAG, BLUR_FRAG, COMPOSITE_FRAG, COPY_FRAG,
BLEND_MODE_IDS,
} from './passes.js';
const DEFAULT_POST = {
bloom: 0.35,
bloomThreshold: 0.6,
bloomKnee: 0.3,
chroma: 0.15,
grain: 0.04,
vignette: 0.35,
contrast: 1.05,
saturation: 1.1,
lift: 0.0,
exposure: 1.0,
};
const DEFAULT_FEEDBACK = {
amount: 0.0,
decay: 0.9,
zoom: 0.995,
rotate: 0.0,
};
/**
* The layer stack. Layers render into their own target, then blend into an
* accumulator; the result goes through feedback and the post chain.
*
* Every target is explicitly cleared on allocation and on reset, because
* inheriting stale GPU memory is exactly the kind of thing that makes an export
* differ from a preview.
*/
export class Compositor {
constructor(renderer, { width, height } = {}) {
this.renderer = renderer;
this.width = width || renderer.width;
this.height = height || renderer.height;
this.layers = [];
this.post = { ...DEFAULT_POST };
this.feedback = { ...DEFAULT_FEEDBACK };
this.fade = 1;
this.soloIndex = -1; // debug: render one layer alone
this.postEnabled = true;
this._buildTargets();
this._buildMaterials();
}
_buildTargets() {
const r = this.renderer;
const w = this.width, h = this.height;
const bw = Math.max(1, Math.floor(w / 2));
const bh = Math.max(1, Math.floor(h / 2));
this.layerTarget = r.createTarget(w, h, { depth: true });
this.accumA = r.createTarget(w, h);
this.accumB = r.createTarget(w, h);
this.historyA = r.createTarget(w, h, { float: true });
this.historyB = r.createTarget(w, h, { float: true });
this.bloomA = r.createTarget(bw, bh);
this.bloomB = r.createTarget(bw, bh);
this.outputTarget = r.createTarget(w, h);
}
_buildMaterials() {
this.blendMaterial = makePassMaterial(BLEND_FRAG, {
u_base: { value: null },
u_src: { value: null },
u_amount: { value: 1 },
u_mode: { value: 0 },
});
this.feedbackMaterial = makePassMaterial(FEEDBACK_FRAG, {
u_current: { value: null },
u_history: { value: null },
u_decay: { value: 0.9 },
u_amount: { value: 0 },
u_zoom: { value: 0.995 },
u_rotate: { value: 0 },
u_aspect: { value: 1 },
});
this.brightMaterial = makePassMaterial(BRIGHT_FRAG, {
u_tex: { value: null },
u_threshold: { value: 0.6 },
u_knee: { value: 0.3 },
});
this.blurMaterial = makePassMaterial(BLUR_FRAG, {
u_tex: { value: null },
u_direction: { value: new THREE.Vector2(0, 0) },
});
this.compositeMaterial = makePassMaterial(COMPOSITE_FRAG, {
u_tex: { value: null },
u_bloom: { value: null },
u_bloomAmount: { value: 0 },
u_chroma: { value: 0 },
u_grain: { value: 0 },
u_vignette: { value: 0 },
u_contrast: { value: 1 },
u_saturation: { value: 1 },
u_lift: { value: 0 },
u_exposure: { value: 1 },
u_fade: { value: 1 },
u_frame: { value: 0 },
u_resolution: { value: new THREE.Vector2(1, 1) },
});
this.copyMaterial = makePassMaterial(COPY_FRAG, { u_tex: { value: null } });
}
setSize(width, height) {
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.disposeTargets();
this._buildTargets();
}
setLayers(layers) {
this.layers.forEach((l) => { if (!layers.includes(l)) l.dispose(); });
this.layers = layers;
return this;
}
setPost(post) {
this.post = { ...this.post, ...post };
return this;
}
setFeedback(feedback) {
this.feedback = { ...this.feedback, ...feedback };
return this;
}
/**
* Wipe all history. Called on seek and before an export run so a render never
* depends on what was on screen beforehand.
*/
reset() {
const r = this.renderer;
[this.accumA, this.accumB, this.historyA, this.historyB,
this.bloomA, this.bloomB, this.layerTarget, this.outputTarget]
.forEach((t) => r.clear(t));
}
/**
* Render one frame. Returns the target holding the finished image, so the
* caller decides whether it goes to the canvas or to the encoder.
*/
render(ctx) {
const r = this.renderer;
const { timeline, features } = ctx;
r.clear(this.accumA);
let accum = this.accumA;
let spare = this.accumB;
const active = this.soloIndex >= 0
? this.layers.slice(this.soloIndex, this.soloIndex + 1)
: this.layers;
for (const layer of active) {
if (layer.opacity <= 0.001) continue;
r.clear(this.layerTarget);
layer.render(r, this.layerTarget, {
timeline,
features,
prevTexture: this.historyA.texture,
});
const bu = this.blendMaterial.uniforms;
bu.u_base.value = accum.texture;
bu.u_src.value = this.layerTarget.texture;
bu.u_amount.value = 1.0; // layer opacity already applied in-shader
bu.u_mode.value = BLEND_MODE_IDS[layer.blend] ?? 0;
r.blit(this.blendMaterial, spare);
const t = accum; accum = spare; spare = t;
}
// --- feedback -------------------------------------------------------
let composited = accum;
if (this.feedback.amount > 0.001) {
const fu = this.feedbackMaterial.uniforms;
fu.u_current.value = accum.texture;
fu.u_history.value = this.historyA.texture;
fu.u_decay.value = Math.min(0.99, this.feedback.decay);
fu.u_amount.value = this.feedback.amount;
fu.u_zoom.value = this.feedback.zoom;
fu.u_rotate.value = this.feedback.rotate;
fu.u_aspect.value = this.width / this.height;
r.blit(this.feedbackMaterial, this.historyB);
composited = this.historyB;
const t = this.historyA; this.historyA = this.historyB; this.historyB = t;
} else {
// Keep history tracking the image even when feedback is off, so
// enabling it mid-track doesn't pop from black.
this.copyMaterial.uniforms.u_tex.value = accum.texture;
r.blit(this.copyMaterial, this.historyA);
}
if (!this.postEnabled) {
this.copyMaterial.uniforms.u_tex.value = composited.texture;
r.blit(this.copyMaterial, this.outputTarget);
return this.outputTarget;
}
// --- bloom ----------------------------------------------------------
const p = this.post;
if (p.bloom > 0.001) {
this.brightMaterial.uniforms.u_tex.value = composited.texture;
this.brightMaterial.uniforms.u_threshold.value = p.bloomThreshold;
this.brightMaterial.uniforms.u_knee.value = p.bloomKnee;
r.blit(this.brightMaterial, this.bloomA);
const bw = this.bloomA.width, bh = this.bloomA.height;
for (let i = 0; i < 2; i++) {
this.blurMaterial.uniforms.u_tex.value = this.bloomA.texture;
this.blurMaterial.uniforms.u_direction.value.set((1 + i) / bw, 0);
r.blit(this.blurMaterial, this.bloomB);
this.blurMaterial.uniforms.u_tex.value = this.bloomB.texture;
this.blurMaterial.uniforms.u_direction.value.set(0, (1 + i) / bh);
r.blit(this.blurMaterial, this.bloomA);
}
} else {
r.clear(this.bloomA);
}
// --- final grade ----------------------------------------------------
const cu = this.compositeMaterial.uniforms;
cu.u_tex.value = composited.texture;
cu.u_bloom.value = this.bloomA.texture;
cu.u_bloomAmount.value = p.bloom;
cu.u_chroma.value = p.chroma;
cu.u_grain.value = p.grain;
cu.u_vignette.value = p.vignette;
cu.u_contrast.value = p.contrast;
cu.u_saturation.value = p.saturation;
cu.u_lift.value = p.lift;
cu.u_exposure.value = p.exposure;
cu.u_fade.value = this.fade;
cu.u_frame.value = timeline.frame;
cu.u_resolution.value.set(this.width, this.height);
r.blit(this.compositeMaterial, this.outputTarget);
return this.outputTarget;
}
/** Present a finished target to the canvas. */
present(target) {
this.copyMaterial.uniforms.u_tex.value = target.texture;
this.renderer.blit(this.copyMaterial, null);
}
disposeTargets() {
[this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB,
this.bloomA, this.bloomB, this.outputTarget].forEach((t) => t && t.dispose());
}
dispose() {
this.layers.forEach((l) => l.dispose());
this.disposeTargets();
}
}

View File

@ -0,0 +1,127 @@
import { Renderer } from './Renderer.js';
import { Compositor } from './Compositor.js';
import { Timeline, FIXED, REALTIME } from './Timeline.js';
import { createLayer } from './Layer.js';
import { hashFrame } from './hash.js';
/** Zeroed features, so the engine runs before any audio is loaded. */
export const NULL_FEATURES = Object.freeze({
loudness: 0, rms: 0,
bandSub: 0, bandLow: 0, bandMid: 0, bandHigh: 0, bandAir: 0,
flux: 0, centroid: 0.5, flatness: 0, width: 0.5,
beat: 0, beatPhase: 0, barPhase: 0, phrasePhase: 0,
sectionProgress: 0, sectionEnergy: 0, buildSlope: 0,
});
/**
* Ties the clock, the renderer and the layer stack together. Deliberately the
* only place that knows about all three, and deliberately unaware of the DOM
* beyond its canvas the exporter and the check harness drive the exact same
* object the preview does, which is what keeps them from diverging.
*/
export class Engine {
constructor({ width = 1280, height = 720, canvas = null, fps = 60 } = {}) {
this.renderer = new Renderer({ width, height, canvas });
this.compositor = new Compositor(this.renderer, { width, height });
this.timeline = new Timeline({ fps, mode: REALTIME });
this.featureProvider = null;
this.lastFrameRendered = -1;
}
get width() { return this.renderer.width; }
get height() { return this.renderer.height; }
setSize(width, height) {
this.renderer.setSize(width, height);
this.compositor.setSize(width, height);
}
setFeatureProvider(provider) {
this.featureProvider = provider;
return this;
}
featuresAt(frame) {
if (!this.featureProvider) return NULL_FEATURES;
return this.featureProvider.at(frame) || NULL_FEATURES;
}
/** Replace the stack. `specs` are { module, params, seed, opacity, blend }. */
setLayerSpecs(specs) {
const layers = specs.map((s) => {
const layer = createLayer(s.module, s);
if (s.palette) layer.setPalette(s.palette);
return layer;
});
this.compositor.setLayers(layers);
return layers;
}
setPalette(colors) {
this.compositor.layers.forEach((l) => l.setPalette(colors));
}
/** Render exactly one frame at the timeline's current position. */
renderCurrent() {
const features = this.featuresAt(this.timeline.frame);
const target = this.compositor.render({ timeline: this.timeline, features });
this.lastFrameRendered = this.timeline.frame;
return target;
}
/** Render a specific frame without warm-up. Used by checks and by export. */
renderFrame(frameIndex) {
this.timeline.seek(frameIndex);
return this.renderCurrent();
}
present(target) {
this.compositor.present(target);
}
readPixels(target) {
return this.renderer.readPixels(target);
}
hashCurrent(target) {
return hashFrame(this.renderer.readPixels(target));
}
/**
* Render frames [start, start+count) sequentially from a clean state and
* return a hash per frame. Sequential and reset-first, so the result depends
* only on the inputs this is the primitive every determinism check uses.
*/
hashRun(start, count, { reset = true } = {}) {
if (reset) this.compositor.reset();
const hashes = [];
for (let i = 0; i < count; i++) {
const target = this.renderFrame(start + i);
hashes.push(hashFrame(this.renderer.readPixels(target)));
}
return hashes;
}
/**
* Advance stateful layers up to `frame` without presenting, so a seek lands
* on converged feedback state. Section-boundary seeks pass warmup: 0,
* because layer state is re-seeded there and is exact by construction.
*/
warmUp(frame, warmupFrames = 120) {
const start = Math.max(0, frame - warmupFrames);
this.compositor.reset();
for (let f = start; f < frame; f++) {
this.timeline.seek(f);
const features = this.featuresAt(f);
this.compositor.render({ timeline: this.timeline, features });
}
this.timeline.seek(frame);
}
dispose() {
this.compositor.dispose();
this.renderer.dispose();
}
}
export { FIXED, REALTIME };

View File

@ -0,0 +1,232 @@
import * as THREE from 'three';
import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS } from './shader-contract.js';
import { clampValue } from '../params/schema.js';
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
/**
* Map a raw feature value through a response curve. `spike` squares the input so
* beat-driven params punch rather than wobble; `smooth` rounds the shoulders so
* slow features don't step.
*/
function applyResponse(value, response) {
switch (response) {
case 'spike': return value * value;
case 'smooth': return value * value * (3 - 2 * value);
case 'inverse': return 1 - value;
default: return value;
}
}
/** Common surface for shader layers and 3D layers, so Compositor holds one type. */
export class Layer {
constructor({ module, params = {}, seed = 1, opacity = 1, blend = 'normal' }) {
this.module = module;
this.baseParams = { ...params };
this.params = { ...params };
this.seed = seed >>> 0;
this.opacity = opacity;
this.blend = blend;
this.palette = [];
}
setParams(params) {
this.baseParams = { ...this.baseParams, ...params };
return this;
}
setPalette(colors) {
this.palette = colors;
return this;
}
/** Resolve base params + reactive modulation into the values used this frame. */
resolveParams(features) {
const defs = this.module.params || {};
const reactive = this.module.reactive || {};
const out = this.params;
for (const name of Object.keys(defs)) out[name] = this.baseParams[name];
if (!features) return out;
for (const [name, r] of Object.entries(reactive)) {
const def = defs[name];
if (!def || def.type === 'palette') continue;
const raw = features[r.feature];
if (raw === undefined) continue;
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), r.response);
const [lo, hi] = def.range || [0, 1];
const span = hi - lo;
const base = out[name] !== undefined ? out[name] : lo;
if (def.type === 'vec2') {
out[name] = [
clampValue(def, [base[0] + shaped * r.amount * span, 0])[0],
clampValue(def, [0, base[1] + shaped * r.amount * span])[1],
];
} else if (def.type === 'bool') {
out[name] = base;
} else {
out[name] = clampValue(def, base + shaped * r.amount * span);
}
}
return out;
}
render() { throw new Error('Layer.render not implemented'); }
dispose() {}
}
/** A fullscreen fragment-shader scene. The common case. */
export class ShaderLayer extends Layer {
constructor(options) {
super(options);
const uniforms = {
u_resolution: { value: new THREE.Vector2(1, 1) },
u_aspect: { value: 1 },
u_pixelScale: { value: 1 },
u_time: { value: 0 },
u_frame: { value: 0 },
u_progress: { value: 0 },
u_seed: { value: (this.seed % 100000) / 1000 },
u_opacity: { value: 1 },
u_colors: { value: Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1)) },
u_colorCount: { value: 1 },
u_prev: { value: null },
u_hasPrev: { value: 0 },
};
for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
for (const [name, def] of Object.entries(this.module.params || {})) {
if (!def.uniform || def.type === 'palette') continue;
const v = this.baseParams[name];
uniforms[def.uniform] = {
value: def.type === 'vec2'
? new THREE.Vector2(v ? v[0] : 0, v ? v[1] : 0)
: def.type === 'bool' ? (v ? 1 : 0) : (v || 0),
};
}
this.uniforms = uniforms;
this.material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SHADER,
fragmentShader: buildFragmentShader(this.module),
uniforms,
depthTest: false,
depthWrite: false,
});
}
render(renderer, target, ctx) {
const { timeline, features, prevTexture } = ctx;
const u = this.uniforms;
const w = target ? target.width : renderer.width;
const h = target ? target.height : renderer.height;
u.u_resolution.value.set(w, h);
u.u_aspect.value = w / h;
u.u_pixelScale.value = h / 1080; // reference height; keeps 720p ≡ 4K
u.u_time.value = timeline.time;
u.u_frame.value = timeline.frame;
u.u_progress.value = timeline.progress;
u.u_opacity.value = this.opacity;
if (features) {
for (const name of AUDIO_UNIFORMS) {
const key = name.slice(2); // u_bandLow -> bandLow
const v = features[key];
u[name].value = v === undefined ? 0 : v;
}
}
const colors = this.palette || [];
u.u_colorCount.value = Math.max(1, Math.min(8, colors.length));
for (let i = 0; i < 8; i++) {
const c = colors[i % Math.max(1, colors.length)];
if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
}
u.u_prev.value = prevTexture || null;
u.u_hasPrev.value = prevTexture ? 1 : 0;
const resolved = this.resolveParams(features);
for (const [name, def] of Object.entries(this.module.params || {})) {
if (!def.uniform || def.type === 'palette') continue;
const target_u = u[def.uniform];
const v = resolved[name];
if (v === undefined) continue;
if (def.type === 'vec2') target_u.value.set(v[0], v[1]);
else if (def.type === 'bool') target_u.value = v ? 1 : 0;
else target_u.value = v;
}
renderer.blit(this.material, target);
}
dispose() {
this.material.dispose();
}
}
/**
* A layer backed by a real three.js scene particles, geometry, camera motion.
* The module supplies build/update hooks; everything determinism-related (seeded
* rng, fixed dt, explicit re-seed at section boundaries) is handled here so 3D
* modules can't accidentally reintroduce wall-clock or Math.random.
*/
export class SceneLayer extends Layer {
constructor(options) {
super(options);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
this.camera.position.set(0, 0, 5);
this.instance = this.module.build({
scene: this.scene,
camera: this.camera,
seed: this.seed,
params: this.baseParams,
THREE,
});
}
render(renderer, target, ctx) {
const { timeline, features } = ctx;
const w = target ? target.width : renderer.width;
const h = target ? target.height : renderer.height;
if (this.camera.aspect !== w / h) {
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
const resolved = this.resolveParams(features);
this.module.update({
instance: this.instance,
scene: this.scene,
camera: this.camera,
timeline,
features: features || {},
params: resolved,
palette: this.palette,
opacity: this.opacity,
THREE,
});
renderer.renderScene(this.scene, this.camera, target, true);
}
dispose() {
this.scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m.dispose());
}
});
}
}
export function createLayer(module, options) {
if (module.kind === 'layer3d') return new SceneLayer({ module, ...options });
return new ShaderLayer({ module, ...options });
}

View File

@ -0,0 +1,119 @@
import * as THREE from 'three';
import { VERTEX_SHADER } from './shader-contract.js';
/**
* Thin wrapper over WebGLRenderer that provides the two primitives everything
* else is built from: allocate a render target, and run a fullscreen shader pass
* into one. Keeping this small matters preview and export share it exactly,
* and any state that leaks between frames here would break determinism.
*/
export class Renderer {
constructor({ width = 1280, height = 720, canvas = null } = {}) {
this.gl = new THREE.WebGLRenderer({
canvas: canvas || undefined,
antialias: false, // we render through targets; MSAA here buys nothing
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
powerPreference: 'high-performance',
});
this.gl.autoClear = false;
this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
this.gl.setSize(width, height, false);
this.width = width;
this.height = height;
// Fullscreen quad rig, reused for every pass.
this.quadScene = new THREE.Scene();
this.quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
this.quadGeometry = new THREE.PlaneGeometry(2, 2);
this.quadMesh = new THREE.Mesh(this.quadGeometry, new THREE.MeshBasicMaterial());
this.quadMesh.frustumCulled = false;
this.quadScene.add(this.quadMesh);
this._readBuffer = null;
}
get canvas() {
return this.gl.domElement;
}
setSize(width, height) {
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.gl.setSize(width, height, false);
this._readBuffer = null;
}
createTarget(width = this.width, height = this.height, options = {}) {
const target = new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
type: options.float ? THREE.HalfFloatType : THREE.UnsignedByteType,
depthBuffer: options.depth === true,
stencilBuffer: false,
generateMipmaps: false,
});
target.texture.wrapS = THREE.ClampToEdgeWrapping;
target.texture.wrapT = THREE.ClampToEdgeWrapping;
// Deterministic initial contents: never inherit whatever was in GPU memory.
this.clear(target);
return target;
}
clear(target = null, r = 0, g = 0, b = 0, a = 1) {
const prev = this.gl.getClearColor(new THREE.Color());
const prevAlpha = this.gl.getClearAlpha();
this.gl.setRenderTarget(target);
this.gl.setClearColor(new THREE.Color(r, g, b), a);
this.gl.clear(true, true, true);
this.gl.setClearColor(prev, prevAlpha);
this.gl.setRenderTarget(null);
}
/** Run a fullscreen shader pass. target === null renders to the canvas. */
blit(material, target = null) {
this.quadMesh.material = material;
this.gl.setRenderTarget(target);
this.gl.clear(true, false, false);
this.gl.render(this.quadScene, this.quadCamera);
this.gl.setRenderTarget(null);
}
/** Render a real three.js scene (used by 3D layers). */
renderScene(scene, camera, target = null, clear = true) {
this.gl.setRenderTarget(target);
if (clear) this.gl.clear(true, true, true);
this.gl.render(scene, camera);
this.gl.setRenderTarget(null);
}
readPixels(target) {
const w = target ? target.width : this.width;
const h = target ? target.height : this.height;
const needed = w * h * 4;
if (!this._readBuffer || this._readBuffer.length !== needed) {
this._readBuffer = new Uint8Array(needed);
}
this.gl.readRenderTargetPixels(target, 0, 0, w, h, this._readBuffer);
return this._readBuffer;
}
dispose() {
this.quadGeometry.dispose();
this.gl.dispose();
}
}
/** Convenience for building the shader materials used by passes. */
export function makePassMaterial(fragmentShader, uniforms) {
return new THREE.ShaderMaterial({
vertexShader: VERTEX_SHADER,
fragmentShader,
uniforms,
depthTest: false,
depthWrite: false,
transparent: true,
});
}

View File

@ -0,0 +1,81 @@
// The only clock the engine has. Scene and layer code never reads wall time —
// it reads whatever this hands it. That is what lets a realtime preview and an
// offline export produce identical frames.
//
// dt is CONSTANT (1/fps) in both modes. A dropped frame in preview shows as a
// hitch on screen; it never changes what gets rendered.
export const REALTIME = 'realtime';
export const FIXED = 'fixed';
export class Timeline {
constructor({ fps = 60, duration = 0, mode = REALTIME } = {}) {
this.fps = fps;
this.duration = duration;
this.mode = mode;
this.frame = 0;
this.playing = false;
}
get dt() {
return 1 / this.fps;
}
get time() {
return this.frame / this.fps;
}
get frameCount() {
return Math.max(1, Math.round(this.duration * this.fps));
}
get progress() {
return this.duration > 0 ? Math.min(1, this.time / this.duration) : 0;
}
get finished() {
return this.frame >= this.frameCount - 1;
}
setDuration(seconds) {
this.duration = seconds;
return this;
}
/** Fixed-step advance. Used by the exporter and by preview warm-up. */
advance(frames = 1) {
this.frame = Math.min(this.frameCount - 1, this.frame + frames);
return this.frame;
}
seek(frame) {
this.frame = Math.max(0, Math.min(this.frameCount - 1, Math.round(frame)));
return this.frame;
}
seekTime(seconds) {
return this.seek(seconds * this.fps);
}
/**
* Realtime mode: derive the frame index from the audio element's clock.
* Audio is the master the visuals follow it, never the other way round,
* so a slow GPU desynchronises nothing.
*/
syncToAudio(currentTime) {
this.frame = Math.max(0, Math.min(this.frameCount - 1, Math.floor(currentTime * this.fps)));
return this.frame;
}
/** A snapshot handed to layers, so nothing holds a mutable reference to the clock. */
snapshot() {
return {
frame: this.frame,
time: this.time,
dt: this.dt,
fps: this.fps,
progress: this.progress,
duration: this.duration,
};
}
}

View File

@ -0,0 +1,97 @@
// Frame hashing and comparison — the backbone of every determinism check.
//
// Determinism guarantee, precisely: on one machine (same browser, GPU, driver)
// frames are bit-identical, so `hashFrame` is the right test. ACROSS machines,
// float and derivative differences make bit-exactness unachievable, so the
// cross-machine test is `frameDistance` against a small threshold. Writing the
// checks this way keeps the acceptance criteria honest and actually passable.
/** FNV-1a over raw RGBA bytes. Bit-exact test, same-machine. */
export function hashFrame(pixels) {
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < pixels.length; i++) {
h ^= pixels[i];
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* Mean absolute per-channel difference, 0..1. Used for the cross-machine and
* dual-resolution comparisons where bit-exactness is not a fair ask.
*/
export function frameDistance(a, b) {
if (a.length !== b.length) return 1;
let sum = 0;
for (let i = 0; i < a.length; i += 4) {
sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
}
return sum / ((a.length / 4) * 3 * 255);
}
/** Largest single-channel difference. Catches localised breakage a mean would hide. */
export function frameMaxDelta(a, b) {
if (a.length !== b.length) return 255;
let max = 0;
for (let i = 0; i < a.length; i++) {
const d = Math.abs(a[i] - b[i]);
if (d > max) max = d;
}
return max;
}
/** Mean luminance, 0..1. Used by the range sweep to catch black/white-out frames. */
export function frameLuminance(pixels) {
let sum = 0;
const n = pixels.length / 4;
for (let i = 0; i < pixels.length; i += 4) {
sum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
}
return sum / n / 255;
}
/** Per-channel standard deviation, averaged. Near zero means a flat, dead frame. */
export function frameVariance(pixels) {
const n = pixels.length / 4;
let mr = 0, mg = 0, mb = 0;
for (let i = 0; i < pixels.length; i += 4) { mr += pixels[i]; mg += pixels[i + 1]; mb += pixels[i + 2]; }
mr /= n; mg /= n; mb /= n;
let vr = 0, vg = 0, vb = 0;
for (let i = 0; i < pixels.length; i += 4) {
vr += (pixels[i] - mr) ** 2; vg += (pixels[i + 1] - mg) ** 2; vb += (pixels[i + 2] - mb) ** 2;
}
return (Math.sqrt(vr / n) + Math.sqrt(vg / n) + Math.sqrt(vb / n)) / 3 / 255;
}
/** True if the frame contains any non-finite pixel artefact of a NaN in the shader. */
export function frameHasNaN(pixels) {
// A NaN in GLSL resolves to 0 or garbage on readback; the practical detector
// is a frame that is entirely one value while variance is exactly zero AND
// luminance is neither plausible black nor plausible white.
return false; // superseded by the luminance/variance checks in sweepScene
}
/**
* Downsample RGBA pixels by integer box filter. Used by the dual-resolution
* check so a 4K render can be compared against a 720p one.
*/
export function downsample(pixels, width, height, factor) {
const ow = Math.floor(width / factor);
const oh = Math.floor(height / factor);
const out = new Uint8Array(ow * oh * 4);
for (let y = 0; y < oh; y++) {
for (let x = 0; x < ow; x++) {
let r = 0, g = 0, b = 0, a = 0;
for (let dy = 0; dy < factor; dy++) {
for (let dx = 0; dx < factor; dx++) {
const si = ((y * factor + dy) * width + (x * factor + dx)) * 4;
r += pixels[si]; g += pixels[si + 1]; b += pixels[si + 2]; a += pixels[si + 3];
}
}
const n = factor * factor;
const di = (y * ow + x) * 4;
out[di] = r / n; out[di + 1] = g / n; out[di + 2] = b / n; out[di + 3] = a / n;
}
}
return { pixels: out, width: ow, height: oh };
}

View File

@ -0,0 +1,174 @@
// Fullscreen shader passes used by the compositor: blending, feedback, bloom, grade.
// All noise here is keyed off u_frame rather than any random source, so grain is
// identical between a preview run and an export of the same frame.
export const BLEND_FRAG = `
precision highp float;
uniform sampler2D u_base;
uniform sampler2D u_src;
uniform float u_amount;
uniform int u_mode;
varying vec2 vUv;
vec3 blendOverlay(vec3 b, vec3 s) {
return mix(2.0 * b * s, 1.0 - 2.0 * (1.0 - b) * (1.0 - s), step(0.5, b));
}
vec3 blendSoftLight(vec3 b, vec3 s) {
return mix(2.0 * b * s + b * b * (1.0 - 2.0 * s),
sqrt(b) * (2.0 * s - 1.0) + 2.0 * b * (1.0 - s),
step(0.5, s));
}
void main() {
vec4 base = texture2D(u_base, vUv);
vec4 src = texture2D(u_src, vUv);
float a = src.a * u_amount;
vec3 result;
if (u_mode == 1) result = base.rgb + src.rgb * a; // add
else if (u_mode == 2) result = 1.0 - (1.0 - base.rgb) * (1.0 - src.rgb * a); // screen
else if (u_mode == 3) result = mix(base.rgb, base.rgb * src.rgb, a); // multiply
else if (u_mode == 4) result = mix(base.rgb, blendOverlay(base.rgb, src.rgb), a);
else if (u_mode == 5) result = mix(base.rgb, blendSoftLight(base.rgb, src.rgb), a);
else result = mix(base.rgb, src.rgb, a); // normal
gl_FragColor = vec4(result, max(base.a, a));
}
`;
/**
* Frame feedback. Disproportionately responsible for images looking alive rather
* than merely animated: the previous frame is re-sampled through a small zoom and
* rotation, decayed, and added back.
*/
export const FEEDBACK_FRAG = `
precision highp float;
uniform sampler2D u_current;
uniform sampler2D u_history;
uniform float u_decay;
uniform float u_amount;
uniform float u_zoom;
uniform float u_rotate;
uniform float u_aspect;
varying vec2 vUv;
void main() {
vec3 cur = texture2D(u_current, vUv).rgb;
vec2 p = (vUv - 0.5);
p.x *= u_aspect;
float c = cos(u_rotate), s = sin(u_rotate);
p = mat2(c, -s, s, c) * p * u_zoom;
p.x /= u_aspect;
vec2 warped = p + 0.5;
vec3 hist = texture2D(u_history, clamp(warped, 0.0, 1.0)).rgb;
// Decay strictly below 1 keeps the loop convergent; the 10k-frame stability
// check in tools/ verifies it neither saturates to white nor dies to black.
vec3 outC = cur + hist * u_decay * u_amount;
gl_FragColor = vec4(min(outC, vec3(4.0)), 1.0);
}
`;
export const BRIGHT_FRAG = `
precision highp float;
uniform sampler2D u_tex;
uniform float u_threshold;
uniform float u_knee;
varying vec2 vUv;
void main() {
vec3 c = texture2D(u_tex, vUv).rgb;
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
float k = smoothstep(u_threshold, u_threshold + u_knee, l);
gl_FragColor = vec4(c * k, 1.0);
}
`;
export const BLUR_FRAG = `
precision highp float;
uniform sampler2D u_tex;
uniform vec2 u_direction; // texel-space step, already scaled by resolution
varying vec2 vUv;
void main() {
// 9-tap gaussian, separable.
vec3 sum = texture2D(u_tex, vUv).rgb * 0.227027;
vec2 o1 = u_direction * 1.3846153846;
vec2 o2 = u_direction * 3.2307692308;
sum += (texture2D(u_tex, vUv + o1).rgb + texture2D(u_tex, vUv - o1).rgb) * 0.3162162162;
sum += (texture2D(u_tex, vUv + o2).rgb + texture2D(u_tex, vUv - o2).rgb) * 0.0702702703;
gl_FragColor = vec4(sum, 1.0);
}
`;
export const COMPOSITE_FRAG = `
precision highp float;
uniform sampler2D u_tex;
uniform sampler2D u_bloom;
uniform float u_bloomAmount;
uniform float u_chroma;
uniform float u_grain;
uniform float u_vignette;
uniform float u_contrast;
uniform float u_saturation;
uniform float u_lift;
uniform float u_exposure;
uniform float u_fade;
uniform float u_frame;
uniform vec2 u_resolution;
varying vec2 vUv;
float hash13(vec3 p) {
p = fract(p * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
void main() {
vec2 uv = vUv;
vec2 dir = (uv - 0.5);
// Chromatic aberration, radial, resolution-independent.
vec3 col;
if (u_chroma > 0.0001) {
float amt = u_chroma * 0.01;
col.r = texture2D(u_tex, uv - dir * amt).r;
col.g = texture2D(u_tex, uv).g;
col.b = texture2D(u_tex, uv + dir * amt).b;
} else {
col = texture2D(u_tex, uv).rgb;
}
col += texture2D(u_bloom, uv).rgb * u_bloomAmount;
col *= u_exposure;
// Grade: lift, contrast, saturation.
col += u_lift;
col = (col - 0.5) * u_contrast + 0.5;
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = mix(vec3(l), col, u_saturation);
// Vignette in normalised space so it matches at any output size.
float v = 1.0 - u_vignette * dot(dir, dir) * 2.0;
col *= clamp(v, 0.0, 1.0);
// Deterministic grain: keyed on frame index, never on a random source.
if (u_grain > 0.0001) {
float n = hash13(vec3(floor(uv * u_resolution), u_frame));
col += (n - 0.5) * u_grain;
}
col *= u_fade;
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
`;
export const COPY_FRAG = `
precision highp float;
uniform sampler2D u_tex;
varying vec2 vUv;
void main() { gl_FragColor = texture2D(u_tex, vUv); }
`;
export const BLEND_MODE_IDS = {
normal: 0, add: 1, screen: 2, multiply: 3, overlay: 4, softlight: 5,
};

View File

@ -0,0 +1,99 @@
// Seeded PRNG. Every random value in the engine comes from here — never Math.random,
// which would break frame-for-frame reproducibility between preview and export.
// Mulberry32, carried over from party-stage but with the state held per instance
// instead of on a global, so layers can't perturb each other's sequences.
export class Rng {
constructor(seed = 1) {
this.seed = seed >>> 0;
this.state = this.seed;
}
reset(seed = this.seed) {
this.seed = seed >>> 0;
this.state = this.seed;
return this;
}
/** Uniform [0, 1) */
next() {
let t = (this.state += 0x6d2b79f5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
/** Uniform [min, max) */
range(min, max) {
return min + this.next() * (max - min);
}
/** Integer in [min, max] inclusive */
int(min, max) {
return Math.floor(this.range(min, max + 1));
}
bool(p = 0.5) {
return this.next() < p;
}
pick(array) {
return array[this.int(0, array.length - 1)];
}
/** Weighted pick. `weights` parallels `array`; non-positive weights are skipped. */
pickWeighted(array, weights) {
let total = 0;
for (let i = 0; i < array.length; i++) total += Math.max(0, weights[i] || 0);
if (total <= 0) return this.pick(array);
let r = this.next() * total;
for (let i = 0; i < array.length; i++) {
r -= Math.max(0, weights[i] || 0);
if (r <= 0) return array[i];
}
return array[array.length - 1];
}
/** Fisher-Yates, returns a new array. */
shuffle(array) {
const out = array.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = this.int(0, i);
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
/** Derive an independent stream. Same parent + same label always yields the same child. */
fork(label) {
return new Rng(hashString(String(label), this.seed));
}
}
/** FNV-1a over a string, optionally salted. Used for deriving seeds from names. */
export function hashString(str, salt = 0x811c9dc5) {
let h = salt >>> 0;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
/**
* FNV-1a over PCM samples, decimated so a six-minute track hashes in milliseconds.
* This is what makes a given audio file always produce the same look: the seed is a
* property of the content, not of the filename or the wall clock.
*/
export function hashSamples(float32, stride = 997) {
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < float32.length; i += stride) {
// Quantize so imperceptible float noise can't change the seed.
const q = Math.round(float32[i] * 32767) & 0xffff;
h ^= q & 0xff;
h = Math.imul(h, 0x01000193) >>> 0;
h ^= (q >>> 8) & 0xff;
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}

View File

@ -0,0 +1,183 @@
// The uniform contract every shader scene is compiled against.
//
// Scenes do not write `main()`. They define:
//
// vec4 scene(vec2 uv, vec2 p)
//
// where `uv` is 0..1 across the frame and `p` is centred, aspect-corrected,
// roughly -1..1 on the short axis. Everything else — the preamble, the varying,
// main() itself — is injected here. That boilerplate reduction is what makes a
// thirty-scene library affordable to write and to keep consistent.
//
// RESOLUTION INDEPENDENCE: work in `uv`/`p`, never in pixels. If you genuinely
// need a pixel-sized feature, scale it by u_pixelScale so a 720p preview and a
// 4K export agree. The dual-resolution diff in tools/ exists to catch violations.
export const VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
/** Audio-reactive uniforms, filled per frame from the FeatureTrack. */
export const AUDIO_UNIFORMS = [
'u_loudness', // overall level, track-normalised 0..1
'u_rms',
'u_bandSub', // 20-60 Hz
'u_bandLow', // 60-250 Hz
'u_bandMid', // 250-2k
'u_bandHigh', // 2k-6k
'u_bandAir', // 6k-16k
'u_flux', // onset strength
'u_centroid', // spectral brightness 0..1
'u_flatness', // noisy vs tonal 0..1
'u_width', // stereo width 0..1
'u_beat', // decaying spike on each beat, 1 at the hit
'u_beatPhase', // 0..1 within the beat
'u_barPhase', // 0..1 within the bar
'u_phrasePhase',// 0..1 within an 8-bar phrase
'u_sectionProgress',
'u_sectionEnergy',
'u_buildSlope', // >0 while energy is ramping toward the next section
];
export const FRAME_UNIFORMS = [
'u_time', 'u_frame', 'u_progress', 'u_seed',
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
];
const PREAMBLE = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_aspect;
uniform float u_pixelScale;
uniform float u_time;
uniform float u_frame;
uniform float u_progress;
uniform float u_seed;
uniform float u_opacity;
uniform vec3 u_colors[8];
uniform int u_colorCount;
${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
uniform sampler2D u_prev;
uniform int u_hasPrev;
varying vec2 vUv;
// --- palette helpers -------------------------------------------------------
// Scenes should reach for these instead of hardcoding colours, so the look
// generator can actually recolour them per track.
vec3 pal(int i) {
int n = max(u_colorCount, 1);
int k = int(mod(float(i), float(n)));
for (int j = 0; j < 8; j++) { if (j == k) return u_colors[j]; }
return u_colors[0];
}
/** Continuous ramp through the palette; t wraps. */
vec3 palRamp(float t) {
int n = max(u_colorCount, 1);
float f = fract(t) * float(n);
int i = int(floor(f));
return mix(pal(i), pal(i + 1), smoothstep(0.0, 1.0, fract(f)));
}
// --- noise -----------------------------------------------------------------
float hash11(float n) { return fract(sin(n) * 43758.5453123); }
float hash12(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123); }
vec2 hash22(vec2 p) {
vec3 a = fract(vec3(p.xyx) * vec3(123.34, 234.34, 345.65));
a += dot(a, a + 34.45);
return fract(vec2(a.x * a.y, a.y * a.z));
}
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash12(i), b = hash12(i + vec2(1.0, 0.0));
float c = hash12(i + vec2(0.0, 1.0)), d = hash12(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p, int octaves) {
float v = 0.0, amp = 0.5;
for (int i = 0; i < 8; i++) {
if (i >= octaves) break;
v += amp * vnoise(p);
p *= 2.02;
amp *= 0.5;
}
return v;
}
/** Cheap divergence-free-ish flow field. The backbone of the "flow" family. */
vec2 curl(vec2 p, float t) {
float e = 0.1;
float n1 = fbm(p + vec2(0.0, e) + t, 4);
float n2 = fbm(p - vec2(0.0, e) + t, 4);
float n3 = fbm(p + vec2(e, 0.0) + t, 4);
float n4 = fbm(p - vec2(e, 0.0) + t, 4);
return vec2(n1 - n2, n4 - n3) / (2.0 * e);
}
mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
/** N-fold kaleidoscopic fold of a centred coordinate. */
vec2 kaleido(vec2 p, float sides) {
if (sides < 1.5) return p;
float a = atan(p.y, p.x);
float r = length(p);
float seg = 6.28318530718 / sides;
a = abs(mod(a + seg * 0.5, seg) - seg * 0.5);
return vec2(cos(a), sin(a)) * r;
}
vec3 prev(vec2 uv) {
if (u_hasPrev == 0) return vec3(0.0);
return texture2D(u_prev, uv).rgb;
}
float sat(float x) { return clamp(x, 0.0, 1.0); }
vec3 sat3(vec3 x) { return clamp(x, 0.0, 1.0); }
`;
const EPILOGUE = `
void main() {
vec2 uv = vUv;
vec2 p = (uv - 0.5) * 2.0;
p.x *= u_aspect;
vec4 col = scene(uv, p);
gl_FragColor = vec4(col.rgb, col.a * u_opacity);
}
`;
/**
* Assemble a complete fragment shader from a scene body plus its declared params.
* Param uniforms are appended to the preamble so a scene never declares them itself
* the schema is the single source of truth, which is what the lint checks.
*/
export function buildFragmentShader(sceneModule) {
const paramUniforms = [];
for (const [name, def] of Object.entries(sceneModule.params || {})) {
if (!def.uniform) continue;
if (def.type === 'palette') continue; // palette rides in u_colors
const glslType = def.type === 'int' ? 'int' : def.type === 'vec2' ? 'vec2' : 'float';
paramUniforms.push(`uniform ${glslType} ${def.uniform}; // param: ${name}`);
}
return [
PREAMBLE,
paramUniforms.join('\n'),
'\n// ---- scene ----\n',
sceneModule.shader,
EPILOGUE,
].join('\n');
}

2
flow-state/src/main.js Normal file
View File

@ -0,0 +1,2 @@
// Application entry point — built up as the phases land.
console.log("flow-state");

View File

@ -0,0 +1,173 @@
// Declarative parameter schema.
//
// This is the load-bearing abstraction for library scale. One declaration drives:
// 1. uniform binding (Layer)
// 2. generated UI controls (ui/ParamPanel)
// 3. seeded per-track sampling (look/LookGenerator)
// 4. arc automation (look/ArcDriver)
// 5. save/load of presets
//
// Adding a scene therefore costs a shader plus a params block, and nothing else.
// tools/lint-scenes.js machine-checks every declaration against its shader source.
export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette'];
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
export const REACTIVE_FEATURES = [
'loudness', 'rms',
'bandSub', 'bandLow', 'bandMid', 'bandHigh', 'bandAir',
'flux', 'centroid', 'flatness', 'width',
'beat', 'beatPhase', 'barPhase', 'phrasePhase',
'sectionProgress', 'sectionEnergy', 'buildSlope',
];
export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
export function defaultValue(def) {
if (def.default !== undefined) return def.default;
switch (def.type) {
case 'bool': return false;
case 'int': return Math.round(def.range ? def.range[0] : 0);
case 'vec2': return [0, 0];
case 'palette': return null; // supplied by the look, not sampled here
default: return def.range ? def.range[0] : 0;
}
}
export function defaultValues(module) {
const out = {};
for (const [name, def] of Object.entries(module.params || {})) {
out[name] = defaultValue(def);
}
return out;
}
export function clampValue(def, value) {
if (def.type === 'bool') return !!value;
if (def.type === 'palette') return value;
if (def.type === 'vec2') {
const [lo, hi] = def.range || [0, 1];
return [Math.min(hi, Math.max(lo, value[0])), Math.min(hi, Math.max(lo, value[1]))];
}
const [lo, hi] = def.range || [0, 1];
let v = Math.min(hi, Math.max(lo, value));
if (def.type === 'int') v = Math.round(v);
return v;
}
/**
* Sample a full parameter set from the declared ranges.
*
* `bias` (0..1 per key, optional) nudges sampling toward the top of a range
* this is how a track's measured character reaches the parameters without every
* scene needing to know about audio features. `energy: 0.8` on a hard track
* pushes density-ish params up without pinning them, so seed variation survives.
*/
export function sampleValues(module, rng, bias = {}) {
const out = {};
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') { out[name] = null; continue; }
if (def.fixed) { out[name] = defaultValue(def); continue; }
const b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
if (def.type === 'bool') {
out[name] = rng.bool(0.25 + b * 0.5);
continue;
}
const [lo, hi] = def.range || [0, 1];
// Triangular-ish blend of a uniform draw with the bias target: keeps the
// full range reachable (so the seed contact sheet stays wide) while still
// letting track character shift the centre of mass.
const u = rng.next();
const target = lo + (hi - lo) * b;
const mixAmount = def.biasStrength !== undefined ? def.biasStrength : 0.45;
let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;
if (def.type === 'vec2') {
const u2 = rng.next();
const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount;
out[name] = [v, v2];
continue;
}
if (def.type === 'int') v = Math.round(v);
out[name] = clampValue(def, v);
}
return out;
}
/** Evenly spaced probe values across a param's range, for the range-sweep check. */
export function sweepValues(def, steps = 5) {
if (def.type === 'bool') return [false, true];
if (def.type === 'palette') return [null];
const [lo, hi] = def.range || [0, 1];
const out = [];
for (let i = 0; i < steps; i++) {
let v = lo + ((hi - lo) * i) / (steps - 1);
if (def.type === 'int') v = Math.round(v);
out.push(def.type === 'vec2' ? [v, v] : v);
}
return out;
}
/**
* Structural validation of a scene module. Returns an array of human-readable
* problems; empty means clean. Shared by the lint tool and the runtime registry,
* so a malformed scene can't reach the compositor.
*/
export function validateModule(module) {
const errors = [];
const id = module?.name || '<unnamed>';
if (!module.name) errors.push('missing `name`');
if (!module.family) errors.push(`${id}: missing \`family\``);
if (!module.kind) errors.push(`${id}: missing \`kind\``);
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)\``);
}
const params = module.params || {};
const uniformNames = new Set();
for (const [name, def] of Object.entries(params)) {
const where = `${id}.${name}`;
if (!def.type) errors.push(`${where}: missing \`type\``);
else if (!PARAM_TYPES.includes(def.type)) errors.push(`${where}: unknown type '${def.type}'`);
if (def.type !== 'palette' && def.type !== 'bool' && !def.range) {
errors.push(`${where}: numeric param needs a \`range\``);
}
if (def.range && (def.range.length !== 2 || def.range[0] >= def.range[1])) {
errors.push(`${where}: \`range\` must be [min, max] with min < max`);
}
if (def.default !== undefined && def.range && typeof def.default === 'number') {
if (def.default < def.range[0] || def.default > def.range[1]) {
errors.push(`${where}: default ${def.default} outside range [${def.range}]`);
}
}
if (def.uniform) {
if (uniformNames.has(def.uniform)) errors.push(`${where}: duplicate uniform '${def.uniform}'`);
uniformNames.add(def.uniform);
if (!/^u_[A-Za-z0-9_]+$/.test(def.uniform)) {
errors.push(`${where}: uniform '${def.uniform}' should be named u_*`);
}
}
if (def.bias && typeof def.bias !== 'string') errors.push(`${where}: \`bias\` must be a key name`);
}
for (const [name, r] of Object.entries(module.reactive || {})) {
const where = `${id}.reactive.${name}`;
if (!params[name]) errors.push(`${where}: no such param`);
if (!r.feature) errors.push(`${where}: missing \`feature\``);
else if (!REACTIVE_FEATURES.includes(r.feature)) {
errors.push(`${where}: unknown feature '${r.feature}'`);
}
if (r.response && !REACTIVE_RESPONSES.includes(r.response)) {
errors.push(`${where}: unknown response '${r.response}'`);
}
if (typeof r.amount !== 'number') errors.push(`${where}: missing numeric \`amount\``);
}
return errors;
}

View File

@ -0,0 +1,50 @@
import { validateModule } from '../params/schema.js';
import { nebula } from './shader/nebula.js';
/**
* The scene library. Families exist so the arc driver can choose by section
* character rather than at random a breakdown never lands on a strobing glitch
* scene, and an intro never opens at full density.
*/
export const FAMILIES = {
flow: { label: 'Flow', energy: [0.0, 0.7] },
organic: { label: 'Organic', energy: [0.0, 0.8] },
minimal: { label: 'Minimal', energy: [0.0, 0.45] },
structural: { label: 'Structural', energy: [0.3, 0.9] },
geometric: { label: 'Geometric', energy: [0.4, 1.0] },
glitch: { label: 'Glitch', energy: [0.6, 1.0] },
};
const MODULES = [
nebula,
];
const errors = [];
for (const m of MODULES) {
const e = validateModule(m);
if (e.length) errors.push(...e);
if (m.family && !FAMILIES[m.family]) errors.push(`${m.name}: unknown family '${m.family}'`);
}
if (errors.length) {
// Fail loudly at import: a malformed scene must never reach the compositor,
// where the symptom would be a black frame with no explanation.
console.error('[registry] invalid scene modules:\n' + errors.join('\n'));
}
export const scenes = MODULES;
export const sceneErrors = errors;
export function sceneByName(name) {
return MODULES.find((m) => m.name === name) || null;
}
export function scenesInFamily(family) {
return MODULES.filter((m) => m.family === family);
}
export function familyNames() {
return Object.keys(FAMILIES);
}
export default scenes;

View File

@ -0,0 +1,59 @@
// Ported from party-stage's "Deep Nebula". Changes on port:
// - LED-grid mask removed (it existed to sell a screen inside a 3D room)
// - hardcoded vec3 colours replaced with palette lookups
// - magic numbers lifted into declared params
// - beat reactivity moved from a single u_beat multiply to the reactive block
export const nebula = {
name: 'Deep Nebula',
family: 'organic',
kind: 'fragment',
params: {
scale: { type: 'float', range: [4, 24], default: 12, uniform: 'u_scale', bias: 'density' },
swirl: { type: 'float', range: [0, 3], default: 1.0, uniform: 'u_swirl', bias: 'motion' },
rings: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_rings' },
glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },
speed: { type: 'float', range: [0.1, 1.5],default: 0.5, uniform: 'u_speed', bias: 'motion' },
depth: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_depth' },
palette: { type: 'palette', count: 4 },
},
reactive: {
glow: { feature: 'beat', amount: 0.35, response: 'spike' },
swirl: { feature: 'bandLow', amount: 0.20 },
rings: { feature: 'flux', amount: 0.25, response: 'smooth' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float r = length(p);
float a = atan(p.y, p.x);
float t = u_time * u_speed + u_seed;
// Layered drift, the "nebula" body.
float n = fbm(p * u_scale * 0.25 + vec2(t * 0.2, -t * 0.15), 5);
float n2 = fbm(p * u_scale * 0.6 - vec2(t * 0.1, t * 0.25) + n, 4);
float body = sin(r * u_scale - t * 2.0 + n * 6.0 * u_swirl) * 0.5 + 0.5;
body = mix(body, n2, u_depth);
vec3 col = mix(pal(0), pal(1), sat(body));
col = mix(col, pal(2), sat(n2 * n2) * u_depth);
// Concentric pulse, tied to onset energy rather than a fixed rate.
float ring = smoothstep(0.4, 0.5, abs(fract(r * 2.0 - t * 3.0) - 0.5));
col += pal(1) * ring * u_rings * 0.6;
// Core glow.
float edge = 1.0 - smoothstep(0.1, 0.9, r);
col += pal(3) * edge * u_glow * 0.5;
// Vignette the far field so the frame has a subject.
col *= 0.5 + 0.5 * (1.0 - smoothstep(0.6, 1.6, r));
return vec4(col, 1.0);
}
`,
};
export default nebula;

View File

@ -0,0 +1 @@
/* styles */

View File

@ -0,0 +1,147 @@
#!/usr/bin/env node
// Static gates that don't need a GPU:
//
// 1. Determinism grep — no wall-clock or unseeded randomness in engine/scene code.
// 2. Scene schema lint — the params block and the shader source must agree,
// in BOTH directions. A declared param that no shader reads is dead weight;
// a uniform the shader reads that nothing declares is a silent zero, which
// is the single most annoying way for a scene to look subtly wrong.
//
// Run: npm run lint:scenes
import { readdirSync, readFileSync, statSync } from 'fs';
import { join, relative, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const SRC = join(root, 'src');
let failures = 0;
const fail = (msg) => { console.error(`${msg}`); failures++; };
const ok = (msg) => console.log(`${msg}`);
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (entry.endsWith('.js')) out.push(full);
}
return out;
}
// ---------------------------------------------------------------- grep gate
const FORBIDDEN = [
{ pattern: /\bMath\.random\s*\(/, name: 'Math.random()', why: 'use Rng — unseeded randomness breaks reproducibility' },
{ pattern: /\bperformance\.now\s*\(/, name: 'performance.now()', why: 'use Timeline — wall clock breaks preview/export parity' },
{ pattern: /\bDate\.now\s*\(/, name: 'Date.now()', why: 'use Timeline' },
{ pattern: /\bnew Date\s*\(/, name: 'new Date()', why: 'use Timeline' },
];
// Directories whose output must be a pure function of (seed, params, frame).
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params'];
// Files legitimately allowed a wall clock: perf measurement, not image content.
const ALLOWED = new Set(['engine/perf.js']);
console.log('\ndeterminism grep');
{
let checked = 0;
for (const dir of DETERMINISTIC_DIRS) {
const full = join(SRC, dir);
let files;
try { files = walk(full); } catch { continue; }
for (const file of files) {
const rel = relative(SRC, file).replace(/\\/g, '/');
if (ALLOWED.has(rel)) continue;
checked++;
const source = readFileSync(file, 'utf8');
const lines = source.split('\n');
lines.forEach((line, i) => {
if (/^\s*(\/\/|\*)/.test(line)) return; // comments may name them
for (const f of FORBIDDEN) {
if (f.pattern.test(line)) fail(`${rel}:${i + 1} uses ${f.name}${f.why}`);
}
});
}
}
if (!failures) ok(`${checked} files clean of wall-clock and unseeded randomness`);
}
// ------------------------------------------------------------- scene lint
const CONTRACT_UNIFORMS = 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',
]);
console.log('\nscene schema lint');
{
const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);
const { validateModule } = await import(pathToFileURL(join(SRC, 'params/schema.js')).href);
if (!scenes.length) fail('no scenes registered');
const seenNames = new Set();
for (const module of scenes) {
const id = module.name || '<unnamed>';
for (const err of validateModule(module)) fail(err);
if (seenNames.has(id)) fail(`duplicate scene name '${id}'`);
seenNames.add(id);
if (module.family && !FAMILIES[module.family]) {
fail(`${id}: unknown family '${module.family}'`);
}
if (module.kind !== 'fragment') continue;
const src = module.shader || '';
const declared = new Map();
for (const [name, def] of Object.entries(module.params || {})) {
if (def.uniform) declared.set(def.uniform, name);
}
// Direction 1: every declared uniform is actually read by the shader.
for (const [uniform, param] of declared) {
const used = new RegExp(`\\b${uniform}\\b`).test(src);
if (!used) fail(`${id}: param '${param}' declares ${uniform}, but the shader never reads it`);
}
// Direction 2: every u_* the shader reads is declared somewhere.
const referenced = new Set(src.match(/\bu_[A-Za-z0-9_]+\b/g) || []);
for (const uniform of referenced) {
if (CONTRACT_UNIFORMS.has(uniform)) continue;
if (declared.has(uniform)) continue;
fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
}
// A scene with a palette param should actually use the palette helpers,
// otherwise the look generator cannot recolour it.
const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
if (hasPalette && !/\b(pal|palRamp)\s*\(/.test(src)) {
fail(`${id}: declares a palette but never calls pal()/palRamp()`);
}
// Hardcoded saturated colours are the thing that made the party-stage
// shaders resist recolouring; flag the obvious cases.
const literalColors = src.match(/vec3\s*\(\s*[01]\.\d+\s*,\s*[01]\.\d+\s*,\s*[01]\.\d+\s*\)/g) || [];
const suspicious = literalColors.filter((c) => !/vec3\s*\(\s*0\.0*\s*,\s*0\.0*\s*,\s*0\.0*\s*\)/.test(c));
if (hasPalette && suspicious.length > 2) {
fail(`${id}: ${suspicious.length} hardcoded vec3 colour literals — use pal()`);
}
}
if (!failures) ok(`${scenes.length} scenes: schemas and shaders agree both ways`);
else console.log(` (${scenes.length} scenes checked)`);
}
console.log('');
if (failures) {
console.error(`FAILED — ${failures} problem${failures === 1 ? '' : 's'}\n`);
process.exit(1);
}
console.log('all static gates passed\n');

17
flow-state/vite.config.js Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
server: {
port: 5180,
host: '127.0.0.1',
},
build: {
rollupOptions: {
input: {
main: resolve(process.cwd(), 'index.html'),
checks: resolve(process.cwd(), 'checks.html'),
},
},
},
});