@cyberart-io/engine 0.0.5 → 0.0.7
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/README.md +117 -14
- package/dist/headless.d.ts +483 -6
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +883 -6
- package/dist/index.js +1 -1
- package/docs/audio.md +152 -0
- package/docs/calculation-carts.md +134 -0
- package/docs/capability-manifest.md +5 -2
- package/docs/compositor.md +8 -3
- package/docs/executable-modules.md +112 -0
- package/docs/midi.md +128 -0
- package/docs/runtime-group.md +3 -3
- package/docs/snapshots.md +102 -0
- package/docs/visual-layers.md +151 -0
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Versioned snapshots
|
|
2
|
+
|
|
3
|
+
Portable save envelope for carts and embedding hosts. Schema **1** is the previous engine-owned `CartStateBundle` (`CART_STATE_BUNDLE_VERSION = 1`). Schema **2** wraps that blob in `engineState` and keeps host-owned data in `hostState` / `hostStateRef`. Cyberart migrates envelopes in memory; the host owns persistence (local storage, a database, cross-user saves). This package is not the database of record.
|
|
4
|
+
|
|
5
|
+
Related: [deterministic mode](deterministic-mode.md) (`rng` / clock), [capability manifest](capability-manifest.md) (cart id). Back to the [package README](../README.md).
|
|
6
|
+
|
|
7
|
+
## One-command reproduce (this repo)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-50-snapshots.repro.spec.ts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Scheme (`SNAPSHOT_SCHEMA_VERSION = 2`)
|
|
14
|
+
|
|
15
|
+
`ENGINE_SNAPSHOT_RUNTIME` is `'0.0.5'`. It is a snapshot runtime id, independent of the npm package version — bump whichever one the change actually belongs to.
|
|
16
|
+
|
|
17
|
+
| Field | Meaning |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `schemaVersion` | Envelope schema. Current **2**. |
|
|
20
|
+
| `runtimeVersion` | `ENGINE_SNAPSHOT_RUNTIME` at save. |
|
|
21
|
+
| `cart` | `{ id, version, generative? }` — exact cart version (`'0'` if unversioned). |
|
|
22
|
+
| `modules` | Optional `{ id, version }[]`. |
|
|
23
|
+
| `seed` | Deterministic token hash. |
|
|
24
|
+
| `rng` | Optional `RandomState` (sfc32 snapshot). Recorded when available; cart `importState` still does not replay the PRNG. |
|
|
25
|
+
| `clock` | `framesElapsed`, optional `elapsedSinceStart` / `now` / `frameRate`. |
|
|
26
|
+
| `engineState` | The existing `CartStateBundle` (bundle `version` remains **1**). |
|
|
27
|
+
| `hostState` | Host-owned JSON payload. Opaque to the engine. |
|
|
28
|
+
| `hostStateRef` | Host persistence pointer (slot id, URL, …). Not loaded by the engine. |
|
|
29
|
+
| `assets` | Optional `{ id, version?, ref? }[]` content version references. |
|
|
30
|
+
| `createdAt` | ISO-8601 creation time. Migrated v1 bundles use `1970-01-01T00:00:00.000Z`. |
|
|
31
|
+
| `provenance` | Optional `{ source?, integrity?: { alg, hash } }`. Hosts fill integrity; the engine does not hash. |
|
|
32
|
+
|
|
33
|
+
`cart.id` is required. `snapshotFromCartBundle` and the 1→2 migration fail closed when the legacy bundle has no `cartId` — they do not stamp `'unknown'`.
|
|
34
|
+
|
|
35
|
+
`defineSnapshot` / `parseSnapshot` / `validateSnapshot` return `{ ok: true, snapshot }` or `{ ok: false, errors }`. They do not throw. JSON round-trip: `JSON.parse(JSON.stringify(snapshot))` equals the snapshot.
|
|
36
|
+
|
|
37
|
+
Legacy cart bundles (no `schemaVersion`, have `version` / `seed` / `framesElapsed` / `state`) parse as schema **1**. `exportState` / `importState` still use that blob so existing carts keep working. `importState` also accepts a current envelope: it migrates if needed and restores `engineState` only.
|
|
38
|
+
|
|
39
|
+
## Migrations
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import {
|
|
43
|
+
applySnapshotMigrations,
|
|
44
|
+
createEngineSnapshotMigrations,
|
|
45
|
+
createSnapshotMigrationRegistry,
|
|
46
|
+
defineSnapshot,
|
|
47
|
+
parseSnapshot,
|
|
48
|
+
} from '@cyberart-io/engine';
|
|
49
|
+
|
|
50
|
+
const defined = defineSnapshot({
|
|
51
|
+
cart: { id: 'art.cart', version: '3' },
|
|
52
|
+
seed: '0x' + '50'.repeat(32),
|
|
53
|
+
clock: { framesElapsed: 12 },
|
|
54
|
+
engineState: {
|
|
55
|
+
version: 1,
|
|
56
|
+
cartId: 'art.cart',
|
|
57
|
+
seed: '0x' + '50'.repeat(32),
|
|
58
|
+
framesElapsed: 12,
|
|
59
|
+
state: { n: 12 },
|
|
60
|
+
},
|
|
61
|
+
hostState: { sceneId: 'alpha' },
|
|
62
|
+
createdAt: '2026-01-15T00:00:00.000Z',
|
|
63
|
+
});
|
|
64
|
+
if (!defined.ok) throw new Error(defined.errors.map((e) => e.detail).join('; '));
|
|
65
|
+
|
|
66
|
+
const registry = createSnapshotMigrationRegistry({
|
|
67
|
+
migrations: createEngineSnapshotMigrations(),
|
|
68
|
+
});
|
|
69
|
+
const migrated = applySnapshotMigrations(legacyBundle, registry);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`applySnapshotMigrations` clones the input, walks `from → from+1` until schema 2, and validates each step. The original object is left unchanged, including when a step is missing or a migrate function fails.
|
|
73
|
+
|
|
74
|
+
| Diagnostic `code` | When |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `invalid-json` | `parseSnapshot` received unparseable text. |
|
|
77
|
+
| `invalid-snapshot` | Root value is not a JSON object, or a legacy blob is missing. |
|
|
78
|
+
| `invalid-field` | A required string/number/object field is the wrong shape. |
|
|
79
|
+
| `invalid-schema` | `validateSnapshot` / `defineSnapshot` saw the wrong `schemaVersion`. |
|
|
80
|
+
| `invalid-engine-state` | Nested `CartStateBundle` failed `parseCartStateBundle`. |
|
|
81
|
+
| `unsupported-schema` | `schemaVersion` is newer than 2. |
|
|
82
|
+
| `missing-migration` | No registered step from the snapshot's schema to the next integer. |
|
|
83
|
+
| `invalid-migration-result` | A `migrate` function threw, produced the wrong schema, or failed validation. |
|
|
84
|
+
|
|
85
|
+
Disjoint live cart state still throws `IncompatibleCartStateError` from `importState` / `importSnapshot` after the envelope has been migrated. Missing envelope paths surface as `missing-migration` diagnostics; `importSnapshot` wraps those details in `IncompatibleCartStateError`.
|
|
86
|
+
|
|
87
|
+
The current engine table is only 1→2 (`SNAPSHOT_SCHEMA_VERSION` is 2). When a later envelope schema ships, compose host migrations after that table; each step must advance by exactly one. Duplicate `from` values throw when the registry is created.
|
|
88
|
+
|
|
89
|
+
## Runtime
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const envelope = await cart.exportSnapshot({
|
|
93
|
+
cartVersion: '3',
|
|
94
|
+
hostState: { sceneId: 'alpha' },
|
|
95
|
+
modules: [{ id: 'overlay.module', version: '1' }],
|
|
96
|
+
});
|
|
97
|
+
await cart.importSnapshot(envelope);
|
|
98
|
+
await cart.importState(legacyBundle); // still a CartStateBundle
|
|
99
|
+
await cart.importState(envelope); // envelope → engineState
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`exportSnapshot` wraps `exportState()` plus clock / rng. `importSnapshot` always migrates. Hosts restore `hostState` themselves. W/E localStorage helpers still store the cart bundle; `parseStoredCartState` migrates a stored envelope first and returns null if the path is missing.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Visual layers
|
|
2
|
+
|
|
3
|
+
Versioned overlay / mask / sprite declarations with deterministic show, hide, replace, and crossfade. Presentation cues own timing. The compositor swaps `ImageData` / canvas sources in one `setLayer` so a partial or failed asset never becomes the live frame.
|
|
4
|
+
|
|
5
|
+
Hosts persist `controller.snapshot()` JSON inside envelope `hostState`. This module does not edit the snapshot envelope.
|
|
6
|
+
|
|
7
|
+
Related: [presentation cue](presentation-cue.md), [compositor](compositor.md), [asset resolver](asset-resolver.md), [snapshots](snapshots.md). Back to the [package README](../README.md).
|
|
8
|
+
|
|
9
|
+
## One-command reproduce (this repo)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-66-visual-layers.repro.spec.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
| Surface | Import | Use |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Production host | `createVisualLayerController` from `@cyberart-io/engine` | Bind `host.state.accepted` to a preloaded overlay version. |
|
|
20
|
+
| Vitest / jsdom | same controller after `installHeadlessCanvas()` | `captureVisualLayers` / `captureComposedFrame` / `inspect`. |
|
|
21
|
+
|
|
22
|
+
Generic ids only: overlay `overlay`, versions `v1` / `v2`, `sceneId: 'alpha'`. Cyberart owns presentation; the host owns which version is authoritative.
|
|
23
|
+
|
|
24
|
+
## `createVisualLayerController(options)`
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import {
|
|
28
|
+
ASSET_READY_EVENT,
|
|
29
|
+
HOST_STATE_ACCEPTED_EVENT,
|
|
30
|
+
createCompositor,
|
|
31
|
+
createRuntimeGroup,
|
|
32
|
+
createVisualLayerController,
|
|
33
|
+
} from '@cyberart-io/engine';
|
|
34
|
+
|
|
35
|
+
const group = createRuntimeGroup({
|
|
36
|
+
participants: [{ id: 'base', cart: baseCart }],
|
|
37
|
+
});
|
|
38
|
+
const compositor = createCompositor({
|
|
39
|
+
group,
|
|
40
|
+
host: { color: '#000080' },
|
|
41
|
+
layers: [
|
|
42
|
+
{ id: 'base', order: 0 },
|
|
43
|
+
{ id: 'overlay', order: 1, visible: false },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
const layers = createVisualLayerController({
|
|
47
|
+
compositor,
|
|
48
|
+
sceneId: 'alpha',
|
|
49
|
+
originFrame: 0,
|
|
50
|
+
layers: [
|
|
51
|
+
{
|
|
52
|
+
id: 'overlay',
|
|
53
|
+
kind: 'layer', // also 'mask' | 'sprite'
|
|
54
|
+
order: 1,
|
|
55
|
+
initialVersion: 'v1',
|
|
56
|
+
fallback: 'keep-prior',
|
|
57
|
+
versions: [
|
|
58
|
+
{ id: 'v1', assetId: 'overlay.v1', provenance: { source: 'fixture', version: 'v1' } },
|
|
59
|
+
{ id: 'v2', assetId: 'overlay.v2', provenance: { source: 'fixture', version: 'v2' } },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
onAccepted: [{ layerId: 'overlay', toVersion: 'v2', kind: 'replace', durationFrames: 2 }],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
layers.registerSource('overlay.v1', imageV1);
|
|
67
|
+
layers.registerSource('overlay.v2', imageV2);
|
|
68
|
+
layers.handleHostEvent({ type: ASSET_READY_EVENT, kind: 'state', payload: { id: 'overlay.v1' } });
|
|
69
|
+
layers.handleHostEvent({ type: ASSET_READY_EVENT, kind: 'state', payload: { id: 'overlay.v2' } });
|
|
70
|
+
layers.handleHostEvent({
|
|
71
|
+
type: HOST_STATE_ACCEPTED_EVENT,
|
|
72
|
+
kind: 'state',
|
|
73
|
+
payload: { sceneId: 'alpha' },
|
|
74
|
+
});
|
|
75
|
+
layers.step(2);
|
|
76
|
+
const capture = layers.captureComposedFrame();
|
|
77
|
+
// capture.imageData is v2; inspect().activeVersion is 'v2'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`kind: 'mask'` defaults `pointerEvents` to `none`. Incoming crossfade pixels use compositor layer `overlay:incoming` (`visualIncomingLayerId('overlay')`).
|
|
81
|
+
|
|
82
|
+
### Options
|
|
83
|
+
|
|
84
|
+
| Option | Default | Meaning |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `compositor` | required | `createCompositor` handle. The controller drives `setLayer` / `addLayer`. |
|
|
87
|
+
| `layers` | required | Versioned declarations with stable ids. |
|
|
88
|
+
| `preloader` | none | Optional `createAssetPreloader`. `get(id)` counts as ready when no event has been recorded yet. |
|
|
89
|
+
| `originFrame` | `0` | Passed to `createPresentationTimeline`. |
|
|
90
|
+
| `reducedMotion` | `false` | Host flag. Instant-complete cues still wait for a ready source before revealing. |
|
|
91
|
+
| `sceneId` | none | When set, `host.state.accepted` with a different `sceneId` is ignored. |
|
|
92
|
+
| `fallback` | `'keep-prior'` | Default when a version asset fails: keep the last valid frame, `'hide'`, or `{ version: 'v1' }`. |
|
|
93
|
+
| `onAccepted` | none | Transitions to play when `host.state.accepted` has no `overlayId`/`version`. |
|
|
94
|
+
| `dispatch` | none | Optional mailbox for `visual.layer.*` events. Failed events use `kind: 'diagnostic'`. |
|
|
95
|
+
|
|
96
|
+
Payload `{ overlayId, version }` (or `layerId`) on `host.state.accepted` plays a replace of that version (duration 2). `ASSET_READY_EVENT` / `ASSET_FAILED_EVENT` are keyed by payload `id` (`overlay.v2`).
|
|
97
|
+
|
|
98
|
+
### Handle
|
|
99
|
+
|
|
100
|
+
| Member | Meaning |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `registerSource(assetId, imageData \| canvas)` | Pixel buffer for that asset. Cloned for `ImageData`. Required before a version can become visible. |
|
|
103
|
+
| `handleHostEvent(event)` | `host.state.accepted`, `ASSET_READY_EVENT`, `ASSET_FAILED_EVENT`. |
|
|
104
|
+
| `play(spec)` | `CueSpec` plus `layerId`, `kind` (`show` / `hide` / `replace` / `crossfade`), `toVersion`. |
|
|
105
|
+
| `step(frames?)` | Advance the presentation timeline (default 1). Returns new `visual.layer.*` events. |
|
|
106
|
+
| `override(layerId, version \| null)` | Host override. Ready versions swap immediately; missing assets keep the prior frame. |
|
|
107
|
+
| `snapshot()` | JSON-serializable visibility, versions, transition position, provenance, assets, events, diagnostics. |
|
|
108
|
+
| `restore(json)` | `{ ok: true, snapshot }` or `{ ok: false, errors }`. Pixels are not in the JSON — register sources first. |
|
|
109
|
+
| `inspect()` | Per-layer `activeVersion`, `pendingVersion`, `committedVersion`, transition progress. |
|
|
110
|
+
| `captureComposedFrame()` | Delegates to the compositor (headless `ImageData` + `declaredOrder`). |
|
|
111
|
+
| `destroy()` | Drops cues. Does not destroy the compositor. |
|
|
112
|
+
|
|
113
|
+
`captureVisualLayers(controller)` returns `{ frame, layers }` (also from `@cyberart-io/engine/headless`).
|
|
114
|
+
|
|
115
|
+
## Atomic swaps
|
|
116
|
+
|
|
117
|
+
Replace does not paint `v2` until the cue completes **and** `overlay.v2` is ready with a registered source. Mid-cue pixels stay on the last valid frame (`v1`). Crossfade paints `overlay:incoming` only after that same gate; opacity follows cue progress (outgoing `1 - t`, incoming `t`). If the cue ends before the asset is ready, the pending version waits for `ASSET_READY_EVENT` and then swaps in one `setLayer`. `ASSET_FAILED_EVENT` keeps the prior frame and records a diagnostic (`code: 'asset-failed'`).
|
|
118
|
+
|
|
119
|
+
## Snapshot / restore
|
|
120
|
+
|
|
121
|
+
`snapshot()` survives `JSON.parse(JSON.stringify(snapshot))`. Hosts store it in envelope `hostState` (for example `{ sceneId: 'alpha', visualLayers: snapshot }`). Restore:
|
|
122
|
+
|
|
123
|
+
| When | Result |
|
|
124
|
+
|---|---|
|
|
125
|
+
| Mid-transition | Same `frame`, `committedVersion`, and cue `progress`. Further `step` continues the fade. |
|
|
126
|
+
| After complete | `activeVersion` is the committed version; `transition` is `null`. |
|
|
127
|
+
|
|
128
|
+
Restore does not replay host events. It reapplies version ids onto sources already registered on that controller. `schemaVersion` is `VISUAL_LAYER_SNAPSHOT_SCHEMA_VERSION` (`1`).
|
|
129
|
+
|
|
130
|
+
## Public exports
|
|
131
|
+
|
|
132
|
+
| Export | Meaning |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `createVisualLayerController` | Constructor. |
|
|
135
|
+
| `captureVisualLayers` | `{ frame, layers }` helper. |
|
|
136
|
+
| `visualIncomingLayerId` / `VISUAL_LAYER_INCOMING_SUFFIX` | Incoming compositor id (`overlay:incoming`). |
|
|
137
|
+
| `VISUAL_LAYER_KINDS` / `isVisualLayerKind` | `'layer'` \| `'mask'` \| `'sprite'`. |
|
|
138
|
+
| `VISUAL_LAYER_TRANSITIONS` / `isVisualLayerTransitionKind` | `'show'` \| `'hide'` \| `'replace'` \| `'crossfade'`. |
|
|
139
|
+
| `VISUAL_LAYER_EVENTS` / `isVisualLayerEventType` | Lifecycle type names. |
|
|
140
|
+
| `VISUAL_LAYER_REVEALED_EVENT` | `'visual.layer.revealed'` |
|
|
141
|
+
| `VISUAL_LAYER_HIDDEN_EVENT` | `'visual.layer.hidden'` |
|
|
142
|
+
| `VISUAL_LAYER_TRANSITION_STARTED_EVENT` | `'visual.layer.transition-started'` |
|
|
143
|
+
| `VISUAL_LAYER_TRANSITION_COMPLETED_EVENT` | `'visual.layer.transition-completed'` |
|
|
144
|
+
| `VISUAL_LAYER_FAILED_EVENT` | `'visual.layer.failed'` |
|
|
145
|
+
| `VISUAL_LAYER_FAILURE_CODES` | `'asset-failed'` \| `'missing-source'` \| `'unknown-layer'` \| `'unknown-version'` \| `'invalid-snapshot'` |
|
|
146
|
+
| `VISUAL_LAYER_SNAPSHOT_SCHEMA_VERSION` | `1` |
|
|
147
|
+
| `HOST_STATE_ACCEPTED_EVENT` | `'host.state.accepted'` |
|
|
148
|
+
|
|
149
|
+
Types: `VisualLayerDeclaration`, `VisualLayerVersionDeclaration`, `VisualLayerCueSpec`, `VisualLayerController`, `VisualLayerControllerSnapshot`, `VisualLayerInspect`, `VisualLayerEvent`, `VisualLayerDiagnostic`, `VisualLayerFallbackPolicy`, `VisualLayerAcceptedBinding`, `PlayVisualLayerResult`, `RestoreVisualLayerResult`, `CreateVisualLayerControllerOptions`, `VisualLayerCapture`, and related aliases.
|
|
150
|
+
|
|
151
|
+
Do not `await` wall-clock loads inside cart `update` / `render`. Drive `step` from the same clock as deterministic `cart.step`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyberart-io/engine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
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",
|