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.ownedLayers = []; 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 from specs. `specs` are { module, params, seed, opacity, * blend }. Layers built this way are owned by the Engine and disposed with * it; layers supplied directly by the arc driver are owned by the driver. */ setLayerSpecs(specs) { this.ownedLayers.forEach((l) => l.dispose()); 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; this.compositor.setLayers(layers); return layers; } setPalette(colors) { this.compositor.layers.forEach((l) => l.setPalette(colors)); } /** * Compile every shader and discard a warm frame, so the next frame rendered * is correct. Required before any frame-exact use (export, hashing). */ prime(frame = 0) { this.timeline.seek(frame); this.compositor.prime({ timeline: this.timeline, features: this.featuresAt(frame) }); return this; } /** 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, prime = true } = {}) { if (prime) this.prime(start); 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.ownedLayers.forEach((l) => l.dispose()); this.ownedLayers = []; this.compositor.dispose(); this.renderer.dispose(); } } export { FIXED, REALTIME };