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
|
@@ -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
|
+
}
|