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
@@ -0,0 +1,603 @@
1
+ // The Node adapter for the release export. It owns every actual byte and
2
+ // process: the fresh native bake in a throwaway project copy, the real
3
+ // vendored PieceHost derivation, the real pngine reflection parser, the real
4
+ // `pacer pack` CLI over that reflection join, the playable-by-this-host
5
+ // audit and the pinned owner esbuild bundle. `lib/export.mjs` owns the
6
+ // portable layout, manifest, size report and staged-write decisions; the
7
+ // bake/derive/reflect/pack/audit sequence itself is the shared pipeline in
8
+ // `lib/pipeline.mjs`, the same stages `animader build` and dev's preflight
9
+ // run over this export's own input snapshot.
10
+
11
+ import { execFile } from 'node:child_process';
12
+ import { promises as fsp } from 'node:fs';
13
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+ import { promisify } from 'node:util';
16
+ import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib';
17
+
18
+ import { createNodeIo, hashBytes, hashFile, removeTree } from './node-fs.mjs';
19
+ import { createPngineNative } from './pngine.mjs';
20
+ import { listSnapshotInputFiles, snapshotProject } from './snapshot.mjs';
21
+ import { assertProjectSnapshotsMatch, buildProject } from '../lib/pipeline.mjs';
22
+ import {
23
+ BAKED_PAYLOAD,
24
+ BUILD_RECEIPT,
25
+ GENERATED_DIR,
26
+ PACKED_SCORE,
27
+ inspectProject,
28
+ } from '../lib/contract.mjs';
29
+ import { DemoError, buildError, projectError } from '../lib/errors.mjs';
30
+ import { releasePlayerConfig, selectedSoundtrack } from '../lib/export.mjs';
31
+ import { resolveRoots } from '../toolchain/prepare.mjs';
32
+ import { contributorToolchain } from '../src/toolchain.mjs';
33
+
34
+ // The input snapshot moved to its own adapter so the export, `animader
35
+ // build` and dev's preflight share one filter and one hashing walk; the
36
+ // historical export-node import paths keep working.
37
+ export { snapshotProject, snapshotFilter, listSnapshotInputFiles } from './snapshot.mjs';
38
+
39
+ const HERE = dirname(fileURLToPath(import.meta.url));
40
+ const PKG = resolve(HERE, '..');
41
+ const REPO = resolve(PKG, '..', '..');
42
+ const PLAYER = join(PKG, 'player');
43
+ const INDEX = join(PLAYER, 'index.html');
44
+ const CSS = join(PLAYER, 'player.css');
45
+ const RELEASE_ENTRY = join(PLAYER, 'release.mjs');
46
+ const LICENSE = join(REPO, 'LICENSE');
47
+ const execFileAsync = promisify(execFile);
48
+
49
+ export function createExportIo() {
50
+ return {
51
+ async inspect(path) {
52
+ try {
53
+ const info = await fsp.lstat(path);
54
+ return {
55
+ kind: info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'dir' : info.isFile() ? 'file' : 'other',
56
+ size: info.size,
57
+ };
58
+ } catch (error) {
59
+ if (error.code === 'ENOENT') return null;
60
+ throw error;
61
+ }
62
+ },
63
+ remove: (path) => fsp.rm(path, { recursive: true, force: true }),
64
+ ensureDir: (path) => fsp.mkdir(path, { recursive: true }),
65
+ writeFile: (path, bytes) => fsp.writeFile(path, bytes),
66
+ rename: (from, to) => fsp.rename(from, to),
67
+ hashBytes,
68
+ };
69
+ }
70
+
71
+ export function measureBytes(bytes) {
72
+ return {
73
+ gzip9: gzipSync(bytes, { level: 9 }).byteLength,
74
+ brotli11: brotliCompressSync(bytes, {
75
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
76
+ }).byteLength,
77
+ };
78
+ }
79
+
80
+ /** The real CLI binary the contributor export is pinned to (the pacer checkout's zig-out). */
81
+ export function resolvePacerCli(roots = resolveRoots()) {
82
+ return join(roots.pacerRoot, 'zig-out', 'bin', 'pacer');
83
+ }
84
+
85
+ /**
86
+ * The reflection dump pacer's `--targets-json` adapter reads, written from
87
+ * the table's own insertion order. Only the two keys the adapter consumes
88
+ * ride the file; size/offset are host parser details, not join inputs.
89
+ */
90
+ export function reflectionJson(reflection) {
91
+ const rows = [];
92
+ for (const [name, field] of Object.entries(reflection ?? {})) {
93
+ rows.push(`${JSON.stringify(name)}:${JSON.stringify({
94
+ type: field?.type,
95
+ elemCount: field?.elemCount ?? 0,
96
+ })}`);
97
+ }
98
+ return `{${rows.join(',')}}\n`;
99
+ }
100
+
101
+ /**
102
+ * Run the real `pacer pack` over the exact derived score and the reflection's
103
+ * own target list. The CLI owns compilation, the pack preflight and the
104
+ * 'PACB' encoding; the export never repairs a score or invents a target. The
105
+ * temporary score and reflection are removed on success and failure. The
106
+ * answer carries the CLI's own tool identity (name and binary digest) for
107
+ * the build receipt's toolchain row.
108
+ *
109
+ * @param {{scoreText: string, reflection: object, outPath: string, workDir: string, pacerCli?: string}} options
110
+ */
111
+ export async function packScore({
112
+ scoreText,
113
+ reflection,
114
+ outPath,
115
+ workDir,
116
+ pacerCli = resolvePacerCli(),
117
+ }) {
118
+ const scorePath = join(workDir, 'score.sjon');
119
+ const targetsPath = join(workDir, 'targets.json');
120
+ await fsp.mkdir(dirname(outPath), { recursive: true });
121
+ await fsp.writeFile(scorePath, Buffer.from(scoreText, 'utf8'));
122
+ await fsp.writeFile(targetsPath, reflectionJson(reflection));
123
+
124
+ const args = ['pack', scorePath, '--out', outPath, '--targets-json', targetsPath];
125
+ try {
126
+ let run;
127
+ try {
128
+ run = await execFileAsync(pacerCli, args, {
129
+ cwd: dirname(pacerCli),
130
+ encoding: 'utf8',
131
+ maxBuffer: 8 * 1024 * 1024,
132
+ });
133
+ } catch (cause) {
134
+ if (cause?.code === 'ENOENT') {
135
+ throw new DemoError(
136
+ 'pack_tool_missing',
137
+ `the real pacer CLI is missing at ${pacerCli}; the export does not fall back to a text compiler`,
138
+ {
139
+ exitCode: 4,
140
+ stage: 'pack',
141
+ remedy: 'Build it at the pinned pacer checkout: cd $ANIMADER_PACER && zig build cli',
142
+ cause,
143
+ },
144
+ );
145
+ }
146
+ const stderr = String(cause?.stderr ?? '').trim();
147
+ const detail = stderr || String(cause?.stdout ?? '').trim() || String(cause?.message ?? cause);
148
+ throw new DemoError(
149
+ 'pack_failed',
150
+ `the real pacer pack refused the derived score (exit ${cause?.code ?? 'unknown'}): ${detail}`,
151
+ {
152
+ exitCode: 4,
153
+ stage: 'pack',
154
+ remedy: 'Inspect the CLI diagnostics; the export never edits or repairs a derived score.',
155
+ cause,
156
+ },
157
+ );
158
+ }
159
+
160
+ let bytes;
161
+ try {
162
+ bytes = await fsp.readFile(outPath);
163
+ } catch (cause) {
164
+ throw new DemoError('pack_failed', `pacer pack exited 0 but wrote no score at ${outPath}`, {
165
+ exitCode: 4,
166
+ stage: 'pack',
167
+ remedy: 'Inspect the recorded CLI report; a successful pack must land a PACB file.',
168
+ cause,
169
+ });
170
+ }
171
+ if (bytes.byteLength < 4 || bytes.subarray(0, 4).toString('latin1') !== 'PACB') {
172
+ throw new DemoError('pack_failed', `pacer pack wrote no 'PACB' container at ${outPath}`, {
173
+ exitCode: 4,
174
+ stage: 'pack',
175
+ remedy: 'Inspect the pinned CLI; the export only ships the runtime container.',
176
+ });
177
+ }
178
+
179
+ let report = null;
180
+ try {
181
+ report = JSON.parse(run.stdout);
182
+ } catch {
183
+ // The exit code and container are the contract; stdout is a report.
184
+ }
185
+ return {
186
+ bytes,
187
+ stdout: run.stdout,
188
+ stderr: run.stderr,
189
+ args,
190
+ pacerCli,
191
+ report,
192
+ tool: { name: 'pacer', sha256: await hashFile(pacerCli) },
193
+ };
194
+ } finally {
195
+ await fsp.rm(scorePath, { force: true });
196
+ await fsp.rm(targetsPath, { force: true });
197
+ await fsp.rm(`${outPath}.tmp`, { force: true });
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Parse one baked payload with pngine's own public bytecode and uniform-table
203
+ * readers, then spell the WGSL type names with the same helper the existing
204
+ * integration test uses. This is a build-time parser, never shipped.
205
+ *
206
+ * The readers resolve through the toolchain descriptor: the vendored viewer
207
+ * plus the pngine checkout's own core/helper in contributor mode, the
208
+ * installed packages' exports otherwise (the helper itself is the packaged
209
+ * artifact the plan names, because pngine does not export it).
210
+ *
211
+ * @param {{payloadBytes: Uint8Array, toolchain?: object, vendorRoot?: string, pngineRoot?: string}} options
212
+ */
213
+ export async function reflectionOfPayload({ payloadBytes, toolchain = null, vendorRoot, pngineRoot }) {
214
+ const chain = toolchain ?? contributorToolchain({
215
+ vendorRoot,
216
+ roots: pngineRoot !== undefined ? { pngineRoot } : undefined,
217
+ });
218
+ const { extractBytecode } = await import(pathToFileURL(chain.pngine.viewer).href);
219
+ const { parseUniformTable } = await import(pathToFileURL(chain.pngine.core).href);
220
+ const { uniformTypeName } = await import(pathToFileURL(chain.pngine.uniformTypeName).href);
221
+ const bytecode = await extractBytecode(new Uint8Array(payloadBytes));
222
+ const table = parseUniformTable(bytecode);
223
+ const fields = {};
224
+ for (const [name, info] of table.uniforms) {
225
+ fields[name] = {
226
+ type: uniformTypeName(info.type),
227
+ elemCount: info.elemCount,
228
+ size: info.size,
229
+ offset: info.offset,
230
+ };
231
+ }
232
+ return fields;
233
+ }
234
+
235
+
236
+ /** The release HTML is the dev DOM with exactly the two references relative. */
237
+ export async function releaseIndexHtml() {
238
+ const source = await fsp.readFile(INDEX, 'utf8');
239
+ const css = '<link rel="stylesheet" href="/player/player.css">';
240
+ const script = '<script type="module" src="/player/page.mjs"></script>';
241
+ if (!source.includes(css) || !source.includes(script)) {
242
+ throw new DemoError('index_template_changed', 'the dev index no longer carries the two references the release rewrite owns', {
243
+ exitCode: 4,
244
+ remedy: 'Inspect demo/player/index.html; the release must keep the same DOM and only rewrite its stylesheet and entry references.',
245
+ });
246
+ }
247
+ return Buffer.from(source.replace(css, '<link rel="stylesheet" href="./player.css">')
248
+ .replace(script, '<script type="module" src="./app.mjs"></script>'));
249
+ }
250
+
251
+ /**
252
+ * Bundle the release entry with the toolchain's pinned esbuild: the pngine
253
+ * owner's install in contributor mode, the CLI's own dependency in an
254
+ * installed package tree. No install, no new dependency, no source map,
255
+ * one ESM file.
256
+ *
257
+ * The entry's third-party imports are bare specifiers ('pngine/viewer',
258
+ * '@mr_hugo/pacer/rt', '@mr_hugo/pacer/route'); esbuild resolves them through `alias`
259
+ * against the toolchain's runtime files, so the entry itself never carries
260
+ * a path out of the package.
261
+ */
262
+ export function bundleRelease({ toolchain = null, roots, vendorRoot, entry = RELEASE_ENTRY } = {}) {
263
+ const chain = toolchain ?? contributorToolchain({
264
+ vendorRoot: vendorRoot ?? join(REPO, 'demo', 'vendor'),
265
+ roots,
266
+ });
267
+ const esbuild = chain.esbuild.require();
268
+
269
+ const runtimeModules = {
270
+ 'pngine/viewer': chain.runtime.path('pngine.mjs'),
271
+ '@mr_hugo/pacer/rt': chain.runtime.path('pacer/rt.js'),
272
+ '@mr_hugo/pacer/route': chain.runtime.path('pacer/route.js'),
273
+ };
274
+
275
+ let result;
276
+ try {
277
+ result = esbuild.buildSync({
278
+ entryPoints: [entry],
279
+ outfile: join(PKG, '.tmp', 'release-app.mjs'),
280
+ bundle: true,
281
+ alias: runtimeModules,
282
+ write: false,
283
+ format: 'esm',
284
+ platform: 'browser',
285
+ target: 'es2022',
286
+ minify: true,
287
+ sourcemap: false,
288
+ legalComments: 'eof',
289
+ charset: 'utf8',
290
+ metafile: true,
291
+ logLevel: 'silent',
292
+ absWorkingDir: PKG,
293
+ });
294
+ } catch (cause) {
295
+ const details = (cause?.errors ?? []).map((error) => error.text ?? error.message).join('; ');
296
+ throw new DemoError('bundle_failed', `esbuild could not bundle the release entry: ${details || cause?.message || cause}`, {
297
+ exitCode: 4,
298
+ remedy: 'Fix the release entry/import graph; the export never renames an unminified mirror as production.',
299
+ cause,
300
+ });
301
+ }
302
+
303
+ const files = result.outputFiles ?? [];
304
+ const app = files.filter((file) => file.path.endsWith('.mjs'));
305
+ if (app.length !== 1) {
306
+ throw new DemoError('bundle_shape', `esbuild answered ${app.length} ESM outputs; the release needs exactly one app.mjs`, {
307
+ exitCode: 4,
308
+ remedy: 'Remove dynamic imports from the release entry or make them static so the bundle stays one file.',
309
+ });
310
+ }
311
+ const bytes = Buffer.from(app[0].contents);
312
+ const text = bytes.toString('utf8');
313
+ const inputs = Object.keys(result.metafile?.inputs ?? {})
314
+ .map((path) => path.split('\\').join('/'))
315
+ .sort();
316
+
317
+ // The compiler/authoring vocabulary. esbuild minifies identifiers, so this
318
+ // is a token audit over the actual text plus the module paths below; it is
319
+ // not a proof against an obfuscated dynamic reach. The module-path audit is
320
+ // the stronger half: the compile driver entry and every animader authoring
321
+ // module must be absent from the bundle's own import graph.
322
+ const authoringTokens = [
323
+ 'PieceHost',
324
+ 'animader.wasm',
325
+ 'animader/piece.js',
326
+ 'piece.js',
327
+ 'devProfile',
328
+ '/project/',
329
+ '/runtime/',
330
+ '/app/lib/',
331
+ '/examples/hello/',
332
+ 'loadDerivedShow',
333
+ 'Pacer.load',
334
+ 'score.sjon',
335
+ 'pacer.wasm',
336
+ 'vendor/pacer/index.js',
337
+ ];
338
+ for (const banned of authoringTokens) {
339
+ if (text.includes(banned)) {
340
+ throw new DemoError('release_authoring_leak', `the release bundle retains authoring/compiler token ${banned}`, {
341
+ exitCode: 4,
342
+ stage: 'bundle',
343
+ remedy: 'Remove the import that reaches the dev/compiler graph; release output must not include the compile driver or its source score.',
344
+ });
345
+ }
346
+ }
347
+
348
+ // The authoring/compiler module audit is toolchain-relative: the banned
349
+ // compile driver and the required playback runtime have one path each in
350
+ // the toolchain descriptor (the vendored bundle in contributor mode, the
351
+ // installed packages otherwise), so the audit names the exact module
352
+ // instead of guessing a directory layout.
353
+ const playbackRuntime = chain.runtime.path('pacer/rt.js');
354
+ const compileDriver = chain.pacer.index;
355
+ const inputPaths = inputs.map((path) => resolve(PKG, path));
356
+ if (inputPaths.includes(resolve(compileDriver)) || inputs.includes('vendor/pacer/index.js')) {
357
+ throw new DemoError('release_authoring_leak', 'the release bundle imports the pacer compile driver', {
358
+ exitCode: 4,
359
+ stage: 'bundle',
360
+ remedy: 'Import the playback runtime (pacer/rt) and the static release entry only; the compiler closure must not be reachable.',
361
+ });
362
+ }
363
+ for (const banned of ['vendor/animader/', 'player/page.mjs']) {
364
+ const hit = inputs.find((path) => path.includes(banned));
365
+ if (hit) {
366
+ throw new DemoError('release_authoring_leak', `the release bundle imports the compiler/authoring module ${hit}`, {
367
+ exitCode: 4,
368
+ stage: 'bundle',
369
+ remedy: 'Import the playback runtime (pacer/rt) and the static release entry only; the compiler closure must not be reachable.',
370
+ });
371
+ }
372
+ }
373
+
374
+ if (!text.includes('restartSettled')) {
375
+ throw new DemoError('release_bundle_stale', 'the release bundle does not carry the owner restartSettled verb', {
376
+ exitCode: 4,
377
+ stage: 'bundle',
378
+ remedy: 'Re-vendor pngine with node demo/prepare.mjs from the current owner build.',
379
+ });
380
+ }
381
+ if (entry === RELEASE_ENTRY && !inputPaths.includes(resolve(playbackRuntime))) {
382
+ throw new DemoError('release_bundle_stale', 'the release bundle does not import the playback-only pacer runtime', {
383
+ exitCode: 4,
384
+ stage: 'bundle',
385
+ remedy: 'The release entry must import the toolchain playback runtime (pacer/rt), never the compile driver.',
386
+ });
387
+ }
388
+ return { bytes, esbuildVersion: esbuild.version, text, inputs };
389
+ }
390
+
391
+ function licenses({ pngineVersion, pacerVersion, licenseText }) {
392
+ return [
393
+ 'Animader Meridian standalone release',
394
+ '',
395
+ 'This export contains:',
396
+ `- pngine ${pngineVersion} viewer (CC0-1.0)`,
397
+ `- pacer ${pacerVersion} runtime JS and pacer-rt.wasm (CC0-1.0)`,
398
+ '- the Animader demo/player core and this authored content (CC0-1.0)',
399
+ '',
400
+ 'The bundled music is the project soundtrack, kept byte-identical; it is not',
401
+ 'a new licensed recording produced by this export.',
402
+ '',
403
+ 'The full CC0 1.0 Universal text follows.',
404
+ '',
405
+ licenseText,
406
+ ].join('\n');
407
+ }
408
+
409
+ /**
410
+ * Refuse export output or staging paths that alias the authored project.
411
+ * Resolution walks to each target's nearest existing ancestor and reads it
412
+ * through `realpath`, so symlinked paths are judged by where they actually
413
+ * land. Refused: the target is the project folder itself, the project
414
+ * folder lives inside the target, or the target resolves inside an authored
415
+ * folder of the project (the nearest existing ancestor below the project
416
+ * root is deeper than the root — `kernels/…`, for example). A fresh
417
+ * `dist` directly beside the authored files stays allowed.
418
+ *
419
+ * @param {Object} options
420
+ * @param {string} options.folder the real project folder
421
+ * @param {{outDir: string, stageDir: string}} options.paths
422
+ */
423
+ export async function assertSafeExportPaths({ folder, paths }) {
424
+ const projectReal = await fsp.realpath(folder);
425
+ for (const label of ['outDir', 'stageDir']) {
426
+ const target = resolve(paths[label]);
427
+ let probe = target;
428
+ let rest = '';
429
+ for (;;) {
430
+ let exists = true;
431
+ try {
432
+ await fsp.stat(probe);
433
+ } catch (error) {
434
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
435
+ exists = false;
436
+ }
437
+ if (exists) break;
438
+ const parent = dirname(probe);
439
+ if (parent === probe) break;
440
+ rest = rest === '' ? basename(probe) : `${basename(probe)}/${rest}`;
441
+ probe = parent;
442
+ }
443
+ const anchorReal = await fsp.realpath(probe);
444
+ const targetReal = rest === '' ? anchorReal : `${anchorReal}/${rest}`;
445
+ const refused = (detail, remedy) => projectError(
446
+ 'output_aliases_input',
447
+ `the export ${label} aliases the authored project: ${paths[label]} ${detail}`.trim(),
448
+ remedy,
449
+ );
450
+ if (targetReal === projectReal) {
451
+ throw refused(
452
+ 'is the project folder itself',
453
+ 'Choose a fresh output folder; the project folder itself is never an export target.',
454
+ );
455
+ }
456
+ if (projectReal.startsWith(`${targetReal}/`)) {
457
+ throw refused(
458
+ 'contains the project folder',
459
+ 'Choose a fresh output folder that does not contain the project; publishing it would replace the project.',
460
+ );
461
+ }
462
+ if (targetReal.startsWith(`${projectReal}/`)) {
463
+ // The target itself exists: it is an in-project path, and the honest
464
+ // refusal is the staged-write rule's own output_exists /
465
+ // staging_exists, not an aliasing claim. Aliasing is about a fresh
466
+ // path whose nearest existing ancestor is an authored folder.
467
+ const anchorRel = relative(projectReal, anchorReal);
468
+ if (rest !== '' && anchorRel !== '' && anchorRel !== '..') {
469
+ throw refused(
470
+ `resolves inside the authored ${anchorRel}/ folder`,
471
+ `Choose a fresh output folder directly beside the authored files (for example --out dist), not inside ${anchorRel}/.`,
472
+ );
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Produce the exact minimal release file set from the actual project. Every
480
+ * byte comes from a real producer; the source project is never modified.
481
+ * The bake, derivation, reflection, pack and the playable-by-this-host
482
+ * audit are the shared pipeline (`lib/pipeline.mjs`) over this export's own
483
+ * input snapshot — the same stages `animader build` and dev's preflight
484
+ * run — and the audit runs BEFORE the player bundle, so a packed score this
485
+ * host cannot play never reaches a release. The producers (and, for tests,
486
+ * the native adapter) are injected.
487
+ */
488
+ export async function prepareRelease({
489
+ project,
490
+ seconds,
491
+ paths,
492
+ toolchain = null,
493
+ roots,
494
+ vendorRoot,
495
+ workRoot,
496
+ producers,
497
+ native,
498
+ }) {
499
+ const chain = toolchain ?? contributorToolchain({
500
+ roots: roots ?? resolveRoots(),
501
+ vendorRoot: vendorRoot ?? join(REPO, 'demo', 'vendor'),
502
+ });
503
+ const io = createNodeIo();
504
+ const nativeAdapter = native ?? createPngineNative({ binary: chain.pngine.cli });
505
+ const { work, workProject } = await snapshotProject({ project, paths, workRoot });
506
+ try {
507
+ // The snapshot filter already excludes the generated directory; this
508
+ // removes any stale copy in the owned work folder defensively.
509
+ await fsp.rm(join(workProject, GENERATED_DIR), { recursive: true, force: true });
510
+
511
+ const copied = await inspectProject({ folder: workProject, io });
512
+ await assertProjectSnapshotsMatch({ original: project, copied, what: 'export' });
513
+
514
+ const runtime = await chain.verifyRuntime();
515
+ const built = await buildProject({
516
+ project: copied,
517
+ seconds,
518
+ paths: {
519
+ folder: workProject,
520
+ outDir: join(workProject, GENERATED_DIR),
521
+ payload: join(workProject, GENERATED_DIR, BAKED_PAYLOAD),
522
+ score: join(paths.stageDir, 'assets', PACKED_SCORE),
523
+ receipt: join(workProject, GENERATED_DIR, BUILD_RECEIPT),
524
+ workDir: work,
525
+ projectName: project.folder.split('/').at(-1) || 'project',
526
+ payloadRelative: `${GENERATED_DIR}/${BAKED_PAYLOAD}`,
527
+ scoreRelative: `assets/${PACKED_SCORE}`,
528
+ receiptRelative: `${GENERATED_DIR}/${BUILD_RECEIPT}`,
529
+ },
530
+ io,
531
+ runtime: { verify: async () => runtime },
532
+ native: nativeAdapter,
533
+ hashBytes,
534
+ inputFiles: await listSnapshotInputFiles({ workProject, paths }),
535
+ producers,
536
+ });
537
+
538
+ const payloadBytes = await fsp.readFile(join(workProject, GENERATED_DIR, BAKED_PAYLOAD));
539
+ if (hashBytes(payloadBytes) !== built.receipt.outputs.payload.sha256) {
540
+ throw buildError('export_payload_drift', 'the fresh bake does not match its own receipt', 'Re-run the native preflight and inspect pngine.');
541
+ }
542
+
543
+ const pacerRtWasm = await fsp.readFile(chain.runtime.path('pacer/pacer-rt.wasm'));
544
+
545
+ const bundled = bundleRelease({ toolchain: chain });
546
+ const indexHtml = await releaseIndexHtml();
547
+ const css = await fsp.readFile(CSS);
548
+ const audioName = selectedSoundtrack(project);
549
+ const soundtrack = await fsp.readFile(join(project.folder, audioName));
550
+ const soundtrackDigest = hashBytes(soundtrack);
551
+ if (soundtrackDigest !== project.files.soundtrack.sha256 || project.files.soundtrack.bytes !== soundtrack.byteLength) {
552
+ throw buildError('export_soundtrack_drift', `${audioName} changed while the export was preparing`, 'Re-run the export from a quiet project folder.');
553
+ }
554
+
555
+ const pngineVersion = await chain.producerVersion('pngine');
556
+ const pacerVersion = await chain.producerVersion('pacer');
557
+ const licenseText = await chain.licenseText();
558
+ const files = {
559
+ 'index.html': indexHtml,
560
+ 'player.css': css,
561
+ 'app.mjs': bundled.bytes,
562
+ 'config.json': Buffer.from(releasePlayerConfig({ seconds, soundtrackName: audioName })),
563
+ 'pacer-rt.wasm': pacerRtWasm,
564
+ 'assets/show.png': payloadBytes,
565
+ 'assets/score.pacb': built.scoreBytes,
566
+ [`assets/${audioName}`]: soundtrack,
567
+ 'LICENSES.txt': Buffer.from(licenses({
568
+ pngineVersion,
569
+ pacerVersion,
570
+ licenseText,
571
+ })),
572
+ };
573
+
574
+ return {
575
+ files,
576
+ meta: {
577
+ payload: { bytes: payloadBytes.byteLength, sha256: hashBytes(payloadBytes) },
578
+ score: { bytes: built.scoreBytes.byteLength, sha256: hashBytes(built.scoreBytes) },
579
+ soundtrack: { name: audioName, bytes: soundtrack.byteLength, sha256: soundtrackDigest },
580
+ pacerRtWasm: { bytes: pacerRtWasm.byteLength, sha256: hashBytes(pacerRtWasm) },
581
+ bundle: {
582
+ bytes: bundled.bytes.byteLength,
583
+ sha256: hashBytes(bundled.bytes),
584
+ esbuild: bundled.esbuildVersion,
585
+ inputs: bundled.inputs,
586
+ },
587
+ build: {
588
+ stages: built.stages,
589
+ join: built.join,
590
+ toolchain: built.receipt.toolchain,
591
+ },
592
+ reflection: built.reflection,
593
+ join: built.join,
594
+ runtimeDigest: runtime.digest,
595
+ payloadReceipt: built.receipt.outputs.payload.sha256,
596
+ },
597
+ };
598
+ } finally {
599
+ // The disposal guard (lib/rmtree.mjs): a completed export must not fail
600
+ // on a transient ENOTEMPTY while the work tree settles.
601
+ await removeTree(work);
602
+ }
603
+ }