@cyberart-io/engine 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/audio.md CHANGED
@@ -8,6 +8,7 @@ Back to the [package README](../README.md). Related: [presentation cue](presenta
8
8
 
9
9
  ```bash
10
10
  pnpm exec vitest run packages/engine/src/canvas/cyb-67-audio.repro.spec.ts
11
+ pnpm exec vitest run packages/engine/src/canvas/cyb-102-audio-sidecar-restore.repro.spec.ts
11
12
  ```
12
13
 
13
14
  ## Why this exists
@@ -136,6 +137,29 @@ adapter.destroy();
136
137
 
137
138
  Replay two runs with the same seed/tape and compare `events`. Do not compare speakers, meters, or decoded PCM — those are not deterministic across machines or browsers.
138
139
 
140
+ ## Snapshot schema (version 1)
141
+
142
+ `AudioCueTimeline.snapshot()` / `createHeadlessAudioAdapter().snapshot()` write a versioned sidecar. `schemaVersion` is `1` (`AUDIO_CUE_SNAPSHOT_SCHEMA_VERSION`). Restore validates the **entire** payload before mutation. Unknown schema, unknown cue idempotency keys, or malformed JSON return `{ ok: false, errors }` and leave live state unchanged.
143
+
144
+ ```ts
145
+ const snap = adapter.snapshot();
146
+ adapter.play({ /* another cue */ });
147
+ const restored = adapter.restore(JSON.parse(JSON.stringify(snap)));
148
+ if (!restored.ok) throw new Error(restored.errors.map((e) => e.detail).join('; '));
149
+ ```
150
+
151
+ | Field | Meaning |
152
+ |---|---|
153
+ | `schemaVersion` | `1`. Any other value fails closed. |
154
+ | `frame` | Presentation clock at save. |
155
+ | `reducedSensory` | Skip-playback flag. Restored onto the timeline. |
156
+ | `cues` | Live cue views (`scheduled` / `started` / `skipped`; completed and failed rows are event-log only). |
157
+ | `events` | Deterministic cue event log. |
158
+ | `muted` | Headless adapter only. Restored via `broker.mute()` / `unmute()`. |
159
+ | `unlock` | Headless adapter only. **Observation.** Unlock cannot be faithfully restored (it is async and may require a user gesture). Restore applies cue / mute / event log only; `snapshot().unlock` after reload is the live broker status. |
160
+
161
+ `parseAudioCueSnapshot(input)` is the shared validator. Browser (`@cyberart-io/engine`) and headless (`@cyberart-io/engine/headless`) share the same semantic contract.
162
+
139
163
  ## Host wiring
140
164
 
141
165
  ```ts
@@ -76,7 +76,7 @@ The host owns overlay markup, reducer state, and event type names. The harness o
76
76
  | `viewport.width` / `height` / `deviceScaleFactor` | `320` / `180` / `1` | CSS viewport and DPR. |
77
77
  | `reducedMotion` | `false` | Stubs `matchMedia('(prefers-reduced-motion: reduce)')`. |
78
78
  | `inputModality` | `'pointer'` | Recorded on the root (`data-input-modality`). |
79
- | `geometry` | none | Optional geometry document for `placeRegion` / CSS hit-testing. |
79
+ | `geometry` | none | Optional geometry document for `placeRegion` / CSS hit-testing. Replace later with `setGeometry`. |
80
80
  | `contentWidth` / `contentHeight` / `fit` | viewport / `contain` | Intrinsic box for `createPresentationLayout`. |
81
81
  | `mount` | none | Host builds DOM and returns carts / compositor / cleanup. |
82
82
 
@@ -86,6 +86,7 @@ The host owns overlay markup, reducer state, and event type names. The harness o
86
86
  |---|---|
87
87
  | `goto()` | No-op ready check (fixture is already mounted). |
88
88
  | `setViewport(width, height, dpr?)` | Resize root, group canvases, compositor; call host `relayout`. |
89
+ | `setGeometry(document?)` | Replace the geometry document used by `placeRegion` and CSS hit-testing; call host `relayout`. Pointer hit-tests only regions in the active document (semantic hits whose ids are not in that document are ignored). |
89
90
  | `click(selector)` / `click(x, y)` | Dispatch `pointerdown` / `pointerup` / `click` on DOM. |
90
91
  | `key` / `focus` | Keyboard through the focused element. |
91
92
  | `step` / `advance` | Deterministic group clock. |
@@ -8,6 +8,10 @@ Related: [Browser harness](browser-harness.md), [Compositor](compositor.md), [Vi
8
8
 
9
9
  ```bash
10
10
  pnpm exec vitest run packages/engine/src/canvas/cyb-82-production-scenario.repro.spec.ts
11
+ pnpm exec vitest run packages/engine/src/canvas/cyb-101-host-audio-observation.repro.spec.ts
12
+ pnpm exec vitest run packages/engine/src/canvas/cyb-102-audio-sidecar-restore.repro.spec.ts
13
+ pnpm exec vitest run packages/engine/src/canvas/cyb-103-reduced-motion-propagate.repro.spec.ts
14
+ pnpm exec vitest run packages/engine/src/canvas/cyb-104-dynamic-host-geometry.repro.spec.ts
11
15
  ```
12
16
 
13
17
  ## When to use
@@ -17,7 +21,7 @@ pnpm exec vitest run packages/engine/src/canvas/cyb-82-production-scenario.repro
17
21
  | Production host | `defineProductionScenario` / `createProductionScenarioRunner` from `@cyberart-io/engine` | Prove the shipped composition, not a second runtime. Persist `save()` envelopes in hostState. |
18
22
  | Vitest / jsdom | same, or `@cyberart-io/engine/headless` | `installHeadlessCanvas()` first. CI and local must pass the **same** scenario definition object. |
19
23
 
20
- This module composes existing harnesses. It does not reimplement Canvas2D, routing, or playback. Audio is the headless adapter: invocation and provenance only, never physical speakers.
24
+ This module composes existing harnesses. It does not reimplement Canvas2D, routing, or playback. Sequence audio is the headless adapter: invocation and provenance only, never physical speakers. Hosts that already own a production audio controller pass it as `hostAudio` instead of duplicating the beat as a presentation sequence.
21
25
 
22
26
  Generic fixture ids only: `whistle`, `ripple`, `room`, `npc`.
23
27
 
@@ -25,11 +29,11 @@ Generic fixture ids only: `whistle`, `ripple`, `room`, `npc`.
25
29
 
26
30
  1. **Definition.** `schemaVersion` `1`, stable `id`, `seed`, `required.participants`, `required.layers`. JSON only. Callbacks and incomplete steps (a `click` without `selector` or `x`/`y`, a `key` without `key`) fail closed at define time. Invalid sequence or binding manifests throw when the runner is created, never silently dropped.
27
31
  2. **Required composition.** After mount, every required participant and compositor layer must be present. Omitting the cart under test is a failure, not a skipped assertion. Missing participant localizes to `routing`; missing layer localizes to `pixels`.
28
- 3. **Input.** Pointer and keyboard go through the browser harness (DOM coordinates and a11y ids), then the host reducer, then the group router.
29
- 4. **Observation.** Evidence records reducer accept/reject, routed envelopes, selected bindings, cue sequences, composed pixels, captions, audio invocation, focus, and cleanup.
30
- 5. **Save / reload / replay.** `save()` writes a snapshot envelope. `reload` restores host + presentation sidecars. `replay` re-runs the inspector tape on a fresh composition and compares traces.
31
- 6. **Matrix.** Desktop/mobile viewport, DPR, reduced-motion, mute, failed-asset ids, and audio fallback are fields on the same definition. Not every cell needs a unique golden.
32
- 7. **Evidence bundle.** `ProductionScenarioEvidenceBundle` carries pixel/semantic/a11y/trace/snapshot/asset hashes, revisions, traces, screenshot metadata, and `firstBrokenBoundary` localization: input → reducer → routing → binding → cue → pixels/caption/audio → save-replay. The optional `selectionTrace` field holds a [selection trace](selection-trace.md) export (`attachSelectionTrace(bundle, exported)`); the runner never populates it itself.
32
+ 3. **Input.** Pointer and keyboard go through the browser harness (DOM coordinates and a11y ids), then the host reducer, then the group router. After an accepted reduce, and on reload, the runner syncs the active input surface from `host.projectInputSurface(state)` when provided: visible/focusable controls and the `GeometryDocument` update atomically. Controls the projection omits are removed from the DOM (not clickable, focusable, or hit-testable). Construction-time `geometry` / region ids remain the fallback when the host does not project. Missing, overlapping/duplicate, or stale geometry (a control with no matching region) fails closed at boundary `input` with `invalid-geometry`, `ambiguous-geometry`, or `stale-geometry`. Evidence records `observation.activeControlIds`. The seam is host-generic: it does not encode product room names.
33
+ 4. **Observation.** Evidence records reducer accept/reject, routed envelopes, selected bindings, cue sequences, composed pixels, captions, audio invocation, focus, and cleanup. Caption and audio evidence come from the presentation-sequence player **or** an optional host-owned audio observer (`hostAudio` on runner options)—not only from a test sequence.
34
+ 5. **Save / reload / replay.** `save()` writes a snapshot envelope. `hostState.audio` is the versioned headless-adapter sidecar (`schemaVersion` `1`; see [Audio](audio.md)). `reload` restores host, then visual / semantic / bindings / sequences, then **audio last** so a sequence reapply cannot double-invoke cues. `applyPresentation()` is not run on reload. The runner does call `syncInputSurface()` so restored `hostState` rebuilds the active controls and geometry without playing sequences. Unlock status is observation-only and is not restored. Tampered or unknown audio sidecars fail closed: live audio (and the rest of the composition) stay unchanged, `observation.audioRestoreOk` is `false`, and `firstBrokenBoundary` is `audio` with code `invalid-audio-sidecar`. Omitting `hostState.audio` declares audio non-restorable (`audioRestoreOk` false, no localization). `replay` re-runs the inspector tape on a fresh composition and compares traces.
35
+ 6. **Matrix.** Desktop/mobile viewport, DPR, reduced-motion, mute, failed-asset ids, and audio fallback are fields on the same definition. Not every cell needs a unique golden. Construction-time `matrix.reducedMotion` still seeds browser, visual, sequence, and audio policy.
36
+ 7. **Evidence bundle.** `ProductionScenarioEvidenceBundle` carries pixel/semantic/a11y/trace/snapshot/asset hashes, revisions, traces, screenshot metadata, and `firstBrokenBoundary` localization: input → reducer → routing → binding → cue → pixels/caption/audio → save-replay. `traces.audio` is the sequence/headless adapter cue log. `traces.hostAudio` is host-owned observer evidence (invocation, completion, failure, skip, fallback, captions, envelope identity). `observation.audioInvoked` / `audioFailed` / `captions` merge both sources. `observation.audioRestoreOk` reports whether reload restored the audio sidecar. `observation.activeControlIds` is the projected input surface. A runtime `reducedMotion` step records `observation.reducedMotion` and `observation.reducedMotionPropagation` (from/to, per-participant applied/reason, active sequence policy). The optional `selectionTrace` field holds a [selection trace](selection-trace.md) export (`attachSelectionTrace(bundle, exported)`); the runner never populates it itself.
33
37
  8. **CI = local.** `run(steps)` executes the definition’s steps (or a shared step list). Do not fork scenario JSON for CI.
34
38
 
35
39
  ```ts
@@ -79,3 +83,85 @@ runner.destroy();
79
83
  | `production.scenario.diagnostic.lifecycle` | Machine-readable rejection detail (`code`, `boundary`); emitted alongside every `state.failed` |
80
84
 
81
85
  Contracts: `productionScenarioEventContracts()`.
86
+
87
+ ## Host-owned audio observation
88
+
89
+ A production host may already own an audio/presentation controller outside Cyberart sequences. Pass that controller (or a thin snapshot wrapper) as `hostAudio` on `createProductionScenarioRunner`. The runner does not construct a second sequence for the same beat and does not play speakers.
90
+
91
+ The adapter is generic. It reports `invoked` / `completed` / `failed` / `skipped` / `fallback` plus optional caption text and envelope identity (`id`, `correlationId`, `causationId`). It must not encode product-specific dialogue fields.
92
+
93
+ ```ts
94
+ const hostAudio = {
95
+ snapshot() {
96
+ return {
97
+ events: [
98
+ {
99
+ kind: 'invoked',
100
+ name: 'whistle',
101
+ caption: 'whistle taken',
102
+ id: 'env-whistle',
103
+ correlationId: 'corr-101',
104
+ causationId: 'cause-click',
105
+ },
106
+ { kind: 'completed', name: 'whistle', id: 'env-whistle', correlationId: 'corr-101' },
107
+ ],
108
+ };
109
+ },
110
+ };
111
+
112
+ const runner = createProductionScenarioRunner({
113
+ scenario: defined.scenario,
114
+ participants,
115
+ compositorLayers,
116
+ geometry,
117
+ host, // reduce() invokes the host controller; it does not set playSequence
118
+ hostAudio,
119
+ });
120
+ ```
121
+
122
+ - Omit `hostAudio` when the host has no controller. Sequence/headless `traces.audio` still works.
123
+ - Supplying a non-object, an object without `snapshot()` or `collect()`, or an adapter whose `inspect` / `snapshot` / `collect` throws fails closed: `firstBrokenBoundary` is `audio`, code `invalid-host-audio`.
124
+ - Browser and headless use the same scenario object and the same observer. Evidence is semantic (invocation, captions, identity), not PCM.
125
+ - `observation.audioInvoked` follows host `invoked` / `completed` (the analogue of sequence `audio.cue.scheduled` / `audio.cue.started`). `failed`, `skipped`, and `fallback` stay in `traces.hostAudio` and can set `audioFailed` or captions; they do not count as invoked.
126
+
127
+ ## Dynamic input surface
128
+
129
+ Hosts that change visible hotspots after a reducer transition implement `host.projectInputSurface(state)`. The runner rebuilds the control set and geometry document after every accepted (or rejected) reduce and on reload.
130
+
131
+ ```ts
132
+ const host = {
133
+ initialState: { place: 'a' },
134
+ reduce(state, intent) {
135
+ const id = (intent.payload as { id?: string } | undefined)?.id;
136
+ if (intent.type === 'host.intent.select' && id === 'whistle' && state.place === 'a') {
137
+ return { accepted: true, state: { ...state, place: 'b' } };
138
+ }
139
+ return { accepted: true, state };
140
+ },
141
+ projectInputSurface(state) {
142
+ const place = state.place === 'b' ? 'b' : 'a';
143
+ const geometry =
144
+ place === 'a'
145
+ ? { version: 1, landmarks: [], regions: [{ id: 'whistle', rect: { x: 0.1, y: 0.1, width: 0.3, height: 0.3 } }] }
146
+ : { version: 1, landmarks: [], regions: [{ id: 'npc', rect: { x: 0.55, y: 0.55, width: 0.3, height: 0.3 } }] };
147
+ return { controls: geometry.regions.map((row) => ({ id: row.id })), geometry };
148
+ },
149
+ };
150
+ ```
151
+
152
+ - Place `a` exposes `#whistle`. After the accepted transition, place `b` exposes `#npc` and whistle is gone.
153
+ - `inspect().inputSurface` and `observation.activeControlIds` are the active semantic targets. Browser and headless share the scenario object.
154
+ - Reload calls `syncInputSurface()` only. It does not call `applyPresentation()`, so sequences are not replayed.
155
+ - Invalid documents, overlapping/duplicate ids, or a control without a matching region fail closed at boundary `input`.
156
+
157
+ ## Runtime reduced motion
158
+
159
+ A `reducedMotion` step (or `runner.setReducedMotion(value)`) is one host-generic fan-out. It does not encode product names.
160
+
161
+ 1. **Browser / a11y.** `BrowserHarness.setReducedMotion` (already present). `matchMedia('(prefers-reduced-motion: reduce)')` and `root.dataset.reducedMotion` match the new value.
162
+ 2. **Visual.** `visual.setReducedMotion`. In-flight transitions keep compiled durations; future `play()` uses the new flag.
163
+ 3. **Sequence.** `sequences.setReducedMotion`. **Active invocation policy is `continue`:** the current play keeps the durations it was compiled with. Queued plays that have not started, and every later `playSequence`, compile with the new policy. `inspectCueIntent` reports the future policy immediately.
164
+ 4. **Audio.** `audio.setReducedSensory(value || muted)`. Already-started cues continue; later starts skip with reason `reduced-sensory` when the flag is on. Matches construction-time `matrix.reducedMotion || matrix.mute`.
165
+ 5. **Semantic.** No reduced-motion policy. Evidence records a declared no-op (`applied: false`, `reason: 'no-reduced-motion-policy'`). A missing visual/sequence controller is the same shape (`participant-not-mounted`). A mounted controller without `setReducedMotion` / `setReducedSensory` fails closed at boundary `cue`, code `unsupported-preference`.
166
+
167
+ Evidence: `observation.reducedMotion` is the current preference. After a runtime change, `observation.reducedMotionPropagation` records `{ from, to, activeSequencePolicy: 'continue', participants }`. Browser and headless fixtures assert that object, plus inspect getters (`sequences.reducedMotion`, `audio.reducedSensory`, harness reproduction). Construction-time `matrix.reducedMotion` is unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyberart-io/engine",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "CyberArt host engine: mount carts, events, capability manifests, geometry, runtime groups, and a Node/jsdom headless entry.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",