animader-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +121 -0
- package/adapters/build-node.mjs +265 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +603 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +183 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +80 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +143 -0
- package/adapters/target.mjs +104 -0
- package/adapters/watch.mjs +355 -0
- package/adapters/webaudio.mjs +102 -0
- package/bin/animader.mjs +31 -0
- package/hello/join.js +46 -0
- package/hello/run.js +259 -0
- package/lib/build.mjs +126 -0
- package/lib/buildloop.mjs +254 -0
- package/lib/contract.mjs +371 -0
- package/lib/errors.mjs +51 -0
- package/lib/export.mjs +445 -0
- package/lib/new.mjs +304 -0
- package/lib/pipeline.mjs +247 -0
- package/lib/receipt.mjs +331 -0
- package/lib/rmtree.mjs +49 -0
- package/lib/routes.mjs +170 -0
- package/lib/score.mjs +434 -0
- package/lib/show.mjs +458 -0
- package/lib/soundtrack.mjs +67 -0
- package/lib/vendor.mjs +99 -0
- package/lib/wav.mjs +197 -0
- package/package.json +47 -4
- package/player/boot.mjs +792 -0
- package/player/controller.mjs +366 -0
- package/player/devchannel.mjs +306 -0
- package/player/index.html +35 -0
- package/player/page.mjs +134 -0
- package/player/player.css +393 -0
- package/player/release.mjs +76 -0
- package/src/commands/build.mjs +201 -0
- package/src/commands/dev.mjs +214 -0
- package/src/commands/doctor.mjs +348 -0
- package/src/commands/export.mjs +158 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/project.mjs +46 -0
- package/src/config.mjs +110 -0
- package/src/router.mjs +149 -0
- package/src/toolchain.mjs +691 -0
- package/starters/circle/README.md +50 -0
- package/starters/circle/animader.json +4 -0
- package/starters/circle/gitignore +9 -0
- package/starters/circle/kernels/circle.wgsl +32 -0
- package/starters/circle/program.sjon +34 -0
- package/starters/circle/timeline.piece.sjon +26 -0
- package/starters/ember/bake/spans.bin +0 -0
- package/starters/ember/kernels/curl.wgsl +10 -0
- package/starters/ember/kernels/ember.wgsl +10 -0
- package/starters/ember/kernels/hash.wgsl +12 -0
- package/starters/ember/kernels/simplex.wgsl +27 -0
- package/starters/ember/program.sjon +328 -0
- package/starters/ember/timeline.piece.sjon +34 -0
- package/toolchain/licenses/animader.txt +121 -0
- package/toolchain/licenses/esbuild.txt +21 -0
- package/toolchain/licenses/pacer.txt +121 -0
- package/toolchain/licenses/pngine.txt +121 -0
- package/toolchain/manifest.json +212 -0
- package/toolchain/prepare.mjs +446 -0
- package/toolchain/uniform-type-name.js +184 -0
- package/README.md +0 -3
package/lib/export.mjs
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// The portable release-export contract: argument grammar, the exact shipped
|
|
2
|
+
// file set, the safe staged write, the self-excluded manifest and the
|
|
3
|
+
// detached size report. Every byte producer, native process, compression
|
|
4
|
+
// codec and filesystem call is injected by the host adapter; this module
|
|
5
|
+
// carries no node: import, no process state and no DOM.
|
|
6
|
+
//
|
|
7
|
+
// The export is deliberately not a global transaction. The target must not
|
|
8
|
+
// exist (including a symlink), the tree is written under `<out>.next` and one
|
|
9
|
+
// rename publishes it. The staging path is refused, never deleted, when it
|
|
10
|
+
// already exists: a predictable `OUT.next` that this invocation did not
|
|
11
|
+
// create is unowned, and no export may recursively remove someone else's
|
|
12
|
+
// directory. On any failure before the rename, the staging directory this
|
|
13
|
+
// invocation created is removed. A concurrent writer is not locked out, and
|
|
14
|
+
// the detached report is written after the payload commit — the report is
|
|
15
|
+
// outside OUT and never part of its own measurement.
|
|
16
|
+
|
|
17
|
+
import { projectError, usageError } from './errors.mjs';
|
|
18
|
+
import { MAX_SECONDS, parseSeconds } from './contract.mjs';
|
|
19
|
+
import { isSoundtrackName, PLAYER_CONFIG_FORMATS, soundtrackNamesText } from './soundtrack.mjs';
|
|
20
|
+
|
|
21
|
+
export const EXPORT_FORMAT = 'animader-release-manifest/1';
|
|
22
|
+
export const EXPORT_SIZE_FORMAT = 'animader-release-size/1';
|
|
23
|
+
|
|
24
|
+
/** The name-independent release files, sorted here for the manifest. */
|
|
25
|
+
export const EXPORT_BASE_FILES = Object.freeze([
|
|
26
|
+
'app.mjs',
|
|
27
|
+
'assets/score.pacb',
|
|
28
|
+
'assets/show.png',
|
|
29
|
+
'config.json',
|
|
30
|
+
'index.html',
|
|
31
|
+
'LICENSES.txt',
|
|
32
|
+
'pacer-rt.wasm',
|
|
33
|
+
'player.css',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The exact minimal root a release ships for one selected soundtrack name,
|
|
38
|
+
* sorted for the manifest. The audio path follows the selection; nothing
|
|
39
|
+
* else in the set depends on it.
|
|
40
|
+
*/
|
|
41
|
+
export function exportFileSet(soundtrackName) {
|
|
42
|
+
if (!isSoundtrackName(soundtrackName)) {
|
|
43
|
+
throw projectError(
|
|
44
|
+
'invalid_soundtrack',
|
|
45
|
+
`the export needs the inspected soundtrack selection; got ${JSON.stringify(soundtrackName ?? null)}`,
|
|
46
|
+
`Inspect the project first; the release ships exactly one of ${soundtrackNamesText()}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze([...EXPORT_BASE_FILES, `assets/${soundtrackName}`].sort());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SOUNDTRACK_PATH = /^assets\/soundtrack\.(wav|ogg|opus)$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The inspected soundtrack selection an export or release config needs.
|
|
56
|
+
* The name comes from the project inspection; there is no fixed default.
|
|
57
|
+
*/
|
|
58
|
+
export function selectedSoundtrack(project) {
|
|
59
|
+
const selected = project?.files?.soundtrack?.name;
|
|
60
|
+
if (!isSoundtrackName(selected)) {
|
|
61
|
+
throw projectError(
|
|
62
|
+
'missing_soundtrack_selection',
|
|
63
|
+
`the export needs the inspected soundtrack selection; got ${JSON.stringify(selected ?? null)}`,
|
|
64
|
+
`Inspect the project folder first; the release ships exactly one of ${soundtrackNamesText()}.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return selected;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The generated release player config: version 2 with the explicit
|
|
72
|
+
* selection the loader requires for compressed audio.
|
|
73
|
+
*/
|
|
74
|
+
export function releasePlayerConfig({ seconds, soundtrackName }) {
|
|
75
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_SECONDS) {
|
|
76
|
+
throw usageError('invalid_seconds', `--seconds must be a positive number at most ${MAX_SECONDS}`, 'Pass the host show length, for example --seconds 36');
|
|
77
|
+
}
|
|
78
|
+
if (!isSoundtrackName(soundtrackName)) {
|
|
79
|
+
throw projectError(
|
|
80
|
+
'invalid_soundtrack',
|
|
81
|
+
`the release config needs the selected soundtrack name; got ${JSON.stringify(soundtrackName ?? null)}`,
|
|
82
|
+
`The config carries exactly one of ${soundtrackNamesText()}.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return `${JSON.stringify({ format: PLAYER_CONFIG_FORMATS.release, seconds, soundtrack: soundtrackName })}\n`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function cleanRelative(path) {
|
|
89
|
+
return typeof path === 'string'
|
|
90
|
+
&& path !== ''
|
|
91
|
+
&& !path.startsWith('/')
|
|
92
|
+
&& !path.includes('\\')
|
|
93
|
+
&& !path.includes('\0')
|
|
94
|
+
&& path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function categoryOf(path, musicPath) {
|
|
98
|
+
if (path === musicPath) return 'music';
|
|
99
|
+
if (path === 'app.mjs') return 'runtime-js';
|
|
100
|
+
if (path === 'pacer-rt.wasm') return 'runtime-wasm';
|
|
101
|
+
if (path === 'index.html' || path === 'player.css') return 'ui';
|
|
102
|
+
if (path === 'assets/show.png') return 'program';
|
|
103
|
+
if (path === 'assets/score.pacb') return 'program-score';
|
|
104
|
+
if (path === 'config.json') return 'config';
|
|
105
|
+
if (path === 'manifest.json') return 'metadata';
|
|
106
|
+
if (path === 'LICENSES.txt') return 'licenses';
|
|
107
|
+
return 'other';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function emptySum() {
|
|
111
|
+
return { raw: 0, gzip9: 0, brotli11: 0 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function add(sum, entry) {
|
|
115
|
+
sum.raw += entry.bytes;
|
|
116
|
+
sum.gzip9 += entry.gzip9;
|
|
117
|
+
sum.brotli11 += entry.brotli11;
|
|
118
|
+
return sum;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function display(sum) {
|
|
122
|
+
const round = (value) => Math.round(value * 1000) / 1000;
|
|
123
|
+
return {
|
|
124
|
+
rawKB: round(sum.raw / 1000),
|
|
125
|
+
rawKiB: round(sum.raw / 1024),
|
|
126
|
+
gzip9KB: round(sum.gzip9 / 1000),
|
|
127
|
+
gzip9KiB: round(sum.gzip9 / 1024),
|
|
128
|
+
brotli11KB: round(sum.brotli11 / 1000),
|
|
129
|
+
brotli11KiB: round(sum.brotli11 / 1024),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the one detached size-report shape shared by the export and the
|
|
135
|
+
* archive tool. `measured` rows are already encoded per file; `musicPath`
|
|
136
|
+
* is the release's actual selected audio path, never a fixed name.
|
|
137
|
+
*/
|
|
138
|
+
export function buildSizeReport(measured, musicPath) {
|
|
139
|
+
if (!SOUNDTRACK_PATH.test(musicPath)) {
|
|
140
|
+
throw projectError(
|
|
141
|
+
'invalid_soundtrack',
|
|
142
|
+
`the size report needs the release's selected audio path; got ${JSON.stringify(musicPath ?? null)}`,
|
|
143
|
+
`Pass the audio path the release actually ships: assets/ plus one of ${soundtrackNamesText()}.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const music = measured.filter((entry) => entry.category === 'music');
|
|
147
|
+
const nonMusic = measured.filter((entry) => entry.category !== 'music');
|
|
148
|
+
const sums = { all: emptySum(), music: emptySum(), withoutMusic: emptySum() };
|
|
149
|
+
for (const entry of measured) {
|
|
150
|
+
add(sums.all, entry);
|
|
151
|
+
add(entry.category === 'music' ? sums.music : sums.withoutMusic, entry);
|
|
152
|
+
}
|
|
153
|
+
const categories = {};
|
|
154
|
+
for (const entry of measured) {
|
|
155
|
+
if (!categories[entry.category]) {
|
|
156
|
+
categories[entry.category] = { files: 0, raw: 0, gzip9: 0, brotli11: 0 };
|
|
157
|
+
}
|
|
158
|
+
categories[entry.category].files += 1;
|
|
159
|
+
add(categories[entry.category], entry);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
format: EXPORT_SIZE_FORMAT,
|
|
164
|
+
metric: 'per-file encoded sums (not an archive or an HTTP wire measurement)',
|
|
165
|
+
units: {
|
|
166
|
+
decimalKB: 1000,
|
|
167
|
+
binaryKiB: 1024,
|
|
168
|
+
note: 'KB is 1000 bytes; KiB is 1024 bytes. Compressed values are per-file encoded sums, not an archive.',
|
|
169
|
+
},
|
|
170
|
+
scope: 'every file in OUT including manifest.json; this report is outside OUT and excluded from itself',
|
|
171
|
+
selfIncluded: false,
|
|
172
|
+
musicPath,
|
|
173
|
+
files: measured,
|
|
174
|
+
categories,
|
|
175
|
+
sums,
|
|
176
|
+
display: {
|
|
177
|
+
all: display(sums.all),
|
|
178
|
+
music: display(sums.music),
|
|
179
|
+
withoutMusic: display(sums.withoutMusic),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse the export command's argv. It is the same grammar family as
|
|
186
|
+
* `show.mjs`: one folder positional, required `--seconds`, required `--out`,
|
|
187
|
+
* `--` for a dash-leading folder, and named usage errors. There is no
|
|
188
|
+
* `--force` in this slice; an existing target is always refused.
|
|
189
|
+
*/
|
|
190
|
+
export function parseExportArgv(argv) {
|
|
191
|
+
if (!Array.isArray(argv)) {
|
|
192
|
+
throw usageError('invalid_argv', 'the CLI adapter must pass an argument array', 'Call node demo/export.mjs <folder> --seconds <n> --out <folder>');
|
|
193
|
+
}
|
|
194
|
+
let folder = null;
|
|
195
|
+
let secondsRaw;
|
|
196
|
+
let out = null;
|
|
197
|
+
let help = false;
|
|
198
|
+
let endOptions = false;
|
|
199
|
+
|
|
200
|
+
for (let index = 0; index < argv.length; index++) {
|
|
201
|
+
const argument = String(argv[index]);
|
|
202
|
+
if (!endOptions && argument === '--') {
|
|
203
|
+
endOptions = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!endOptions && (argument === '--help' || argument === '-h')) {
|
|
207
|
+
help = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!endOptions && argument.startsWith('--')) {
|
|
211
|
+
const equals = argument.indexOf('=');
|
|
212
|
+
const name = equals === -1 ? argument : argument.slice(0, equals);
|
|
213
|
+
const inline = equals === -1 ? undefined : argument.slice(equals + 1);
|
|
214
|
+
const takeValue = () => {
|
|
215
|
+
if (inline !== undefined) return inline;
|
|
216
|
+
if (index + 1 >= argv.length) {
|
|
217
|
+
throw usageError('missing_option_value', `${name} needs a value`, `Pass ${name} <value>`);
|
|
218
|
+
}
|
|
219
|
+
index++;
|
|
220
|
+
return String(argv[index]);
|
|
221
|
+
};
|
|
222
|
+
if (name === '--seconds') {
|
|
223
|
+
secondsRaw = takeValue();
|
|
224
|
+
} else if (name === '--out') {
|
|
225
|
+
out = takeValue();
|
|
226
|
+
} else {
|
|
227
|
+
throw usageError(
|
|
228
|
+
'unknown_option',
|
|
229
|
+
`unknown option ${name}`,
|
|
230
|
+
'The supported options are --seconds, --out and --help; an existing target is refused',
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!endOptions && argument.startsWith('-') && argument !== '-') {
|
|
236
|
+
throw usageError('unknown_option', `unknown option ${argument}`, 'The supported options are --seconds, --out and --help');
|
|
237
|
+
}
|
|
238
|
+
if (folder !== null) {
|
|
239
|
+
throw usageError('extra_argument', `extra folder argument ${JSON.stringify(argument)}`, 'Pass exactly one project folder');
|
|
240
|
+
}
|
|
241
|
+
folder = argument;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (help) return { help: true, folder, seconds: undefined, out };
|
|
245
|
+
if (folder === null) {
|
|
246
|
+
throw usageError('missing_folder', 'exactly one project folder is required', 'Run: node demo/export.mjs <folder> --seconds 36 --out <folder>');
|
|
247
|
+
}
|
|
248
|
+
const seconds = parseSeconds(secondsRaw);
|
|
249
|
+
if (out === null || out === '') {
|
|
250
|
+
throw usageError('missing_out', '--out is required', 'Pass the release folder, for example --out .agent/releases/meridian');
|
|
251
|
+
}
|
|
252
|
+
return { help: false, folder, seconds, out };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function exportUsageText() {
|
|
256
|
+
return [
|
|
257
|
+
'animader demo export',
|
|
258
|
+
'',
|
|
259
|
+
'Usage:',
|
|
260
|
+
' node demo/export.mjs <folder> --seconds S --out OUT',
|
|
261
|
+
'',
|
|
262
|
+
'The folder carries the fixed inputs and exactly one recognized soundtrack:',
|
|
263
|
+
' program.sjon',
|
|
264
|
+
' timeline.piece.sjon',
|
|
265
|
+
` one of ${soundtrackNamesText()}`,
|
|
266
|
+
'',
|
|
267
|
+
'The export bakes the program, derives the score once, packs it offline',
|
|
268
|
+
'with the real pacer CLI over the reflected join, bundles app.mjs with the',
|
|
269
|
+
'owner esbuild, and writes a minimal static release plus manifest.json and',
|
|
270
|
+
'a detached OUT.size.json.',
|
|
271
|
+
'',
|
|
272
|
+
'Options:',
|
|
273
|
+
` --seconds S required host show length, a positive number at most ${MAX_SECONDS}`,
|
|
274
|
+
' --out OUT required release folder; it must not exist (including a symlink)',
|
|
275
|
+
' -h, --help this text',
|
|
276
|
+
'',
|
|
277
|
+
'No --force, no archive, no .gz/.br sidecars. The size report is outside OUT.',
|
|
278
|
+
].join('\n');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Export one show.
|
|
283
|
+
*
|
|
284
|
+
* @param {object} options
|
|
285
|
+
* @param {{folder: string, files: object}} options.project inspected project
|
|
286
|
+
* @param {number} options.seconds host show length
|
|
287
|
+
* @param {{outDir: string, stageDir: string, sizePath: string, parentDir: string}} options.paths
|
|
288
|
+
* @param {object} options.io
|
|
289
|
+
* @param {(path: string) => Promise<null|{kind: string, size: number}>} options.io.inspect
|
|
290
|
+
* @param {(path: string) => Promise<void>} options.io.remove
|
|
291
|
+
* @param {(path: string) => Promise<void>} options.io.ensureDir
|
|
292
|
+
* @param {(path: string, bytes: Uint8Array) => Promise<void>} options.io.writeFile
|
|
293
|
+
* @param {(from: string, to: string) => Promise<void>} options.io.rename
|
|
294
|
+
* @param {(bytes: Uint8Array) => string} options.io.hashBytes
|
|
295
|
+
* @param {{prepare: (options: object) => Promise<{files: Object<string, Uint8Array>}>}} options.producer
|
|
296
|
+
* the actual bake, derive, pack and bundle adapter
|
|
297
|
+
* @param {(bytes: Uint8Array) => {gzip9: number, brotli11: number}} options.measure
|
|
298
|
+
*/
|
|
299
|
+
export async function exportShow({ project, seconds, paths, io, producer, measure }) {
|
|
300
|
+
if (!io
|
|
301
|
+
|| typeof io.inspect !== 'function'
|
|
302
|
+
|| typeof io.remove !== 'function'
|
|
303
|
+
|| typeof io.ensureDir !== 'function'
|
|
304
|
+
|| typeof io.writeFile !== 'function'
|
|
305
|
+
|| typeof io.rename !== 'function'
|
|
306
|
+
|| typeof io.hashBytes !== 'function') {
|
|
307
|
+
throw new TypeError('exportShow needs the injected filesystem port (inspect, remove, ensureDir, writeFile, rename, hashBytes)');
|
|
308
|
+
}
|
|
309
|
+
if (!producer || typeof producer.prepare !== 'function') {
|
|
310
|
+
throw new TypeError('exportShow needs producer.prepare (the actual bake, derive, compile and bundle adapter)');
|
|
311
|
+
}
|
|
312
|
+
if (typeof measure !== 'function') throw new TypeError('exportShow needs a measure(bytes) codec port');
|
|
313
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_SECONDS) {
|
|
314
|
+
throw usageError('invalid_seconds', `--seconds must be a positive number at most ${MAX_SECONDS}`, 'Pass the host show length, for example --seconds 36');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (await io.inspect(paths.outDir) !== null) {
|
|
318
|
+
throw projectError(
|
|
319
|
+
'output_exists',
|
|
320
|
+
`the export target already exists: ${paths.outDir}`,
|
|
321
|
+
'Choose a fresh target; this slice has no --force and never overwrites an existing path.',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const selected = selectedSoundtrack(project);
|
|
325
|
+
const exportFiles = exportFileSet(selected);
|
|
326
|
+
const required = new Set(exportFiles);
|
|
327
|
+
const musicPath = `assets/${selected}`;
|
|
328
|
+
const parent = await io.inspect(paths.parentDir);
|
|
329
|
+
if (parent === null || parent.kind !== 'dir') {
|
|
330
|
+
throw projectError(
|
|
331
|
+
'output_parent_missing',
|
|
332
|
+
`the export target's parent folder does not exist: ${paths.parentDir}`,
|
|
333
|
+
'Create the parent folder first, then choose the fresh export target inside it.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The staging path is owned only when this invocation creates it. A
|
|
338
|
+
// pre-existing `<out>.next` — another export's debris or something else
|
|
339
|
+
// entirely — is refused, never recursively deleted on the strength of a
|
|
340
|
+
// predictable name.
|
|
341
|
+
if (await io.inspect(paths.stageDir) !== null) {
|
|
342
|
+
throw projectError(
|
|
343
|
+
'staging_exists',
|
|
344
|
+
`the export staging path already exists: ${paths.stageDir}`,
|
|
345
|
+
`This export never deletes a staging directory it did not create; remove ${paths.stageDir} yourself if you own it, or choose a fresh --out.`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
let committed = false;
|
|
349
|
+
try {
|
|
350
|
+
await io.ensureDir(paths.stageDir);
|
|
351
|
+
const prepared = await producer.prepare({ project, seconds, outDir: paths.outDir });
|
|
352
|
+
const files = prepared?.files;
|
|
353
|
+
if (files === null || typeof files !== 'object' || Array.isArray(files)) {
|
|
354
|
+
throw projectError('bad_export_payload', 'the producer answered no files object', 'Fix the export adapter; nothing was published.');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const [path, bytes] of Object.entries(files)) {
|
|
358
|
+
if (!cleanRelative(path)) {
|
|
359
|
+
throw projectError('export_path_unsafe', `the producer answered an unsafe export path: ${JSON.stringify(path)}`, 'Every export path must be a clean relative path.');
|
|
360
|
+
}
|
|
361
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
362
|
+
throw projectError('bad_export_payload', `the producer answered no bytes for ${path}`, 'Every export file must be a Uint8Array.');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const path of exportFiles) {
|
|
366
|
+
if (!(path in files)) {
|
|
367
|
+
throw projectError('export_file_missing', `the producer did not answer ${path}`, 'The release file set is exact; fix the export adapter.');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (const path of Object.keys(files)) {
|
|
371
|
+
if (!required.has(path)) {
|
|
372
|
+
const duplicate = SOUNDTRACK_PATH.test(path) && SOUNDTRACK_PATH.test(musicPath)
|
|
373
|
+
? `; the release ships exactly one soundtrack asset (${musicPath}) and never a second spelling`
|
|
374
|
+
: '';
|
|
375
|
+
throw projectError(
|
|
376
|
+
'export_file_unexpected',
|
|
377
|
+
`the producer answered an unapproved export file: ${path}${duplicate}`,
|
|
378
|
+
'Keep the release minimal; no unbundled source mirror, debug file or extra soundtrack ships.',
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const rows = [];
|
|
384
|
+
for (const path of [...exportFiles].sort()) {
|
|
385
|
+
const bytes = files[path];
|
|
386
|
+
const slash = path.lastIndexOf('/');
|
|
387
|
+
if (slash !== -1) await io.ensureDir(`${paths.stageDir}/${path.slice(0, slash)}`);
|
|
388
|
+
await io.writeFile(`${paths.stageDir}/${path}`, bytes);
|
|
389
|
+
rows.push({ path, bytes: bytes.byteLength, sha256: io.hashBytes(bytes) });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const index = rows.find((row) => row.path === 'index.html');
|
|
393
|
+
const manifest = {
|
|
394
|
+
format: EXPORT_FORMAT,
|
|
395
|
+
entry: 'index.html',
|
|
396
|
+
self: { excluded: true, path: 'manifest.json' },
|
|
397
|
+
index: { path: 'index.html', bytes: index.bytes, sha256: index.sha256 },
|
|
398
|
+
files: rows,
|
|
399
|
+
};
|
|
400
|
+
const manifestBytes = new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
401
|
+
await io.writeFile(`${paths.stageDir}/manifest.json`, manifestBytes);
|
|
402
|
+
|
|
403
|
+
const measured = [];
|
|
404
|
+
for (const path of [...exportFiles, 'manifest.json'].sort()) {
|
|
405
|
+
const bytes = path === 'manifest.json' ? manifestBytes : files[path];
|
|
406
|
+
const encoded = await measure(bytes);
|
|
407
|
+
if (!Number.isInteger(encoded?.gzip9) || !Number.isInteger(encoded?.brotli11)) {
|
|
408
|
+
throw new TypeError(`measure answered no gzip9/brotli11 integer lengths for ${path}`);
|
|
409
|
+
}
|
|
410
|
+
measured.push({
|
|
411
|
+
path,
|
|
412
|
+
bytes: bytes.byteLength,
|
|
413
|
+
gzip9: encoded.gzip9,
|
|
414
|
+
brotli11: encoded.brotli11,
|
|
415
|
+
category: categoryOf(path, musicPath),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const report = buildSizeReport(measured, musicPath);
|
|
420
|
+
const reportBytes = `${JSON.stringify(report, null, 2)}\n`;
|
|
421
|
+
|
|
422
|
+
await io.rename(paths.stageDir, paths.outDir);
|
|
423
|
+
committed = true;
|
|
424
|
+
try {
|
|
425
|
+
await io.writeFile(paths.sizePath, new TextEncoder().encode(reportBytes));
|
|
426
|
+
} catch (cause) {
|
|
427
|
+
throw projectError(
|
|
428
|
+
'size_report_failed',
|
|
429
|
+
`the release was committed but its detached size report could not be written: ${cause?.message ?? cause}`,
|
|
430
|
+
`The OUT is complete at ${paths.outDir}; write the report beside it or re-export to a fresh target.`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
outDir: paths.outDir,
|
|
436
|
+
sizePath: paths.sizePath,
|
|
437
|
+
manifest,
|
|
438
|
+
report,
|
|
439
|
+
reportBytes,
|
|
440
|
+
files: manifest.files,
|
|
441
|
+
};
|
|
442
|
+
} finally {
|
|
443
|
+
if (!committed) await io.remove(paths.stageDir);
|
|
444
|
+
}
|
|
445
|
+
}
|