animader-cli 0.0.0-stage → 0.1.0
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 +261 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +601 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +211 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +68 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +129 -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/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
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// One-time prepare adapter: fills the ignored demo/vendor/ bundle from the
|
|
2
|
+
// workspace's actual built producers and writes the ignored fixture WAV.
|
|
3
|
+
// It records every copied JS/WASM file's content sha256, its source path and
|
|
4
|
+
// the producer's package version. Revision labels are supplementary; the
|
|
5
|
+
// content rows and their digest are the proof.
|
|
6
|
+
//
|
|
7
|
+
// Roots: the content root is the repository root that owns this package's
|
|
8
|
+
// checkout (npm/cli/toolchain → three levels up). Sibling checkouts are two
|
|
9
|
+
// levels above it, and ANIMADER_PNGINE / ANIMADER_PACER / PNGINE override
|
|
10
|
+
// them using the checkout convention. `$HOME/Dev` is not consulted.
|
|
11
|
+
//
|
|
12
|
+
// This module is the contributor workspace's prepare; the installed package
|
|
13
|
+
// resolves the same artifacts through Node module resolution instead
|
|
14
|
+
// (src/toolchain.mjs, stage 2 of the installed-toolchain plan).
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import { DemoError } from '../lib/errors.mjs';
|
|
22
|
+
import { createTestTone } from '../lib/wav.mjs';
|
|
23
|
+
import {
|
|
24
|
+
VENDOR_FILE,
|
|
25
|
+
VENDOR_FORMAT,
|
|
26
|
+
makeProvenance,
|
|
27
|
+
provenanceLines,
|
|
28
|
+
validateProvenance,
|
|
29
|
+
} from '../lib/vendor.mjs';
|
|
30
|
+
|
|
31
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
export function defaultContentRoot() {
|
|
34
|
+
return resolve(HERE, '..', '..', '..');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveRoots({ contentRoot = defaultContentRoot(), env = process.env } = {}) {
|
|
38
|
+
const absoluteContent = resolve(contentRoot);
|
|
39
|
+
const workspaceRoot = resolve(absoluteContent, '..', '..');
|
|
40
|
+
const pngineRoot = resolve(env.ANIMADER_PNGINE || join(workspaceRoot, 'pngine'));
|
|
41
|
+
const pacerRoot = resolve(env.ANIMADER_PACER || join(workspaceRoot, 'pacer'));
|
|
42
|
+
const pngineCli = resolve(env.PNGINE || join(pngineRoot, 'zig-out', 'bin', 'pngine'));
|
|
43
|
+
return { contentRoot: absoluteContent, workspaceRoot, pngineRoot, pacerRoot, pngineCli };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sha256(bytes) {
|
|
47
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJsonIfPresent(path) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return null;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function listJs(root, producer, remedy) {
|
|
60
|
+
let names;
|
|
61
|
+
try {
|
|
62
|
+
names = await readdir(root);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
65
|
+
throw new DemoError('missing_producer_input', `${producer} source directory is missing: ${root}`, { remedy });
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return names.filter((name) => name.endsWith('.js')).sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The vendor plan: every runtime file the contributor bundle (and the
|
|
73
|
+
* release manifest's runtime rows — scripts/release-runtime.mjs) carries,
|
|
74
|
+
* as destination → producer-source pairs. The whole producer subtrees,
|
|
75
|
+
* not an import closure: the dir-mode mount serves these by containment,
|
|
76
|
+
* so any narrower derivation could drift from what it serves. */
|
|
77
|
+
export async function planSources(roots) {
|
|
78
|
+
const sources = [];
|
|
79
|
+
const add = (producer, dest, source, remedy) => sources.push({ producer, dest, source, remedy });
|
|
80
|
+
|
|
81
|
+
add(
|
|
82
|
+
'animader',
|
|
83
|
+
'animader/animader.wasm',
|
|
84
|
+
join(roots.contentRoot, 'zig-out', 'bin', 'animader.wasm'),
|
|
85
|
+
"Build it with 'zig build wasm' from the content root, or point the demo at a built wasm artifact.",
|
|
86
|
+
);
|
|
87
|
+
for (const name of await listJs(
|
|
88
|
+
join(roots.contentRoot, 'npm', 'animader', 'src'),
|
|
89
|
+
'animader npm',
|
|
90
|
+
'The animader JS glue is part of this checkout; restore npm/animader/src before preparing the demo.',
|
|
91
|
+
)) {
|
|
92
|
+
add('animader', `animader/${name}`, join(roots.contentRoot, 'npm', 'animader', 'src', name), 'Restore the animader npm/animader/src tree.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
add(
|
|
96
|
+
'pngine',
|
|
97
|
+
'pngine.mjs',
|
|
98
|
+
join(roots.pngineRoot, 'npm', 'pngine', 'dist', 'viewer.mjs'),
|
|
99
|
+
"Build pngine's npm viewer, or set ANIMADER_PNGINE to the pngine checkout root.",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
for (const name of await listJs(
|
|
103
|
+
join(roots.pacerRoot, 'npm', 'pacer', 'src'),
|
|
104
|
+
'pacer npm',
|
|
105
|
+
'Set ANIMADER_PACER to the pacer checkout root, or restore its npm/pacer/src tree.',
|
|
106
|
+
)) {
|
|
107
|
+
add('pacer', `pacer/${name}`, join(roots.pacerRoot, 'npm', 'pacer', 'src', name), 'Set ANIMADER_PACER to the pacer checkout root, or restore its npm/pacer/src tree.');
|
|
108
|
+
}
|
|
109
|
+
add(
|
|
110
|
+
'pacer',
|
|
111
|
+
'pacer/pacer.wasm',
|
|
112
|
+
join(roots.pacerRoot, 'npm', 'pacer', 'pacer.wasm'),
|
|
113
|
+
"Build pacer.wasm, or set ANIMADER_PACER to the pacer checkout root.",
|
|
114
|
+
);
|
|
115
|
+
add(
|
|
116
|
+
'pacer',
|
|
117
|
+
'pacer/pacer-rt.wasm',
|
|
118
|
+
join(roots.pacerRoot, 'npm', 'pacer', 'pacer-rt.wasm'),
|
|
119
|
+
"Build pacer-rt.wasm with 'zig build wasm-rt', or set ANIMADER_PACER to the pacer checkout root.",
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const pnginePackage = await readJsonIfPresent(join(roots.pngineRoot, 'npm', 'pngine', 'package.json'));
|
|
123
|
+
const pacerPackage = await readJsonIfPresent(join(roots.pacerRoot, 'npm', 'pacer', 'package.json'));
|
|
124
|
+
return {
|
|
125
|
+
sources: sources.sort((a, b) => (a.dest < b.dest ? -1 : a.dest > b.dest ? 1 : 0)),
|
|
126
|
+
producers: {
|
|
127
|
+
animader: { contentRoot: roots.contentRoot },
|
|
128
|
+
pngine: { root: roots.pngineRoot, packageVersion: pnginePackage?.version ?? null },
|
|
129
|
+
pacer: { root: roots.pacerRoot, packageVersion: pacerPackage?.version ?? null },
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readSource(entry) {
|
|
135
|
+
let info;
|
|
136
|
+
try {
|
|
137
|
+
info = await stat(entry.source);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error.code === 'ENOENT') {
|
|
140
|
+
throw new DemoError('missing_producer_input', `${entry.producer} input is missing: ${entry.source}`, {
|
|
141
|
+
remedy: entry.remedy,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
if (!info.isFile() || info.size === 0) {
|
|
147
|
+
throw new DemoError('missing_producer_input', `${entry.producer} input is not a non-empty file: ${entry.source}`, {
|
|
148
|
+
remedy: entry.remedy,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return readFile(entry.source);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Copy the planned runtime into vendorRoot with a fresh provenance document.
|
|
156
|
+
* All sources are checked before the live bundle is touched.
|
|
157
|
+
*
|
|
158
|
+
* @param {{contentRoot: string, pngineRoot: string, pacerRoot: string, vendorRoot: string}} roots
|
|
159
|
+
*/
|
|
160
|
+
export async function prepareVendor(roots) {
|
|
161
|
+
const plan = await planSources(roots);
|
|
162
|
+
const staged = [];
|
|
163
|
+
for (const entry of plan.sources) {
|
|
164
|
+
const bytes = await readSource(entry);
|
|
165
|
+
staged.push({ entry, bytes });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const staging = `${roots.vendorRoot}.next`;
|
|
169
|
+
let committed = false;
|
|
170
|
+
try {
|
|
171
|
+
await rm(staging, { recursive: true, force: true });
|
|
172
|
+
for (const { entry, bytes } of staged) {
|
|
173
|
+
const destination = join(staging, entry.dest);
|
|
174
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
175
|
+
await writeFile(destination, bytes);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const files = [];
|
|
179
|
+
for (const { entry } of staged) {
|
|
180
|
+
const bytes = await readFile(join(staging, entry.dest));
|
|
181
|
+
files.push({
|
|
182
|
+
path: entry.dest,
|
|
183
|
+
source: entry.source,
|
|
184
|
+
sha256: sha256(bytes),
|
|
185
|
+
bytes: bytes.byteLength,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
189
|
+
const digest = sha256(provenanceLines(files));
|
|
190
|
+
const provenance = makeProvenance({ producers: plan.producers, files, digest });
|
|
191
|
+
await writeFile(join(staging, VENDOR_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
|
|
192
|
+
|
|
193
|
+
await rm(roots.vendorRoot, { recursive: true, force: true });
|
|
194
|
+
await rename(staging, roots.vendorRoot);
|
|
195
|
+
committed = true;
|
|
196
|
+
return { vendorRoot: roots.vendorRoot, provenance };
|
|
197
|
+
} finally {
|
|
198
|
+
if (!committed) await rm(staging, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function walkFiles(root, prefix = '') {
|
|
203
|
+
const entries = await readdir(join(root, prefix), { withFileTypes: true });
|
|
204
|
+
const files = [];
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
207
|
+
if (entry.isDirectory()) files.push(...await walkFiles(root, relative));
|
|
208
|
+
else files.push({ path: relative, kind: entry.isFile() ? 'file' : 'other' });
|
|
209
|
+
}
|
|
210
|
+
return files;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read and validate a bundle's provenance document, including its content
|
|
215
|
+
* digest. Missing or malformed documents are named distinctly from content
|
|
216
|
+
* drift so a caller can report the right remedy.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} vendorRoot
|
|
219
|
+
*/
|
|
220
|
+
async function loadProvenance(vendorRoot) {
|
|
221
|
+
let text;
|
|
222
|
+
try {
|
|
223
|
+
text = await readFile(join(vendorRoot, VENDOR_FILE), 'utf8');
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error.code === 'ENOENT') {
|
|
226
|
+
throw new DemoError('vendor_missing', `the runtime bundle is missing: ${join(vendorRoot, VENDOR_FILE)}`, {
|
|
227
|
+
remedy: 'Run the one-time prepare: node demo/prepare.mjs',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(text);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new DemoError('bad_vendor_provenance', `${VENDOR_FILE} is not JSON: ${error.message}`, {
|
|
238
|
+
remedy: 'Regenerate the bundle with: node demo/prepare.mjs',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const provenance = validateProvenance(parsed);
|
|
242
|
+
const digest = sha256(provenanceLines(provenance.files));
|
|
243
|
+
if (parsed.digest !== digest) {
|
|
244
|
+
throw new DemoError('vendor_provenance_digest', `${VENDOR_FILE} digest does not match its content rows`, {
|
|
245
|
+
remedy: 'Regenerate the bundle with: node demo/prepare.mjs',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return { provenance, digest };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The producer roots recorded in the bundle, keyed by provenance name. */
|
|
252
|
+
function recordedProducerRoots(producers) {
|
|
253
|
+
const roots = {};
|
|
254
|
+
if (typeof producers?.animader?.contentRoot === 'string') roots.animader = producers.animader.contentRoot;
|
|
255
|
+
if (typeof producers?.pngine?.root === 'string') roots.pngine = producers.pngine.root;
|
|
256
|
+
if (typeof producers?.pacer?.root === 'string') roots.pacer = producers.pacer.root;
|
|
257
|
+
return roots;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The current root for a producer, preferring an explicitly resolved root. */
|
|
261
|
+
function currentProducerRoot(name, resolvedRoots, recordedRoot) {
|
|
262
|
+
if (!resolvedRoots) return recordedRoot;
|
|
263
|
+
const current = {
|
|
264
|
+
animader: resolvedRoots.contentRoot,
|
|
265
|
+
pngine: resolvedRoots.pngineRoot,
|
|
266
|
+
pacer: resolvedRoots.pacerRoot,
|
|
267
|
+
}[name];
|
|
268
|
+
return current ?? recordedRoot;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Match a recorded absolute source against the recorded producer roots and
|
|
273
|
+
* return the producer-relative path. Longest root wins so a nested checkout
|
|
274
|
+
* cannot steal a file from its parent. Returns null when no root contains it.
|
|
275
|
+
*/
|
|
276
|
+
function locateProducerSource(source, roots) {
|
|
277
|
+
let match = null;
|
|
278
|
+
for (const [name, root] of Object.entries(roots)) {
|
|
279
|
+
const rel = relative(resolve(root), resolve(source));
|
|
280
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) continue;
|
|
281
|
+
if (match === null || root.length > match.root.length) match = { name, root, relative: rel };
|
|
282
|
+
}
|
|
283
|
+
return match;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Close the post-prepare blind spot: compare every vendored file with the
|
|
288
|
+
* current bytes of the producer file its provenance says it came from. A
|
|
289
|
+
* bundle that agrees with its own record can still be stale against a
|
|
290
|
+
* producer that has moved since prepare.
|
|
291
|
+
*
|
|
292
|
+
* @param {{vendorRoot: string, roots?: Object}} options
|
|
293
|
+
*/
|
|
294
|
+
export async function verifyProducerFreshness({ vendorRoot, roots = null }) {
|
|
295
|
+
const { provenance } = await loadProvenance(vendorRoot);
|
|
296
|
+
const recorded = recordedProducerRoots(provenance.producers);
|
|
297
|
+
let checked = 0;
|
|
298
|
+
|
|
299
|
+
for (const entry of provenance.files) {
|
|
300
|
+
const located = locateProducerSource(entry.source, recorded);
|
|
301
|
+
if (located === null) {
|
|
302
|
+
throw new DemoError(
|
|
303
|
+
'bad_vendor_provenance',
|
|
304
|
+
`${entry.path} records no source under a known producer root: ${entry.source}`,
|
|
305
|
+
{ remedy: 'Regenerate the bundle with: node demo/prepare.mjs' },
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const root = currentProducerRoot(located.name, roots, located.root);
|
|
309
|
+
const producerPath = join(root, located.relative);
|
|
310
|
+
|
|
311
|
+
let info;
|
|
312
|
+
try {
|
|
313
|
+
info = await stat(producerPath);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error.code === 'ENOENT') {
|
|
316
|
+
throw new DemoError(
|
|
317
|
+
'missing_producer_input',
|
|
318
|
+
`${located.name} input is missing: ${producerPath}`,
|
|
319
|
+
{ remedy: `Restore ${producerPath}, or re-sync with the current producer roots.` },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
if (!info.isFile() || info.size === 0) {
|
|
325
|
+
throw new DemoError(
|
|
326
|
+
'missing_producer_input',
|
|
327
|
+
`${located.name} input is not a non-empty file: ${producerPath}`,
|
|
328
|
+
{ remedy: `Restore ${producerPath}, or re-sync with the current producer roots.` },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let vendoredBytes;
|
|
333
|
+
try {
|
|
334
|
+
vendoredBytes = await readFile(join(vendorRoot, entry.path));
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error.code === 'ENOENT') {
|
|
337
|
+
throw new DemoError('vendor_missing_file', `the runtime bundle is missing ${entry.path}`, {
|
|
338
|
+
remedy: 'Run the one-time prepare: node demo/prepare.mjs',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const producerBytes = await readFile(producerPath);
|
|
345
|
+
if (!vendoredBytes.equals(producerBytes)) {
|
|
346
|
+
throw new DemoError(
|
|
347
|
+
'vendor_stale_producer',
|
|
348
|
+
`the vendored ${entry.path} is stale against ${located.name}'s ${producerPath}: ` +
|
|
349
|
+
`sha256 ${sha256(vendoredBytes)} vs ${sha256(producerBytes)}`,
|
|
350
|
+
{
|
|
351
|
+
remedy:
|
|
352
|
+
'Re-sync the bundle: ANIMADER_PACER=<pacer checkout> ANIMADER_PNGINE=<pngine checkout> ' +
|
|
353
|
+
'node demo/prepare.mjs, then node demo/prepare.mjs --verify',
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
checked++;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { files: checked, producers: provenance.producers };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Verify the prepared bundle against its own content manifest. This catches
|
|
365
|
+
* a missing or stale file, a stale extra file, and any byte drift.
|
|
366
|
+
*
|
|
367
|
+
* @param {{vendorRoot: string}} options
|
|
368
|
+
*/
|
|
369
|
+
export async function verifyVendor({ vendorRoot }) {
|
|
370
|
+
const { provenance, digest } = await loadProvenance(vendorRoot);
|
|
371
|
+
const actual = await walkFiles(vendorRoot);
|
|
372
|
+
const actualPaths = new Set(actual.map((entry) => entry.path));
|
|
373
|
+
const expectedPaths = new Set(provenance.files.map((entry) => entry.path));
|
|
374
|
+
for (const entry of provenance.files) {
|
|
375
|
+
if (!actualPaths.has(entry.path)) {
|
|
376
|
+
throw new DemoError('vendor_missing_file', `the runtime bundle is missing ${entry.path}`, {
|
|
377
|
+
remedy: 'Run the one-time prepare: node demo/prepare.mjs',
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const bytes = await readFile(join(vendorRoot, entry.path));
|
|
381
|
+
if (bytes.byteLength !== entry.bytes || sha256(bytes) !== entry.sha256) {
|
|
382
|
+
throw new DemoError('vendor_content_drift', `${entry.path} does not match its recorded content`, {
|
|
383
|
+
remedy: 'Run the one-time prepare: node demo/prepare.mjs',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const entry of actual) {
|
|
388
|
+
if (entry.path === VENDOR_FILE) continue;
|
|
389
|
+
if (entry.kind !== 'file' || !expectedPaths.has(entry.path)) {
|
|
390
|
+
throw new DemoError('vendor_stale', `the runtime bundle carries an unrecorded entry: ${entry.path}`, {
|
|
391
|
+
remedy: 'Run the one-time prepare: node demo/prepare.mjs',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
format: VENDOR_FORMAT,
|
|
398
|
+
producers: provenance.producers,
|
|
399
|
+
files: provenance.files,
|
|
400
|
+
digest,
|
|
401
|
+
vendorRoot,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Write the ignored fixture soundtrack. The bytes come from the deterministic
|
|
407
|
+
* helper, never from the network or a captured artifact.
|
|
408
|
+
*
|
|
409
|
+
* @param {{fixtureRoot: string}} options
|
|
410
|
+
*/
|
|
411
|
+
export async function writeFixtureTone({ fixtureRoot }) {
|
|
412
|
+
const bytes = createTestTone();
|
|
413
|
+
await mkdir(fixtureRoot, { recursive: true });
|
|
414
|
+
const path = join(fixtureRoot, 'soundtrack.wav');
|
|
415
|
+
await writeFile(path, bytes);
|
|
416
|
+
return { path, bytes: bytes.byteLength, sha256: sha256(bytes) };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
420
|
+
const roots = resolveRoots();
|
|
421
|
+
if (argv.includes('--verify')) {
|
|
422
|
+
const vendorRoot = join(roots.contentRoot, 'demo', 'vendor');
|
|
423
|
+
const verified = await verifyVendor({ vendorRoot });
|
|
424
|
+
const fresh = await verifyProducerFreshness({ vendorRoot, roots });
|
|
425
|
+
console.log(`vendor: ok (${verified.files.length} files, digest ${verified.digest})`);
|
|
426
|
+
console.log(`producer: ok (${fresh.files} files match their current producer bytes)`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const prepared = await prepareVendor({ ...roots, vendorRoot: join(roots.contentRoot, 'demo', 'vendor') });
|
|
430
|
+
const tone = await writeFixtureTone({ fixtureRoot: join(roots.contentRoot, 'demo', 'fixture') });
|
|
431
|
+
console.log(`vendor: ${prepared.vendorRoot}`);
|
|
432
|
+
console.log(`provenance: ${join(prepared.vendorRoot, VENDOR_FILE)}`);
|
|
433
|
+
console.log(`runtime digest: ${prepared.provenance.digest}`);
|
|
434
|
+
console.log(`runtime files: ${prepared.provenance.files.length}`);
|
|
435
|
+
console.log(`soundtrack: ${tone.path} (${tone.bytes} bytes, sha256 ${tone.sha256})`);
|
|
436
|
+
console.log('next: node demo/show.mjs demo/fixture --seconds 8 --no-open');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const invoked = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url;
|
|
440
|
+
if (invoked) {
|
|
441
|
+
main().catch((error) => {
|
|
442
|
+
console.error(`error: ${error.message}`);
|
|
443
|
+
if (error.remedy) console.error(`remedy: ${error.remedy}`);
|
|
444
|
+
process.exitCode = error.exitCode ?? 1;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// setUniform's value → bytes conversion: the UniformType tag table and the
|
|
2
|
+
// per-type packers.
|
|
3
|
+
//
|
|
4
|
+
// Split out of gpu.js (Phase 4 of the js-runtime-hardening plan) because it is
|
|
5
|
+
// the one part of that file with no GPU in it — pure, module-scoped, already
|
|
6
|
+
// unit-tested on its own, and coupled instead to the WIRE format: the UT_* tags
|
|
7
|
+
// must match uniform_table.zig, which tests/npm/uniform-type-lanes.test.js pins
|
|
8
|
+
// by parsing THIS file. gpu.js keeps a re-export so `import { uniformToTypedArray }
|
|
9
|
+
// from "./gpu.js"` still resolves.
|
|
10
|
+
//
|
|
11
|
+
// ## Invariants
|
|
12
|
+
// - A tag this runtime doesn't recognize is packed as f32 rather than dropped:
|
|
13
|
+
// 255 (unreflectable) is a legitimate wire value, and an unknown one means the
|
|
14
|
+
// payload is newer than the runtime. Best-effort beats a blank frame.
|
|
15
|
+
// - Array fields expand at the WIRE stride (size / elemCount), not packed —
|
|
16
|
+
// WGSL uniform-space array strides are 16-byte multiples.
|
|
17
|
+
|
|
18
|
+
// Build flag — see gpu.js. Guarded so this module is importable raw.
|
|
19
|
+
if (typeof DEBUG === "undefined") globalThis.DEBUG = false;
|
|
20
|
+
|
|
21
|
+
// UniformType enum values (must match uniform_table.zig — the coupling is
|
|
22
|
+
// gated by tests/npm/uniform-type-lanes.test.js)
|
|
23
|
+
const UT_F32 = 0, UT_I32 = 1, UT_U32 = 2;
|
|
24
|
+
const UT_VEC2F = 3, UT_VEC3F = 4, UT_VEC4F = 5;
|
|
25
|
+
const UT_MAT3X3F = 6, UT_MAT4X4F = 7;
|
|
26
|
+
const UT_VEC2I = 8, UT_VEC3I = 9, UT_VEC4I = 10;
|
|
27
|
+
const UT_VEC2U = 11, UT_VEC3U = 12, UT_VEC4U = 13;
|
|
28
|
+
const UT_UNKNOWN = 255; // unreflectable field type — best-effort f32 by design
|
|
29
|
+
// Input lanes (JS numbers) per single value of each type — used to slice a
|
|
30
|
+
// flat array into per-element chunks for array uniforms. Scalars and unknown
|
|
31
|
+
// tags fall back to 1 (matches convertOne's f32 best-effort default).
|
|
32
|
+
const UT_LANES = {
|
|
33
|
+
[UT_VEC2F]: 2, [UT_VEC2I]: 2, [UT_VEC2U]: 2,
|
|
34
|
+
[UT_VEC3F]: 3, [UT_VEC3I]: 3, [UT_VEC3U]: 3,
|
|
35
|
+
[UT_VEC4F]: 4, [UT_VEC4I]: 4, [UT_VEC4U]: 4,
|
|
36
|
+
[UT_MAT3X3F]: 9, // row-major input, padded to 48B on convert
|
|
37
|
+
[UT_MAT4X4F]: 16,
|
|
38
|
+
};
|
|
39
|
+
// The tags by name, as WGSL spells them and uniform_table.zig's enum names
|
|
40
|
+
// them — what getUniforms reports and what a refusal message says. Indexed
|
|
41
|
+
// by tag: the enum is contiguous from 0, and uniform-type-lanes.test.js holds
|
|
42
|
+
// this order to the Zig file. A tag past the table — 255, the word the
|
|
43
|
+
// compiler writes for a type it could not reflect, or one newer than this
|
|
44
|
+
// runtime — reads as "unknown".
|
|
45
|
+
const UT_NAMES = "f32 i32 u32 vec2f vec3f vec4f mat3x3f mat4x4f vec2i vec3i vec4i vec2u vec3u vec4u".split(" ");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A wire type tag's WGSL name.
|
|
49
|
+
* @param {number} tag - UniformType enum value
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function uniformTypeName(tag) {
|
|
53
|
+
return UT_NAMES[tag] ?? "unknown";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The public description of a reflected uniform table — what getUniforms
|
|
58
|
+
* resolves to. The wire map carries a dispatcher buffer id and a type tag,
|
|
59
|
+
* neither of which anything public accepts; the description carries the
|
|
60
|
+
* buffer's `(buffer :name …)` — the name writeBuffer and getBuffers speak —
|
|
61
|
+
* and the type's WGSL name. A pool's members are consecutive ids from the
|
|
62
|
+
* block's base. `buffer` is null when the payload names nothing: one minted
|
|
63
|
+
* before the buffer-name block (3.0.1 and earlier), or a buffer the block
|
|
64
|
+
* does not list.
|
|
65
|
+
* @param {Map<string, {bufferId: number, offset: number, size: number, type: number, elemCount: number}>} uniforms
|
|
66
|
+
* @param {Map<string, {id: number, pool?: number}>} buffers - the buffer-name block
|
|
67
|
+
* @returns {Record<string, {type: string, size: number, buffer: string | null, offset: number, elemCount: number}>}
|
|
68
|
+
*/
|
|
69
|
+
export function describeUniforms(uniforms, buffers) {
|
|
70
|
+
const nameOf = (id) => {
|
|
71
|
+
for (const [name, b] of buffers) if (id >= b.id && id < b.id + (b.pool || 1)) return name;
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
/** @type {Record<string, {type: string, size: number, buffer: string | null, offset: number, elemCount: number}>} */
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [name, info] of uniforms) {
|
|
77
|
+
out[name] = { type: uniformTypeName(info.type), size: info.size, buffer: nameOf(info.bufferId), offset: info.offset, elemCount: info.elemCount };
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Convert JS value to a TypedArray-backed byte view based on UniformType.
|
|
84
|
+
* For array fields (elemCount > 0) the value is expanded per-element at the
|
|
85
|
+
* wire-implied stride (size / elemCount — WGSL uniform-space array strides
|
|
86
|
+
* are 16-byte multiples, so elements are padded, not packed). Accepts flat
|
|
87
|
+
* ([1,2,3,4,5,6]) or nested ([[1,2,3],[4,5,6]]) input; missing trailing
|
|
88
|
+
* elements stay zero. Exported for tests/npm/gpu-uniform-arrays.test.js.
|
|
89
|
+
* @param {number|number[]|number[][]} value
|
|
90
|
+
* @param {number} uniformType - UniformType enum value (the ELEMENT type for arrays)
|
|
91
|
+
* @param {number} size - Expected byte size (TOTAL array bytes for arrays)
|
|
92
|
+
* @param {number} [elemCount] - Array element count; 0/undefined = not an array
|
|
93
|
+
* @returns {Uint8Array|null}
|
|
94
|
+
*/
|
|
95
|
+
export function uniformToTypedArray(value, uniformType, size, elemCount) {
|
|
96
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
97
|
+
if (!elemCount) return convertOne(/** @type {number[]} */ (arr), uniformType, size); // not an array field
|
|
98
|
+
const stride = Math.floor(size / elemCount);
|
|
99
|
+
if (stride === 0) return convertOne(/** @type {number[]} */ (arr), uniformType, size); // degenerate wire data
|
|
100
|
+
const lanes = UT_LANES[uniformType] || 1;
|
|
101
|
+
const out = new Uint8Array(size);
|
|
102
|
+
// `nested` picks which of the two shapes `arr` actually is; the two views
|
|
103
|
+
// below just name that invariant so each branch reads as the shape it needs.
|
|
104
|
+
const nested = Array.isArray(arr[0]);
|
|
105
|
+
const rows = /** @type {number[][]} */ (arr);
|
|
106
|
+
const flat = /** @type {number[]} */ (arr);
|
|
107
|
+
for (let i = 0; i < elemCount; i++) {
|
|
108
|
+
const elem = nested ? rows[i] : flat.slice(i * lanes, (i + 1) * lanes);
|
|
109
|
+
if (!elem || elem.length === 0) break; // short input: rest stays zero
|
|
110
|
+
const chunk = convertOne(elem, uniformType, stride);
|
|
111
|
+
if (!chunk) return null;
|
|
112
|
+
out.set(chunk.subarray(0, Math.min(chunk.length, stride)), i * stride);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Convert ONE value (a normalized number[]) to bytes for a single element of
|
|
119
|
+
* the given UniformType; `size` clamps the output byte length.
|
|
120
|
+
* @param {number[]} arr
|
|
121
|
+
* @param {number} uniformType - UniformType enum value
|
|
122
|
+
* @param {number} size - Expected byte size
|
|
123
|
+
* @returns {Uint8Array|null}
|
|
124
|
+
*/
|
|
125
|
+
function convertOne(arr, uniformType, size) {
|
|
126
|
+
switch (uniformType) {
|
|
127
|
+
case UT_F32:
|
|
128
|
+
case UT_VEC2F:
|
|
129
|
+
case UT_VEC3F:
|
|
130
|
+
case UT_VEC4F: {
|
|
131
|
+
const f32 = new Float32Array(arr);
|
|
132
|
+
return new Uint8Array(f32.buffer, 0, Math.min(f32.byteLength, size));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case UT_I32:
|
|
136
|
+
case UT_VEC2I:
|
|
137
|
+
case UT_VEC3I:
|
|
138
|
+
case UT_VEC4I: {
|
|
139
|
+
const i32 = new Int32Array(arr);
|
|
140
|
+
return new Uint8Array(i32.buffer, 0, Math.min(i32.byteLength, size));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case UT_U32:
|
|
144
|
+
case UT_VEC2U:
|
|
145
|
+
case UT_VEC3U:
|
|
146
|
+
case UT_VEC4U: {
|
|
147
|
+
const u32 = new Uint32Array(arr);
|
|
148
|
+
return new Uint8Array(u32.buffer, 0, Math.min(u32.byteLength, size));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case UT_MAT3X3F: {
|
|
152
|
+
// mat3x3 in WGSL is 3 vec4 columns (48 bytes with padding)
|
|
153
|
+
// Input: 9 floats (row-major), output: 3 vec4 columns (column-major with padding)
|
|
154
|
+
const f32 = new Float32Array(12); // 3 columns × 4 floats
|
|
155
|
+
if (arr.length >= 9) {
|
|
156
|
+
// Column 0
|
|
157
|
+
f32[0] = arr[0]; f32[1] = arr[3]; f32[2] = arr[6]; f32[3] = 0;
|
|
158
|
+
// Column 1
|
|
159
|
+
f32[4] = arr[1]; f32[5] = arr[4]; f32[6] = arr[7]; f32[7] = 0;
|
|
160
|
+
// Column 2
|
|
161
|
+
f32[8] = arr[2]; f32[9] = arr[5]; f32[10] = arr[8]; f32[11] = 0;
|
|
162
|
+
}
|
|
163
|
+
return new Uint8Array(f32.buffer, 0, Math.min(48, size));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case UT_MAT4X4F: {
|
|
167
|
+
// mat4x4 in WGSL is 4 vec4 columns (64 bytes)
|
|
168
|
+
// Assume input is column-major (WebGPU convention)
|
|
169
|
+
const f32 = new Float32Array(arr.length >= 16 ? arr : [...arr, ...Array(16 - arr.length).fill(0)]);
|
|
170
|
+
return new Uint8Array(f32.buffer, 0, Math.min(64, size));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
default: {
|
|
174
|
+
// 255 (unknown) is a legitimate wire tag for unreflectable field types —
|
|
175
|
+
// best-effort f32 is the designed path there. Any OTHER tag means this
|
|
176
|
+
// runtime predates the tag (version skew): say so, then still try f32.
|
|
177
|
+
if (uniformType !== UT_UNKNOWN) {
|
|
178
|
+
DEBUG && console.warn(`[GPU] setUniform: unrecognized UniformType tag ${uniformType} — treating as f32 (runtime older than payload?)`);
|
|
179
|
+
}
|
|
180
|
+
const f32 = new Float32Array(arr);
|
|
181
|
+
return new Uint8Array(f32.buffer, 0, Math.min(f32.byteLength, size));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
package/README.md
DELETED