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.
Files changed (71) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +265 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +603 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +183 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +80 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +143 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/rmtree.mjs +49 -0
  28. package/lib/routes.mjs +170 -0
  29. package/lib/score.mjs +434 -0
  30. package/lib/show.mjs +458 -0
  31. package/lib/soundtrack.mjs +67 -0
  32. package/lib/vendor.mjs +99 -0
  33. package/lib/wav.mjs +197 -0
  34. package/package.json +47 -4
  35. package/player/boot.mjs +792 -0
  36. package/player/controller.mjs +366 -0
  37. package/player/devchannel.mjs +306 -0
  38. package/player/index.html +35 -0
  39. package/player/page.mjs +134 -0
  40. package/player/player.css +393 -0
  41. package/player/release.mjs +76 -0
  42. package/src/commands/build.mjs +201 -0
  43. package/src/commands/dev.mjs +214 -0
  44. package/src/commands/doctor.mjs +348 -0
  45. package/src/commands/export.mjs +158 -0
  46. package/src/commands/new.mjs +164 -0
  47. package/src/commands/project.mjs +46 -0
  48. package/src/config.mjs +110 -0
  49. package/src/router.mjs +149 -0
  50. package/src/toolchain.mjs +691 -0
  51. package/starters/circle/README.md +50 -0
  52. package/starters/circle/animader.json +4 -0
  53. package/starters/circle/gitignore +9 -0
  54. package/starters/circle/kernels/circle.wgsl +32 -0
  55. package/starters/circle/program.sjon +34 -0
  56. package/starters/circle/timeline.piece.sjon +26 -0
  57. package/starters/ember/bake/spans.bin +0 -0
  58. package/starters/ember/kernels/curl.wgsl +10 -0
  59. package/starters/ember/kernels/ember.wgsl +10 -0
  60. package/starters/ember/kernels/hash.wgsl +12 -0
  61. package/starters/ember/kernels/simplex.wgsl +27 -0
  62. package/starters/ember/program.sjon +328 -0
  63. package/starters/ember/timeline.piece.sjon +34 -0
  64. package/toolchain/licenses/animader.txt +121 -0
  65. package/toolchain/licenses/esbuild.txt +21 -0
  66. package/toolchain/licenses/pacer.txt +121 -0
  67. package/toolchain/licenses/pngine.txt +121 -0
  68. package/toolchain/manifest.json +212 -0
  69. package/toolchain/prepare.mjs +446 -0
  70. package/toolchain/uniform-type-name.js +184 -0
  71. 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
+ }