Visual automation in both of its scopes — exhibit-scope tracks measured from activation and system-scope tracks measured from an instance's own instantiation, so a track written against spawn-relative time behaves the same whether the spawn happens at four seconds or at four minutes — together with the spawned-system lifecycle, the camera, and the seven post-effects of 19.4. Automation on a missing target object now raises ERR_INVALID_REFERENCE rather than an unguarded TypeError, per 19.1. Post-effect channel math is clamped explicitly before assignment and the device blur radius is capped against a pixel budget, so a large projected radius cannot turn a legal exhibit into a frame-long stall. Trail fade tapers per segment from the item's own head opacity to the tail factor, which is what 18.8 describes; the previous flat alpha ignored the item's ramped opacity entirely. The canvas backend consumes the per-object buffer grants the engine allocates, rasterizing a buffered node once and compositing it once under its own blend and alpha, rather than compositing fill, stroke, glow, and filter separately and double-blending the overlap. Glow is drawn as a halo around the result rather than painted over it, and text now gets the glow branch it was silently missing. Traces 19.7.3 and 19.7.4 are implemented as specified rather than approximated: the two-spawn four-second offset assertion, and the four negative target cases that separate ERR_INVALID_REFERENCE from ERR_UNSUPPORTED_TARGET. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
136 lines
5.1 KiB
JavaScript
136 lines
5.1 KiB
JavaScript
import { ActionExecutor } from './actions.js';
|
|
import { SeededRNG } from './rng.js';
|
|
import { ResolutionEngine } from './resolution.js';
|
|
import { CadenceSubsystem } from './cadence.js';
|
|
|
|
export class CommonGrammarPerformance {
|
|
constructor(record, rootSeed, options = {}) {
|
|
this.record = record;
|
|
this.rootSeed = rootSeed;
|
|
this.rng = new SeededRNG(rootSeed);
|
|
this.diagnostics = options.diagnostics;
|
|
this.onUpdate = options.onUpdate ?? (() => {});
|
|
// Called once per animation frame, after any logical ticks. A frame draws
|
|
// the most recent tick's state and advances nothing (9.1, 18.2).
|
|
this.onFrame = options.onFrame ?? (() => {});
|
|
// Called once per logical tick, with the fixed step of 9.1. Procedural
|
|
// motion advances here and nowhere else.
|
|
this.onTick = options.onTick ?? (() => {});
|
|
this.engine = new ResolutionEngine(record.document, this.rng, {
|
|
diagnostics: this.diagnostics,
|
|
initialParameters: options.initialParameters,
|
|
onParameterChange: options.onParameterChange
|
|
});
|
|
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
|
|
this.cadence = new CadenceSubsystem({
|
|
document: record.document,
|
|
rng: this.rng,
|
|
resolution: this.engine,
|
|
audio: options.audio ?? null,
|
|
diagnostics: this.diagnostics
|
|
});
|
|
this.state = 'prepared';
|
|
this.resources = new Set();
|
|
this.frameRequest = null;
|
|
this.lastFrame = undefined;
|
|
this.accumulator = 0;
|
|
this.boundFrame = (now) => this.frame(now);
|
|
this.boundPointer = (event) => {
|
|
this.engine.signals.set('signals.pointer.x', event.clientX);
|
|
this.engine.signals.set('signals.pointer.y', event.clientY);
|
|
this.engine.invalidate();
|
|
};
|
|
this.boundResize = () => {
|
|
this.engine.signals.set('signals.viewport.width', globalThis.innerWidth ?? 0);
|
|
this.engine.signals.set('signals.viewport.height', globalThis.innerHeight ?? 0);
|
|
this.engine.invalidate();
|
|
};
|
|
}
|
|
|
|
async activate() {
|
|
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
|
|
this.state = 'active';
|
|
this.onUpdate(this.engine);
|
|
if (typeof globalThis.addEventListener === 'function') {
|
|
globalThis.addEventListener('pointermove', this.boundPointer, { passive: true });
|
|
globalThis.addEventListener('resize', this.boundResize, { passive: true });
|
|
}
|
|
if (typeof globalThis.requestAnimationFrame === 'function') this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
|
}
|
|
|
|
frame(now) {
|
|
if (this.state !== 'active') return;
|
|
if (this.lastFrame === undefined) this.lastFrame = now;
|
|
const observed = Math.max(0, now - this.lastFrame);
|
|
this.lastFrame = now;
|
|
const accepted = Math.min(observed, 250);
|
|
this.accumulator += accepted;
|
|
const step = 1000 / 60;
|
|
let ticks = 0;
|
|
while (this.accumulator + 1e-9 >= step && ticks < 8) {
|
|
this.actions.resetBudget();
|
|
this.engine.advance(step);
|
|
this.cadence.advance(step);
|
|
this.onTick(step, this.engine);
|
|
this.accumulator -= step;
|
|
ticks += 1;
|
|
}
|
|
if (observed > 250 || this.accumulator >= step) {
|
|
this.accumulator = Math.min(this.accumulator, step - 1e-9);
|
|
this.diagnostics?.warn('WARN_CLOCK_STALL', 'Discarded excess elapsed time to preserve the fixed-step work bound.', { exhibitId: this.record.id, section: 'scheduler' });
|
|
}
|
|
if (ticks > 0) this.onUpdate(this.engine);
|
|
this.onFrame(this.engine);
|
|
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
|
}
|
|
|
|
setAudio(audio) {
|
|
this.actions.audio = audio;
|
|
this.cadence.setAudio(audio);
|
|
}
|
|
|
|
setParameter(id, value) {
|
|
const result = this.engine.setParameter(id, value);
|
|
this.onUpdate(this.engine);
|
|
return result;
|
|
}
|
|
|
|
execute(actionArray, context) {
|
|
const result = this.actions.execute(actionArray, context);
|
|
this.onUpdate(this.engine);
|
|
return result;
|
|
}
|
|
|
|
releaseOverride(id) {
|
|
const released = this.engine.overrides.beginRelease(id);
|
|
this.engine.resolveAll();
|
|
this.onUpdate(this.engine);
|
|
return released;
|
|
}
|
|
|
|
async deactivate() {
|
|
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
|
|
this.frameRequest = null;
|
|
this.lastFrame = undefined;
|
|
if (typeof globalThis.removeEventListener === 'function') {
|
|
globalThis.removeEventListener('pointermove', this.boundPointer);
|
|
globalThis.removeEventListener('resize', this.boundResize);
|
|
}
|
|
if (this.state === 'active') this.state = 'inactive';
|
|
this.engine.overrides.clear();
|
|
this.resources.clear();
|
|
}
|
|
|
|
async dispose() {
|
|
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
|
|
this.frameRequest = null;
|
|
if (typeof globalThis.removeEventListener === 'function') {
|
|
globalThis.removeEventListener('pointermove', this.boundPointer);
|
|
globalThis.removeEventListener('resize', this.boundResize);
|
|
}
|
|
this.engine.dispose();
|
|
this.resources.clear();
|
|
this.state = 'disposed';
|
|
}
|
|
}
|