feat(visual): implement the slice 4f execution, automation, and post-effects
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
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
# Slice 4f: automation, lifecycle, effects and ceilings
|
||||||
|
|
||||||
|
Implemented September 6, 2026. Production integration is in `visual-engine.js`, `visual-automation.js`, `visual-lifecycle.js`, `visual-effects.js`, `visual-subsystem.js`, the shared resolver and action executor.
|
||||||
|
|
||||||
|
Automation uses the audio interpolation implementation and adds visual repeat/ping-pong time folding. Public numeric visual targets enter the existing binding/automation/override/modulation/clamp stages. Internal graphic and procedural channels remain private. Tracks resolve once in scope order, including independent spawn scope sampling; behavior collisions reject at import. Viewport camera defaults continue to participate in the pipeline.
|
||||||
|
|
||||||
|
Spawn and remove actions use PRD 28's `target`, `with`, optional `lifetime`, and `instances.*` addressing. Instances enter the layer on their next tick, retain independent resource-owner and originating-scenario relationships, fade without rewriting authored opacity, and reclaim tracks and item state at disposal/failure. Instance, track and point budget refusal is atomic and does not consume an ordinal. Runtime failure is isolated to its system. Scenario orchestration remains Phase 6; its cleanup integration surface is `VisualEngine.cleanup(owner)`.
|
||||||
|
|
||||||
|
All seven effects execute against frame pixels in authored order. Grain has no access to seeded streams. Blur/bloom use a separable approximation and report it once per effect. Unavailable effects skip with a diagnostic. The specification's stray sentence exposing boolean `enabled` conflicted with the four-family numeric target table; it now explicitly follows that table and remains once-resolved.
|
||||||
|
|
||||||
|
Aggregate particle, emitter, trail, link and field budgets enforce the section 19.5 shedding rules. Field work is ordered nearest-first; particle/item eviction chooses the largest population and its oldest item. Existing buffer/backing-store enforcement and new effect pass and spawn/automation admission use centralized values. Diagnostic accounting counts logical ticks rather than repeated refusals in a tick.
|
||||||
|
|
||||||
|
`test/phase4-execution.test.mjs` adds twenty execution tests: curves/modes, loops, shared override release, sampling order, lifecycle edges, zero release, ownership/forced cleanup, atomic budgets, actions, known effect pixels and order, stream independence, unavailable effects, diagnostic recovery, population/history/link/field overloads, resolved count limits and failure isolation. Existing camera/projection and structural validation tests remain in the renderer/contract suites.
|
||||||
|
|
||||||
|
No hardware performance result is claimed. Ceiling values and frame-time governance thresholds remain provisional until 4h. Post-effect pixel checks establish operation, not perceptual equivalence on a real display. No live audio acceptance or full scenario execution is claimed.
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
All **223 checks pass** across fourteen test files, including the twelve GC2 fixture checks and 109 Phase 4 tests. The sandbox blocks Node's default child-process test isolation (`spawn EPERM`), so each file was run separately with `node --test --test-isolation=none`. Running GC2 in the same process as other suites is insufficient because its legacy runner exits the process; the separate invocations avoid silently skipping later tests.
|
||||||
|
|
||||||
|
Production and acceptance build determinism and bundled JavaScript syntax pass; `git diff --check` is clean. Generated artifact SHA-256 values:
|
||||||
|
|
||||||
|
- `XZBT.html`: `46619d8ff2fbadf188136409d5cfe658bf5492ba0ba111d58ad488e95b343770`
|
||||||
|
- `prototypes/phase4/XZBT-visual-acceptance.html`: `4298121998e7532f0824139c48ccab6b8c022ccf6a8079cf3672cded4bc6db74`
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 4 visual challenge
|
||||||
|
|
||||||
|
Open `XZBT-visual-acceptance.html` directly in Chrome. It embeds the production visual runtime and all fourteen PRD 130 challenge fixtures plus the first visuals for Exhibits A–D; no server or network is needed.
|
||||||
|
|
||||||
|
Select a composition, let it run, pause or restart its seed, and record whether the named visual is convincingly expressed. Challenge 10 can be spawned repeatedly. Challenge 12 has a **synthetic audio signal** slider; Exhibit D has a state slider. These exercise real binding paths but do not establish live audio or full reference-exhibit acceptance. Text labels remain static under the 0.1 contract.
|
||||||
|
|
||||||
|
Export observations with browser, display, hardware details and any artifacts you see. Visual judgment remains user-performed. This page is not the frozen GC6 benchmark; slice 4h still requires its 30-second warm-up and 120-second measurement on recorded reference hardware.
|
||||||
|
|
||||||
|
Rebuild using `npm run build:visual-acceptance`. Individual JSON exhibits are written to `exhibits/visual-challenges` and `exhibits/exhibit-{a,b,c,d}.xzbt` and can also be imported into `XZBT.html`.
|
||||||
File diff suppressed because one or more lines are too long
@@ -33,6 +33,10 @@ export class XZBTApplication {
|
|||||||
onUpdate: (engine) => {
|
onUpdate: (engine) => {
|
||||||
if (this.activation.current?.record.id === record.id) this.renderValues(engine);
|
if (this.activation.current?.record.id === record.id) this.renderValues(engine);
|
||||||
},
|
},
|
||||||
|
onTick: (step) => {
|
||||||
|
if (this.activation.current?.record.id !== record.id) return;
|
||||||
|
this.visual.advance(step);
|
||||||
|
},
|
||||||
// The renderer runs at display rate; the simulation stays on the fixed
|
// The renderer runs at display rate; the simulation stays on the fixed
|
||||||
// logical tick (9.1), so a frame draws the most recent tick's state.
|
// logical tick (9.1), so a frame draws the most recent tick's state.
|
||||||
onFrame: (engine) => {
|
onFrame: (engine) => {
|
||||||
@@ -179,6 +183,7 @@ export class XZBTApplication {
|
|||||||
rng: current.performance.rng
|
rng: current.performance.rng
|
||||||
});
|
});
|
||||||
canvas.hidden = engine === null;
|
canvas.hidden = engine === null;
|
||||||
|
current.performance.actions.visual = engine;
|
||||||
if (engine) this.visual.render({ logicalMilliseconds: current.performance.engine.logicalMilliseconds });
|
if (engine) this.visual.render({ logicalMilliseconds: current.performance.engine.logicalMilliseconds });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
canvas.hidden = true;
|
canvas.hidden = true;
|
||||||
@@ -244,11 +249,14 @@ export class XZBTApplication {
|
|||||||
this.audio.setMasterVolume(Number(element('master-volume').value));
|
this.audio.setMasterVolume(Number(element('master-volume').value));
|
||||||
}
|
}
|
||||||
await this.audio.unlock();
|
await this.audio.unlock();
|
||||||
|
current.performance.setAudio(this.audio);
|
||||||
this.renderAudio();
|
this.renderAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
async disposeAudio() {
|
async disposeAudio() {
|
||||||
if (!this.audio) return;
|
if (!this.audio) return;
|
||||||
|
const current = this.activation.current;
|
||||||
|
if (current) current.performance.setAudio(null);
|
||||||
await this.audio.dispose();
|
await this.audio.dispose();
|
||||||
this.audio = null;
|
this.audio = null;
|
||||||
this.renderAudio();
|
this.renderAudio();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ActionExecutor } from './actions.js';
|
import { ActionExecutor } from './actions.js';
|
||||||
import { SeededRNG } from './rng.js';
|
import { SeededRNG } from './rng.js';
|
||||||
import { ResolutionEngine } from './resolution.js';
|
import { ResolutionEngine } from './resolution.js';
|
||||||
|
import { CadenceSubsystem } from './cadence.js';
|
||||||
|
|
||||||
export class CommonGrammarPerformance {
|
export class CommonGrammarPerformance {
|
||||||
constructor(record, rootSeed, options = {}) {
|
constructor(record, rootSeed, options = {}) {
|
||||||
@@ -12,12 +13,22 @@ export class CommonGrammarPerformance {
|
|||||||
// Called once per animation frame, after any logical ticks. A frame draws
|
// 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).
|
// the most recent tick's state and advances nothing (9.1, 18.2).
|
||||||
this.onFrame = options.onFrame ?? (() => {});
|
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, {
|
this.engine = new ResolutionEngine(record.document, this.rng, {
|
||||||
diagnostics: this.diagnostics,
|
diagnostics: this.diagnostics,
|
||||||
initialParameters: options.initialParameters,
|
initialParameters: options.initialParameters,
|
||||||
onParameterChange: options.onParameterChange
|
onParameterChange: options.onParameterChange
|
||||||
});
|
});
|
||||||
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
|
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.state = 'prepared';
|
||||||
this.resources = new Set();
|
this.resources = new Set();
|
||||||
this.frameRequest = null;
|
this.frameRequest = null;
|
||||||
@@ -57,7 +68,10 @@ export class CommonGrammarPerformance {
|
|||||||
const step = 1000 / 60;
|
const step = 1000 / 60;
|
||||||
let ticks = 0;
|
let ticks = 0;
|
||||||
while (this.accumulator + 1e-9 >= step && ticks < 8) {
|
while (this.accumulator + 1e-9 >= step && ticks < 8) {
|
||||||
|
this.actions.resetBudget();
|
||||||
this.engine.advance(step);
|
this.engine.advance(step);
|
||||||
|
this.cadence.advance(step);
|
||||||
|
this.onTick(step, this.engine);
|
||||||
this.accumulator -= step;
|
this.accumulator -= step;
|
||||||
ticks += 1;
|
ticks += 1;
|
||||||
}
|
}
|
||||||
@@ -70,6 +84,11 @@ export class CommonGrammarPerformance {
|
|||||||
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAudio(audio) {
|
||||||
|
this.actions.audio = audio;
|
||||||
|
this.cadence.setAudio(audio);
|
||||||
|
}
|
||||||
|
|
||||||
setParameter(id, value) {
|
setParameter(id, value) {
|
||||||
const result = this.engine.setParameter(id, value);
|
const result = this.engine.setParameter(id, value);
|
||||||
this.onUpdate(this.engine);
|
this.onUpdate(this.engine);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { RuntimeFault } from './types.js';
|
||||||
|
import { sampleAutomationTrack, automationValueAt, applyAutomationMode, resolveNumericStages } from './audio-automation.js';
|
||||||
|
import { visualGetPath, visualSetPath } from './visual-motion.js';
|
||||||
|
|
||||||
|
export function visualLocalTracks(definitions, root, evaluate, warn) {
|
||||||
|
return (definitions ?? []).map(definition => {
|
||||||
|
const track = sampleAutomationTrack(definition, evaluate, warn);
|
||||||
|
let object = root, property = definition.target;
|
||||||
|
if (Array.isArray(root)) {
|
||||||
|
const parts = property.split('.');
|
||||||
|
const first = parts.shift();
|
||||||
|
object = root.find(node => node.key === first);
|
||||||
|
if (!object) {
|
||||||
|
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${first}' not found.`);
|
||||||
|
}
|
||||||
|
while (object?.children?.some(node => node.key === parts[0])) {
|
||||||
|
const child = parts.shift();
|
||||||
|
object = object.children.find(node => node.key === child);
|
||||||
|
}
|
||||||
|
if (!object) {
|
||||||
|
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${definition.target}' not found.`);
|
||||||
|
}
|
||||||
|
property = parts.join('.');
|
||||||
|
object = object.baseRaw;
|
||||||
|
}
|
||||||
|
const authored = visualGetPath(object, property);
|
||||||
|
const base = authored ?? (/opacity|scale|zoom/.test(property) ? 1 : 0);
|
||||||
|
return { track, object, property, base };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceVisualTracks(tracks, milliseconds) {
|
||||||
|
for (const { track, object, property, base } of tracks ?? []) {
|
||||||
|
const value = resolveNumericStages(base, { automation: lower => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) });
|
||||||
|
visualSetPath(object, property, property === 'drag' ? Math.max(0, Math.min(1, value)) : property === 'rate' ? Math.max(0, value) : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Frame-only post processing. No scene or procedural random stream is accessible here.
|
||||||
|
import { parseColor } from './visual-math.js';
|
||||||
|
|
||||||
|
function visualBoxBlur(source, width, height, radius) {
|
||||||
|
const maxDeviceRadius = Math.min(Math.max(width, height), 128);
|
||||||
|
const r = Math.min(maxDeviceRadius, Math.max(0, Math.round(radius)));
|
||||||
|
if (!r) return new Uint8ClampedArray(source);
|
||||||
|
const horizontal = new Float32Array(source.length), output = new Uint8ClampedArray(source.length);
|
||||||
|
// Sliding windows keep work linear in pixel count even for large projected radii.
|
||||||
|
for (let axis = 0; axis < 2; axis += 1) {
|
||||||
|
const input = axis ? horizontal : source, target = axis ? output : horizontal;
|
||||||
|
const major = axis ? width : height, minor = axis ? height : width;
|
||||||
|
const offset = (a, b, c) => ((axis ? b * width + a : a * width + b) * 4 + c);
|
||||||
|
for (let a = 0; a < major; a += 1) for (let c = 0; c < 4; c += 1) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = -r; k <= r; k += 1) sum += input[offset(a, Math.max(0, Math.min(minor - 1, k)), c)];
|
||||||
|
for (let b = 0; b < minor; b += 1) {
|
||||||
|
target[offset(a, b, c)] = sum / (2 * r + 1);
|
||||||
|
sum += input[offset(a, Math.min(minor - 1, b + r + 1), c)] - input[offset(a, Math.max(0, b - r), c)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyVisualEffect(pixels, width, height, effect, seconds = 0) {
|
||||||
|
const output = new Uint8ClampedArray(pixels);
|
||||||
|
const color = parseColor(effect.color ?? '#000000');
|
||||||
|
if (effect.type === 'blur') return visualBoxBlur(pixels, width, height, effect.deviceRadius ?? effect.radius ?? 4);
|
||||||
|
if (effect.type === 'bloom') {
|
||||||
|
const bright = new Uint8ClampedArray(pixels.length);
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
const luminance = (pixels[i] * 0.2126 + pixels[i + 1] * 0.7152 + pixels[i + 2] * 0.0722) / 255;
|
||||||
|
const contribution = luminance > (effect.threshold ?? 0.7) ? 1 : 0;
|
||||||
|
for (let c = 0; c < 3; c += 1) bright[i + c] = pixels[i + c] * contribution;
|
||||||
|
bright[i + 3] = pixels[i + 3];
|
||||||
|
}
|
||||||
|
const glow = visualBoxBlur(bright, width, height, effect.deviceRadius ?? effect.radius ?? 8);
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, pixels[i + c] + glow[i + c] * (effect.intensity ?? 0.6)));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
const theta = (effect.hueRotate ?? 0) * Math.PI / 180, cs = Math.cos(theta), sn = Math.sin(theta);
|
||||||
|
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||||
|
const i = (y * width + x) * 4;
|
||||||
|
let amount = effect.amount ?? 0, target = [color.r, color.g, color.b];
|
||||||
|
switch (effect.type) {
|
||||||
|
case 'fade': break;
|
||||||
|
case 'vignette': {
|
||||||
|
const d = Math.hypot((x + 0.5 - width / 2) / (width / 2), (y + 0.5 - height / 2) / (height / 2)) / Math.SQRT2;
|
||||||
|
const edge = Math.max(0.000001, effect.softness ?? 0.5);
|
||||||
|
const u = Math.max(0, Math.min(1, (d - (effect.radius ?? 0.75)) / edge));
|
||||||
|
amount = (effect.amount ?? 0.5) * u * u * (3 - 2 * u); break;
|
||||||
|
}
|
||||||
|
case 'scanlines': {
|
||||||
|
const phase = ((y / (effect.spacing ?? 3) + seconds * (effect.speed ?? 0)) % 1 + 1) % 1;
|
||||||
|
amount = phase < (effect.thickness ?? 0.5) ? (effect.amount ?? 0.3) : 0;
|
||||||
|
target = [0, 0, 0]; break;
|
||||||
|
}
|
||||||
|
case 'grain': {
|
||||||
|
const cell = effect.scale ?? 1;
|
||||||
|
let hash = Math.imul(Math.floor(x / cell) + 1, 374761393) ^ Math.imul(Math.floor(y / cell) + 1, 668265263) ^ Math.imul(Math.floor(seconds * (effect.speed ?? 24)), 1274126177);
|
||||||
|
hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
|
||||||
|
const noise = ((hash ^ (hash >>> 16)) >>> 0) / 4294967296 - 0.5;
|
||||||
|
for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] + noise * 255 * (effect.amount ?? 0.15);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 'color-adjust': {
|
||||||
|
const [r, g, b] = [pixels[i], pixels[i + 1], pixels[i + 2]].map(v => v / 255);
|
||||||
|
const hue = [
|
||||||
|
(0.213 + cs * 0.787 - sn * 0.213) * r + (0.715 - cs * 0.715 - sn * 0.715) * g + (0.072 - cs * 0.072 + sn * 0.928) * b,
|
||||||
|
(0.213 - cs * 0.213 + sn * 0.143) * r + (0.715 + cs * 0.285 + sn * 0.140) * g + (0.072 - cs * 0.072 - sn * 0.283) * b,
|
||||||
|
(0.213 - cs * 0.213 - sn * 0.787) * r + (0.715 - cs * 0.715 + sn * 0.715) * g + (0.072 + cs * 0.928 + sn * 0.072) * b
|
||||||
|
];
|
||||||
|
const l = hue[0] * 0.2126 + hue[1] * 0.7152 + hue[2] * 0.0722;
|
||||||
|
for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, (((l + (hue[c] - l) * (effect.saturation ?? 1)) * (effect.brightness ?? 1) - 0.5) * (effect.contrast ?? 1) + 0.5) * 255));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
default: throw new Error(`Unavailable visual effect '${effect.type}'.`);
|
||||||
|
}
|
||||||
|
for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] * (1 - amount) + target[c] * amount;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderVisualEffects(context, plan, warn = () => {}) {
|
||||||
|
for (const effect of plan.effects ?? []) {
|
||||||
|
try {
|
||||||
|
const frame = context.getImageData(0, 0, plan.backing.width, plan.backing.height);
|
||||||
|
frame.data.set(applyVisualEffect(frame.data, frame.width, frame.height, effect, plan.logicalMilliseconds / 1000));
|
||||||
|
context.putImageData(frame, 0, 0);
|
||||||
|
if (['blur', 'bloom'].includes(effect.type)) warn(effect, 'Separable blur approximation.');
|
||||||
|
} catch { warn(effect, 'Effect unavailable on this rendering surface; skipped.'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { VisualEngine } from '../src/runtime/visual-engine.js';
|
||||||
|
import { ResolutionEngine } from '../src/runtime/resolution.js';
|
||||||
|
import { SeededRNG } from '../src/runtime/rng.js';
|
||||||
|
import { ActionExecutor } from '../src/runtime/actions.js';
|
||||||
|
import { VisualDiagnosticCadence } from '../src/runtime/visual-diagnostics.js';
|
||||||
|
import { VisualInstance } from '../src/runtime/visual-lifecycle.js';
|
||||||
|
import { applyVisualEffect, renderVisualEffects } from '../src/runtime/visual-effects.js';
|
||||||
|
import { validateExhibit } from '../src/runtime/validator.js';
|
||||||
|
|
||||||
|
const point = { type: 'point', position: { x: 10, y: 20 }, style: { fill: '#ffffff', pointSize: 3 } };
|
||||||
|
const track = (target, a = 0, b = 1, extra = {}) => ({ target, points: [{ at: '0ms', value: a }, { at: '1s', value: b }], ...extra });
|
||||||
|
function setup(visuals, extra = {}) {
|
||||||
|
const document = { xzbt: '0.1', meta: { id: 'execution-test', name: 'Execution test' }, runtime: { seed: 42 },
|
||||||
|
visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, ...visuals }, ...extra };
|
||||||
|
const rng = new SeededRNG(42), resolution = new ResolutionEngine(document, rng);
|
||||||
|
const engine = new VisualEngine(document, { rng, resolution });
|
||||||
|
const tick = ms => { resolution.advance(ms); engine.advance(ms); };
|
||||||
|
return { document, engine, resolution, rng, tick, frame: () => engine.planFrame({ width: 100, height: 100 }) };
|
||||||
|
}
|
||||||
|
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-8, `${actual} != ${expected}`);
|
||||||
|
|
||||||
|
test('19.7.1 shared curves and modes drive actual graphic geometry', () => {
|
||||||
|
for (const interpolation of ['step', 'linear', 'smooth', 'exponential']) for (const mode of ['absolute', 'offset', 'scale']) {
|
||||||
|
const run = setup({ systems: { s: { type: 'graphic', content: { p: point }, automation: [track('p.position.x', 2, 8, { interpolation, mode })] } } });
|
||||||
|
run.tick(500);
|
||||||
|
const value = interpolation === 'step' ? 2 : interpolation === 'exponential' ? 4 : 5;
|
||||||
|
close(run.frame().layers[0].nodes[0].center[0], mode === 'offset' ? 10 + value : mode === 'scale' ? 10 * value : value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.2 repeat and finite ping-pong loops hold the correct endpoint', () => {
|
||||||
|
for (const mode of ['repeat', 'ping-pong']) {
|
||||||
|
const run = setup({ camera: { zoom: 1 }, automation: [track('camera.zoom', 1, 3, { loop: { mode, count: 2 } })], systems: {} });
|
||||||
|
run.tick(1500); close(run.frame().camera.zoom, 2);
|
||||||
|
run.tick(3500); close(run.frame().camera.zoom, mode === 'repeat' ? 3 : 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4e rendered graphic motion, nested repeater motion, and morph reach geometry', () => {
|
||||||
|
const run = setup({ systems: {
|
||||||
|
s: { type: 'graphic', content: {
|
||||||
|
p: { ...point, behaviors: [{ type: 'drift', velocity: { x: 10, y: 0 } }] },
|
||||||
|
a: { type: 'polyline', points: [{ x: 0, y: 0 }, { x: 10, y: 0 }], behaviors: [{ type: 'morph', to: 'b', duration: '1s' }] },
|
||||||
|
b: { type: 'polyline', visible: false, points: [{ x: 0, y: 10 }, { x: 20, y: 10 }] }
|
||||||
|
} },
|
||||||
|
r: { type: 'repeater', count: 1, repeat: { ...point, behaviors: [{ type: 'drift', velocity: { x: 20, y: 0 } }] } }
|
||||||
|
} });
|
||||||
|
run.tick(500);
|
||||||
|
const nodes = run.frame().layers[0].nodes;
|
||||||
|
close(nodes[0].center[0], 15);
|
||||||
|
close(nodes[1].subpaths[0].start[1], 5);
|
||||||
|
close(nodes[2].children[0].center[0], 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.4 behavior and automation collision is rejected at import', () => {
|
||||||
|
const run = setup({ systems: {} });
|
||||||
|
run.document.visuals.systems.s = { type: 'graphic', content: { p: { ...point, behaviors: [{ type: 'drift', velocity: { x: 1 } }] } }, automation: [track('p.position.x')] };
|
||||||
|
assert.ok(validateExhibit(run.document).errors.some(error => error.code === 'ERR_AUTOMATION_CONFLICT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.4 cross-scope, undeclared, and out-of-registry targets raise documented errors', () => {
|
||||||
|
const doc = visuals => ({ xzbt: '0.1', meta: { id: 't', name: 'T' }, runtime: { seed: 42 }, visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, ...visuals } });
|
||||||
|
|
||||||
|
// Cross-scope target from exhibit to system: ERR_INVALID_REFERENCE
|
||||||
|
const r1 = doc({ systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('systems.s.p.position.x')] });
|
||||||
|
assert.ok(validateExhibit(r1).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
|
||||||
|
|
||||||
|
// Cross-scope target from system to camera: ERR_INVALID_REFERENCE
|
||||||
|
const r2 = doc({ systems: { s: { type: 'graphic', content: { p: point }, automation: [track('camera.zoom')] } } });
|
||||||
|
assert.ok(validateExhibit(r2).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
|
||||||
|
|
||||||
|
// Undeclared layer in exhibit scope: ERR_INVALID_REFERENCE
|
||||||
|
const r3 = doc({ layers: { main: {} }, systems: { s: { type: 'graphic', layer: 'main', content: { p: point } } }, automation: [track('layers.missing.opacity')] });
|
||||||
|
assert.ok(validateExhibit(r3).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
|
||||||
|
|
||||||
|
// Out-of-range effect index: ERR_INVALID_REFERENCE
|
||||||
|
const r4 = doc({ effects: [{ type: 'blur', radius: 4 }], systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('effects[5].radius')] });
|
||||||
|
assert.ok(validateExhibit(r4).errors.some(e => e.code === 'ERR_INVALID_REFERENCE'));
|
||||||
|
|
||||||
|
// Out-of-registry target (scene.background is a color, not automatable): ERR_UNSUPPORTED_TARGET
|
||||||
|
const r5 = doc({ systems: { s: { type: 'graphic', content: { p: point } } }, automation: [track('scene.background')] });
|
||||||
|
assert.ok(validateExhibit(r5).errors.some(e => e.code === 'ERR_UNSUPPORTED_TARGET'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.3 system-scope track at is measured from instantiation (two spawns offset by 4s)', () => {
|
||||||
|
const visuals = {
|
||||||
|
systems: {
|
||||||
|
s: {
|
||||||
|
type: 'graphic',
|
||||||
|
lifecycle: 'spawned',
|
||||||
|
content: { p: { ...point, position: { x: 0, y: 0 } } },
|
||||||
|
automation: [track('p.position.x', 0, 100)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const run = setup(visuals);
|
||||||
|
const a = run.engine.spawn('s');
|
||||||
|
run.tick(4000);
|
||||||
|
const b = run.engine.spawn('s');
|
||||||
|
// At t=4000ms, b has age 0. Advance 500ms further (halfway through the 1s track).
|
||||||
|
run.tick(500);
|
||||||
|
// b's age is 500ms, so its position.x should be 50.
|
||||||
|
close(b.system.objects[0].position.x, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.7 override masks moving visual automation and releases to its current value', () => {
|
||||||
|
const run = setup({ automation: [track('camera.zoom', 1, 3)], systems: {} });
|
||||||
|
const actions = new ActionExecutor(run.resolution);
|
||||||
|
const [result] = actions.execute([{ type: 'override', target: 'visuals.camera.zoom', value: 5, scope: 'duration', duration: '10s', release: '0ms' }]);
|
||||||
|
run.tick(500); close(run.frame().camera.zoom, 5);
|
||||||
|
run.resolution.overrides.beginRelease(result.instanceId); run.tick(100);
|
||||||
|
close(run.frame().camera.zoom, 2.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.8-10 spawned templates activate next tick, fade, dispose and reproduce', () => {
|
||||||
|
const visuals = { systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { release: '1s' }, content: { p: { ...point, position: { x: { random: { min: 0, max: 100 } }, y: 20 }, style: { opacity: 0.4 } } } } } };
|
||||||
|
const first = setup(visuals), second = setup(visuals);
|
||||||
|
const a = first.engine.spawn('s'), b = second.engine.spawn('s');
|
||||||
|
assert.equal(first.frame().layers[0].nodes.length, 0);
|
||||||
|
first.tick(10); second.tick(10);
|
||||||
|
assert.deepEqual(first.frame().layers, second.frame().layers);
|
||||||
|
first.engine.remove(a.id); first.tick(500);
|
||||||
|
close(first.frame().layers[0].nodes[0].alpha, 0.2);
|
||||||
|
first.tick(500); assert.equal(a.state, 'DISPOSED');
|
||||||
|
first.engine.remove(a.id); assert.equal(first.engine.instances.size, 0);
|
||||||
|
assert.equal(b.state, 'ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.9 every lifecycle edge is enforced, zero release lasts a tick', () => {
|
||||||
|
const edges = { CREATED: ['ACTIVE', 'FINISHED', 'FAILED'], ACTIVE: ['RELEASING', 'FINISHED', 'FAILED'], RELEASING: ['FINISHED', 'FAILED'], FINISHED: ['DISPOSED'], DISPOSED: [], FAILED: [] };
|
||||||
|
for (const [from, allowed] of Object.entries(edges)) for (const to of Object.keys(edges)) {
|
||||||
|
const instance = new VisualInstance({ system: { objects: [] } }); instance.state = from;
|
||||||
|
if (allowed.includes(to)) assert.doesNotThrow(() => instance.transition(to));
|
||||||
|
else assert.throws(() => instance.transition(to));
|
||||||
|
}
|
||||||
|
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', content: { p: point } } } });
|
||||||
|
const instance = run.engine.spawn('s'); run.tick(10); run.engine.remove(instance.id);
|
||||||
|
assert.equal(instance.state, 'RELEASING'); assert.equal(instance.factor, 0);
|
||||||
|
run.tick(10); assert.equal(instance.state, 'DISPOSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.11 ownership, originating scenario and forced cleanup are separate', () => {
|
||||||
|
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { ownership: 'persistent', cancelWithScenario: false, release: '10s' }, content: { p: point } } } });
|
||||||
|
const inherited = run.engine.spawn('s', { owner: 'scenario' });
|
||||||
|
const persistent = run.engine.spawn('s', { owner: 'scenario', ownership: 'persistent' });
|
||||||
|
run.tick(10); run.engine.cleanup('scenario');
|
||||||
|
assert.equal(inherited.state, 'RELEASING'); assert.equal(persistent.state, 'ACTIVE');
|
||||||
|
run.tick(5100); assert.equal(inherited.state, 'DISPOSED');
|
||||||
|
assert.ok(run.engine.cadence.raised.some(w => w.code === 'WARN_CLEANUP_FORCED'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.6/12 live spawn and automation budgets refuse atomically without consuming ordinal', () => {
|
||||||
|
for (const manyTracks of [false, true]) {
|
||||||
|
const content = Object.fromEntries(Array.from({ length: 3 }, (_, i) => [`p${i}`, point]));
|
||||||
|
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', content, automation: manyTracks ? [0, 1, 2].map(i => track(`p${i}.position.x`)) : [] } } });
|
||||||
|
const count = manyTracks ? 42 : 64;
|
||||||
|
const instances = Array.from({ length: count }, () => run.engine.spawn('s'));
|
||||||
|
assert.ok(instances.every(Boolean));
|
||||||
|
assert.equal(run.engine.spawn('s'), null); assert.equal(run.engine.spawn('s'), null);
|
||||||
|
assert.equal(run.engine.spawnOrdinals.get('s'), count);
|
||||||
|
assert.equal(run.engine.cadence.raised.length, 1);
|
||||||
|
run.engine.remove(instances[0].id); run.tick(10);
|
||||||
|
assert.equal(run.engine.spawn('s').id, `instances.s#${count}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spawn/remove actions use PRD target/with shape and instance addresses', () => {
|
||||||
|
const run = setup({ systems: { s: { type: 'graphic', lifecycle: 'spawned', spawn: { inputs: { x: { type: 'number', default: 1 } } }, content: { p: { ...point, position: { x: { ref: 'inputs.x' }, y: 20 } } } } } });
|
||||||
|
const actions = new ActionExecutor(run.resolution); actions.visual = run.engine;
|
||||||
|
const [result] = actions.execute([{ type: 'spawn', target: 'visuals.systems.s', with: { x: 77 } }]);
|
||||||
|
run.tick(10); close(run.frame().layers[0].nodes[0].center[0], 77);
|
||||||
|
actions.execute([{ type: 'remove', target: result.instanceId }]); run.tick(10);
|
||||||
|
assert.equal(run.frame().layers[0].nodes.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.15 each effect changes known pixels, preserves alpha and respects order', () => {
|
||||||
|
const source = new Uint8ClampedArray([255, 255, 255, 255, 60, 40, 20, 255, 0, 0, 0, 255]);
|
||||||
|
for (const effect of [
|
||||||
|
{ type: 'fade', amount: 0.5 }, { type: 'vignette', radius: 0, amount: 1, softness: 0.1 },
|
||||||
|
{ type: 'scanlines', amount: 1 }, { type: 'grain', amount: 1 },
|
||||||
|
{ type: 'color-adjust', brightness: 0.5 }, { type: 'blur', radius: 1 }, { type: 'bloom', radius: 1, intensity: 1 }
|
||||||
|
]) {
|
||||||
|
const actual = applyVisualEffect(source, 3, 1, effect);
|
||||||
|
assert.notDeepEqual(actual, source, effect.type);
|
||||||
|
assert.equal(actual[3], 255);
|
||||||
|
}
|
||||||
|
const bloom = data => applyVisualEffect(data, 3, 1, { type: 'bloom', radius: 1 });
|
||||||
|
const adjust = data => applyVisualEffect(data, 3, 1, { type: 'color-adjust', brightness: 0.4 });
|
||||||
|
assert.notDeepEqual(bloom(adjust(source)), adjust(bloom(source)));
|
||||||
|
assert.deepEqual([...applyVisualEffect(new Uint8ClampedArray([0, 0, 0, 255]), 1, 1, { type: 'fade', color: '#804020', amount: 1 })], [128, 64, 32, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.16-17 effects do not consume procedural randomness and unavailable passes warn', () => {
|
||||||
|
const run = setup({ effects: [{ type: 'grain', enabled: false }, { type: 'blur', radius: 2 }], systems: {} });
|
||||||
|
const before = run.rng.stream('visual', 'later').nextFloat();
|
||||||
|
for (let i = 0; i < 10; i++) run.frame();
|
||||||
|
const reference = new SeededRNG(42); close(before, reference.stream('visual', 'later').nextFloat());
|
||||||
|
close(run.rng.stream('visual', 'later').nextFloat(), reference.stream('visual', 'later').nextFloat());
|
||||||
|
assert.equal(run.frame().effects.length, 1);
|
||||||
|
renderVisualEffects({}, run.frame(), (effect, message) => run.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message));
|
||||||
|
renderVisualEffects({}, run.frame(), (effect, message) => run.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message));
|
||||||
|
assert.equal(run.engine.cadence.raised.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.18 diagnostic cadence counts ticks, not refusals, and recovers promptly', () => {
|
||||||
|
const cadence = new VisualDiagnosticCadence();
|
||||||
|
for (let tick = 0; tick <= 120; tick++) {
|
||||||
|
for (let i = 0; i < 4; i++) cadence.shed('WARN_VISUAL_CEILING', 'test', 'test', tick * 1000 / 60);
|
||||||
|
cadence.endTick();
|
||||||
|
}
|
||||||
|
assert.equal(cadence.keys.get('WARN_VISUAL_CEILING test').consecutive, 121);
|
||||||
|
assert.ok(cadence.raised.at(-1).sustained);
|
||||||
|
cadence.endTick(); cadence.shed('WARN_VISUAL_CEILING', 'test', 'test', 2010);
|
||||||
|
assert.equal(cadence.raised.at(-1).at, 2010);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.18 particle overload evicts oldest from largest populations; declared systems survive', () => {
|
||||||
|
const run = setup({ systems: Object.fromEntries([0, 1, 2].map(i => [`s${i}`, { type: 'particles', capacity: 4096, count: 4096, render: { type: 'point' } }])) });
|
||||||
|
assert.equal(run.engine.systems.length, 3);
|
||||||
|
assert.equal(run.engine.systems.reduce((sum, system) => sum + system.procedural.items.length, 0), 8192);
|
||||||
|
assert.ok(run.engine.systems.every(system => system.procedural.items[0].ordinal > 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.18 emitted items, trail histories, and link tails obey aggregate ceilings', () => {
|
||||||
|
const run = setup({ systems: Object.fromEntries([0, 1, 2, 3, 4].map(i => [`s${i}`, { type: 'emitter', capacity: 512, count: 512, emit: { type: 'point' } }])) });
|
||||||
|
const systems = run.engine.systems.map(system => system.procedural);
|
||||||
|
assert.equal(systems.reduce((n, system) => n + system.items.length, 0), 2048);
|
||||||
|
for (const system of systems) {
|
||||||
|
for (const item of system.items) item.trail = Array.from({ length: 17 }, (_, i) => ({ t: i, x: 0, y: 0 }));
|
||||||
|
system.linkPairs = () => Array.from({ length: 1024 }, (_, ordinal) => ({ ordinal }));
|
||||||
|
}
|
||||||
|
run.engine.enforcePopulations();
|
||||||
|
assert.equal(systems.reduce((n, system) => n + system.items.reduce((m, item) => m + item.trail.length, 0), 0), 32768);
|
||||||
|
assert.equal(run.engine.systems.reduce((n, system) => n + system.links.length, 0), 4096);
|
||||||
|
assert.equal(run.engine.systems.at(-1).links.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.18 fields evaluate nearest items first and remaining forces become zero', () => {
|
||||||
|
const run = setup({
|
||||||
|
fields: Object.fromEntries([0, 1, 2, 3].map(i => [`f${i}`, { type: 'directional', strength: 1, direction: 0 }])),
|
||||||
|
systems: { far: { type: 'particles', count: 4096, capacity: 4096, z: 100, fields: ['f0', 'f1', 'f2', 'f3'], render: { type: 'point' }, behaviors: [{ type: 'field-follow', field: 'f0' }] },
|
||||||
|
near: { type: 'particles', count: 4096, capacity: 4096, z: 0, fields: ['f0', 'f1', 'f2', 'f3'], render: { type: 'point' }, behaviors: [{ type: 'field-follow', field: 'f0' }] } }
|
||||||
|
});
|
||||||
|
run.tick(1000 / 60);
|
||||||
|
assert.equal(run.engine.fieldSet.evaluations, 32768);
|
||||||
|
assert.equal(run.engine.fieldSet.refused, true);
|
||||||
|
assert.ok(run.engine.systems[1].procedural.items.every(item => item.vx > 0));
|
||||||
|
assert.equal(run.engine.systems[0].procedural.items.at(-1).vx, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.19 resolved counts cannot enlarge pools or repeater limits', () => {
|
||||||
|
assert.throws(() => setup({ systems: { s: { type: 'repeater', count: { random: { min: 1025, max: 1026 } }, repeat: point } } }), error => error.code === 'ERR_VISUAL_LIMIT_EXCEEDED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime failure reclaims a spawned instance without stopping another system', () => {
|
||||||
|
const run = setup({ systems: { s: { type: 'particles', lifecycle: 'spawned', count: 1, render: point }, healthy: { type: 'graphic', content: { p: point } } } });
|
||||||
|
const instance = run.engine.spawn('s');
|
||||||
|
instance.system.procedural.advance = () => { throw new Error('injected failure'); };
|
||||||
|
assert.doesNotThrow(() => run.tick(10));
|
||||||
|
assert.equal(instance.state, 'FAILED'); assert.equal(instance.system.procedural.items.length, 0);
|
||||||
|
assert.equal(run.engine.instances.size, 0); assert.equal(run.frame().layers[0].nodes.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewport default camera center still participates in automation and bindings', () => {
|
||||||
|
const run = setup({ scene: { coordinateSpace: 'viewport' }, automation: [track('camera.x', 0, 100)], systems: {} });
|
||||||
|
run.tick(250); close(run.frame().camera.x, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('19.7.3 exhibit automation samples point values in one scope stream', () => {
|
||||||
|
const value = { random: { min: 1, max: 2 } };
|
||||||
|
const run = setup({ automation: [track('camera.zoom', value, value), track('camera.rotation', value, value)], systems: {} });
|
||||||
|
const stream = new SeededRNG(42).stream('visual', 'automation');
|
||||||
|
const expected = Array.from({ length: 4 }, () => 1 + stream.nextFloat());
|
||||||
|
assert.deepEqual([...run.resolution.automation.values()].flatMap(track => track.points.map(point => point.value)), expected);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { bundleRuntime } from './build-xzbt.mjs';
|
||||||
|
import { visualChallenges, visualExhibits } from './visual-challenge-fixtures.mjs';
|
||||||
|
import { validateExhibit } from '../src/runtime/validator.js';
|
||||||
|
|
||||||
|
export function buildVisualAcceptance() {
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const fixtures = [...visualChallenges, ...visualExhibits];
|
||||||
|
mkdirSync(resolve(root, 'exhibits/visual-challenges'), { recursive: true });
|
||||||
|
mkdirSync(resolve(root, 'prototypes/phase4'), { recursive: true });
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
const result = validateExhibit(fixture.document);
|
||||||
|
if (!result.valid) throw new Error(JSON.stringify(result.errors));
|
||||||
|
writeFileSync(resolve(root, fixture.challenge ? 'exhibits/visual-challenges' : 'exhibits', `${fixture.id}.xzbt`), JSON.stringify(fixture.document, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
const data = JSON.stringify(fixtures).replace(/</g, '\\u003c');
|
||||||
|
const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>XZBT Visual Challenge</title><style>
|
||||||
|
body{margin:0;background:#101820;color:#e5eee9;font:15px system-ui}header{padding:20px 28px;border-bottom:1px solid #33464b}h1{font-size:22px;margin:0 0 12px}select,button{font:inherit;padding:8px;margin-right:8px;background:#24373c;color:inherit;border:1px solid #607572;border-radius:5px}main{padding:24px}canvas{display:block;width:100%;height:min(70vh,800px);background:#000}p{color:#a9bfba}label{margin-right:16px}output{font-variant-numeric:tabular-nums}#notes{white-space:pre-wrap;font:12px monospace}</style>
|
||||||
|
<header><h1>Visual Challenge / XZBT 0.1</h1><select id="fixture"></select><button id="restart">Restart seed</button><button id="pause">Pause</button><button id="burst">Spawn burst</button><button id="export">Export observation</button></header>
|
||||||
|
<main><canvas id="canvas"></canvas><p id="description"></p><label>Test audio signal <input id="energy" type="range" min="0" max="1" step="0.01" value="0.6"></label><label>Display state <input id="level" type="range" min="0" max="1" step="0.01" value="0.7"></label><output id="time"></output><p>This page supplies synthetic audio signals for reproducible visual checks. It does not establish live audio acceptance or benchmark results. Judge the composition on your display and record observations below.</p><textarea id="observation" rows="3" cols="80" placeholder="Visual observations, fixture ID, display, browser and hardware"></textarea><pre id="notes"></pre></main>
|
||||||
|
<script>${bundleRuntime(false)}
|
||||||
|
const acceptanceFixtures = ${data};
|
||||||
|
const acceptanceSelect = document.getElementById('fixture');
|
||||||
|
for (const fixture of acceptanceFixtures) { const option = document.createElement('option'); option.value = fixture.id; option.textContent = (fixture.challenge ? fixture.challenge + '. ' : '') + fixture.document.meta.name; acceptanceSelect.append(option); }
|
||||||
|
let acceptanceResolution, acceptanceRng, acceptanceVisual, acceptanceLast, acceptanceAccumulator = 0, acceptancePaused = false, acceptanceWarnings = [];
|
||||||
|
function acceptanceStart() {
|
||||||
|
acceptanceVisual?.deactivate(); acceptanceResolution?.dispose();
|
||||||
|
const fixture = acceptanceFixtures.find(f => f.id === acceptanceSelect.value);
|
||||||
|
acceptanceWarnings = []; acceptanceRng = new SeededRNG(fixture.document.runtime.seed);
|
||||||
|
acceptanceResolution = new ResolutionEngine(fixture.document, acceptanceRng);
|
||||||
|
acceptanceVisual = new VisualSubsystem({ diagnostics: { warn: (code, message) => { acceptanceWarnings.push({ code, message }); document.getElementById('notes').textContent = acceptanceWarnings.map(w => w.code + ': ' + w.message).join('\\n'); } } });
|
||||||
|
acceptanceVisual.attach(document.getElementById('canvas')); acceptanceVisual.activate(fixture.document, { resolution: acceptanceResolution, rng: acceptanceRng });
|
||||||
|
if (fixture.id === 'transient-impact') acceptanceVisual.engine.spawn('burst');
|
||||||
|
document.getElementById('description').textContent = fixture.document.meta.description;
|
||||||
|
document.getElementById('burst').disabled = !fixture.document.visuals.systems.burst;
|
||||||
|
document.getElementById('notes').textContent = ''; acceptanceLast = undefined; acceptanceAccumulator = 0;
|
||||||
|
}
|
||||||
|
function acceptanceFrame(now) {
|
||||||
|
const elapsed = acceptanceLast === undefined ? 0 : Math.min(100, now - acceptanceLast); acceptanceLast = now;
|
||||||
|
if (!acceptancePaused && !document.hidden) {
|
||||||
|
acceptanceAccumulator += elapsed;
|
||||||
|
while (acceptanceAccumulator >= 1000 / 60) {
|
||||||
|
const energy = Number(document.getElementById('energy').value);
|
||||||
|
acceptanceResolution.signals.set('signals.audio.energy', energy); acceptanceResolution.signals.set('signals.audio.low', energy);
|
||||||
|
if (acceptanceResolution.document.state?.level) acceptanceResolution.setState('state.level', Number(document.getElementById('level').value));
|
||||||
|
acceptanceResolution.advance(1000 / 60); acceptanceVisual.advance(1000 / 60); acceptanceAccumulator -= 1000 / 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acceptanceVisual.render({ logicalMilliseconds: acceptanceResolution.logicalMilliseconds });
|
||||||
|
document.getElementById('time').textContent = (acceptanceResolution.logicalMilliseconds / 1000).toFixed(1) + ' s';
|
||||||
|
requestAnimationFrame(acceptanceFrame);
|
||||||
|
}
|
||||||
|
acceptanceSelect.onchange = acceptanceStart; document.getElementById('restart').onclick = acceptanceStart;
|
||||||
|
document.getElementById('pause').onclick = event => { acceptancePaused = !acceptancePaused; event.target.textContent = acceptancePaused ? 'Resume' : 'Pause'; };
|
||||||
|
document.getElementById('burst').onclick = () => acceptanceVisual.engine.spawn('burst');
|
||||||
|
document.addEventListener('visibilitychange', () => { acceptanceLast = undefined; acceptanceAccumulator = 0; });
|
||||||
|
document.getElementById('export').onclick = () => {
|
||||||
|
const report = { fixture: acceptanceSelect.value, seed: acceptanceRng.rootSeed, browser: navigator.userAgent, display: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio }, logicalMilliseconds: acceptanceResolution.logicalMilliseconds, warnings: acceptanceWarnings, observation: document.getElementById('observation').value, acceptance: 'user observation only; no automatic visual pass', exportedAt: new Date().toISOString() };
|
||||||
|
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = 'xzbt-visual-observation.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
};
|
||||||
|
acceptanceStart(); requestAnimationFrame(acceptanceFrame);
|
||||||
|
</script></html>`;
|
||||||
|
const path = resolve(root, 'prototypes/phase4/XZBT-visual-acceptance.html');
|
||||||
|
writeFileSync(path, html); return { path, count: fixtures.length, html };
|
||||||
|
}
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const result = buildVisualAcceptance(); console.log(`Built ${result.path}; ${result.count} fixtures.`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user