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,355 @@
|
|
|
1
|
+
// The recursive project watcher (plan section 5). One native `fs.watch`
|
|
2
|
+
// handle covers the whole project tree (recursive watching is this Node's
|
|
3
|
+
// own), and every event is filtered before it is forwarded: authored input,
|
|
4
|
+
// kernels and nested relative asset directories forward, while the
|
|
5
|
+
// generated `out/` tree, the default release/staging trees (`dist/`,
|
|
6
|
+
// `dist.next/`), the detached `dist.size.json` report, `.git`,
|
|
7
|
+
// `node_modules` and legacy `.animader-export-work-*` debris never do — so
|
|
8
|
+
// a build's own outputs cannot reach the loop. Exports to other output
|
|
9
|
+
// names need matching entries; the defaults cover the plan's named ones
|
|
10
|
+
// only.
|
|
11
|
+
//
|
|
12
|
+
// The watcher PROMPTS; it never decides. Forwarding is unconditional for
|
|
13
|
+
// every filtered event — a creation, a rewrite, a reappearance, a
|
|
14
|
+
// directory change, even a replayed event for a write that predates the
|
|
15
|
+
// watch (macOS fs.watch({recursive:true}) is FSEvents and answers recent
|
|
16
|
+
// history for a newly watched tree). Whether a prompt means a change is
|
|
17
|
+
// the session's to answer, from the filesystem, against snapshots it owns:
|
|
18
|
+
// a platform's event delivery affects only how soon the loop looks, never
|
|
19
|
+
// whether it looks correctly. There is no event baseline, no start
|
|
20
|
+
// timestamp and no per-path record here to drift — which is also why a
|
|
21
|
+
// tree that cannot be walked cannot suppress anything: nothing is walked.
|
|
22
|
+
// The watcher does not coalesce either: one editor save may legitimately
|
|
23
|
+
// answer several events (an atomic save is a temp-file create plus a
|
|
24
|
+
// rename), and coalescing bursts into one build is the build loop's
|
|
25
|
+
// decision.
|
|
26
|
+
//
|
|
27
|
+
// Beside the watcher stands the stat safety net (`createInputSweep`):
|
|
28
|
+
// some platforms keep reporting the OLD vnode after an atomic save, so the
|
|
29
|
+
// path goes silent for the process's life (measured on obs), and network
|
|
30
|
+
// filesystems answer no event at all. The sweep stats the build's own
|
|
31
|
+
// consumed-input rows — the receipt's `inputs.files`, the same input set
|
|
32
|
+
// the build records, so there is no second list to drift — and lists the
|
|
33
|
+
// filtered entries of the directories that hold them, and feeds any
|
|
34
|
+
// difference to the SAME loop entry point a watcher event feeds. Both are
|
|
35
|
+
// prompts; the session judges both the same way.
|
|
36
|
+
//
|
|
37
|
+
// Failures are actionable, never silent: a folder that cannot be watched
|
|
38
|
+
// throws `watch_failed` before anything starts, and a runtime watcher error
|
|
39
|
+
// reaches `onError` with the same identity — the caller decides to report
|
|
40
|
+
// and keep serving. `close()` is final and idempotent.
|
|
41
|
+
|
|
42
|
+
import { promises as fsp } from 'node:fs';
|
|
43
|
+
import { watch as nodeWatch } from 'node:fs';
|
|
44
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
45
|
+
|
|
46
|
+
import { serverError } from '../lib/errors.mjs';
|
|
47
|
+
import { LEGACY_WORK_PREFIX, SNAPSHOT_PRUNED_NAMES } from './snapshot.mjs';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Root-relative paths the watcher excludes by default: the generated
|
|
51
|
+
* `out/` tree, the default release output and its staging directory, and
|
|
52
|
+
* the detached size report.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_WATCH_EXCLUDED_PATHS = Object.freeze(['out', 'dist', 'dist.next', 'dist.size.json']);
|
|
55
|
+
|
|
56
|
+
function watchFailure(cause) {
|
|
57
|
+
return serverError(
|
|
58
|
+
'watch_failed',
|
|
59
|
+
`watching the project failed: ${cause?.message ?? String(cause)}`,
|
|
60
|
+
'Check that the project folder exists and is readable, then restart animader dev; serving the last successful generation does not need the watcher.',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The change filter: true when an absolute path is authored input this
|
|
66
|
+
* dev session must rebuild for. A path is excluded when it leaves the
|
|
67
|
+
* project root, or when it sits inside (or is) any excluded root-relative
|
|
68
|
+
* tree or report, at any depth. `.git`, `node_modules` and legacy work
|
|
69
|
+
* debris are pruned at any depth, exactly like the input snapshot's own
|
|
70
|
+
* filter.
|
|
71
|
+
*
|
|
72
|
+
* @param {{projectRoot: string, excludedPaths?: string[]}} options
|
|
73
|
+
* @returns {(source: string) => boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function createProjectWatchFilter({ projectRoot, excludedPaths = DEFAULT_WATCH_EXCLUDED_PATHS }) {
|
|
76
|
+
const excluded = new Set(excludedPaths);
|
|
77
|
+
return (source) => {
|
|
78
|
+
const rel = relative(projectRoot, source);
|
|
79
|
+
if (rel === '') return true;
|
|
80
|
+
if (rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false;
|
|
81
|
+
const segments = rel.split(sep);
|
|
82
|
+
if (segments.some((name) => SNAPSHOT_PRUNED_NAMES.includes(name) || name.startsWith(LEGACY_WORK_PREFIX))) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
// Any excluded root-relative path is a subtree: a file or a deeper
|
|
86
|
+
// directory under it is excluded with it.
|
|
87
|
+
for (let depth = 1; depth <= segments.length; depth += 1) {
|
|
88
|
+
if (excluded.has(segments.slice(0, depth).join('/'))) return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── the watcher: filtered events forward as prompts ────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Watch one project folder recursively. Every filtered event forwards as
|
|
98
|
+
* one prompt (`onChange` with the changed path); an event that carries no
|
|
99
|
+
* filename forwards with null — a prompt the session cannot miss, because
|
|
100
|
+
* a false prompt costs one look at the filesystem while a dropped edit
|
|
101
|
+
* costs a stale generation. The watcher decides nothing else: no start
|
|
102
|
+
* clock, no baseline, no per-path record — whether a prompt means a
|
|
103
|
+
* change is judged downstream, from the filesystem.
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} options
|
|
106
|
+
* @param {string} options.folder the project folder (watched recursively)
|
|
107
|
+
* @param {(source: string|null) => boolean} options.filter receives the
|
|
108
|
+
* absolute changed path (or null when the platform answers no filename)
|
|
109
|
+
* @param {(source: string|null) => void} options.onChange one filtered
|
|
110
|
+
* change, forwarded as a prompt
|
|
111
|
+
* @param {(error: Error) => void} options.onError a runtime watcher failure
|
|
112
|
+
* @param {(folder: string, options: Object, handler: Function) => object} [options.watchImpl]
|
|
113
|
+
* @returns {{close(): void}}
|
|
114
|
+
*/
|
|
115
|
+
export function watchProject({ folder, filter, onChange, onError, watchImpl = nodeWatch }) {
|
|
116
|
+
let watcher;
|
|
117
|
+
try {
|
|
118
|
+
watcher = watchImpl(folder, { recursive: true }, (eventType, filename) => {
|
|
119
|
+
if (filename === null || filename === undefined) {
|
|
120
|
+
// No filename to filter on: forward the prompt anyway; the
|
|
121
|
+
// session's decision is a stat pass, never the event's metadata.
|
|
122
|
+
onChange(null);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const source = join(folder, String(filename));
|
|
126
|
+
if (!filter(source)) return;
|
|
127
|
+
onChange(source);
|
|
128
|
+
});
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
throw watchFailure(cause);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
watcher.on('error', (cause) => {
|
|
134
|
+
onError(watchFailure(cause));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let closed = false;
|
|
138
|
+
return {
|
|
139
|
+
close() {
|
|
140
|
+
if (closed) return;
|
|
141
|
+
closed = true;
|
|
142
|
+
watcher.close();
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── the stat safety net ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** The default sweep period: ~1 s, the order the dev session promises. */
|
|
150
|
+
export const DEFAULT_SWEEP_INTERVAL_MS = 1000;
|
|
151
|
+
|
|
152
|
+
/** Project-relative rows use '/', whatever the host separator is. */
|
|
153
|
+
function rowContainer(row) {
|
|
154
|
+
const cut = row.lastIndexOf('/');
|
|
155
|
+
return cut === -1 ? '' : row.slice(0, cut);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One file's cheap identity: size, mtime and inode (where the platform
|
|
160
|
+
* answers one — a rename-over always changes the inode on the hosts that
|
|
161
|
+
* have inodes). A row that is gone, replaced by a directory, or currently
|
|
162
|
+
* unreadable is its own identity, so the change is still reported.
|
|
163
|
+
*/
|
|
164
|
+
function statIdentity(stats) {
|
|
165
|
+
if (stats === null || stats.isDirectory()) return null;
|
|
166
|
+
return `${stats.size}:${stats.mtimeMs}:${stats.ino ?? '-'}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One path's stat-row identity, shared by the sweep's periodic comparison
|
|
171
|
+
* and the dev session's publish-time freshness check: `statIdentity`'s
|
|
172
|
+
* {size, mtimeMs, inode} for a file, null when the path is gone (a clean
|
|
173
|
+
* ENOENT/ENOTDIR is an answer — a deletion — not an error), and
|
|
174
|
+
* `unreadable:<code>` when the stat cannot answer at all.
|
|
175
|
+
*/
|
|
176
|
+
export async function statRow(path) {
|
|
177
|
+
try {
|
|
178
|
+
return statIdentity(await fsp.stat(path));
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
181
|
+
return `unreadable:${error.code ?? 'unknown'}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One directory's cheap identity: the names of its entries that pass the
|
|
187
|
+
* SAME filter the watcher uses — excluded trees (`out/`, `dist/`, `.git`,
|
|
188
|
+
* `node_modules`, legacy work debris) are not part of the identity, so a
|
|
189
|
+
* build's own outputs can never look like a change. A missing directory
|
|
190
|
+
* is its own identity. Entry names are enough here: a replaced file keeps
|
|
191
|
+
* its name, and its own row's stat identity is what reports the swap.
|
|
192
|
+
*
|
|
193
|
+
* Exported for the dev session's own input snapshots, which compare the
|
|
194
|
+
* same directory discipline the sweep compares.
|
|
195
|
+
*/
|
|
196
|
+
export async function statDir(dir, filter) {
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
202
|
+
return `unreadable:${error.code ?? 'unknown'}`;
|
|
203
|
+
}
|
|
204
|
+
return entries
|
|
205
|
+
.filter((entry) => filter(join(dir, entry.name)))
|
|
206
|
+
.map((entry) => `${entry.isDirectory() ? 'd' : 'f'}:${entry.name}`)
|
|
207
|
+
.sort()
|
|
208
|
+
.join('|');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The stat-based safety net beside the watcher: a bounded, periodic
|
|
213
|
+
* comparison over the project's authored input set — the consumed-input
|
|
214
|
+
* rows the build's own receipt records (program, timeline, soundtrack and
|
|
215
|
+
* every authored resource beside them, kernels included) plus the
|
|
216
|
+
* directories that hold them, so a newly created kernel is noticed. One
|
|
217
|
+
* sweep is N stats and D directory listings, never a tree walk; a
|
|
218
|
+
* difference is reported once through the same `onChange` the watcher
|
|
219
|
+
* feeds, and the baselines move to the observed state, so the same
|
|
220
|
+
* difference is never reported twice.
|
|
221
|
+
*
|
|
222
|
+
* Baseline discipline is what keeps one edit at exactly one build:
|
|
223
|
+
* `accounted()` re-reads every baseline when a build starts (everything
|
|
224
|
+
* up to that instant is inside the running build, watcher event or not),
|
|
225
|
+
* and `inputs()` adopts the finished build's rows WITHOUT resetting the
|
|
226
|
+
* identities it already holds, so an edit that landed mid-build still
|
|
227
|
+
* reports on the next sweep. Before the first successful build the row
|
|
228
|
+
* set is empty and the watcher is the only net; every build then replaces
|
|
229
|
+
* the set with its own record.
|
|
230
|
+
*
|
|
231
|
+
* `close()` is final and idempotent, and leaves no timer behind.
|
|
232
|
+
*
|
|
233
|
+
* @param {Object} options
|
|
234
|
+
* @param {string} options.folder the project folder the rows are relative to
|
|
235
|
+
* @param {(source: string) => boolean} options.filter the watcher's own
|
|
236
|
+
* exclusion filter, applied to directory entries
|
|
237
|
+
* @param {(reasons: string[]) => void} options.onChange one detected
|
|
238
|
+
* difference (the labels name the rows/directories that differ)
|
|
239
|
+
* @param {number} [options.intervalMs] the sweep period
|
|
240
|
+
* @returns {{inputs(rows: string[]): void, accounted(): void, close(): void}}
|
|
241
|
+
*/
|
|
242
|
+
export function createInputSweep({ folder, filter, onChange, intervalMs = DEFAULT_SWEEP_INTERVAL_MS }) {
|
|
243
|
+
if (typeof folder !== 'string' || folder === '') {
|
|
244
|
+
throw new TypeError('createInputSweep needs the project folder');
|
|
245
|
+
}
|
|
246
|
+
if (typeof filter !== 'function' || typeof onChange !== 'function') {
|
|
247
|
+
throw new TypeError('createInputSweep needs filter and onChange functions');
|
|
248
|
+
}
|
|
249
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
250
|
+
throw new TypeError('createInputSweep needs a positive intervalMs');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let closed = false;
|
|
254
|
+
let timer = null;
|
|
255
|
+
// Every read runs through one queue: a sweep never interleaves with an
|
|
256
|
+
// adoption or an accounting refresh, so a difference is always judged
|
|
257
|
+
// against baselines that were read atomically.
|
|
258
|
+
let queue = Promise.resolve();
|
|
259
|
+
const fileRows = new Map(); // absolute path -> identity
|
|
260
|
+
const dirRows = new Map(); // absolute directory -> filtered entry key
|
|
261
|
+
|
|
262
|
+
const toAbs = (rel) => (rel === '' ? folder : join(folder, ...rel.split('/')));
|
|
263
|
+
const toLabel = (path) => relative(folder, path).split(sep).join('/');
|
|
264
|
+
|
|
265
|
+
async function readRows(paths, directories) {
|
|
266
|
+
const files = await Promise.all([...paths].map(async (path) => [path, await statRow(path)]));
|
|
267
|
+
const dirs = await Promise.all([...directories].map(async (dir) => [dir, await statDir(dir, filter)]));
|
|
268
|
+
return { files, dirs };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function store({ files, dirs }) {
|
|
272
|
+
fileRows.clear();
|
|
273
|
+
for (const [path, identity] of files) fileRows.set(path, identity);
|
|
274
|
+
dirRows.clear();
|
|
275
|
+
for (const [dir, key] of dirs) dirRows.set(dir, key);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function enqueue(work) {
|
|
279
|
+
queue = queue.then(async () => {
|
|
280
|
+
if (closed) return;
|
|
281
|
+
await work();
|
|
282
|
+
});
|
|
283
|
+
// A programmer error in the sweep must not kill the queue; the next
|
|
284
|
+
// tick still runs and the difference still reports.
|
|
285
|
+
queue = queue.catch(() => {});
|
|
286
|
+
return queue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function sweepOnce() {
|
|
290
|
+
return enqueue(async () => {
|
|
291
|
+
const paths = new Set(fileRows.keys());
|
|
292
|
+
const directories = new Set(dirRows.keys());
|
|
293
|
+
const current = await readRows(paths, directories);
|
|
294
|
+
const reasons = [];
|
|
295
|
+
for (const [path, identity] of current.files) {
|
|
296
|
+
if (fileRows.get(path) !== identity) reasons.push(toLabel(path));
|
|
297
|
+
}
|
|
298
|
+
for (const [dir, key] of current.dirs) {
|
|
299
|
+
if (dirRows.get(dir) !== key) reasons.push(dir === folder ? '/' : `${toLabel(dir)}/`);
|
|
300
|
+
}
|
|
301
|
+
store(current);
|
|
302
|
+
if (reasons.length > 0) onChange(reasons);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
timer = setInterval(() => {
|
|
307
|
+
sweepOnce();
|
|
308
|
+
}, intervalMs);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
/**
|
|
312
|
+
* Adopt the build's consumed-input rows (project-relative, '/'
|
|
313
|
+
* separated). Identities already held are kept — an edit that landed
|
|
314
|
+
* while the build ran must still report — and new rows are baselined
|
|
315
|
+
* as they are now, without notifying.
|
|
316
|
+
*
|
|
317
|
+
* @param {string[]} rows
|
|
318
|
+
*/
|
|
319
|
+
inputs(rows) {
|
|
320
|
+
if (!Array.isArray(rows) || rows.some((row) => typeof row !== 'string' || row === '' || row.startsWith('/') || row.split('/').some((name) => name === '..'))) {
|
|
321
|
+
throw new TypeError('createInputSweep inputs needs the receipt\'s project-relative row paths');
|
|
322
|
+
}
|
|
323
|
+
enqueue(async () => {
|
|
324
|
+
const paths = new Set(rows.map(toAbs));
|
|
325
|
+
const directories = new Set(rows.map(rowContainer).map(toAbs));
|
|
326
|
+
const files = await Promise.all([...paths].map(async (path) => [path, fileRows.has(path) ? fileRows.get(path) : await statRow(path)]));
|
|
327
|
+
const dirs = await Promise.all([...directories].map(async (dir) => [dir, dirRows.has(dir) ? dirRows.get(dir) : await statDir(dir, filter)]));
|
|
328
|
+
store({ files, dirs });
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Every baseline is re-read now, without notifying: the build that is
|
|
334
|
+
* starting includes everything up to this instant, so the sweep must
|
|
335
|
+
* not re-report it. Called at the start of every build, successful or
|
|
336
|
+
* not — a build that fails has still accounted its inputs, which is
|
|
337
|
+
* what keeps a persistently failing rebuild from looping.
|
|
338
|
+
*/
|
|
339
|
+
accounted() {
|
|
340
|
+
enqueue(async () => {
|
|
341
|
+
store(await readRows(new Set(fileRows.keys()), new Set(dirRows.keys())));
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
/** Final: stop sweeping and leave no timer behind. */
|
|
346
|
+
close() {
|
|
347
|
+
if (closed) return;
|
|
348
|
+
closed = true;
|
|
349
|
+
if (timer !== null) {
|
|
350
|
+
clearInterval(timer);
|
|
351
|
+
timer = null;
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The browser WebAudio adapter (M1 slice 2): one port over an injected
|
|
2
|
+
// AudioContext, matching what `createShow`'s transport expects. This is
|
|
3
|
+
// browser-only wiring — no `node:` import, no DOM, and the context
|
|
4
|
+
// itself is injected: the page constructs it (it starts suspended)
|
|
5
|
+
// before decode, so READY needs no gesture; only the Start gesture that
|
|
6
|
+
// calls `show.start()` resumes it. A portable core never owns the
|
|
7
|
+
// platform graph.
|
|
8
|
+
//
|
|
9
|
+
// The graph is the stable QA seam: every started source connects
|
|
10
|
+
// through one AnalyserNode into the context destination, so a real page
|
|
11
|
+
// run can measure a NONZERO signal on the analyser while the show
|
|
12
|
+
// plays instead of trusting the context state alone. Each start is a
|
|
13
|
+
// fresh single-use AudioBufferSourceNode started at the transport's
|
|
14
|
+
// captured origin with its STOP scheduled at origin+seconds — the
|
|
15
|
+
// audio ends itself on time regardless of paint slack.
|
|
16
|
+
//
|
|
17
|
+
// Ownership: the wiring only. Decode, resume/suspend and currentTime
|
|
18
|
+
// belong to the context; the clock policy belongs to lib/show.mjs.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the audio port over one AudioContext.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @param {AudioContext} options.context
|
|
25
|
+
* the page's own context, constructed suspended before decode; only
|
|
26
|
+
* the Start gesture resumes it.
|
|
27
|
+
* @returns {{
|
|
28
|
+
* decode: (bytes: ArrayBuffer) => Promise<AudioBuffer>,
|
|
29
|
+
* resume: () => Promise<void>,
|
|
30
|
+
* suspend: () => Promise<void>,
|
|
31
|
+
* now: () => number,
|
|
32
|
+
* play: (decoded: AudioBuffer, origin: number, stopAt: number) => AudioBufferSourceNode,
|
|
33
|
+
* stop: (source: AudioBufferSourceNode) => void,
|
|
34
|
+
* analyser: AnalyserNode,
|
|
35
|
+
* }}
|
|
36
|
+
*/
|
|
37
|
+
export function createWebAudioPort({ context }) {
|
|
38
|
+
if (!context
|
|
39
|
+
|| typeof context.decodeAudioData !== 'function'
|
|
40
|
+
|| typeof context.resume !== 'function'
|
|
41
|
+
|| typeof context.suspend !== 'function'
|
|
42
|
+
|| typeof context.createBufferSource !== 'function'
|
|
43
|
+
|| typeof context.createAnalyser !== 'function'
|
|
44
|
+
|| typeof context.destination === 'undefined') {
|
|
45
|
+
throw new TypeError('createWebAudioPort needs an AudioContext-shaped context');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const analyser = context.createAnalyser();
|
|
49
|
+
analyser.connect(context.destination);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** The QA measurement point: source -> analyser -> destination. */
|
|
53
|
+
analyser,
|
|
54
|
+
|
|
55
|
+
async decode(bytes) {
|
|
56
|
+
return await context.decodeAudioData(bytes);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async resume() {
|
|
60
|
+
await context.resume();
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/** Once this settles, the context clock (below) is frozen. */
|
|
64
|
+
async suspend() {
|
|
65
|
+
await context.suspend();
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
now() {
|
|
69
|
+
return context.currentTime;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One fresh single-use source per start, scheduled on the context's
|
|
74
|
+
* own timeline: begin at `origin`, STOP at `stopAt` (origin+seconds).
|
|
75
|
+
*/
|
|
76
|
+
play(decoded, origin, stopAt) {
|
|
77
|
+
const source = context.createBufferSource();
|
|
78
|
+
source.buffer = decoded;
|
|
79
|
+
source.connect(analyser);
|
|
80
|
+
source.start(origin);
|
|
81
|
+
source.stop(stopAt);
|
|
82
|
+
return source;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* End a source now. The platform allows repeated stops (the latest
|
|
87
|
+
* wins); only stop-before-start throws, and teardown ignores that.
|
|
88
|
+
*/
|
|
89
|
+
stop(source) {
|
|
90
|
+
try {
|
|
91
|
+
source.stop();
|
|
92
|
+
} catch {
|
|
93
|
+
// The node never started; there is nothing left to end.
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
source.disconnect();
|
|
97
|
+
} catch {
|
|
98
|
+
// A disconnected node is already out of the graph.
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/bin/animader.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `animader` executable: the thin process adapter. It parses
|
|
3
|
+
// process.argv.slice(2), dispatches through the router, and maps thrown
|
|
4
|
+
// DemoErrors to their exit codes and the error:/remedy: lines
|
|
5
|
+
// (lib/errors.mjs is the one owner of that vocabulary and format).
|
|
6
|
+
//
|
|
7
|
+
// The whole command layer, its lib and node adapters, the player page, the
|
|
8
|
+
// hello player core and the starters live inside this package
|
|
9
|
+
// (installed-toolchain plan, stage 1): nothing under bin/, src/, lib/,
|
|
10
|
+
// adapters/, player/, hello/, starters/ or toolchain/ imports across the
|
|
11
|
+
// package boundary, so the packed package works without the checkout. The
|
|
12
|
+
// contributor workspace's demo/ entries are compatibility adapters over
|
|
13
|
+
// these modules.
|
|
14
|
+
|
|
15
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
16
|
+
import { createNodeIo } from '../adapters/node-fs.mjs';
|
|
17
|
+
import { errorLines } from '../lib/errors.mjs';
|
|
18
|
+
import { runRouter } from '../src/router.mjs';
|
|
19
|
+
|
|
20
|
+
const context = {
|
|
21
|
+
version: pkg.version,
|
|
22
|
+
env: process.env,
|
|
23
|
+
io: createNodeIo(),
|
|
24
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
25
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
runRouter(process.argv.slice(2), context).catch((error) => {
|
|
29
|
+
for (const line of errorLines(error)) context.err(line);
|
|
30
|
+
process.exitCode = error.exitCode ?? 1;
|
|
31
|
+
});
|
package/hello/join.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The hello player's score target join, built from the compiled program's
|
|
2
|
+
// own reflection (step 12's second landing, the seventh ruling's (c)).
|
|
3
|
+
// pngine's `getUniforms` answers the program's reflected fields by name,
|
|
4
|
+
// with the type as WGSL spells it and the array length; registry 0 is that
|
|
5
|
+
// reflection, so a score target the program does not declare is refused by
|
|
6
|
+
// pacer's compile instead of drifting with a hand-written list.
|
|
7
|
+
//
|
|
8
|
+
// The wire target's `ncomp` is 1..4 lanes: a scalar or a vector of at most
|
|
9
|
+
// four. An array field (`elemCount` non-zero) and a matrix are not score
|
|
10
|
+
// targets (a mat3x3f is nine lanes and pacer's target arity is 1..4), and
|
|
11
|
+
// an unknown type is skipped rather than guessed.
|
|
12
|
+
|
|
13
|
+
/** The lanes one reflected WGSL type contributes to a score target. */
|
|
14
|
+
const LANES = {
|
|
15
|
+
f32: 1,
|
|
16
|
+
i32: 1,
|
|
17
|
+
u32: 1,
|
|
18
|
+
vec2f: 2,
|
|
19
|
+
vec2i: 2,
|
|
20
|
+
vec2u: 2,
|
|
21
|
+
vec3f: 3,
|
|
22
|
+
vec3i: 3,
|
|
23
|
+
vec3u: 3,
|
|
24
|
+
vec4f: 4,
|
|
25
|
+
vec4i: 4,
|
|
26
|
+
vec4u: 4,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One pacer registry from pngine's reflected field table, in the table's
|
|
31
|
+
* own order (the target_id space).
|
|
32
|
+
*
|
|
33
|
+
* @param {Object<string, {type: string, elemCount: number}>} fields
|
|
34
|
+
* `getUniforms`'s answer (or its parsed equivalent).
|
|
35
|
+
* @returns {Array<{name: string, ncomp: number}>}
|
|
36
|
+
*/
|
|
37
|
+
export function buildJoin(fields) {
|
|
38
|
+
const join = [];
|
|
39
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
40
|
+
if (field.elemCount) continue;
|
|
41
|
+
const ncomp = LANES[field.type];
|
|
42
|
+
if (ncomp === undefined) continue;
|
|
43
|
+
join.push({ name, ncomp });
|
|
44
|
+
}
|
|
45
|
+
return join;
|
|
46
|
+
}
|