animader-cli 0.0.0-stage → 0.1.1
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/LICENSE +121 -0
- package/adapters/build-node.mjs +265 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +603 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +183 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +80 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +143 -0
- package/adapters/target.mjs +104 -0
- package/adapters/watch.mjs +355 -0
- package/adapters/webaudio.mjs +102 -0
- package/bin/animader.mjs +31 -0
- package/hello/join.js +46 -0
- package/hello/run.js +259 -0
- package/lib/build.mjs +126 -0
- package/lib/buildloop.mjs +254 -0
- package/lib/contract.mjs +371 -0
- package/lib/errors.mjs +51 -0
- package/lib/export.mjs +445 -0
- package/lib/new.mjs +304 -0
- package/lib/pipeline.mjs +247 -0
- package/lib/receipt.mjs +331 -0
- package/lib/rmtree.mjs +49 -0
- package/lib/routes.mjs +170 -0
- package/lib/score.mjs +434 -0
- package/lib/show.mjs +458 -0
- package/lib/soundtrack.mjs +67 -0
- package/lib/vendor.mjs +99 -0
- package/lib/wav.mjs +197 -0
- package/package.json +47 -4
- package/player/boot.mjs +792 -0
- package/player/controller.mjs +366 -0
- package/player/devchannel.mjs +306 -0
- package/player/index.html +35 -0
- package/player/page.mjs +134 -0
- package/player/player.css +393 -0
- package/player/release.mjs +76 -0
- package/src/commands/build.mjs +201 -0
- package/src/commands/dev.mjs +214 -0
- package/src/commands/doctor.mjs +348 -0
- package/src/commands/export.mjs +158 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/project.mjs +46 -0
- package/src/config.mjs +110 -0
- package/src/router.mjs +149 -0
- package/src/toolchain.mjs +691 -0
- package/starters/circle/README.md +50 -0
- package/starters/circle/animader.json +4 -0
- package/starters/circle/gitignore +9 -0
- package/starters/circle/kernels/circle.wgsl +32 -0
- package/starters/circle/program.sjon +34 -0
- package/starters/circle/timeline.piece.sjon +26 -0
- package/starters/ember/bake/spans.bin +0 -0
- package/starters/ember/kernels/curl.wgsl +10 -0
- package/starters/ember/kernels/ember.wgsl +10 -0
- package/starters/ember/kernels/hash.wgsl +12 -0
- package/starters/ember/kernels/simplex.wgsl +27 -0
- package/starters/ember/program.sjon +328 -0
- package/starters/ember/timeline.piece.sjon +34 -0
- package/toolchain/licenses/animader.txt +121 -0
- package/toolchain/licenses/esbuild.txt +21 -0
- package/toolchain/licenses/pacer.txt +121 -0
- package/toolchain/licenses/pngine.txt +121 -0
- package/toolchain/manifest.json +212 -0
- package/toolchain/prepare.mjs +446 -0
- package/toolchain/uniform-type-name.js +184 -0
- package/README.md +0 -3
package/lib/show.mjs
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// The demo's portable audio-master transport (M1 slice 2): the finite
|
|
2
|
+
// clock lifecycle around one composed show. The audio context is the
|
|
3
|
+
// master clock (a context-currentTime-style injected reader), the run
|
|
4
|
+
// core is the examples' own `createPlayer` result, and every platform
|
|
5
|
+
// byte — audio graph, wake loop, renderer — is an injected port, so the
|
|
6
|
+
// same decisions run in a page, a headless test or a future host with
|
|
7
|
+
// no `node:` import, no process state and no DOM here.
|
|
8
|
+
//
|
|
9
|
+
// The clock policy (approved): `elapsed = readClock() - origin`, and
|
|
10
|
+
// the origin is captured only AFTER `audio.resume()` resolves — a real
|
|
11
|
+
// context does not sit at zero at a start or restart, and the resume is
|
|
12
|
+
// an async platform settle. The buffer is scheduled at that same origin
|
|
13
|
+
// as a fresh single-use source whose STOP is scheduled at
|
|
14
|
+
// `origin + seconds`, independent of any wake slack: the audio ends
|
|
15
|
+
// itself on time even if paints lag. A stall steps every due tick with
|
|
16
|
+
// no catch-up cap and no rebase (the debt is paid in full); a step
|
|
17
|
+
// draws its own tick and then increments, so the last shown tick is
|
|
18
|
+
// always `tick - 1` (the run core's actual contract). The end bound
|
|
19
|
+
// never overruns the tick grid. A non-finite or backwards clock fails
|
|
20
|
+
// loudly — error state, audio ended, nothing rescheduled — instead of a
|
|
21
|
+
// NaN busy loop; a throwing clock read or step takes that same total
|
|
22
|
+
// failure path instead of escaping the host's wake callback.
|
|
23
|
+
//
|
|
24
|
+
// States: loading, ready, playing, paused, press-to-resume, ended,
|
|
25
|
+
// stopped, error. Commands in the wrong state are ignored (never a
|
|
26
|
+
// duplicate audio start), commands are serialized through one queue so
|
|
27
|
+
// a double start or a pause during a pending resume cannot interleave,
|
|
28
|
+
// and `start`/`pause`/`resume`/`stop`/`restart` return promises so the
|
|
29
|
+
// platform's async settle is observable. `fail()` stays immediate —
|
|
30
|
+
// never queued behind a platform await that may never resolve — and
|
|
31
|
+
// every command continuation re-checks it after its await: the original
|
|
32
|
+
// structured failure is final, keeps its identity, and nothing starts,
|
|
33
|
+
// announces or reschedules over it. `restart` resets the score
|
|
34
|
+
// ticks through the run core's own reset, returns every held control to
|
|
35
|
+
// the document default, and resets the visual feedback state through
|
|
36
|
+
// the renderer's public restart verb — never by patching private
|
|
37
|
+
// workers. `seconds` is a host policy received here (never inferred
|
|
38
|
+
// from the compiled transport) and must be a whole number of ticks on
|
|
39
|
+
// the run core's grid; a score's own `:end loop|hold` stays authored
|
|
40
|
+
// and is not acted on by this transport.
|
|
41
|
+
//
|
|
42
|
+
// Ownership: the state machine, the clock arithmetic, the serialization.
|
|
43
|
+
// The audio graph, the wake source, the renderer and the driver belong
|
|
44
|
+
// to the host adapters.
|
|
45
|
+
|
|
46
|
+
import { TICKS_PER_SECOND } from '../hello/run.js';
|
|
47
|
+
import { ShowError } from './score.mjs';
|
|
48
|
+
|
|
49
|
+
/** Every state `createShow` announcements can carry. */
|
|
50
|
+
export const SHOW_STATES = Object.freeze([
|
|
51
|
+
'loading',
|
|
52
|
+
'ready',
|
|
53
|
+
'playing',
|
|
54
|
+
'paused',
|
|
55
|
+
'press-to-resume',
|
|
56
|
+
'ended',
|
|
57
|
+
'stopped',
|
|
58
|
+
'error',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} options
|
|
63
|
+
* @param {number} options.seconds
|
|
64
|
+
* the host show length; must be positive, finite and a whole number
|
|
65
|
+
* of ticks on the tick grid.
|
|
66
|
+
* @param {() => Promise<{driver: object, player: object, renderer: object}>} options.prepare
|
|
67
|
+
* composes the show: renderer ready, score compiled (a `loadShow`
|
|
68
|
+
* result wired through `createPlayer`). READY waits for this.
|
|
69
|
+
* @param {() => Promise<object>} options.decode
|
|
70
|
+
* decodes the soundtrack; READY waits for this too, so no show plays
|
|
71
|
+
* on audio it has not decoded.
|
|
72
|
+
* @param {object} options.audio
|
|
73
|
+
* the audio port: `resume(): Promise`, `suspend(): Promise` (the
|
|
74
|
+
* clock freezes once it settles), `now(): number` (a
|
|
75
|
+
* context-currentTime-style reader), `play(decoded, origin, stopAt):
|
|
76
|
+
* handle` (a fresh single-use source started at `origin` with its
|
|
77
|
+
* STOP scheduled at `stopAt`), `stop(handle)`.
|
|
78
|
+
* @param {(fn: () => void) => () => void} options.scheduleWake
|
|
79
|
+
* schedules one pump (requestAnimationFrame-shaped) and answers a
|
|
80
|
+
* cancel function.
|
|
81
|
+
* @param {() => number} [options.readClock]
|
|
82
|
+
* defaults to `audio.now` (ship it as a closure).
|
|
83
|
+
* @param {(state: string) => void} [options.onState]
|
|
84
|
+
* called on every transition, `loading` included.
|
|
85
|
+
* @param {number} [options.ticksPerSecond]
|
|
86
|
+
* defaults to the run core's own `TICKS_PER_SECOND`.
|
|
87
|
+
*/
|
|
88
|
+
export function createShow({
|
|
89
|
+
seconds,
|
|
90
|
+
prepare,
|
|
91
|
+
decode,
|
|
92
|
+
audio,
|
|
93
|
+
scheduleWake,
|
|
94
|
+
readClock,
|
|
95
|
+
onState = null,
|
|
96
|
+
ticksPerSecond = TICKS_PER_SECOND,
|
|
97
|
+
}) {
|
|
98
|
+
if (typeof prepare !== 'function' || typeof decode !== 'function') {
|
|
99
|
+
throw new TypeError('createShow needs prepare and decode functions (the composed load and the audio decode)');
|
|
100
|
+
}
|
|
101
|
+
if (!audio
|
|
102
|
+
|| typeof audio.resume !== 'function'
|
|
103
|
+
|| typeof audio.suspend !== 'function'
|
|
104
|
+
|| typeof audio.now !== 'function'
|
|
105
|
+
|| typeof audio.play !== 'function'
|
|
106
|
+
|| typeof audio.stop !== 'function') {
|
|
107
|
+
throw new TypeError('createShow needs audio.resume, audio.suspend, audio.now, audio.play and audio.stop');
|
|
108
|
+
}
|
|
109
|
+
if (typeof scheduleWake !== 'function') {
|
|
110
|
+
throw new TypeError('createShow needs a scheduleWake function (requestAnimationFrame-shaped)');
|
|
111
|
+
}
|
|
112
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) {
|
|
113
|
+
throw new ShowError('invalid_seconds', `--seconds must be a positive finite number; got ${String(seconds)}`, {
|
|
114
|
+
stage: 'transport',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isInteger(seconds * ticksPerSecond)) {
|
|
118
|
+
throw new ShowError(
|
|
119
|
+
'seconds_not_representable',
|
|
120
|
+
`--seconds ${seconds} is not a whole number of ticks on the ${ticksPerSecond}-tick grid; pass a length the grid represents exactly instead of rounding forever`,
|
|
121
|
+
{ stage: 'transport' },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const clock = readClock ?? audio.now;
|
|
126
|
+
const totalTicks = seconds * ticksPerSecond;
|
|
127
|
+
|
|
128
|
+
let state = 'loading';
|
|
129
|
+
let failure = null;
|
|
130
|
+
let loaded = null;
|
|
131
|
+
let origin = null;
|
|
132
|
+
let handle = null;
|
|
133
|
+
let lastNow = null;
|
|
134
|
+
let cancelWake = null;
|
|
135
|
+
const held = new Set();
|
|
136
|
+
|
|
137
|
+
function announce(next) {
|
|
138
|
+
state = next;
|
|
139
|
+
if (onState) onState(next);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** End the audio now and cancel any scheduled pump. */
|
|
143
|
+
function endAudio() {
|
|
144
|
+
if (handle !== null) {
|
|
145
|
+
audio.stop(handle);
|
|
146
|
+
handle = null;
|
|
147
|
+
}
|
|
148
|
+
if (cancelWake !== null) {
|
|
149
|
+
const cancel = cancelWake;
|
|
150
|
+
cancelWake = null;
|
|
151
|
+
cancel();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Enter the error state: audio ended, nothing rescheduled, cause kept. */
|
|
156
|
+
function failShow(code, message, detail = {}) {
|
|
157
|
+
const error = new ShowError(code, message, detail);
|
|
158
|
+
endAudio();
|
|
159
|
+
failure = error;
|
|
160
|
+
announce('error');
|
|
161
|
+
return error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function schedulePump() {
|
|
165
|
+
if (cancelWake !== null) return;
|
|
166
|
+
const cancel = scheduleWake(pump);
|
|
167
|
+
cancelWake = () => {
|
|
168
|
+
cancelWake = null;
|
|
169
|
+
cancel();
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* One paint: step every tick the clock has passed, bounded by the
|
|
175
|
+
* grid's end, then schedule the next paint unless the show is over.
|
|
176
|
+
* Runs only while playing — a stop, an error or a pause is the end of
|
|
177
|
+
* resumption, not a reason to keep scheduling.
|
|
178
|
+
*/
|
|
179
|
+
function pump() {
|
|
180
|
+
cancelWake = null;
|
|
181
|
+
if (state !== 'playing' || loaded === null) return;
|
|
182
|
+
try {
|
|
183
|
+
const now = clock();
|
|
184
|
+
if (!Number.isFinite(now) || (lastNow !== null && now < lastNow)) {
|
|
185
|
+
failShow(
|
|
186
|
+
'clock_invalid',
|
|
187
|
+
`the master clock is not usable (read ${now} after ${lastNow}); the show refuses to guess time`,
|
|
188
|
+
{ stage: 'clock' },
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
lastNow = now;
|
|
193
|
+
// The sub-tick guard: an accumulated context clock (a double) can
|
|
194
|
+
// land a genuinely due boundary a few ulps below the integer — a
|
|
195
|
+
// real 0.15+4.2 s read lands at 434.99999999999994 ticks — which
|
|
196
|
+
// would cost a whole paint. The 1e-6-tick guard (10 ns at 100 tps)
|
|
197
|
+
// absorbs that fuzz. It is a bounded trade, not exactness: a read
|
|
198
|
+
// up to 10 ns before a boundary can fire it early, far below clock
|
|
199
|
+
// and paint granularity.
|
|
200
|
+
const due = Math.floor((now - origin) * ticksPerSecond + 1e-6);
|
|
201
|
+
const target = Math.min(due, totalTicks);
|
|
202
|
+
while (loaded.player.state.tick < target) loaded.player.step();
|
|
203
|
+
if (due >= totalTicks) {
|
|
204
|
+
// The source's STOP was scheduled at origin+seconds when it
|
|
205
|
+
// started, so the audio has already ended itself; the handle is
|
|
206
|
+
// spent and nothing is scheduled again.
|
|
207
|
+
handle = null;
|
|
208
|
+
announce('ended');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
schedulePump();
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
failShow(
|
|
214
|
+
'step_failed',
|
|
215
|
+
`the show could not advance a paint: ${cause?.message ?? cause}`,
|
|
216
|
+
{ stage: 'play', cause },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const readyPromise = Promise.all([prepare(), decode()]).then(
|
|
222
|
+
([prepared, decoded]) => {
|
|
223
|
+
// A host-reported failure can arrive while the load is still settling
|
|
224
|
+
// (an async device or shader error). It is the answer; READY never
|
|
225
|
+
// overwrites a failure that already stopped the show.
|
|
226
|
+
if (state === 'error') throw failure;
|
|
227
|
+
loaded = { driver: prepared.driver, player: prepared.player, renderer: prepared.renderer, decoded };
|
|
228
|
+
announce('ready');
|
|
229
|
+
return 'ready';
|
|
230
|
+
},
|
|
231
|
+
(cause) => {
|
|
232
|
+
if (state === 'error') throw failure;
|
|
233
|
+
// A failed load is discarded (nothing half-wired is kept) and
|
|
234
|
+
// reported as the error state; `ready()` rejects with this
|
|
235
|
+
// structured failure, not the naked cause.
|
|
236
|
+
failure = new ShowError(
|
|
237
|
+
'load_failed',
|
|
238
|
+
`the show could not be loaded: ${cause?.message ?? cause}`,
|
|
239
|
+
{ stage: 'load', cause },
|
|
240
|
+
);
|
|
241
|
+
announce('error');
|
|
242
|
+
throw failure;
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
// The command queue attaches its own handlers, so a rejected load is a
|
|
246
|
+
// state and never an unhandled rejection.
|
|
247
|
+
|
|
248
|
+
// Commands serialize behind the load and each other: a double start,
|
|
249
|
+
// a pause during a pending resume or a restart mid-start cannot
|
|
250
|
+
// interleave, and a rejected command never wedges the queue.
|
|
251
|
+
let chain = readyPromise.then(() => {}, () => {});
|
|
252
|
+
function enqueue(operation) {
|
|
253
|
+
const result = chain.then(() => operation());
|
|
254
|
+
chain = result.then(() => {}, () => {});
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The audio-master gesture: resume, then read the origin the clock answers. */
|
|
259
|
+
function start() {
|
|
260
|
+
return enqueue(async () => {
|
|
261
|
+
if (state !== 'ready' || loaded === null) return state;
|
|
262
|
+
try {
|
|
263
|
+
await audio.resume();
|
|
264
|
+
// A host failure that arrived while the resume was settling is
|
|
265
|
+
// the answer: the continuation never launches the source over
|
|
266
|
+
// it (fail() is immediate — the audio is already ended).
|
|
267
|
+
if (state === 'error') throw failure;
|
|
268
|
+
const now = clock();
|
|
269
|
+
if (!Number.isFinite(now)) {
|
|
270
|
+
throw new ShowError('clock_invalid', `the master clock read ${now} at the start; the show refuses to guess time`, {
|
|
271
|
+
stage: 'clock',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
origin = now;
|
|
275
|
+
lastNow = now;
|
|
276
|
+
handle = audio.play(loaded.decoded, origin, origin + seconds);
|
|
277
|
+
announce('playing');
|
|
278
|
+
schedulePump();
|
|
279
|
+
return state;
|
|
280
|
+
} catch (cause) {
|
|
281
|
+
// The terminal failure is final: neither the bail-out above nor
|
|
282
|
+
// a resume rejection landing after it is rewrapped as a fresh
|
|
283
|
+
// start failure.
|
|
284
|
+
if (state === 'error') throw failure;
|
|
285
|
+
const error = cause instanceof ShowError
|
|
286
|
+
? cause
|
|
287
|
+
: new ShowError('start_failed', `the show could not start: ${cause?.message ?? cause}`, { stage: 'play', cause });
|
|
288
|
+
throw failShow(error.code, error.message, {
|
|
289
|
+
stage: error.stage,
|
|
290
|
+
document: error.document,
|
|
291
|
+
diagnostics: error.diagnostics,
|
|
292
|
+
cause: error.cause ?? cause,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The hidden pause: await the suspend, and the frozen clock moves no ticks. */
|
|
299
|
+
function pause() {
|
|
300
|
+
return enqueue(async () => {
|
|
301
|
+
if (state !== 'playing') return state;
|
|
302
|
+
try {
|
|
303
|
+
await audio.suspend();
|
|
304
|
+
} catch (cause) {
|
|
305
|
+
if (state === 'error') throw failure;
|
|
306
|
+
throw failShow('audio_failed', `the audio could not be suspended: ${cause?.message ?? cause}`, { stage: 'audio', cause });
|
|
307
|
+
}
|
|
308
|
+
// A host failure that arrived while the suspend was settling is
|
|
309
|
+
// the answer: no paused announcement repaints over the error.
|
|
310
|
+
if (state === 'error') throw failure;
|
|
311
|
+
announce('paused');
|
|
312
|
+
return state;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Try to resume: a denial is the explicit `press-to-resume` state
|
|
318
|
+
* (a gesture is owed), never a silent play.
|
|
319
|
+
*/
|
|
320
|
+
function resume() {
|
|
321
|
+
return enqueue(async () => {
|
|
322
|
+
if (state !== 'paused' && state !== 'press-to-resume') return state;
|
|
323
|
+
let denied = null;
|
|
324
|
+
try {
|
|
325
|
+
await audio.resume();
|
|
326
|
+
} catch (cause) {
|
|
327
|
+
denied = cause;
|
|
328
|
+
}
|
|
329
|
+
// A host failure that arrived while the resume was settling is
|
|
330
|
+
// final: it wins over both the denial (press-to-resume is not
|
|
331
|
+
// painted over an error) and the play (a dead show is not
|
|
332
|
+
// resurrected, its pump is not rescheduled).
|
|
333
|
+
if (state === 'error') throw failure;
|
|
334
|
+
if (denied !== null) {
|
|
335
|
+
announce('press-to-resume');
|
|
336
|
+
return state;
|
|
337
|
+
}
|
|
338
|
+
const now = clock();
|
|
339
|
+
if (!Number.isFinite(now) || (lastNow !== null && now < lastNow)) {
|
|
340
|
+
throw failShow(
|
|
341
|
+
'clock_invalid',
|
|
342
|
+
`the master clock read ${now} after ${lastNow} on resume; the show refuses to guess time`,
|
|
343
|
+
{ stage: 'clock' },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
lastNow = now;
|
|
347
|
+
announce('playing');
|
|
348
|
+
schedulePump();
|
|
349
|
+
return state;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** End the show now: the audio stops and no pump is scheduled again. */
|
|
354
|
+
function stop() {
|
|
355
|
+
return enqueue(async () => {
|
|
356
|
+
// A failed show keeps its terminal answer: the failure already
|
|
357
|
+
// ended the audio and cancelled the wake, and a stopped repaint
|
|
358
|
+
// would clear the visible error while `error` still reports it.
|
|
359
|
+
// restart remains the recovery verb from the error state.
|
|
360
|
+
if (state === 'stopped' || state === 'loading' || state === 'error' || loaded === null) return state;
|
|
361
|
+
endAudio();
|
|
362
|
+
announce('stopped');
|
|
363
|
+
return state;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Back to READY at tick zero: the audio ends, every held control
|
|
369
|
+
* returns to its document default, the run core resets through its
|
|
370
|
+
* own verb, and the visual feedback state resets through the
|
|
371
|
+
* renderer's public restart.
|
|
372
|
+
*/
|
|
373
|
+
function restart() {
|
|
374
|
+
return enqueue(async () => {
|
|
375
|
+
if (state === 'loading' || loaded === null) return state;
|
|
376
|
+
// A failure that already exists here is the recoverable case:
|
|
377
|
+
// restart is the public recovery verb from the error state. Only a
|
|
378
|
+
// NEWER failure — a different failure object, which can only be
|
|
379
|
+
// failShow during the reset await below — aborts the resurrection.
|
|
380
|
+
const priorFailure = failure;
|
|
381
|
+
endAudio();
|
|
382
|
+
for (const name of held) loaded.driver.clearControl(name);
|
|
383
|
+
held.clear();
|
|
384
|
+
loaded.player.reset();
|
|
385
|
+
try {
|
|
386
|
+
await loaded.renderer.restart();
|
|
387
|
+
} catch (cause) {
|
|
388
|
+
if (failure !== priorFailure) throw failure;
|
|
389
|
+
throw failShow(
|
|
390
|
+
'renderer_reset_failed',
|
|
391
|
+
`the renderer could not be reset for the restart: ${cause?.message ?? cause}`,
|
|
392
|
+
{ stage: 'restart', cause },
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (failure !== priorFailure) throw failure;
|
|
396
|
+
origin = null;
|
|
397
|
+
lastNow = null;
|
|
398
|
+
failure = null;
|
|
399
|
+
announce('ready');
|
|
400
|
+
return state;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The host's explicit failure entry: a real renderer/GPU/shader error that
|
|
406
|
+
* arrives through a platform callback, not through the clock or step path.
|
|
407
|
+
* It ends the audio, cancels the wake and is final until a successful restart;
|
|
408
|
+
* it never starts a second page lifecycle. The layers split deliberately:
|
|
409
|
+
* this transport's `restart` accepts the error state (tests and QA drive
|
|
410
|
+
* recovery through that public verb), while the demo page intentionally
|
|
411
|
+
* offers no restart from its error state — its policy is conservative-fatal
|
|
412
|
+
* with a manual reload (README, "Playback states and controls").
|
|
413
|
+
*/
|
|
414
|
+
function fail(code, message, detail = {}) {
|
|
415
|
+
if (state === 'error') return state;
|
|
416
|
+
failShow(code, message, detail);
|
|
417
|
+
return state;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Hold one control by its reflected name; the restart clears it. */
|
|
421
|
+
function holdControl(name, value) {
|
|
422
|
+
if (loaded === null || state === 'loading' || state === 'error') return false;
|
|
423
|
+
const accepted = loaded.driver.setControl(name, value);
|
|
424
|
+
if (accepted) held.add(name);
|
|
425
|
+
return accepted;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Return one control to its document default. */
|
|
429
|
+
function releaseControl(name) {
|
|
430
|
+
if (loaded === null || state === 'error') return false;
|
|
431
|
+
const accepted = loaded.driver.clearControl(name);
|
|
432
|
+
if (accepted) held.delete(name);
|
|
433
|
+
return accepted;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (onState) onState('loading');
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
get state() { return state; },
|
|
440
|
+
get error() { return failure; },
|
|
441
|
+
/** The current post-resume origin, or null before Start and after restart. */
|
|
442
|
+
get origin() { return origin; },
|
|
443
|
+
get tick() { return loaded === null ? 0 : loaded.player.state.tick; },
|
|
444
|
+
/** The tick the last paint drew: a step draws its tick then increments. */
|
|
445
|
+
get lastShownTick() { return loaded === null ? -1 : loaded.player.state.tick - 1; },
|
|
446
|
+
seconds,
|
|
447
|
+
totalTicks,
|
|
448
|
+
ready: () => readyPromise,
|
|
449
|
+
start,
|
|
450
|
+
pause,
|
|
451
|
+
resume,
|
|
452
|
+
stop,
|
|
453
|
+
restart,
|
|
454
|
+
fail,
|
|
455
|
+
holdControl,
|
|
456
|
+
releaseControl,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The one soundtrack filename allowlist and the generated player-config
|
|
2
|
+
// format names. This module is pure data and predicates: no `node:` import,
|
|
3
|
+
// no process state, no DOM — the CLI inspection, the receipts, the HTTP
|
|
4
|
+
// routes, the host config and the browser loader all read the same lists
|
|
5
|
+
// from here instead of maintaining private copies.
|
|
6
|
+
//
|
|
7
|
+
// Vorbis and Opus are separate audio codecs; Ogg is a container that can
|
|
8
|
+
// carry either. The conventional extensions are `.ogg` for Ogg Vorbis and
|
|
9
|
+
// `.opus` for Ogg Opus; both are served as `audio/ogg` and the browser
|
|
10
|
+
// decodes the actual bytes, not the extension. `.ogg` does not imply that
|
|
11
|
+
// the loader forces Vorbis decoding.
|
|
12
|
+
|
|
13
|
+
/** The exact case-sensitive input names recognized at a project root. */
|
|
14
|
+
export const SOUNDTRACK_NAMES = Object.freeze([
|
|
15
|
+
'soundtrack.wav',
|
|
16
|
+
'soundtrack.ogg',
|
|
17
|
+
'soundtrack.opus',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** The HTTP content type each recognized name is served as. */
|
|
21
|
+
export const SOUNDTRACK_CONTENT_TYPES = Object.freeze({
|
|
22
|
+
'soundtrack.wav': 'audio/wav',
|
|
23
|
+
'soundtrack.ogg': 'audio/ogg',
|
|
24
|
+
'soundtrack.opus': 'audio/ogg',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Extension → content type for the route MIME vocabulary. */
|
|
28
|
+
export const SOUNDTRACK_EXTENSIONS = Object.freeze({
|
|
29
|
+
'.wav': 'audio/wav',
|
|
30
|
+
'.ogg': 'audio/ogg',
|
|
31
|
+
'.opus': 'audio/ogg',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** The starter's explicit choice; new projects still generate this WAV. */
|
|
35
|
+
export const LEGACY_SOUNDTRACK = 'soundtrack.wav';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The generated player-config formats. Version 1 configs carry no
|
|
39
|
+
* `soundtrack` field and mean the legacy WAV; version 2 configs carry a
|
|
40
|
+
* required explicit selection. Compressed audio requires version 2.
|
|
41
|
+
*/
|
|
42
|
+
export const PLAYER_CONFIG_FORMATS = Object.freeze({
|
|
43
|
+
demoLegacy: 'animader-demo-player/1',
|
|
44
|
+
demo: 'animader-demo-player/2',
|
|
45
|
+
releaseLegacy: 'animader-release-player/1',
|
|
46
|
+
release: 'animader-release-player/2',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const ALL_PLAYER_CONFIG_FORMATS = Object.freeze([
|
|
50
|
+
PLAYER_CONFIG_FORMATS.demoLegacy,
|
|
51
|
+
PLAYER_CONFIG_FORMATS.demo,
|
|
52
|
+
PLAYER_CONFIG_FORMATS.releaseLegacy,
|
|
53
|
+
PLAYER_CONFIG_FORMATS.release,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {unknown} value
|
|
58
|
+
* @returns {boolean} true when the value is one of the exact allowlist names
|
|
59
|
+
*/
|
|
60
|
+
export function isSoundtrackName(value) {
|
|
61
|
+
return typeof value === 'string' && SOUNDTRACK_NAMES.includes(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The names as one line for usage text, errors and remedies. */
|
|
65
|
+
export function soundtrackNamesText() {
|
|
66
|
+
return SOUNDTRACK_NAMES.join(', ');
|
|
67
|
+
}
|
package/lib/vendor.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The prepared runtime bundle's data contract. Portable: the model and its
|
|
2
|
+
// canonical digest input live here; reading, copying and hashing are host
|
|
3
|
+
// adapter work. A revision label alone never proves a file: the bundle's
|
|
4
|
+
// identity is every copied byte's sha256 plus the source it came from.
|
|
5
|
+
|
|
6
|
+
import { DemoError } from './errors.mjs';
|
|
7
|
+
|
|
8
|
+
export const VENDOR_FORMAT = 'animader-demo-vendor/1';
|
|
9
|
+
export const VENDOR_FILE = 'provenance.json';
|
|
10
|
+
|
|
11
|
+
function bad(message) {
|
|
12
|
+
return new DemoError('bad_vendor_provenance', message, {
|
|
13
|
+
remedy: 'Regenerate the bundle with: node demo/prepare.mjs, then run node demo/prepare.mjs --verify.',
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function comparePaths(a, b) {
|
|
18
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate and normalize the provenance document. Paths are bundle-relative;
|
|
23
|
+
* the provenance file itself is not one of its own entries.
|
|
24
|
+
*
|
|
25
|
+
* @param {unknown} provenance
|
|
26
|
+
* @returns {{format: string, producers: Object, files: Array<{path: string, source: string, sha256: string, bytes: number}>}}
|
|
27
|
+
*/
|
|
28
|
+
export function validateProvenance(provenance) {
|
|
29
|
+
if (provenance === null || typeof provenance !== 'object' || Array.isArray(provenance)) {
|
|
30
|
+
throw bad('provenance is not an object');
|
|
31
|
+
}
|
|
32
|
+
if (provenance.format !== VENDOR_FORMAT) {
|
|
33
|
+
throw bad(`provenance format is ${JSON.stringify(provenance.format)}, expected ${VENDOR_FORMAT}`);
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(provenance.files) || provenance.files.length === 0) {
|
|
36
|
+
throw bad('provenance has no files');
|
|
37
|
+
}
|
|
38
|
+
if (provenance.producers === null || typeof provenance.producers !== 'object' || Array.isArray(provenance.producers)) {
|
|
39
|
+
throw bad('provenance has no producers object');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const files = provenance.files.map((entry) => {
|
|
44
|
+
if (entry === null || typeof entry !== 'object') throw bad('provenance file entry is not an object');
|
|
45
|
+
const path = String(entry.path ?? '');
|
|
46
|
+
if (
|
|
47
|
+
path === '' || path === VENDOR_FILE || path.startsWith('/') || path.includes('\\')
|
|
48
|
+
|| path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
49
|
+
) {
|
|
50
|
+
throw bad(`provenance file path is not a clean bundle-relative path: ${JSON.stringify(path)}`);
|
|
51
|
+
}
|
|
52
|
+
if (seen.has(path)) throw bad(`provenance repeats ${path}`);
|
|
53
|
+
seen.add(path);
|
|
54
|
+
if (!/^[0-9a-f]{64}$/.test(String(entry.sha256 ?? ''))) {
|
|
55
|
+
throw bad(`provenance file ${path} has no sha256 content digest`);
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isInteger(entry.bytes) || entry.bytes < 0) {
|
|
58
|
+
throw bad(`provenance file ${path} has no byte length`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
path,
|
|
62
|
+
source: String(entry.source ?? ''),
|
|
63
|
+
sha256: String(entry.sha256),
|
|
64
|
+
bytes: entry.bytes,
|
|
65
|
+
};
|
|
66
|
+
}).sort(comparePaths);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
format: VENDOR_FORMAT,
|
|
70
|
+
producers: provenance.producers,
|
|
71
|
+
files,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The exact bytes that the bundle's digest is taken over: path, content
|
|
77
|
+
* sha256 and byte length, sorted by path. Environment paths and revisions
|
|
78
|
+
* are deliberately absent, so a moved checkout does not change the identity
|
|
79
|
+
* of the copied runtime.
|
|
80
|
+
*
|
|
81
|
+
* @param {Array<{path: string, sha256: string, bytes: number}>} files
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function provenanceLines(files) {
|
|
85
|
+
return [...files]
|
|
86
|
+
.sort(comparePaths)
|
|
87
|
+
.map((entry) => `${entry.path}\0${entry.sha256}\0${entry.bytes}\n`)
|
|
88
|
+
.join('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function makeProvenance({ producers, files, digest }) {
|
|
92
|
+
const normalized = validateProvenance({ format: VENDOR_FORMAT, producers, files });
|
|
93
|
+
return {
|
|
94
|
+
format: VENDOR_FORMAT,
|
|
95
|
+
producers: normalized.producers,
|
|
96
|
+
files: normalized.files,
|
|
97
|
+
digest,
|
|
98
|
+
};
|
|
99
|
+
}
|