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/player/boot.mjs
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// The shared browser page implementation. It owns the DOM view, the
|
|
2
|
+
// controller handoff, fullscreen, the renderer-failure bridge and the real
|
|
3
|
+
// load sequence; each entry supplies only the profile (which modules to
|
|
4
|
+
// import, which bytes to fetch, how the score was prepared). The dev entry
|
|
5
|
+
// points at the server's mount names; the release entry statically imports
|
|
6
|
+
// the same portable core and fetches the exported files relative to itself.
|
|
7
|
+
//
|
|
8
|
+
// One implementation, not two players: the transport, score compile, route
|
|
9
|
+
// pair and pngine wiring below are the same code on both spellings.
|
|
10
|
+
|
|
11
|
+
const canvas = document.getElementById('canvas');
|
|
12
|
+
const performanceElement = document.getElementById('performance');
|
|
13
|
+
const stage = document.getElementById('stage');
|
|
14
|
+
const statusLine = document.getElementById('status');
|
|
15
|
+
const primaryButton = document.getElementById('primary');
|
|
16
|
+
const fullscreenButton = document.getElementById('fullscreen');
|
|
17
|
+
const errorBanner = document.getElementById('error');
|
|
18
|
+
const errorMessage = document.getElementById('error-message');
|
|
19
|
+
const diagnostics = document.getElementById('diagnostics');
|
|
20
|
+
const diagnosticsText = document.getElementById('diagnostics-text');
|
|
21
|
+
|
|
22
|
+
let controller = null;
|
|
23
|
+
let show = null;
|
|
24
|
+
let context = null;
|
|
25
|
+
let port = null;
|
|
26
|
+
let stageScale = 1;
|
|
27
|
+
let hostSeconds = null;
|
|
28
|
+
let decodedDuration = null;
|
|
29
|
+
let reflection = null;
|
|
30
|
+
let join = null;
|
|
31
|
+
let scoreText = null;
|
|
32
|
+
let scoreBytes = null;
|
|
33
|
+
let metaSafe = null;
|
|
34
|
+
let provenance = null;
|
|
35
|
+
let manifest = null;
|
|
36
|
+
let config = null;
|
|
37
|
+
let activeProfile = null;
|
|
38
|
+
let renderer = null;
|
|
39
|
+
let rendererError = null;
|
|
40
|
+
let ShowErrorClass = Error;
|
|
41
|
+
const hashes = { payload: null, timeline: null, soundtrack: null, score: null, manifest: null };
|
|
42
|
+
|
|
43
|
+
function fallbackFatal(error) {
|
|
44
|
+
const message = error?.message ?? String(error);
|
|
45
|
+
statusLine.textContent = 'error — page';
|
|
46
|
+
errorBanner.hidden = false;
|
|
47
|
+
errorMessage.textContent = message;
|
|
48
|
+
diagnostics.hidden = false;
|
|
49
|
+
diagnostics.open = true;
|
|
50
|
+
diagnosticsText.textContent = `error: ${message}\nstage: page`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The compiled metadata carries BigInt fields (pacer's own tick units). The
|
|
54
|
+
// QA projection keeps the actual values but makes them JSON-safe; the actual
|
|
55
|
+
// Show/Pacer objects remain available on the seam.
|
|
56
|
+
function jsonSafe(value) {
|
|
57
|
+
if (typeof value === 'bigint') return value.toString();
|
|
58
|
+
if (Array.isArray(value)) return value.map(jsonSafe);
|
|
59
|
+
if (value && typeof value === 'object') {
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [key, entry] of Object.entries(value)) out[key] = jsonSafe(entry);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderDiagnostics(lines) {
|
|
68
|
+
diagnosticsText.replaceChildren();
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const row = document.createElement('span');
|
|
71
|
+
row.className = 'diag-line';
|
|
72
|
+
const at = line.indexOf(':');
|
|
73
|
+
if (at > 0) {
|
|
74
|
+
const marker = document.createElement('span');
|
|
75
|
+
marker.className = 'marker';
|
|
76
|
+
marker.textContent = line.slice(0, at + 1);
|
|
77
|
+
row.append(marker, document.createTextNode(line.slice(at + 1)));
|
|
78
|
+
} else {
|
|
79
|
+
row.textContent = line;
|
|
80
|
+
}
|
|
81
|
+
diagnosticsText.append(row);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The fullscreen control is the way out as well as the way in: while the
|
|
86
|
+
// browser reports an active fullscreen element, the same button's label must
|
|
87
|
+
// say 'Exit fullscreen'. The button carries no aria-label, so its accessible
|
|
88
|
+
// name is its text — one honest string for the eye and for assistive
|
|
89
|
+
// technology, nothing left to go stale. The controller owns the 'Fullscreen'
|
|
90
|
+
// vocabulary; the page overlays the exit spelling, and both every render and
|
|
91
|
+
// every fullscreenchange re-derive it from the actual fullscreen state.
|
|
92
|
+
const FULLSCREEN_EXIT_LABEL = 'Exit fullscreen';
|
|
93
|
+
|
|
94
|
+
function secondaryLabel(label) {
|
|
95
|
+
return document.fullscreenElement ? FULLSCREEN_EXIT_LABEL : label;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function syncFullscreenLabel() {
|
|
99
|
+
const secondary = controller?.ui?.secondary;
|
|
100
|
+
if (secondary) fullscreenButton.textContent = secondaryLabel(secondary.label);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const view = {
|
|
104
|
+
apply(ui) {
|
|
105
|
+
statusLine.textContent = ui.status;
|
|
106
|
+
if (ui.primary) {
|
|
107
|
+
primaryButton.hidden = false;
|
|
108
|
+
primaryButton.textContent = ui.primary.label;
|
|
109
|
+
} else {
|
|
110
|
+
primaryButton.hidden = true;
|
|
111
|
+
}
|
|
112
|
+
if (ui.secondary) {
|
|
113
|
+
fullscreenButton.hidden = false;
|
|
114
|
+
fullscreenButton.textContent = secondaryLabel(ui.secondary.label);
|
|
115
|
+
} else {
|
|
116
|
+
fullscreenButton.hidden = true;
|
|
117
|
+
}
|
|
118
|
+
errorBanner.hidden = !ui.errorVisible;
|
|
119
|
+
errorMessage.textContent = ui.errorText;
|
|
120
|
+
diagnostics.hidden = false;
|
|
121
|
+
renderDiagnostics(ui.diagnostics);
|
|
122
|
+
diagnostics.open = ui.diagnosticsOpen;
|
|
123
|
+
},
|
|
124
|
+
activeElement() {
|
|
125
|
+
if (document.activeElement === primaryButton) return 'primary';
|
|
126
|
+
if (document.activeElement === fullscreenButton) return 'fullscreen';
|
|
127
|
+
return 'none';
|
|
128
|
+
},
|
|
129
|
+
focusPrimary() {
|
|
130
|
+
if (!primaryButton.hidden) primaryButton.focus();
|
|
131
|
+
},
|
|
132
|
+
focusSecondary() {
|
|
133
|
+
if (!fullscreenButton.hidden) fullscreenButton.focus();
|
|
134
|
+
},
|
|
135
|
+
focusError() {
|
|
136
|
+
errorBanner.focus();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function updateStageScale() {
|
|
141
|
+
const width = stage.clientWidth;
|
|
142
|
+
const height = stage.clientHeight;
|
|
143
|
+
if (width < 1 || height < 1) return;
|
|
144
|
+
const scale = Math.min(width / 1280, height / 720);
|
|
145
|
+
stageScale = scale;
|
|
146
|
+
canvas.style.transform = `scale(${scale})`;
|
|
147
|
+
canvas.style.left = `${(width - 1280 * scale) / 2}px`;
|
|
148
|
+
canvas.style.top = `${(height - 720 * scale) / 2}px`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The fullscreen action is a toggle: the control's label names what the
|
|
152
|
+
// next press does ('Fullscreen' to enter, 'Exit fullscreen' to leave), so
|
|
153
|
+
// the action must do exactly that in both directions. The browser confirms
|
|
154
|
+
// every direction with a fullscreenchange event; the label never assumes a
|
|
155
|
+
// transition the browser has not reported.
|
|
156
|
+
async function toggleFullscreen() {
|
|
157
|
+
if (document.fullscreenElement) {
|
|
158
|
+
if (typeof document.exitFullscreen !== 'function') return false;
|
|
159
|
+
try {
|
|
160
|
+
await document.exitFullscreen();
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!document.fullscreenEnabled || typeof performanceElement.requestFullscreen !== 'function') return false;
|
|
167
|
+
try {
|
|
168
|
+
await performanceElement.requestFullscreen();
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const actions = {
|
|
176
|
+
start: async () => (show ? show.start() : undefined),
|
|
177
|
+
pause: async () => (show ? show.pause() : undefined),
|
|
178
|
+
resume: async () => (show ? show.resume() : undefined),
|
|
179
|
+
stop: async () => (show ? show.stop() : undefined),
|
|
180
|
+
restart: async () => {
|
|
181
|
+
if (!show) return undefined;
|
|
182
|
+
updateStageScale();
|
|
183
|
+
await show.restart();
|
|
184
|
+
if (show.state === 'ready') await show.start();
|
|
185
|
+
return show.state;
|
|
186
|
+
},
|
|
187
|
+
fullscreen: toggleFullscreen,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
function rendererFailure(error) {
|
|
191
|
+
const name = error?.name ?? 'Error';
|
|
192
|
+
const source = error?.source ? ` (source: ${error.source})` : '';
|
|
193
|
+
const line = Number.isInteger(error?.lineNum)
|
|
194
|
+
? ` at line ${error.lineNum}${Number.isInteger(error?.linePos) ? `:${error.linePos}` : ''}`
|
|
195
|
+
: '';
|
|
196
|
+
const detail = error?.message ?? String(error);
|
|
197
|
+
return {
|
|
198
|
+
code: 'renderer_failed',
|
|
199
|
+
message: `the renderer reported ${name}${source}${line}: ${detail}`,
|
|
200
|
+
stage: 'render',
|
|
201
|
+
document: activeProfile?.documents?.payload ?? 'out/show.png',
|
|
202
|
+
diagnostics: [{
|
|
203
|
+
source: 'pngine',
|
|
204
|
+
severity: 'error',
|
|
205
|
+
code: 0,
|
|
206
|
+
start: 0,
|
|
207
|
+
end: 0,
|
|
208
|
+
message: `${name}${source}: ${detail}`,
|
|
209
|
+
}],
|
|
210
|
+
cause: error,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function handlePngineError(error) {
|
|
215
|
+
rendererError = error;
|
|
216
|
+
const failure = rendererFailure(error);
|
|
217
|
+
if (show) show.fail(failure.code, failure.message, failure);
|
|
218
|
+
else if (controller) controller.fail(failure);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function structuredFailure(error) {
|
|
222
|
+
if (error && typeof error === 'object'
|
|
223
|
+
&& ((typeof error.stage === 'string' && error.stage !== '')
|
|
224
|
+
|| (typeof error.code === 'string' && error.code !== ''))) {
|
|
225
|
+
return error;
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
code: 'page_failed',
|
|
229
|
+
message: error?.message ?? String(error),
|
|
230
|
+
stage: 'page',
|
|
231
|
+
document: 'player/boot.mjs',
|
|
232
|
+
diagnostics: [{ source: 'player', severity: 'error', code: 0, message: String(error?.stack ?? error) }],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Route one runtime failure through the page's existing failure surface: the
|
|
238
|
+
* show's structured error state when it can still take one, else the
|
|
239
|
+
* controller's. Returns the structured failure so callers can add their own
|
|
240
|
+
* context (the action handlers also log it to the console).
|
|
241
|
+
*/
|
|
242
|
+
function reportRuntimeFailure(error) {
|
|
243
|
+
const failure = structuredFailure(error);
|
|
244
|
+
if (show && show.state !== 'error') show.fail(failure.code, failure.message, failure);
|
|
245
|
+
else if (controller && controller.state !== 'error') controller.fail(failure);
|
|
246
|
+
return failure;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function qaGeometry() {
|
|
250
|
+
const rect = canvas.getBoundingClientRect();
|
|
251
|
+
return {
|
|
252
|
+
dpr: window.devicePixelRatio,
|
|
253
|
+
stage: { width: stage.clientWidth, height: stage.clientHeight },
|
|
254
|
+
canvas: {
|
|
255
|
+
backingWidth: canvas.width,
|
|
256
|
+
backingHeight: canvas.height,
|
|
257
|
+
cssWidth: rect.width,
|
|
258
|
+
cssHeight: rect.height,
|
|
259
|
+
inlineWidth: canvas.style.width,
|
|
260
|
+
inlineHeight: canvas.style.height,
|
|
261
|
+
},
|
|
262
|
+
viewer: { width: renderer?.width ?? 0, height: renderer?.height ?? 0 },
|
|
263
|
+
scale: stageScale,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The deterministic QA seam: every value is the actual runtime state or a
|
|
268
|
+
// digest of the bytes the page actually loaded. Nothing here is a counter, a
|
|
269
|
+
// forced state or a second renderer path.
|
|
270
|
+
window.animaderDemo = Object.freeze({
|
|
271
|
+
get state() { return show?.state ?? controller?.state ?? 'loading'; },
|
|
272
|
+
get tick() { return show?.tick ?? 0; },
|
|
273
|
+
get lastShownTick() { return show?.lastShownTick ?? -1; },
|
|
274
|
+
get origin() { return show?.origin ?? null; },
|
|
275
|
+
get mode() { return activeProfile?.name ?? null; },
|
|
276
|
+
get hostSeconds() { return hostSeconds; },
|
|
277
|
+
get decodedDuration() { return decodedDuration; },
|
|
278
|
+
get audioContext() { return context; },
|
|
279
|
+
get audioContextState() { return context?.state ?? null; },
|
|
280
|
+
get analyser() { return port?.analyser ?? null; },
|
|
281
|
+
get reflection() { return reflection; },
|
|
282
|
+
get join() { return join; },
|
|
283
|
+
get score() { return scoreText; },
|
|
284
|
+
get scoreBytes() { return scoreBytes; },
|
|
285
|
+
get scoreSha256() { return hashes.score; },
|
|
286
|
+
get payloadSha256() { return hashes.payload; },
|
|
287
|
+
get timelineSha256() { return hashes.timeline; },
|
|
288
|
+
get soundtrackSha256() { return hashes.soundtrack; },
|
|
289
|
+
get manifestSha256() { return hashes.manifest; },
|
|
290
|
+
get config() { return config; },
|
|
291
|
+
get manifest() { return manifest; },
|
|
292
|
+
get provenance() { return provenance; },
|
|
293
|
+
get meta() { return metaSafe; },
|
|
294
|
+
get notes() { return controller?.notes ?? []; },
|
|
295
|
+
get error() {
|
|
296
|
+
const failure = show?.error ?? controller?.error ?? null;
|
|
297
|
+
if (!failure) return null;
|
|
298
|
+
return {
|
|
299
|
+
code: failure.code ?? null,
|
|
300
|
+
message: failure.message ?? String(failure),
|
|
301
|
+
stage: failure.stage ?? '',
|
|
302
|
+
document: failure.document ?? '',
|
|
303
|
+
diagnostics: jsonSafe(failure.diagnostics ?? []),
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
get geometry() { return qaGeometry(); },
|
|
307
|
+
get show() { return show; },
|
|
308
|
+
get port() { return port; },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
function armDprWatcher() {
|
|
312
|
+
if (typeof window.matchMedia !== 'function') return;
|
|
313
|
+
const query = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
|
314
|
+
const onChange = () => {
|
|
315
|
+
controller?.handleResize();
|
|
316
|
+
armDprWatcher();
|
|
317
|
+
};
|
|
318
|
+
if (typeof query.addEventListener === 'function') query.addEventListener('change', onChange, { once: true });
|
|
319
|
+
else query.addListener?.(onChange);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function sha256Hex(bytes) {
|
|
323
|
+
const data = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes;
|
|
324
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
325
|
+
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, '0')).join('');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function safeHash(bytes) {
|
|
329
|
+
try {
|
|
330
|
+
return await sha256Hex(bytes);
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function labelOf(url) {
|
|
337
|
+
return url instanceof URL ? url.pathname : String(url);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function decodeText(bytes) {
|
|
341
|
+
return new TextDecoder().decode(bytes);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseJson(bytes) {
|
|
345
|
+
return JSON.parse(decodeText(bytes));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function fetchBytes(url, { code = 'asset_failed', stageName = 'asset', document = labelOf(url) } = {}) {
|
|
349
|
+
let response;
|
|
350
|
+
try {
|
|
351
|
+
response = await fetch(url, { cache: 'no-store' });
|
|
352
|
+
} catch (cause) {
|
|
353
|
+
throw new ShowErrorClass('network_failed', `${document} could not be fetched: ${cause?.message ?? cause}`, {
|
|
354
|
+
stage: 'network',
|
|
355
|
+
document,
|
|
356
|
+
cause,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
throw new ShowErrorClass(code, `${document} could not be read (HTTP ${response.status})`, {
|
|
361
|
+
stage: stageName,
|
|
362
|
+
document,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
let bytes;
|
|
366
|
+
try {
|
|
367
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
368
|
+
} catch (cause) {
|
|
369
|
+
throw new ShowErrorClass(code, `${document} could not be read: ${cause?.message ?? cause}`, {
|
|
370
|
+
stage: stageName,
|
|
371
|
+
document,
|
|
372
|
+
cause,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return bytes;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function manifestIndex(document) {
|
|
379
|
+
if (!document || typeof document !== 'object' || !Array.isArray(document.files)) {
|
|
380
|
+
throw new ShowErrorClass('manifest_invalid', 'manifest.json has no files array', {
|
|
381
|
+
stage: 'manifest',
|
|
382
|
+
document: 'manifest.json',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const index = new Map();
|
|
386
|
+
for (const entry of document.files) {
|
|
387
|
+
if (!entry || typeof entry.path !== 'string' || entry.path === ''
|
|
388
|
+
|| !Number.isInteger(entry.bytes) || entry.bytes < 0
|
|
389
|
+
|| typeof entry.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(entry.sha256)) {
|
|
390
|
+
throw new ShowErrorClass('manifest_invalid', 'manifest.json carries a malformed file row', {
|
|
391
|
+
stage: 'manifest',
|
|
392
|
+
document: 'manifest.json',
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (index.has(entry.path)) {
|
|
396
|
+
throw new ShowErrorClass('manifest_invalid', `manifest.json lists ${entry.path} twice`, {
|
|
397
|
+
stage: 'manifest',
|
|
398
|
+
document: 'manifest.json',
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
index.set(entry.path, entry);
|
|
402
|
+
}
|
|
403
|
+
return index;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function verifyManifested(manifestDocument, checks) {
|
|
407
|
+
if (!manifestDocument) return;
|
|
408
|
+
const index = manifestIndex(manifestDocument);
|
|
409
|
+
for (const check of checks) {
|
|
410
|
+
if (!check.manifestPath) continue;
|
|
411
|
+
const entry = index.get(check.manifestPath);
|
|
412
|
+
if (!entry) {
|
|
413
|
+
throw new ShowErrorClass('manifest_unlisted', `${check.manifestPath} is not listed in manifest.json`, {
|
|
414
|
+
stage: 'manifest',
|
|
415
|
+
document: 'manifest.json',
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (entry.bytes !== check.bytes.byteLength) {
|
|
419
|
+
throw new ShowErrorClass(
|
|
420
|
+
'asset_tampered',
|
|
421
|
+
`${check.manifestPath} is ${check.bytes.byteLength} bytes but manifest.json records ${entry.bytes}`,
|
|
422
|
+
{ stage: check.stage, document: check.manifestPath },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const digest = await safeHash(check.bytes);
|
|
426
|
+
if (digest !== entry.sha256) {
|
|
427
|
+
throw new ShowErrorClass('asset_tampered', `${check.manifestPath} does not match its manifest sha256`, {
|
|
428
|
+
stage: check.stage,
|
|
429
|
+
document: check.manifestPath,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function loadAsset(asset) {
|
|
436
|
+
const bytes = await fetchBytes(asset.url, {
|
|
437
|
+
code: asset.code ?? 'asset_failed',
|
|
438
|
+
stageName: asset.stage ?? 'assets',
|
|
439
|
+
document: asset.document ?? labelOf(asset.url),
|
|
440
|
+
});
|
|
441
|
+
const result = { bytes };
|
|
442
|
+
if (asset.kind === 'text') result.text = decodeText(bytes);
|
|
443
|
+
else if (asset.kind === 'json') result.json = parseJson(bytes);
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function load(controllerModule, profile) {
|
|
448
|
+
const { audioDurationNote } = controllerModule;
|
|
449
|
+
const documents = profile.documents;
|
|
450
|
+
|
|
451
|
+
controller.setLoading('modules');
|
|
452
|
+
const modules = await profile.loadModules();
|
|
453
|
+
const { score: scoreModule, show: showModule, webaudio: webaudioModule, pngine: pngineModule } = modules;
|
|
454
|
+
const { ShowError } = scoreModule;
|
|
455
|
+
ShowErrorClass = ShowError;
|
|
456
|
+
const { createShow } = showModule;
|
|
457
|
+
const { createWebAudioPort } = webaudioModule;
|
|
458
|
+
const { pngine, draw, setUniform, getUniforms, restartSettled } = pngineModule;
|
|
459
|
+
if (typeof restartSettled !== 'function') {
|
|
460
|
+
throw new ShowError(
|
|
461
|
+
'runtime_stale',
|
|
462
|
+
`the prepared pngine runtime at ${documents.runtimeModule} does not export restartSettled; `
|
|
463
|
+
+ (profile.remedy ?? 're-run the one-time prepare (node demo/prepare.mjs) to re-vendor it from the actual build'),
|
|
464
|
+
{ stage: 'modules', document: documents.runtimeModule },
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const { routeWrites, toUniforms } = modules.route;
|
|
468
|
+
const { createPlayer } = modules.run;
|
|
469
|
+
|
|
470
|
+
controller.setLoading('config');
|
|
471
|
+
const configBytes = await fetchBytes(profile.configUrl, {
|
|
472
|
+
code: 'config_failed',
|
|
473
|
+
stageName: 'config',
|
|
474
|
+
document: documents.config,
|
|
475
|
+
});
|
|
476
|
+
let parsedConfig;
|
|
477
|
+
try {
|
|
478
|
+
parsedConfig = parseJson(configBytes);
|
|
479
|
+
} catch (cause) {
|
|
480
|
+
throw new ShowError('config_failed', `${documents.config} is not JSON: ${cause?.message ?? cause}`, {
|
|
481
|
+
stage: 'config',
|
|
482
|
+
document: documents.config,
|
|
483
|
+
cause,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (typeof parsedConfig?.seconds !== 'number' || !Number.isFinite(parsedConfig.seconds) || parsedConfig.seconds <= 0) {
|
|
487
|
+
throw new ShowError('invalid_config', `the host configuration has no positive seconds value (got ${String(parsedConfig?.seconds)})`, {
|
|
488
|
+
stage: 'config',
|
|
489
|
+
document: documents.config,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
// The generated config names its format and the selected soundtrack; the
|
|
493
|
+
// loader accepts version 1 with no explicit selection as the legacy WAV,
|
|
494
|
+
// refuses compressed names on version 1, and refuses unknown versions or
|
|
495
|
+
// selections outright. The audio URL is derived only from a validated name.
|
|
496
|
+
const soundtrackData = modules.soundtrack;
|
|
497
|
+
const format = parsedConfig.format;
|
|
498
|
+
if (!soundtrackData.ALL_PLAYER_CONFIG_FORMATS.includes(format)) {
|
|
499
|
+
throw new ShowError(
|
|
500
|
+
'invalid_config',
|
|
501
|
+
`${documents.config} names the unsupported format ${JSON.stringify(format ?? null)}; the supported formats are ${soundtrackData.ALL_PLAYER_CONFIG_FORMATS.join(', ')}`,
|
|
502
|
+
{ stage: 'config', document: documents.config },
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const legacyFormats = [soundtrackData.PLAYER_CONFIG_FORMATS.demoLegacy, soundtrackData.PLAYER_CONFIG_FORMATS.releaseLegacy];
|
|
506
|
+
let soundtrackName;
|
|
507
|
+
if (legacyFormats.includes(format)) {
|
|
508
|
+
const requested = parsedConfig.soundtrack;
|
|
509
|
+
if (requested === undefined) {
|
|
510
|
+
soundtrackName = soundtrackData.LEGACY_SOUNDTRACK;
|
|
511
|
+
} else if (requested === soundtrackData.LEGACY_SOUNDTRACK) {
|
|
512
|
+
soundtrackName = requested;
|
|
513
|
+
} else {
|
|
514
|
+
throw new ShowError(
|
|
515
|
+
'invalid_config',
|
|
516
|
+
`${documents.config} is a version-1 config but carries the explicit selection ${JSON.stringify(requested)}; version 1 means the legacy ${soundtrackData.LEGACY_SOUNDTRACK}, and compressed audio requires version 2`,
|
|
517
|
+
{ stage: 'config', document: documents.config },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
} else if (!soundtrackData.isSoundtrackName(parsedConfig.soundtrack)) {
|
|
521
|
+
throw new ShowError(
|
|
522
|
+
'invalid_config',
|
|
523
|
+
`${documents.config} requires a soundtrack field naming exactly one of ${soundtrackData.SOUNDTRACK_NAMES.join(', ')}; got ${JSON.stringify(parsedConfig.soundtrack ?? null)}`,
|
|
524
|
+
{ stage: 'config', document: documents.config },
|
|
525
|
+
);
|
|
526
|
+
} else {
|
|
527
|
+
soundtrackName = parsedConfig.soundtrack;
|
|
528
|
+
}
|
|
529
|
+
config = parsedConfig;
|
|
530
|
+
hostSeconds = parsedConfig.seconds;
|
|
531
|
+
controller.setSeconds(hostSeconds);
|
|
532
|
+
|
|
533
|
+
if (profile.manifestUrl) {
|
|
534
|
+
const manifestBytes = await fetchBytes(profile.manifestUrl, {
|
|
535
|
+
code: 'manifest_failed',
|
|
536
|
+
stageName: 'manifest',
|
|
537
|
+
document: documents.manifest,
|
|
538
|
+
});
|
|
539
|
+
try {
|
|
540
|
+
manifest = parseJson(manifestBytes);
|
|
541
|
+
} catch (cause) {
|
|
542
|
+
throw new ShowError('manifest_failed', `${documents.manifest} is not JSON: ${cause?.message ?? cause}`, {
|
|
543
|
+
stage: 'manifest',
|
|
544
|
+
document: documents.manifest,
|
|
545
|
+
cause,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
hashes.manifest = await safeHash(manifestBytes);
|
|
549
|
+
// Release order: the manifest is validated and the config bytes are
|
|
550
|
+
// verified against their entry before the validated selection constructs
|
|
551
|
+
// any asset request, so a config that is not the shipped one never
|
|
552
|
+
// triggers the audio or asset fetches.
|
|
553
|
+
await verifyManifested(manifest, [
|
|
554
|
+
{ bytes: configBytes, manifestPath: profile.configManifestPath, stage: 'config' },
|
|
555
|
+
]);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
controller.setLoading('audio');
|
|
559
|
+
const AudioContextClass = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
|
560
|
+
if (typeof AudioContextClass !== 'function') {
|
|
561
|
+
throw new ShowError('unsupported_audio', 'this browser exposes no AudioContext for the demo soundtrack', {
|
|
562
|
+
stage: 'audio',
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
context = new AudioContextClass();
|
|
566
|
+
if (context.state !== 'suspended') {
|
|
567
|
+
try {
|
|
568
|
+
await context.suspend();
|
|
569
|
+
} catch {
|
|
570
|
+
// The state check below names the real failure.
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (context.state !== 'suspended') {
|
|
574
|
+
throw new ShowError('audio_suspended_failed', `the audio context could not be created suspended (state ${context.state})`, {
|
|
575
|
+
stage: 'audio',
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
port = createWebAudioPort({ context });
|
|
579
|
+
|
|
580
|
+
controller.setLoading('assets');
|
|
581
|
+
const assetList = [...profile.assets, profile.soundtrackAsset(soundtrackName)];
|
|
582
|
+
const fetched = await Promise.all(assetList.map((asset) => loadAsset(asset)));
|
|
583
|
+
const assets = {};
|
|
584
|
+
assetList.forEach((asset, index) => { assets[asset.key] = fetched[index]; });
|
|
585
|
+
|
|
586
|
+
// The config entry was already verified against the manifest before the
|
|
587
|
+
// asset requests; this second pass covers the fetched assets themselves,
|
|
588
|
+
// including the selected soundtrack, so the decoded audio is the manifest's
|
|
589
|
+
// audio and not merely something served at its URL.
|
|
590
|
+
await verifyManifested(manifest, assetList.map((asset) => ({
|
|
591
|
+
bytes: assets[asset.key].bytes,
|
|
592
|
+
manifestPath: asset.manifestPath,
|
|
593
|
+
stage: asset.stage ?? 'assets',
|
|
594
|
+
})));
|
|
595
|
+
|
|
596
|
+
provenance = assets.provenance?.json ?? null;
|
|
597
|
+
hashes.payload = assets.payload ? await safeHash(assets.payload.bytes) : null;
|
|
598
|
+
hashes.timeline = assets.timeline ? await safeHash(assets.timeline.bytes) : null;
|
|
599
|
+
hashes.soundtrack = assets.soundtrack ? await safeHash(assets.soundtrack.bytes) : null;
|
|
600
|
+
|
|
601
|
+
controller.setLoading('viewer');
|
|
602
|
+
if (!globalThis.navigator?.gpu) {
|
|
603
|
+
throw new ShowError('unsupported_webgpu', 'this browser exposes no WebGPU, which the prepared viewer requires', {
|
|
604
|
+
stage: 'gpu',
|
|
605
|
+
document: documents.payload,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
const payload = assets.payload.bytes;
|
|
609
|
+
try {
|
|
610
|
+
renderer = await pngine(payload, { canvas, onError: handlePngineError });
|
|
611
|
+
} catch (cause) {
|
|
612
|
+
if (cause && typeof cause === 'object' && typeof cause.code === 'string') throw cause;
|
|
613
|
+
throw new ShowError('renderer_failed', `the prepared viewer could not initialize: ${cause?.message ?? cause}`, {
|
|
614
|
+
stage: 'gpu',
|
|
615
|
+
document: documents.payload,
|
|
616
|
+
cause,
|
|
617
|
+
diagnostics: [{ source: 'pngine', severity: 'error', code: 0, message: String(cause?.message ?? cause) }],
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
if (rendererError !== null) throw rendererFailure(rendererError);
|
|
621
|
+
reflection = await getUniforms(renderer);
|
|
622
|
+
if (rendererError !== null) throw rendererFailure(rendererError);
|
|
623
|
+
if (!reflection || Object.keys(reflection).length === 0) {
|
|
624
|
+
throw new ShowError('reflection_empty', 'the prepared program reflects no uniform targets', {
|
|
625
|
+
stage: 'reflect',
|
|
626
|
+
document: documents.payload,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
controller.setLoading('score');
|
|
631
|
+
const loaded = await profile.loadScore({ modules, assets, reflection });
|
|
632
|
+
if (rendererError !== null) throw rendererFailure(rendererError);
|
|
633
|
+
scoreText = typeof loaded.score === 'string' ? loaded.score : null;
|
|
634
|
+
scoreBytes = Number.isInteger(loaded.scoreBytes)
|
|
635
|
+
? loaded.scoreBytes
|
|
636
|
+
: scoreText === null
|
|
637
|
+
? (loaded.score instanceof Uint8Array ? loaded.score.byteLength : null)
|
|
638
|
+
: new TextEncoder().encode(scoreText).byteLength;
|
|
639
|
+
join = loaded.join;
|
|
640
|
+
metaSafe = jsonSafe(loaded.meta);
|
|
641
|
+
hashes.score = await safeHash(loaded.score);
|
|
642
|
+
|
|
643
|
+
controller.setLoading('player');
|
|
644
|
+
const player = createPlayer({
|
|
645
|
+
pacer: {
|
|
646
|
+
setControl: (ref, value) => loaded.driver.setControl(ref, value),
|
|
647
|
+
clearControl: (ref) => loaded.driver.clearControl(ref),
|
|
648
|
+
eval: (now) => loaded.driver.eval(now / 1_000_000),
|
|
649
|
+
},
|
|
650
|
+
routeWrites,
|
|
651
|
+
toUniforms,
|
|
652
|
+
setUniform: (name, value, options) => setUniform(renderer, name, value, options),
|
|
653
|
+
draw: (options) => draw(renderer, options),
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
controller.setLoading('audio decode');
|
|
657
|
+
show = createShow({
|
|
658
|
+
seconds: hostSeconds,
|
|
659
|
+
prepare: async () => ({
|
|
660
|
+
driver: loaded.driver,
|
|
661
|
+
player,
|
|
662
|
+
renderer: {
|
|
663
|
+
// The owner's settled verb resolves only after the real device
|
|
664
|
+
// rebuild; returning it makes the show's restart await the reset
|
|
665
|
+
// instead of resolving on the legacy fire-and-forget post.
|
|
666
|
+
restart: () => restartSettled(renderer),
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
decode: async () => {
|
|
670
|
+
const audioBytes = assets.soundtrack.bytes;
|
|
671
|
+
let decoded;
|
|
672
|
+
try {
|
|
673
|
+
decoded = await port.decode(audioBytes.buffer.slice(
|
|
674
|
+
audioBytes.byteOffset,
|
|
675
|
+
audioBytes.byteOffset + audioBytes.byteLength,
|
|
676
|
+
));
|
|
677
|
+
} catch (cause) {
|
|
678
|
+
// Corrupt, truncated or unsupported audio names the asset and the
|
|
679
|
+
// browser's own decode cause; it is never treated as empty audio.
|
|
680
|
+
throw new ShowErrorClass(
|
|
681
|
+
'audio_decode_failed',
|
|
682
|
+
`${soundtrackName} could not be decoded: ${cause?.message ?? cause}`,
|
|
683
|
+
{ stage: 'audio', document: soundtrackName, cause },
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
decodedDuration = decoded.duration;
|
|
687
|
+
const note = audioDurationNote(decoded.duration, hostSeconds);
|
|
688
|
+
if (note) controller.addNote(note);
|
|
689
|
+
return decoded;
|
|
690
|
+
},
|
|
691
|
+
audio: port,
|
|
692
|
+
scheduleWake: (fn) => {
|
|
693
|
+
const id = requestAnimationFrame(fn);
|
|
694
|
+
return () => cancelAnimationFrame(id);
|
|
695
|
+
},
|
|
696
|
+
onState: (state) => controller.handleState(state, show?.error ?? null),
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
await show.ready();
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (show.state !== 'error') controller.fail(error);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Boot the shared page with one profile.
|
|
708
|
+
*
|
|
709
|
+
* @param {{
|
|
710
|
+
* name: 'dev'|'release',
|
|
711
|
+
* remedy?: string,
|
|
712
|
+
* loadController: () => Promise<object>,
|
|
713
|
+
* loadModules: () => Promise<object>,
|
|
714
|
+
* configUrl: string|URL,
|
|
715
|
+
* configManifestPath?: string|null,
|
|
716
|
+
* manifestUrl?: string|URL|null,
|
|
717
|
+
* assets: Array<{key: string, url: string|URL, kind: 'bytes'|'text'|'json', code?: string,
|
|
718
|
+
* stage?: string, document?: string, manifestPath?: string|null}>,
|
|
719
|
+
* loadScore: (options: {modules: object, assets: object, reflection: object}) => Promise<object>,
|
|
720
|
+
* documents: {config: string, payload: string, runtimeModule: string, manifest: string},
|
|
721
|
+
* }} profile
|
|
722
|
+
*/
|
|
723
|
+
export async function boot(profile) {
|
|
724
|
+
activeProfile = profile;
|
|
725
|
+
let controllerModule;
|
|
726
|
+
try {
|
|
727
|
+
controllerModule = await profile.loadController();
|
|
728
|
+
} catch (error) {
|
|
729
|
+
fallbackFatal(error);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const { createPlayerController } = controllerModule;
|
|
734
|
+
controller = createPlayerController({
|
|
735
|
+
actions,
|
|
736
|
+
view,
|
|
737
|
+
seconds: null,
|
|
738
|
+
fullscreenAvailable: Boolean(document.fullscreenEnabled),
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// A rejecting action must be observable: route it through the page's
|
|
742
|
+
// runtime-failure surface and log it. Swallowing here (`catch(() => {})`)
|
|
743
|
+
// once left a failed Start looking inert for minutes with the reason lost.
|
|
744
|
+
const reportActionFailure = (label) => (error) => {
|
|
745
|
+
const failure = reportRuntimeFailure(error);
|
|
746
|
+
console.error(`animader: the ${label} action failed: ${failure.message}`);
|
|
747
|
+
};
|
|
748
|
+
primaryButton.addEventListener('click', () => {
|
|
749
|
+
Promise.resolve(controller.pressPrimary()).catch(reportActionFailure('primary'));
|
|
750
|
+
});
|
|
751
|
+
fullscreenButton.addEventListener('click', () => {
|
|
752
|
+
Promise.resolve(controller.pressFullscreen()).catch(reportActionFailure('fullscreen'));
|
|
753
|
+
});
|
|
754
|
+
document.addEventListener('keydown', (event) => {
|
|
755
|
+
controller.handleKey(event);
|
|
756
|
+
});
|
|
757
|
+
document.addEventListener('visibilitychange', () => {
|
|
758
|
+
void controller.setVisibility(document.hidden);
|
|
759
|
+
});
|
|
760
|
+
document.addEventListener('fullscreenchange', () => {
|
|
761
|
+
updateStageScale();
|
|
762
|
+
syncFullscreenLabel();
|
|
763
|
+
if (!document.fullscreenElement) controller.handleFullscreenExit();
|
|
764
|
+
});
|
|
765
|
+
window.addEventListener('resize', () => {
|
|
766
|
+
updateStageScale();
|
|
767
|
+
controller.handleResize();
|
|
768
|
+
});
|
|
769
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
770
|
+
const observer = new ResizeObserver(updateStageScale);
|
|
771
|
+
observer.observe(stage);
|
|
772
|
+
}
|
|
773
|
+
updateStageScale();
|
|
774
|
+
armDprWatcher();
|
|
775
|
+
|
|
776
|
+
try {
|
|
777
|
+
await load(controllerModule, profile);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
reportRuntimeFailure(error);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The shared entry failure bridge: entries call this if boot itself rejects
|
|
785
|
+
* before it can own the controller.
|
|
786
|
+
*/
|
|
787
|
+
export function reportBootFailure(error) {
|
|
788
|
+
const failure = structuredFailure(error);
|
|
789
|
+
if (show && show.state !== 'error') show.fail(failure.code, failure.message, failure);
|
|
790
|
+
else if (controller) controller.fail(failure);
|
|
791
|
+
else fallbackFatal(failure);
|
|
792
|
+
}
|