@descix/frqtl-node 0.6.0-p1.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/README.md +6 -4
- package/dist/dist-manifest.json +71 -65
- package/dist/eyes/index.js +5 -2
- package/dist/fat/pkg-node/package.json +2 -10
- package/dist/frqtl/compat/providers/wasm-engine.js +3 -4
- package/dist/frqtl/compat/renderer/providers/p5-provider.js +1 -1
- package/dist/frqtl/objects/FrqtlApp.js +33 -6
- package/dist/frqtl/objects/RecipeContext.js +284 -13
- package/dist/frqtl/objects/constants/WallBehaviors.js +1 -1
- package/dist/frqtl/objects/instruments/capture.js +1 -24
- package/dist/frqtl/objects/instruments/hosted-eyes.js +216 -29
- package/dist/frqtl/objects/objects/makeControlsForObject.js +5 -5
- package/dist/frqtl/objects/record/Record.js +346 -0
- package/dist/frqtl/objects/record/open-record.js +22 -0
- package/dist/frqtl/objects/schema/engine-scopes.js +2 -2
- package/dist/frqtl/objects/schema/scopes.js +2 -2
- package/dist/frqtl/objects/setup/_worldInit.js +1 -2
- package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +1 -2
- package/dist/frqtl/parts/CameraControls.js +8 -0
- package/dist/frqtl/parts/DetectorWall.js +1 -1
- package/dist/frqtl/parts/FrqtlVision.js +21 -6
- package/dist/frqtl/parts/GraphingDetectorWall.js +7 -7
- package/dist/frqtl/parts/LargeObject.js +2 -2
- package/dist/frqtl/parts/LiveChart.js +2 -2
- package/dist/frqtl/parts/LogicGate.js +3 -3
- package/dist/frqtl/parts/OvenBox.js +1 -1
- package/dist/frqtl/parts/PointSource.js +1 -1
- package/dist/frqtl/parts/SlitLightSource.js +1 -1
- package/dist/frqtl/parts/SlitScreen.js +1 -1
- package/dist/frqtl/parts/SphereEmitter.js +12 -12
- package/dist/frqtl/parts/Trace.js +1 -1
- package/dist/frqtl/parts/Tunnel.js +11 -11
- package/dist/frqtl/parts/Wall.js +20 -10
- package/dist/frqtl/scenes/circuit-sat.js +1 -0
- package/dist/frqtl/scenes/dse-one-emitter.js +32 -34
- package/dist/frqtl/scenes/dse-two-emitters.js +33 -34
- package/dist/frqtl/scenes/mirror-route.js +96 -79
- package/dist/shim/bundle/index.js +118 -96
- package/dist/shim/bundle/indexHtml.js +66 -39
- package/dist/shim/controls/index.js +1 -1
- package/dist/shim/engine/artifact-shell.js +1 -1
- package/dist/shim/engine/router.js +3 -3
- package/dist/shim/host/host-view.js +228 -0
- package/dist/shim/host/index.js +18 -0
- package/dist/shim/index.js +3 -5
- package/dist/shim/notebook/format.js +1 -1
- package/dist/shim/notebook/kernel.js +1 -1
- package/dist/shim/notebook/setup-builder.js +2 -2
- package/dist/shim/notebook/store.js +2 -3
- package/dist/shim/package.json +1 -0
- package/dist/shim/page-params/index.js +2 -30
- package/dist/shim/project/fork.js +293 -0
- package/dist/shim/project/identity.js +3 -1
- package/dist/shim/project/index.js +16 -3
- package/dist/shim/project/link.js +224 -46
- package/dist/shim/project/manifest.js +151 -0
- package/dist/shim/project/parts-writer.js +68 -7
- package/dist/shim/project/read-text.js +43 -0
- package/dist/shim/project/record-format.js +345 -0
- package/dist/shim/project/specifiers.js +104 -15
- package/dist/shim/registry/exhibits.js +15 -34
- package/dist/shim/registry/index.js +3 -3
- package/dist/shim/registry/scene-roster.js +35 -0
- package/dist/shim/registry/scene-source.js +48 -64
- package/dist/shim/registry/walkthrough.js +6 -6
- package/dist/shim/render-state/apply.js +66 -31
- package/dist/shim/render-state/index.js +6 -2
- package/dist/shim/runtime/index.js +4 -4
- package/dist/shim/storage/codec-chains.js +88 -0
- package/dist/shim/storage/folders.js +205 -0
- package/dist/shim/storage/index.js +64 -8
- package/dist/shim/storage/make-namespace-store.js +5 -5
- package/llms.txt +4 -3
- package/package.json +19 -1
- package/dist/shim/bundle/frqnb.js +0 -112
- package/dist/shim/bundle/manifest.js +0 -95
- package/dist/shim/registry/experiments.js +0 -215
- package/dist/shim/storage/modules.js +0 -249
- package/dist/shim/storage/projects.js +0 -150
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* import line for Kind (after the last import statement;
|
|
17
17
|
* `module` from parts-module.js moduleOfKind)
|
|
18
18
|
* | { op: 'remove', name } the entry's property node, its comma included
|
|
19
|
+
* | { op: 'kind', name, Kind, module } within entry `name`, replace ONLY its Kind identifier's text, plus
|
|
20
|
+
* at most one import line for Kind (the add's line, after the last
|
|
21
|
+
* import); the entry's old import line is the author's and stays,
|
|
22
|
+
* even when nothing uses it now (a fork: exp.fork)
|
|
19
23
|
*
|
|
20
24
|
* changes[i] = { op, name, key?, from?, to, literalized? } from/to are source TEXT; `literalized: true`
|
|
21
25
|
* when the old value was an expression (its text is `from`), now its literal value
|
|
@@ -28,7 +32,9 @@
|
|
|
28
32
|
* the serializer cannot write (a function, NaN/Infinity, undefined, a class instance), naming its key.
|
|
29
33
|
*
|
|
30
34
|
* entryKeys(text, name) → string[] the plain prop keys entry `name` spells (how an entry spells a value)
|
|
31
|
-
* entryLiteral(text, name, key) → { literal, value? } what a key declares when it is a literal
|
|
35
|
+
* entryLiteral(text, name, key) → { literal, value?, length? } what a key declares when it is a literal
|
|
36
|
+
* (read-back); an array expression's element count otherwise (none when
|
|
37
|
+
* an element is a spread)
|
|
32
38
|
*
|
|
33
39
|
* serializeValue(value, key) → text THE one value serializer: numbers, strings (single-quoted),
|
|
34
40
|
* booleans, null, arrays inline, plain objects. Anything else THROWS
|
|
@@ -38,6 +44,14 @@
|
|
|
38
44
|
import { parse } from '../../frqtl/assets/vendor/acorn.mjs';
|
|
39
45
|
import { importSpecifierOf } from './parts-module.js';
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* parseModule(text) → the acorn syntax tree of an ES module (latest syntax, source ranges) — the vendored parser's
|
|
49
|
+
* ONE entry point in the tree; every other reader of a project file's syntax (fork.js) parses through it.
|
|
50
|
+
*/
|
|
51
|
+
export function parseModule(text) {
|
|
52
|
+
return parse(text, { ecmaVersion: 'latest', sourceType: 'module' });
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
/** The export the writer patches. */
|
|
42
56
|
export const PARTS_EXPORT = 'parts';
|
|
43
57
|
|
|
@@ -46,6 +60,14 @@ const _RESERVED = new Set(('break case catch class const continue debugger defau
|
|
|
46
60
|
'false finally for function if import in instanceof new null return super switch this throw true try typeof ' +
|
|
47
61
|
'var void while with yield let static enum await implements package protected interface private public').split(' '));
|
|
48
62
|
|
|
63
|
+
/**
|
|
64
|
+
* isIdentifierName(s) → true when `s` can name a binding (an identifier that is not a reserved word) — the one
|
|
65
|
+
* test the writer and the fork transform share.
|
|
66
|
+
*/
|
|
67
|
+
export function isIdentifierName(s) {
|
|
68
|
+
return typeof s === 'string' && _IDENT.test(s) && !_RESERVED.has(s);
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
function _keyText(k) {
|
|
50
72
|
return (_IDENT.test(k) && !_RESERVED.has(k)) ? k : _quote(k);
|
|
51
73
|
}
|
|
@@ -229,7 +251,7 @@ export function patchParts(text, edits, opts) {
|
|
|
229
251
|
throw new TypeError('[parts-writer] patchParts(text, edits): text is the sketch file (got ' + typeof text + '). (FAIL-LOUD.)');
|
|
230
252
|
}
|
|
231
253
|
if (!Array.isArray(edits)) {
|
|
232
|
-
throw new TypeError('[parts-writer] patchParts(text, edits): edits is an array of { op: set | add | remove }. (FAIL-LOUD.)');
|
|
254
|
+
throw new TypeError('[parts-writer] patchParts(text, edits): edits is an array of { op: set | add | remove | kind }. (FAIL-LOUD.)');
|
|
233
255
|
}
|
|
234
256
|
const changes = [];
|
|
235
257
|
const refused = [];
|
|
@@ -316,6 +338,37 @@ export function patchParts(text, edits, opts) {
|
|
|
316
338
|
const ins = _appendProperty(text, obj, appended.join(_multiline(text, obj) ? ',\n' + _indentAt(text, (obj.properties[obj.properties.length - 1] || obj).start) : ', '));
|
|
317
339
|
patches.push(ins);
|
|
318
340
|
}
|
|
341
|
+
} else if (e.op === 'kind') {
|
|
342
|
+
const p = entryOf(e);
|
|
343
|
+
if (!p) { continue; }
|
|
344
|
+
const v = p.value;
|
|
345
|
+
if (v.type !== 'ArrayExpression' || v.elements.length !== 2 || !v.elements[0] || v.elements[0].type !== 'Identifier') {
|
|
346
|
+
refuse(e, 'the entry "' + e.name + '" is not written as [Kind, { …props }] — the writer patches only that form.');
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (typeof e.Kind !== 'string' || !_IDENT.test(e.Kind)) {
|
|
350
|
+
throw new TypeError('[parts-writer] kind: Kind is the new kind\'s class name (got ' + JSON.stringify(e.Kind) + '). (FAIL-LOUD.)');
|
|
351
|
+
}
|
|
352
|
+
if (e.module === null || e.module === undefined) {
|
|
353
|
+
refuse(e, 'the kind ' + e.Kind + ' has no importable module, so a sketch cannot import it.');
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const spec = importSpecifierOf(e.module, entry);
|
|
357
|
+
const b = bindings.get(e.Kind);
|
|
358
|
+
if (b && !(b.kind === 'import' && b.source === spec && b.imported === e.Kind)) {
|
|
359
|
+
refuse(e, 'the identifier ' + e.Kind + ' is already bound in this sketch to ' +
|
|
360
|
+
(b.kind === 'import' ? '"' + (b.imported || '*') + '" from \'' + b.source + '\'' : 'its own declaration') +
|
|
361
|
+
' — the writer cannot import the kind ' + e.Kind + ' under that name.');
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const id = v.elements[0];
|
|
365
|
+
if (id.name === e.Kind) { continue; }
|
|
366
|
+
patches.push({ start: id.start, end: id.end, text: e.Kind });
|
|
367
|
+
const line = 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';';
|
|
368
|
+
if (!b && !importLines.some(function (l) { return l.Kind === e.Kind; })) {
|
|
369
|
+
importLines.push({ Kind: e.Kind, line: line });
|
|
370
|
+
}
|
|
371
|
+
changes.push({ op: 'kind', name: e.name, from: id.name, to: e.Kind, import: (!b ? line : null) });
|
|
319
372
|
} else if (e.op === 'remove') {
|
|
320
373
|
const p = entryOf(e);
|
|
321
374
|
if (!p) { continue; }
|
|
@@ -352,7 +405,7 @@ export function patchParts(text, edits, opts) {
|
|
|
352
405
|
changes.push({ op: 'add', name: e.name, from: null, to: _keyText(e.name) + ': [' + e.Kind + ', ' + lit + ']',
|
|
353
406
|
import: (!b ? 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';' : null) });
|
|
354
407
|
} else {
|
|
355
|
-
throw new TypeError('[parts-writer] unknown edit op ' + JSON.stringify(e.op) + ' — set | add | remove. (FAIL-LOUD.)');
|
|
408
|
+
throw new TypeError('[parts-writer] unknown edit op ' + JSON.stringify(e.op) + ' — set | add | remove | kind. (FAIL-LOUD.)');
|
|
356
409
|
}
|
|
357
410
|
}
|
|
358
411
|
if (adds.length) {
|
|
@@ -440,14 +493,22 @@ export function entryKeys(text, name) {
|
|
|
440
493
|
}
|
|
441
494
|
|
|
442
495
|
/**
|
|
443
|
-
* entryLiteral(text, name, key) → { literal: true, value } | { literal: false } — what entry `name`'s
|
|
444
|
-
* declares when its value is a literal (the read-back after a write: the text a patch produced, parsed
|
|
445
|
-
*
|
|
496
|
+
* entryLiteral(text, name, key) → { literal: true, value } | { literal: false, length? } — what entry `name`'s
|
|
497
|
+
* `key` declares when its value is a literal (the read-back after a write: the text a patch produced, parsed
|
|
498
|
+
* again). A non-literal ARRAY expression answers its element count as `length` (the arity it spells); any
|
|
499
|
+
* other expression answers no length. THROWS on no such entry or no such key.
|
|
446
500
|
*/
|
|
447
501
|
export function entryLiteral(text, name, key) {
|
|
448
502
|
for (const q of _entryProps(text, name, 'entryLiteral').properties) {
|
|
449
503
|
if (_plainKey(q) !== key) { continue; }
|
|
450
|
-
|
|
504
|
+
if (_isLiteral(q.value)) { return { literal: true, value: _literalValue(q.value) }; }
|
|
505
|
+
// An array expression still spells its arity (`center: [srcX, cy]` is two numbers) — unless an element is a
|
|
506
|
+
// spread (`center: [...c]`), which spells no arity: the caller applies the live one.
|
|
507
|
+
const arr = q.value.type === 'ArrayExpression';
|
|
508
|
+
if (arr && !q.value.elements.some(function (x) { return x && x.type === 'SpreadElement'; })) {
|
|
509
|
+
return { literal: false, length: q.value.elements.length };
|
|
510
|
+
}
|
|
511
|
+
return { literal: false };
|
|
451
512
|
}
|
|
452
513
|
throw new Error('[parts-writer] entryLiteral: the entry "' + name + '" spells no key "' + key + '". (FAIL-LOUD.)');
|
|
453
514
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* read-text.js — THE reader of a text file by URL, for every host: a `file:` URL is read through node's fs
|
|
5
|
+
* (`process.getBuiltinModule`, Node 20.16+ / 22.3+); any other URL is fetched. The split is a CAPABILITY of the
|
|
6
|
+
* resolved URL, never a host sniff.
|
|
7
|
+
*
|
|
8
|
+
* readText(url[, { fetch }]) → Promise<string> REJECTS naming the URL: a file it cannot read ("reading <url>
|
|
9
|
+
* failed: …"), a non-200 ("GET <url> answered <status>"), no
|
|
10
|
+
* fs for a file: URL, no fetch. `fetch` is injectable (tests).
|
|
11
|
+
*
|
|
12
|
+
* Callers prefix their own subject: lib/shim/registry/scene-source.js (a registry scene's sketch) and
|
|
13
|
+
* RecipeContext.fork (a package part's exact bytes).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} url
|
|
18
|
+
* @param {{fetch?: function}} [opts]
|
|
19
|
+
* @returns {Promise<string>}
|
|
20
|
+
*/
|
|
21
|
+
export function readText(url, opts) {
|
|
22
|
+
return Promise.resolve().then(function () {
|
|
23
|
+
if (typeof url !== 'string' || !url) { throw new TypeError('readText(url): a URL string (got ' + JSON.stringify(url) + ')'); }
|
|
24
|
+
if (url.indexOf('file:') === 0) {
|
|
25
|
+
var getBuiltin = (typeof process !== 'undefined' && process.getBuiltinModule) ? process.getBuiltinModule : null;
|
|
26
|
+
if (typeof getBuiltin !== 'function') {
|
|
27
|
+
throw new Error(url + ' is a file: URL; reading one needs node\'s process.getBuiltinModule (Node 20.16+ / ' +
|
|
28
|
+
'22.3+). In a page, serve the package over http');
|
|
29
|
+
}
|
|
30
|
+
var fs = getBuiltin('node:fs');
|
|
31
|
+
var fileURLToPath = getBuiltin('node:url').fileURLToPath;
|
|
32
|
+
return fs.promises.readFile(fileURLToPath(url), 'utf8').catch(function (e) {
|
|
33
|
+
throw new Error('reading ' + url + ' failed: ' + (e && e.message));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
var f = (opts && opts.fetch) || (typeof fetch === 'function' ? fetch : null);
|
|
37
|
+
if (!f) { throw new Error('no fetch available to load ' + url); }
|
|
38
|
+
return Promise.resolve(f(url)).then(function (res) {
|
|
39
|
+
if (!res || !res.ok) { throw new Error('GET ' + url + ' answered ' + (res ? res.status : 'nothing')); }
|
|
40
|
+
return res.text();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* record-format.js — THE owner of the Record folder's shape (the sketch-projects design §6), and its ONE writer.
|
|
5
|
+
*
|
|
6
|
+
* A Record is its own folder, and it contains the project it recorded:
|
|
7
|
+
*
|
|
8
|
+
* record.json { tickRange: [first, last], clips: ['clip-01.mp4', …], changes: [{tick, name, op, by, from, to}] }
|
|
9
|
+
* clip-01.mp4 … the FrqtlVision footage, one file per CLIP_SECONDS segment, in the order filmed
|
|
10
|
+
* parts.before.json { "<name>": ["<registered kind name>", {props}] } at Record start, in key order
|
|
11
|
+
* parts.after.json the same at stop; their diff IS the code change
|
|
12
|
+
* project/… the project's files at stop (frqtl.json, sketch.js, parts/*, README), replayable
|
|
13
|
+
* through bootProject
|
|
14
|
+
*
|
|
15
|
+
* A host's own files (a lab's lab.json) may sit beside these; this module never reads or checks them.
|
|
16
|
+
*
|
|
17
|
+
* RECORD_FILES the folder's §6 paths (record, partsBefore, partsAfter, project)
|
|
18
|
+
* RECORD_KEYS record.json's keys, exactly — there is no record-level who
|
|
19
|
+
* CHANGE_KEYS one change row's keys, exactly
|
|
20
|
+
* OPS the change ops: a placement is `move` whatever hand made it
|
|
21
|
+
* BY who acted, per row
|
|
22
|
+
* CLIP_SECONDS { min, max } — the length of one clip, in seconds of playback
|
|
23
|
+
* CLIP_NAME the clip file pattern; clipName(n) → 'clip-NN.mp4' (n from 1)
|
|
24
|
+
* jsonText(v) JSON.stringify(v, null, 2) + '\n' — every JSON file of the folder
|
|
25
|
+
* recordFolderFaults(files) → string[] [] for a complete §6 folder, every fault by name otherwise
|
|
26
|
+
* serializeRecordFolder(parts) → { files, record, clips } | { empty: true } THE writer (Record.js calls it;
|
|
27
|
+
* it runs recordFolderFaults on its own output and THROWS on any fault)
|
|
28
|
+
*
|
|
29
|
+
* `files` is a tree { path → string | Uint8Array }: JSON and project text as strings (or UTF-8 bytes), clips
|
|
30
|
+
* as bytes. Values in §6 files are strict JSON, in record.json's own vocabulary; a placeholder ("*",
|
|
31
|
+
* {unavailable}) never sits in a §6 field.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { MANIFEST_FILE } from './manifest.js';
|
|
35
|
+
import { entryOf } from './link.js';
|
|
36
|
+
|
|
37
|
+
/** The folder's §6 paths. */
|
|
38
|
+
export const RECORD_FILES = Object.freeze({
|
|
39
|
+
record: 'record.json',
|
|
40
|
+
partsBefore: 'parts.before.json',
|
|
41
|
+
partsAfter: 'parts.after.json',
|
|
42
|
+
project: 'project/'
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** record.json's keys, exactly. */
|
|
46
|
+
export const RECORD_KEYS = Object.freeze(['tickRange', 'clips', 'changes']);
|
|
47
|
+
|
|
48
|
+
/** One change row's keys, exactly. */
|
|
49
|
+
export const CHANGE_KEYS = Object.freeze(['tick', 'name', 'op', 'by', 'from', 'to']);
|
|
50
|
+
|
|
51
|
+
/** The change ops. */
|
|
52
|
+
export const OPS = Object.freeze(['move', 'setDeclared', 'add', 'remove', 'fork']);
|
|
53
|
+
|
|
54
|
+
/** Who acted (each row's `by`). */
|
|
55
|
+
export const BY = Object.freeze(['human', 'maxi']);
|
|
56
|
+
|
|
57
|
+
/** The length of one clip, in seconds of playback (frames / fps). */
|
|
58
|
+
export const CLIP_SECONDS = Object.freeze({ min: 5, max: 10 });
|
|
59
|
+
|
|
60
|
+
/** The clip file pattern. */
|
|
61
|
+
export const CLIP_NAME = /^clip-(\d{2})\.mp4$/;
|
|
62
|
+
|
|
63
|
+
/** @param {number} n 1-based @returns {string} 'clip-NN.mp4' */
|
|
64
|
+
export function clipName(n) {
|
|
65
|
+
if (!Number.isInteger(n) || n < 1 || n > 99) {
|
|
66
|
+
throw new Error('[record-format] a clip number is a whole number 1–99, got ' + JSON.stringify(n) + '. (FAIL-LOUD.)');
|
|
67
|
+
}
|
|
68
|
+
return 'clip-' + String(n).padStart(2, '0') + '.mp4';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** THE text of every JSON file of a Record folder. */
|
|
72
|
+
export function jsonText(v) {
|
|
73
|
+
return JSON.stringify(v, null, 2) + '\n';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── value shapes ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const POSITION_KEYS = Object.freeze({ center: [3], rect: [4, 6] });
|
|
79
|
+
const FORK_FILE = /^parts\/[^/]+\.js$/;
|
|
80
|
+
|
|
81
|
+
function _isPlain(v) {
|
|
82
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) &&
|
|
83
|
+
(Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The first path inside `v` that is not strict JSON (a function, NaN, undefined, a class instance …), else null. */
|
|
87
|
+
function _strictJsonFault(v, at) {
|
|
88
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean') { return null; }
|
|
89
|
+
if (typeof v === 'number') { return Number.isFinite(v) ? null : at + ' is ' + String(v); }
|
|
90
|
+
if (Array.isArray(v)) {
|
|
91
|
+
for (let i = 0; i < v.length; i++) { const f = _strictJsonFault(v[i], at + '[' + i + ']'); if (f) { return f; } }
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (_isPlain(v)) {
|
|
95
|
+
for (const k of Object.keys(v)) { const f = _strictJsonFault(v[k], at + '.' + k); if (f) { return f; } }
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return at + ' is ' + (v === undefined ? 'undefined' : typeof v === 'object' ? 'a ' + ((v.constructor && v.constructor.name) || 'class') + ' instance' : 'a ' + typeof v);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every placeholder inside `v`: an {unavailable} object or a "*" string. */
|
|
102
|
+
function _placeholders(v, at, out) {
|
|
103
|
+
if (v === '*') { out.push(at + ' is the placeholder "*"'); return out; }
|
|
104
|
+
if (Array.isArray(v)) { v.forEach(function (x, i) { _placeholders(x, at + '[' + i + ']', out); }); return out; }
|
|
105
|
+
if (v && typeof v === 'object') {
|
|
106
|
+
if (Object.prototype.hasOwnProperty.call(v, 'unavailable')) { out.push(at + ' is the placeholder {unavailable}'); return out; }
|
|
107
|
+
Object.keys(v).forEach(function (k) { _placeholders(v[k], at + '.' + k, out); });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _isEntry(e) {
|
|
113
|
+
return Array.isArray(e) && e.length === 2 && typeof e[0] === 'string' && e[0].length > 0 && _isPlain(e[1]) && !_strictJsonFault(e[1], '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** { key, length } of a move value `{center: [3]}` / `{rect: [4|6]}`, else null. */
|
|
117
|
+
function _placement(v) {
|
|
118
|
+
if (!_isPlain(v)) { return null; }
|
|
119
|
+
const ks = Object.keys(v);
|
|
120
|
+
if (ks.length !== 1 || !POSITION_KEYS[ks[0]]) { return null; }
|
|
121
|
+
const a = v[ks[0]];
|
|
122
|
+
if (!Array.isArray(a) || POSITION_KEYS[ks[0]].indexOf(a.length) === -1 || !a.every(function (n) { return typeof n === 'number' && Number.isFinite(n); })) { return null; }
|
|
123
|
+
return { key: ks[0], length: a.length };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const MOVE_SHAPE = 'move carries from/to as ONE key, {center: [x, y, z]} or {rect: [4 numbers flat | 6 with a z-extent]}, the same key and length in both';
|
|
127
|
+
|
|
128
|
+
/** The faults of one change row against its op. */
|
|
129
|
+
function _rowFaults(c, at) {
|
|
130
|
+
const bad = [];
|
|
131
|
+
if (!_isPlain(c)) { return [at + ' is not a change {' + CHANGE_KEYS.join(', ') + '}']; }
|
|
132
|
+
const keys = Object.keys(c);
|
|
133
|
+
if (keys.length !== CHANGE_KEYS.length || !CHANGE_KEYS.every(function (k) { return keys.indexOf(k) !== -1; })) {
|
|
134
|
+
bad.push(at + ' carries exactly {' + CHANGE_KEYS.join(', ') + '}, got {' + keys.join(', ') + '}');
|
|
135
|
+
}
|
|
136
|
+
if (!Number.isInteger(c.tick) || c.tick < 0) { bad.push(at + '.tick is a whole tick ≥ 0, got ' + JSON.stringify(c.tick)); }
|
|
137
|
+
if (typeof c.name !== 'string' || !c.name) { bad.push(at + '.name is the declared name, got ' + JSON.stringify(c.name)); }
|
|
138
|
+
if (BY.indexOf(c.by) === -1) { bad.push(at + '.by is one of ' + BY.join('|') + ', got ' + JSON.stringify(c.by)); }
|
|
139
|
+
['from', 'to'].forEach(function (k) {
|
|
140
|
+
const f = _strictJsonFault(c[k], at + '.' + k);
|
|
141
|
+
if (f) { bad.push(f + ' — from/to are strict JSON'); }
|
|
142
|
+
});
|
|
143
|
+
if (c.op === 'move') {
|
|
144
|
+
const a = _placement(c.from), b = _placement(c.to);
|
|
145
|
+
if (!a || !b || a.key !== b.key || a.length !== b.length) { bad.push(at + ': ' + MOVE_SHAPE + ', got ' + JSON.stringify([c.from, c.to])); }
|
|
146
|
+
else if (JSON.stringify(c.from) === JSON.stringify(c.to)) { bad.push(at + ' is a zero move of "' + c.name + '" — an act that moved nothing writes no row'); }
|
|
147
|
+
} else if (c.op === 'setDeclared') {
|
|
148
|
+
if (!_isPlain(c.from) || !_isPlain(c.to) || !Object.keys(c.to).length ||
|
|
149
|
+
JSON.stringify(Object.keys(c.from).sort()) !== JSON.stringify(Object.keys(c.to).sort())) {
|
|
150
|
+
bad.push(at + ': setDeclared carries from/to as {key: value} of the patched keys, the same keys before and after, got ' + JSON.stringify([c.from, c.to]));
|
|
151
|
+
}
|
|
152
|
+
} else if (c.op === 'add') {
|
|
153
|
+
if (c.from !== null || !_isEntry(c.to)) { bad.push(at + ': add carries from: null and to: ["<registered kind name>", {props}], got ' + JSON.stringify([c.from, c.to])); }
|
|
154
|
+
} else if (c.op === 'remove') {
|
|
155
|
+
if (c.to !== null || !_isEntry(c.from)) { bad.push(at + ': remove carries from: ["<registered kind name>", {props}] and to: null, got ' + JSON.stringify([c.from, c.to])); }
|
|
156
|
+
} else if (c.op === 'fork') {
|
|
157
|
+
const ok = typeof c.from === 'string' && c.from.length > 0 && _isPlain(c.to) &&
|
|
158
|
+
JSON.stringify(Object.keys(c.to).sort()) === '["file","kind"]' &&
|
|
159
|
+
typeof c.to.kind === 'string' && c.to.kind.length > 0 && typeof c.to.file === 'string' && FORK_FILE.test(c.to.file);
|
|
160
|
+
if (!ok) { bad.push(at + ': fork carries from: "<source kind name>" and to: {"kind": "<new kind name>", "file": "parts/<File>.js"}, got ' + JSON.stringify([c.from, c.to])); }
|
|
161
|
+
} else {
|
|
162
|
+
bad.push(at + '.op ' + JSON.stringify(c.op) + ' is not one of ' + OPS.join('|') + ' (a placement is "move" whatever hand made it)');
|
|
163
|
+
}
|
|
164
|
+
return bad;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _text(v) {
|
|
168
|
+
return typeof v === 'string' ? v : new TextDecoder('utf-8', { fatal: true }).decode(v);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _isBytes(v) { return v instanceof Uint8Array; }
|
|
172
|
+
|
|
173
|
+
/** Parse a JSON file of the tree: { value } | { fault }. */
|
|
174
|
+
function _json(files, path) {
|
|
175
|
+
if (!Object.prototype.hasOwnProperty.call(files, path)) { return { fault: 'the folder has no ' + path }; }
|
|
176
|
+
try { return { value: JSON.parse(_text(files[path])) }; }
|
|
177
|
+
catch (e) { return { fault: path + ' is not JSON: ' + ((e && e.message) || String(e)) }; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* recordFolderFaults(files) → string[] — [] for a COMPLETE §6 Record folder, otherwise every fault by name.
|
|
182
|
+
* §6 checks only: record.json's keys and value shapes; each row against its op (the fork shape included);
|
|
183
|
+
* ticks inside tickRange and never decreasing; no zero move; one shape per name's moves; `clips` equal to the
|
|
184
|
+
* clip files present, in order; the parts.*.json entries; `project/frqtl.json` naming an entry present under
|
|
185
|
+
* project/. A host's own files (lab.json …) are never read.
|
|
186
|
+
* @param {Object<string, string|Uint8Array>} files
|
|
187
|
+
* @returns {string[]}
|
|
188
|
+
*/
|
|
189
|
+
export function recordFolderFaults(files) {
|
|
190
|
+
if (!_isPlain(files)) { return ['a Record folder is a tree { path: string | Uint8Array }']; }
|
|
191
|
+
const bad = [];
|
|
192
|
+
Object.keys(files).forEach(function (p) {
|
|
193
|
+
if (typeof files[p] !== 'string' && !_isBytes(files[p])) { bad.push(p + ' is neither text nor bytes'); }
|
|
194
|
+
});
|
|
195
|
+
// record.json
|
|
196
|
+
const r = _json(files, RECORD_FILES.record);
|
|
197
|
+
if (r.fault) { bad.push(r.fault); }
|
|
198
|
+
const rec = r.value;
|
|
199
|
+
let range = null;
|
|
200
|
+
let clips = [];
|
|
201
|
+
if (rec !== undefined) {
|
|
202
|
+
if (!_isPlain(rec)) { bad.push(RECORD_FILES.record + ' is an object {' + RECORD_KEYS.join(', ') + '}'); }
|
|
203
|
+
else {
|
|
204
|
+
const keys = Object.keys(rec);
|
|
205
|
+
if (keys.length !== RECORD_KEYS.length || !RECORD_KEYS.every(function (k) { return keys.indexOf(k) !== -1; })) {
|
|
206
|
+
bad.push(RECORD_FILES.record + ' carries exactly {' + RECORD_KEYS.join(', ') + '} (no record-level who: each change names its `by`), got {' + keys.join(', ') + '}');
|
|
207
|
+
}
|
|
208
|
+
_placeholders(rec, RECORD_FILES.record, []).forEach(function (f) { bad.push(f + ' — a placeholder never sits in a §6 field'); });
|
|
209
|
+
const tr = rec.tickRange;
|
|
210
|
+
if (!Array.isArray(tr) || tr.length !== 2 || !tr.every(function (t) { return Number.isInteger(t) && t >= 0; }) || tr[1] < tr[0]) {
|
|
211
|
+
bad.push(RECORD_FILES.record + ' tickRange is [first, last] whole ticks, first ≤ last, got ' + JSON.stringify(tr));
|
|
212
|
+
} else { range = tr; }
|
|
213
|
+
if (!Array.isArray(rec.clips)) { bad.push(RECORD_FILES.record + ' clips is the list of clip files, got ' + JSON.stringify(rec.clips)); }
|
|
214
|
+
else {
|
|
215
|
+
clips = rec.clips;
|
|
216
|
+
clips.forEach(function (c, i) {
|
|
217
|
+
const want = clipName(i + 1);
|
|
218
|
+
if (c !== want) { bad.push(RECORD_FILES.record + ' clips[' + i + '] is ' + JSON.stringify(c) + ', not ' + want + ' (clips in the order filmed)'); }
|
|
219
|
+
else if (!_isBytes(files[c]) || !files[c].length) { bad.push(c + ' is listed in ' + RECORD_FILES.record + ' clips but is not in the folder as bytes'); }
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(rec.changes)) { bad.push(RECORD_FILES.record + ' changes is the list of change rows, got ' + JSON.stringify(rec.changes)); }
|
|
223
|
+
else {
|
|
224
|
+
const shapeOf = {};
|
|
225
|
+
let last = -Infinity;
|
|
226
|
+
rec.changes.forEach(function (c, i) {
|
|
227
|
+
const at = RECORD_FILES.record + ' changes[' + i + ']';
|
|
228
|
+
_rowFaults(c, at).forEach(function (f) { bad.push(f); });
|
|
229
|
+
if (!_isPlain(c)) { return; }
|
|
230
|
+
if (Number.isInteger(c.tick)) {
|
|
231
|
+
if (range && (c.tick < range[0] || c.tick > range[1])) { bad.push(at + ' at tick ' + c.tick + ' lies outside tickRange ' + JSON.stringify(range)); }
|
|
232
|
+
if (c.tick < last) { bad.push(at + ' at tick ' + c.tick + ' comes after a row at tick ' + last + ' — rows are in the order acted, so ticks only climb'); }
|
|
233
|
+
last = Math.max(last, c.tick);
|
|
234
|
+
}
|
|
235
|
+
if (c.op === 'move') {
|
|
236
|
+
const p = _placement(c.from);
|
|
237
|
+
if (p) {
|
|
238
|
+
const s = p.key + '/' + p.length;
|
|
239
|
+
if (shapeOf[c.name] && shapeOf[c.name] !== s) {
|
|
240
|
+
bad.push(at + ' moves "' + c.name + '" as {' + p.key + ': ' + p.length + ' numbers} after a row that moved it as {' +
|
|
241
|
+
shapeOf[c.name].replace('/', ': ') + ' numbers} — one part moves in the one shape of its position authority');
|
|
242
|
+
}
|
|
243
|
+
shapeOf[c.name] = shapeOf[c.name] || s;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
Object.keys(files).filter(function (f) { return CLIP_NAME.test(f) && clips.indexOf(f) === -1; }).sort().forEach(function (f) {
|
|
251
|
+
bad.push(f + ' is in the folder but not listed in ' + RECORD_FILES.record + ' clips');
|
|
252
|
+
});
|
|
253
|
+
// parts.before.json / parts.after.json
|
|
254
|
+
[RECORD_FILES.partsBefore, RECORD_FILES.partsAfter].forEach(function (p) {
|
|
255
|
+
const j = _json(files, p);
|
|
256
|
+
if (j.fault) { bad.push(j.fault); return; }
|
|
257
|
+
if (!_isPlain(j.value)) { bad.push(p + ' is {"<name>": ["<registered kind name>", {props}]}, got ' + JSON.stringify(j.value)); return; }
|
|
258
|
+
_placeholders(j.value, p, []).forEach(function (f) { bad.push(f + ' — a placeholder never sits in a §6 field'); });
|
|
259
|
+
Object.keys(j.value).forEach(function (n) {
|
|
260
|
+
if (!_isEntry(j.value[n])) { bad.push(p + ' "' + n + '" is not ["<registered kind name>", {props}]: ' + JSON.stringify(j.value[n])); }
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
// project/
|
|
264
|
+
const mp = RECORD_FILES.project + MANIFEST_FILE;
|
|
265
|
+
if (!Object.prototype.hasOwnProperty.call(files, mp)) { bad.push('project/ has no ' + MANIFEST_FILE + ' naming its entry (a Record snapshots a project folder)'); }
|
|
266
|
+
else {
|
|
267
|
+
let entry = null;
|
|
268
|
+
try { entry = entryOf({ [MANIFEST_FILE]: _text(files[mp]) }); }
|
|
269
|
+
catch (e) { bad.push(mp + ' is refused by the entry rule (link.js entryOf): ' + ((e && e.message) || String(e))); }
|
|
270
|
+
if (entry !== null && !Object.prototype.hasOwnProperty.call(files, RECORD_FILES.project + entry)) {
|
|
271
|
+
bad.push('project/ has no ' + entry + ' (the entry ' + MANIFEST_FILE + ' names)');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return bad;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── the writer ──────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/** A parts block → its §6 JSON value, refusing a value that is not strict JSON by name. */
|
|
280
|
+
function _partsValue(block, which) {
|
|
281
|
+
if (!_isPlain(block)) { throw new TypeError('[record-format] ' + which + ' is { "<name>": ["<registered kind name>", {props}] }. (FAIL-LOUD.)'); }
|
|
282
|
+
const out = {};
|
|
283
|
+
Object.keys(block).forEach(function (n) {
|
|
284
|
+
const e = block[n];
|
|
285
|
+
if (!Array.isArray(e) || e.length !== 2 || typeof e[0] !== 'string' || !e[0] || !_isPlain(e[1])) {
|
|
286
|
+
throw new TypeError('[record-format] ' + which + ' "' + n + '" is not ["<registered kind name>", {props}]. (FAIL-LOUD.)');
|
|
287
|
+
}
|
|
288
|
+
const f = _strictJsonFault(e[1], n);
|
|
289
|
+
if (f) {
|
|
290
|
+
throw new TypeError('[record-format] ' + which + ': ' + f + ' — a Record writes props as strict JSON; declare that value ' +
|
|
291
|
+
'as a literal in `parts`. (FAIL-LOUD.)');
|
|
292
|
+
}
|
|
293
|
+
out[n] = [e[0], JSON.parse(JSON.stringify(e[1]))];
|
|
294
|
+
});
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const FILM_AFTER_LAST_CHANGE = 'film after your last change: the footage must show every change';
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* serializeRecordFolder({ changes, clips, partsBefore, partsAfter, project }) — THE writer of a Record folder.
|
|
302
|
+
* changes the rows, in the order acted
|
|
303
|
+
* clips [{ bytes: Uint8Array, firstTick, lastTick }], in the order filmed (Record ticks)
|
|
304
|
+
* partsBefore / partsAfter { name: [registered kind name, props] }
|
|
305
|
+
* project { path: text } — the held files at stop
|
|
306
|
+
* → { empty: true } when there is no change and no clip (a state, with no folder);
|
|
307
|
+
* → { files, record, clips: [{ file, firstTick, lastTick }] } otherwise.
|
|
308
|
+
* REFUSES (throws): a change and no clip, or a change after the last clip ends (FILM_AFTER_LAST_CHANGE); a
|
|
309
|
+
* props value that is not strict JSON (naming its key); and any recordFolderFaults of its own output.
|
|
310
|
+
*/
|
|
311
|
+
export function serializeRecordFolder(p) {
|
|
312
|
+
const changes = p.changes, clips = p.clips;
|
|
313
|
+
if (!changes.length && !clips.length) { return { empty: true }; }
|
|
314
|
+
if (!clips.length) {
|
|
315
|
+
throw new Error('[record-format] this Record has ' + changes.length + ' change(s) and no clip — ' + FILM_AFTER_LAST_CHANGE + '. (FAIL-LOUD.)');
|
|
316
|
+
}
|
|
317
|
+
const end = clips[clips.length - 1].lastTick;
|
|
318
|
+
const late = changes.filter(function (c) { return c.tick > end; });
|
|
319
|
+
if (late.length) {
|
|
320
|
+
throw new Error('[record-format] ' + late.length + ' change(s) after the last clip ends (Record tick ' + end + '): ' +
|
|
321
|
+
late.map(function (c) { return c.op + ' ' + c.name + ' at tick ' + c.tick; }).join('; ') + ' — ' + FILM_AFTER_LAST_CHANGE + '. (FAIL-LOUD.)');
|
|
322
|
+
}
|
|
323
|
+
const first = changes.reduce(function (m, c) { return Math.min(m, c.tick); }, clips[0].firstTick);
|
|
324
|
+
const record = {
|
|
325
|
+
tickRange: [first, end],
|
|
326
|
+
clips: clips.map(function (c, i) { return clipName(i + 1); }),
|
|
327
|
+
changes: changes.map(function (c) { const o = {}; CHANGE_KEYS.forEach(function (k) { o[k] = c[k]; }); return o; })
|
|
328
|
+
};
|
|
329
|
+
// Check the rows as HELD, before any text: JSON.stringify writes NaN/Infinity as null and drops an undefined key,
|
|
330
|
+
// so a check of the written text would read a different value from the one the Record held.
|
|
331
|
+
const held = [];
|
|
332
|
+
record.changes.forEach(function (c, i) { held.push.apply(held, _rowFaults(c, 'changes[' + i + ']')); });
|
|
333
|
+
if (held.length) { throw new Error('[record-format] a change row is not strict JSON: ' + held.join('; ') + ' (FAIL-LOUD.)'); }
|
|
334
|
+
const files = {};
|
|
335
|
+
files[RECORD_FILES.record] = jsonText(record);
|
|
336
|
+
clips.forEach(function (c, i) { files[clipName(i + 1)] = c.bytes; });
|
|
337
|
+
files[RECORD_FILES.partsBefore] = jsonText(_partsValue(p.partsBefore, RECORD_FILES.partsBefore));
|
|
338
|
+
files[RECORD_FILES.partsAfter] = jsonText(_partsValue(p.partsAfter, RECORD_FILES.partsAfter));
|
|
339
|
+
Object.keys(p.project).sort().forEach(function (f) { files[RECORD_FILES.project + f] = p.project[f]; });
|
|
340
|
+
const faults = recordFolderFaults(files);
|
|
341
|
+
if (faults.length) {
|
|
342
|
+
throw new Error('[record-format] the Record folder this producer wrote is not a complete §6 Record:\n- ' + faults.join('\n- ') + '\n(FAIL-LOUD.)');
|
|
343
|
+
}
|
|
344
|
+
return { files: files, record: record, clips: clips.map(function (c, i) { return { file: clipName(i + 1), firstTick: c.firstTick, lastTick: c.lastTick }; }) };
|
|
345
|
+
}
|