@descix/frqtl-node 0.6.0-p1.1 → 0.6.0-p4.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/CLAUDE.md +1 -1
- package/README.md +3 -3
- package/dist/dist-manifest.json +27 -25
- package/dist/eyes/index.js +5 -2
- package/dist/frqtl/objects/FrqtlApp.js +28 -1
- package/dist/frqtl/objects/RecipeContext.js +178 -12
- package/dist/frqtl/objects/instruments/hosted-eyes.js +200 -29
- package/dist/frqtl/objects/record/Record.js +326 -0
- package/dist/frqtl/objects/record/open-record.js +22 -0
- package/dist/frqtl/parts/FrqtlVision.js +21 -6
- package/dist/shim/bundle/index.js +112 -96
- package/dist/shim/bundle/indexHtml.js +66 -39
- package/dist/shim/engine/router.js +2 -2
- package/dist/shim/index.js +1 -1
- package/dist/shim/notebook/format.js +1 -1
- package/dist/shim/notebook/store.js +2 -3
- package/dist/shim/project/index.js +16 -3
- package/dist/shim/project/link.js +181 -43
- package/dist/shim/project/manifest.js +151 -0
- package/dist/shim/project/parts-writer.js +9 -5
- package/dist/shim/project/record-format.js +345 -0
- package/dist/shim/project/specifiers.js +1 -1
- package/dist/shim/registry/index.js +1 -1
- package/dist/shim/registry/scene-source.js +44 -30
- package/dist/shim/registry/walkthrough.js +4 -3
- 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 +2 -2
- package/package.json +1 -1
- package/dist/shim/bundle/frqnb.js +0 -112
- package/dist/shim/bundle/manifest.js +0 -95
- package/dist/shim/storage/modules.js +0 -249
- package/dist/shim/storage/projects.js +0 -150
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Record.js — THE live owner of a Record (the sketch-projects design §6): its change rows, its own clock and
|
|
5
|
+
* its clips. Opened by the facade (`const rec = await exp.startRecord()`, RecipeContext); serialized by the one
|
|
6
|
+
* writer, lib/shim/project/record-format.js serializeRecordFolder.
|
|
7
|
+
*
|
|
8
|
+
* rec.changes READ-ONLY: the rows so far, a new deep-frozen copy per read (open or closed).
|
|
9
|
+
* rec.tick READ-ONLY. The Record's OWN clock: 0 when it opens, +1 per world tick the runner
|
|
10
|
+
* delivers (runner.addTickListener, which survives a rebuild), so it climbs across
|
|
11
|
+
* every re-run inside the Record's world (setDeclared, restart) while the runner's
|
|
12
|
+
* own tick restarts at 0. Every row's tick and every clip span is on this clock.
|
|
13
|
+
* await rec.film({ seconds, fps, everyTicks, stage? })
|
|
14
|
+
* ceil(seconds / 10) back-to-back FrqtlVision recordings of equal frame count, each
|
|
15
|
+
* CLIP_SECONDS long; seconds below CLIP_SECONDS.min are refused. Resolves the clip
|
|
16
|
+
* spans it filmed. The host keeps the world ticking (its loop, or a driver).
|
|
17
|
+
* await rec.stop() → { files, record, clips: [{ file, firstTick, lastTick }] } | { empty: true }.
|
|
18
|
+
* REJECTS (the Record stays open): a change and no clip, a change after the last
|
|
19
|
+
* clip ends, a clip still being filmed, any fault recordFolderFaults finds in its
|
|
20
|
+
* own folder.
|
|
21
|
+
* rec.closed a Promise that settles once, when the Record closes: the stop() value plus
|
|
22
|
+
* `closedBy: 'stop'`, or — when the facade's world is re-authored (a build of
|
|
23
|
+
* text that is not the held text) — the folder of the world before, with
|
|
24
|
+
* `closedBy: 'reauthor'` (rejected when that folder is refused, as stop() is).
|
|
25
|
+
* A refused close nobody READ (the getter records the read) makes the facade's
|
|
26
|
+
* next exp.startRecord() reject, naming it.
|
|
27
|
+
* err.record every rejection of stop() and of `closed` carries { changes, clips } — the
|
|
28
|
+
* Record's rows and clip spans — so a host can name them unheld instead of losing them.
|
|
29
|
+
*
|
|
30
|
+
* `closedBy` and the clip spans are the host's (a lab's own `filmed` list); record.json never carries them.
|
|
31
|
+
*
|
|
32
|
+
* WHERE ITS PARTS COME FROM — nothing here captures a second time:
|
|
33
|
+
* rows the writer's own edits at act end (RecipeContext.toSketch / setDeclared call `_row`); only what
|
|
34
|
+
* was WRITTEN makes a row;
|
|
35
|
+
* clips every FrqtlVision.record on this runner while the Record is open (FrqtlVision asks
|
|
36
|
+
* `openRecordOf(runner)` and hands the recording to `_clipFor`); a recording whose frames / fps
|
|
37
|
+
* falls outside CLIP_SECONDS is refused before it is encoded;
|
|
38
|
+
* parts parts.before / parts.after = linkProject(held text).module.parts at open / at close, each kind
|
|
39
|
+
* by the name the kinds registry resolves (kindNamed);
|
|
40
|
+
* project the held files at close, under project/.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { FrqtlVision } from '../../parts/FrqtlVision.js';
|
|
44
|
+
import { linkProject } from '../../../shim/project/link.js';
|
|
45
|
+
import { serializeRecordFolder, CLIP_SECONDS, BY, CHANGE_KEYS } from '../../../shim/project/record-format.js';
|
|
46
|
+
import { kindNamed } from '../kinds.js';
|
|
47
|
+
import { _setOpenRecord, openRecordOf } from './open-record.js';
|
|
48
|
+
|
|
49
|
+
function _fail(msg) {
|
|
50
|
+
throw new Error('[Record] ' + msg + ' (FAIL-LOUD.)');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _bytesOfBase64(b64) {
|
|
54
|
+
var bin = atob(b64);
|
|
55
|
+
var out = new Uint8Array(bin.length);
|
|
56
|
+
for (var i = 0; i < bin.length; i++) { out[i] = bin.charCodeAt(i); }
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** linkProject(held).module.parts → { name: [registered kind name, props] } in key order. */
|
|
61
|
+
async function _partsOf(held) {
|
|
62
|
+
var linked = await linkProject(held);
|
|
63
|
+
var parts = linked.module && linked.module.parts;
|
|
64
|
+
if (!parts || typeof parts !== 'object') {
|
|
65
|
+
_fail('the held sketch exports no `parts` block — a Record writes parts.before/after from `export const parts`.');
|
|
66
|
+
}
|
|
67
|
+
var out = {};
|
|
68
|
+
Object.keys(parts).forEach(function (name) {
|
|
69
|
+
var e = parts[name];
|
|
70
|
+
var Kind = Array.isArray(e) ? e[0] : null;
|
|
71
|
+
if (typeof Kind !== 'function') { _fail('parts.' + name + ' carries no kind class.'); }
|
|
72
|
+
kindNamed(Kind.name); // the name the registry resolves (THROWS naming an unregistered kind)
|
|
73
|
+
out[name] = [Kind.name, e[1]];
|
|
74
|
+
});
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The held project's files, copied (text values). */
|
|
79
|
+
function _heldFiles(held) {
|
|
80
|
+
return Object.assign({}, held.files);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {object} exp — the RecipeContext that opens it (its runner and its held project)
|
|
85
|
+
* @private — construct through exp.startRecord()
|
|
86
|
+
*/
|
|
87
|
+
function Record(exp) {
|
|
88
|
+
var self = this;
|
|
89
|
+
this._exp = exp;
|
|
90
|
+
this._runner = exp.runner;
|
|
91
|
+
this._tick = 0;
|
|
92
|
+
this._rows = [];
|
|
93
|
+
this._clips = [];
|
|
94
|
+
this._inflight = 0;
|
|
95
|
+
this._open = true;
|
|
96
|
+
this._stopping = false;
|
|
97
|
+
this._filmArm = null;
|
|
98
|
+
this._before = null;
|
|
99
|
+
this._closedSettle = null;
|
|
100
|
+
this._closedRead = false;
|
|
101
|
+
this._refused = null;
|
|
102
|
+
this._closed = new Promise(function (resolve, reject) { self._closedSettle = { resolve: resolve, reject: reject }; });
|
|
103
|
+
// Observed here so node does not crash on an unhandled rejection; a refused close nobody read is LOUD at the next
|
|
104
|
+
// act instead (RecipeContext.startRecord refuses until `rec.closed` has been read).
|
|
105
|
+
this._closed.then(null, function () {});
|
|
106
|
+
this._settled = this._closed.then(function () {}, function () {});
|
|
107
|
+
this._onTick = function () { self._tick += 1; };
|
|
108
|
+
this._off = this._runner.addTickListener(this._onTick);
|
|
109
|
+
_setOpenRecord(this._runner, this);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
Object.defineProperty(Record.prototype, 'tick', { get: function () { return this._tick; } });
|
|
113
|
+
/** A deep-frozen copy. */
|
|
114
|
+
function _frozen(v) {
|
|
115
|
+
if (v === null || typeof v !== 'object') { return v; }
|
|
116
|
+
var o = Array.isArray(v) ? v.map(_frozen) : Object.keys(v).reduce(function (a, k) { a[k] = _frozen(v[k]); return a; }, {});
|
|
117
|
+
return Object.freeze(o);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* rec.changes — the rows so far, a NEW deep-frozen copy per read (record.json's `changes` vocabulary), on an open or a
|
|
121
|
+
* closed Record. One act's rows: `const n = rec.changes.length; await exp.toSketch({ by }); rec.changes.slice(n)`.
|
|
122
|
+
*/
|
|
123
|
+
Object.defineProperty(Record.prototype, 'changes', { get: function () {
|
|
124
|
+
return _frozen(this._rows.map(function (r) { var o = {}; CHANGE_KEYS.forEach(function (k) { o[k] = r[k]; }); return o; }));
|
|
125
|
+
} });
|
|
126
|
+
/** rec.closed — reading it records that the host has heard how the Record closed. */
|
|
127
|
+
Object.defineProperty(Record.prototype, 'closed', { get: function () { this._closedRead = true; return this._closed; } });
|
|
128
|
+
|
|
129
|
+
/** Attach what the Record holds to a refusal — `err.record = { changes, clips }` — so a host can name it unheld. */
|
|
130
|
+
Record.prototype._carry = function (err) {
|
|
131
|
+
var e = (err instanceof Error) ? err : new Error(String(err));
|
|
132
|
+
e.record = {
|
|
133
|
+
changes: this._rows.map(function (r) { return JSON.parse(JSON.stringify(r)); }),
|
|
134
|
+
clips: this._clips.map(function (c) { return { firstTick: c.firstTick, lastTick: c.lastTick }; })
|
|
135
|
+
};
|
|
136
|
+
return e;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Read parts.before (the held text at open). @private */
|
|
140
|
+
Record.prototype._ready = async function (held) {
|
|
141
|
+
this._held = held;
|
|
142
|
+
this._before = await _partsOf(held);
|
|
143
|
+
return this;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Throws unless `by` is one of BY (while a Record is open, every act names who acted). */
|
|
147
|
+
Record.requireBy = function (by, where) {
|
|
148
|
+
if (BY.indexOf(by) === -1) {
|
|
149
|
+
throw new TypeError('[' + where + '] a Record is open: the act names who acted — { by: ' + BY.map(function (b) {
|
|
150
|
+
return JSON.stringify(b); }).join(' | ') + ' } (record-format.js BY); got ' + JSON.stringify(by) + '. (FAIL-LOUD.)');
|
|
151
|
+
}
|
|
152
|
+
return by;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** One change row, at the Record's current tick, in the order acted. @private */
|
|
156
|
+
Record.prototype._row = function (row) {
|
|
157
|
+
if (!this._open) { _fail('this Record is closed — no row is written into it.'); }
|
|
158
|
+
if (this._stopping) { _fail('this Record is stopping — an act during stop() belongs to the next Record.'); }
|
|
159
|
+
this._rows.push({ tick: this._tick, name: row.name, op: row.op, by: row.by, from: row.from, to: row.to });
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The clip hook. FrqtlVision calls it with the normalized record() request, BEFORE it subscribes; it THROWS
|
|
164
|
+
* when the request cannot be a CLIP_SECONDS clip, and otherwise returns the recording's session:
|
|
165
|
+
* sample(i) called as frame i is sampled (inside the tick fan-out), reads this Record's tick
|
|
166
|
+
* check(frames) called before the encoder is finalized; THROWS outside CLIP_SECONDS
|
|
167
|
+
* end(envelope) the encoded clip joins the Record (ignored once the Record is closed)
|
|
168
|
+
* drop() the recording failed
|
|
169
|
+
* @private
|
|
170
|
+
*/
|
|
171
|
+
Record.prototype._clipFor = function (req) {
|
|
172
|
+
var self = this;
|
|
173
|
+
var span = CLIP_SECONDS.min + '–' + CLIP_SECONDS.max + ' s';
|
|
174
|
+
var advice = 'film inside an open Record with rec.film({ seconds, fps, everyTicks }), or record ' + span +
|
|
175
|
+
' (frames / fps) per clip';
|
|
176
|
+
var planned = null;
|
|
177
|
+
if (req.stopAt.ticks !== undefined) { planned = Math.min(req.maxFrames, Math.floor(req.stopAt.ticks / req.everyTicks) + 1); }
|
|
178
|
+
var bound = planned === null ? req.maxFrames : planned;
|
|
179
|
+
var secs = function (n) { return n / req.fps; };
|
|
180
|
+
if ((planned !== null && (secs(planned) < CLIP_SECONDS.min || secs(planned) > CLIP_SECONDS.max)) ||
|
|
181
|
+
(planned === null && secs(bound) < CLIP_SECONDS.min)) {
|
|
182
|
+
_fail('a Record is open on this runner, so this recording is one of its clips, and a clip is ' + span +
|
|
183
|
+
' long: this one is ' + (planned !== null ? secs(planned) : 'at most ' + secs(bound)) + ' s (' + bound +
|
|
184
|
+
' frames at ' + req.fps + ' fps) — ' + advice + '.');
|
|
185
|
+
}
|
|
186
|
+
var arm = this._filmArm;
|
|
187
|
+
this._filmArm = null;
|
|
188
|
+
var ticks = [];
|
|
189
|
+
var done = false;
|
|
190
|
+
this._inflight += 1;
|
|
191
|
+
function settle() { if (!done) { done = true; self._inflight -= 1; } }
|
|
192
|
+
return {
|
|
193
|
+
sample: function (i) {
|
|
194
|
+
ticks[i] = self._tick;
|
|
195
|
+
if (arm && i === req.maxFrames - 1) { var next = arm.onLast; arm = null; next(); }
|
|
196
|
+
},
|
|
197
|
+
check: function (frames) {
|
|
198
|
+
if (secs(frames) < CLIP_SECONDS.min || secs(frames) > CLIP_SECONDS.max) {
|
|
199
|
+
settle();
|
|
200
|
+
_fail('a Record is open on this runner, so this recording is one of its clips, and a clip is ' + span +
|
|
201
|
+
' long: this one stopped at ' + secs(frames) + ' s (' + frames + ' frames at ' + req.fps + ' fps) — ' + advice + '.');
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
end: function (env) {
|
|
205
|
+
settle();
|
|
206
|
+
if (!self._open) { return; }
|
|
207
|
+
var n = env.track.frames.length;
|
|
208
|
+
self._clips.push({ bytes: _bytesOfBase64(env.encoded.bytes), firstTick: ticks[0], lastTick: ticks[n - 1] });
|
|
209
|
+
},
|
|
210
|
+
drop: function () { settle(); }
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* rec.film({ seconds, fps, everyTicks, stage? }) → Promise<[{ file?, firstTick, lastTick }]> — ceil(seconds/10)
|
|
216
|
+
* back-to-back segments of equal frame count. Each segment's recording is started INSIDE the previous one's last
|
|
217
|
+
* sampled tick (the tick fan-out dispatches a snapshot, so the next subscription fires from the next tick): the
|
|
218
|
+
* segments are contiguous on the world's clock whatever drives it. REFUSES: seconds below CLIP_SECONDS.min, a
|
|
219
|
+
* seconds × fps that does not split into equal whole-frame segments.
|
|
220
|
+
*/
|
|
221
|
+
Record.prototype.film = function (opts) {
|
|
222
|
+
var self = this;
|
|
223
|
+
try {
|
|
224
|
+
if (!this._open) { _fail('this Record is closed — open the next one (exp.startRecord()) to film.'); }
|
|
225
|
+
if (!opts || typeof opts !== 'object') { _fail('rec.film({ seconds, fps, everyTicks, stage? })'); }
|
|
226
|
+
Object.keys(opts).forEach(function (k) {
|
|
227
|
+
if (['seconds', 'fps', 'everyTicks', 'stage'].indexOf(k) === -1) { _fail('rec.film takes { seconds, fps, everyTicks, stage? }, not "' + k + '".'); }
|
|
228
|
+
});
|
|
229
|
+
var s = opts.seconds, fps = opts.fps, every = opts.everyTicks;
|
|
230
|
+
if (typeof s !== 'number' || !isFinite(s) || s < CLIP_SECONDS.min) {
|
|
231
|
+
_fail('rec.film: seconds is at least ' + CLIP_SECONDS.min + ' (CLIP_SECONDS), got ' + JSON.stringify(s) + '.');
|
|
232
|
+
}
|
|
233
|
+
if (!Number.isInteger(fps) || fps < 1) { _fail('rec.film: fps is a whole number ≥ 1 (the playback rate), got ' + JSON.stringify(fps) + '.'); }
|
|
234
|
+
if (!Number.isInteger(every) || every < 1) { _fail('rec.film: everyTicks is a whole number ≥ 1 (the capture cadence in ticks), got ' + JSON.stringify(every) + '.'); }
|
|
235
|
+
var n = Math.ceil(s / CLIP_SECONDS.max);
|
|
236
|
+
var total = s * fps;
|
|
237
|
+
if (!Number.isInteger(total) || total % n !== 0) {
|
|
238
|
+
_fail('rec.film: ' + s + ' s at ' + fps + ' fps is ' + total + ' frames, which does not split into ' + n +
|
|
239
|
+
' equal whole-frame segments of at most ' + CLIP_SECONDS.max + ' s — choose seconds × fps divisible by ' + n + '.');
|
|
240
|
+
}
|
|
241
|
+
var f = total / n;
|
|
242
|
+
var req = { everyTicks: every, fps: fps, maxFrames: f, stopAt: { ticks: (f - 1) * every } };
|
|
243
|
+
if (opts.stage !== undefined) { req.stage = opts.stage; }
|
|
244
|
+
var vision = FrqtlVision.attach(this._runner);
|
|
245
|
+
var first = this._clips.length;
|
|
246
|
+
// One deferred per segment: segment k+1 exists once segment k sampled its last frame.
|
|
247
|
+
var segs = [];
|
|
248
|
+
for (var k = 0; k < n; k++) {
|
|
249
|
+
(function () { var d = {}; d.promise = new Promise(function (res, rej) { d.resolve = res; d.reject = rej; }); segs.push(d); })();
|
|
250
|
+
}
|
|
251
|
+
var started = [];
|
|
252
|
+
var start = function (k) {
|
|
253
|
+
started[k] = true;
|
|
254
|
+
self._filmArm = (k < n - 1) ? { onLast: function () { start(k + 1); } } : null;
|
|
255
|
+
var p = vision.record(req);
|
|
256
|
+
segs[k].resolve(p);
|
|
257
|
+
p.then(function () {
|
|
258
|
+
if (k + 1 < n && !started[k + 1]) {
|
|
259
|
+
segs[k + 1].reject(new Error('[Record.film] segment ' + (k + 1) + ' of ' + n + ' ended without sampling its last frame, ' +
|
|
260
|
+
'so segment ' + (k + 2) + ' never started. (FAIL-LOUD.)'));
|
|
261
|
+
}
|
|
262
|
+
}, function (e) {
|
|
263
|
+
self._filmArm = null;
|
|
264
|
+
for (var j = k + 1; j < n; j++) { if (!started[j]) { started[j] = true; segs[j].reject(e); } }
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
start(0);
|
|
268
|
+
return Promise.all(segs.map(function (d) { return d.promise; })).then(function () {
|
|
269
|
+
return self._clips.slice(first).map(function (c) { return { firstTick: c.firstTick, lastTick: c.lastTick }; });
|
|
270
|
+
});
|
|
271
|
+
} catch (e) { return Promise.reject(e); }
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** Serialize this Record's folder from `held` (the files it snapshots). @private */
|
|
275
|
+
Record.prototype._folder = async function (held) {
|
|
276
|
+
var after = await _partsOf(held);
|
|
277
|
+
return serializeRecordFolder({
|
|
278
|
+
changes: this._rows.slice(), clips: this._clips.slice(),
|
|
279
|
+
partsBefore: this._before, partsAfter: after, project: _heldFiles(held)
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Detach from the world (no row, tick or clip after this). @private */
|
|
284
|
+
Record.prototype._detach = function () {
|
|
285
|
+
this._open = false;
|
|
286
|
+
if (this._off) { this._off(); this._off = null; }
|
|
287
|
+
_setOpenRecord(this._runner, null);
|
|
288
|
+
this._exp._record = null;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** rec.stop() — see the module docstring. */
|
|
292
|
+
Record.prototype.stop = async function () {
|
|
293
|
+
var self = this;
|
|
294
|
+
var refuse = function (msg) { try { _fail(msg); } catch (e) { throw self._carry(e); } };
|
|
295
|
+
if (!this._open) { refuse('this Record is already closed' + (this._closedBy ? ' (closedBy: ' + this._closedBy + ')' : '') + ' — read rec.closed.'); }
|
|
296
|
+
if (this._stopping) { refuse('stop() is already running.'); }
|
|
297
|
+
if (this._inflight) { refuse(this._inflight + ' clip(s) are still being filmed — await rec.film (or the record()) before stop().'); }
|
|
298
|
+
this._stopping = true;
|
|
299
|
+
var out;
|
|
300
|
+
try { out = await this._folder(this._exp._project); }
|
|
301
|
+
catch (e) { throw this._carry(e); }
|
|
302
|
+
finally { this._stopping = false; }
|
|
303
|
+
this._closedBy = 'stop';
|
|
304
|
+
this._detach();
|
|
305
|
+
this._closedSettle.resolve(Object.assign({}, out, { closedBy: 'stop' }));
|
|
306
|
+
return out;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The facade re-authors its world: close with the folder of the world BEFORE (`held` = the held project before
|
|
311
|
+
* the build). Synchronous detach; the folder settles `closed`. @private
|
|
312
|
+
*/
|
|
313
|
+
Record.prototype._closeOnReauthor = function (held) {
|
|
314
|
+
var self = this;
|
|
315
|
+
this._closedBy = 'reauthor';
|
|
316
|
+
this._detach();
|
|
317
|
+
this._folder(held).then(function (out) {
|
|
318
|
+
self._closedSettle.resolve(Object.assign({}, out, { closedBy: 'reauthor' }));
|
|
319
|
+
}, function (e) {
|
|
320
|
+
self._refused = self._carry(e);
|
|
321
|
+
self._refused.closedBy = 'reauthor';
|
|
322
|
+
self._closedSettle.reject(self._refused);
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
export { Record, openRecordOf };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* open-record.js — which Record is open on a runner: the ONE store FrqtlVision asks (its clip hook) and the
|
|
5
|
+
* Record writes. One open Record per runner; a second open is refused by the facade (RecipeContext.startRecord).
|
|
6
|
+
*
|
|
7
|
+
* openRecordOf(runner) → Record | null
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var _open = new WeakMap();
|
|
11
|
+
|
|
12
|
+
/** @param {object} runner @returns {object|null} the Record open on it */
|
|
13
|
+
function openRecordOf(runner) {
|
|
14
|
+
return (runner && _open.get(runner)) || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @private — Record.js only */
|
|
18
|
+
function _setOpenRecord(runner, rec) {
|
|
19
|
+
if (rec) { _open.set(runner, rec); } else { _open.delete(runner); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { openRecordOf, _setOpenRecord };
|
|
@@ -183,6 +183,7 @@ import * as CaptureEnvelope from '../../shim/render/capture-envelope.js';
|
|
|
183
183
|
import { CameraPath } from './CameraPath.js';
|
|
184
184
|
import { FrqtlCaptureHost } from '../objects/instruments/capture-host.js';
|
|
185
185
|
import { vendorUrl } from '../compat/vendor.js';
|
|
186
|
+
import { openRecordOf } from '../objects/record/open-record.js';
|
|
186
187
|
|
|
187
188
|
function _rgba8Format(RS) { return RS.STAGE_FORMATS[RS.finalStage()]; }
|
|
188
189
|
|
|
@@ -574,16 +575,24 @@ function _record(runner, RS, opts) {
|
|
|
574
575
|
'localhost). The encoder is not optional — there is no second path');
|
|
575
576
|
}
|
|
576
577
|
|
|
578
|
+
// THE CLIP HOOK: while a Record is open on this runner, every recording is one of its clips. The Record
|
|
579
|
+
// refuses a request that cannot be a CLIP_SECONDS clip (before anything subscribes) and keeps the clip's
|
|
580
|
+
// ticks and bytes; this recorder holds no Record state of its own (objects/record/Record.js _clipFor).
|
|
581
|
+
var openRec = openRecordOf(runner);
|
|
582
|
+
var clip = openRec ? openRec._clipFor(req) : null;
|
|
583
|
+
|
|
577
584
|
// Everything that can THROW synchronously happens BEFORE the sampler subscribes: a
|
|
578
585
|
// CameraPath refused at attach (no pose control, an undeclared pose field) must not leave
|
|
579
586
|
// a subscribed sampler holding the clock behind a rejection nobody can reach.
|
|
580
587
|
var path = null;
|
|
581
588
|
if (req.stopAt.path) {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
589
|
+
try {
|
|
590
|
+
path = CameraPath.attach(runner, {
|
|
591
|
+
waypoints: req.stopAt.path.waypoints,
|
|
592
|
+
loop: !!req.stopAt.path.loop,
|
|
593
|
+
waypointTimeoutMs: req.stopAt.path.waypointTimeoutMs
|
|
594
|
+
});
|
|
595
|
+
} catch (e) { if (clip) { clip.drop(); } throw e; }
|
|
587
596
|
}
|
|
588
597
|
var frames = [];
|
|
589
598
|
var inflight = [];
|
|
@@ -628,6 +637,7 @@ function _record(runner, RS, opts) {
|
|
|
628
637
|
_fail('record', 'the recording stopped before a single still was captured — nothing to ' +
|
|
629
638
|
'encode. Is the runner advancing ticks?');
|
|
630
639
|
}
|
|
640
|
+
if (clip) { clip.check(frames.length); }
|
|
631
641
|
return encodeChain;
|
|
632
642
|
}).then(function (enc) {
|
|
633
643
|
return enc.output.finalize().then(function () { return enc.output.getMimeType(); })
|
|
@@ -654,7 +664,11 @@ function _record(runner, RS, opts) {
|
|
|
654
664
|
truncated: truncated
|
|
655
665
|
});
|
|
656
666
|
});
|
|
657
|
-
}).then(
|
|
667
|
+
}).then(function (env) {
|
|
668
|
+
if (clip) { clip.end(env); }
|
|
669
|
+
settle.resolve(env);
|
|
670
|
+
}, function (e) {
|
|
671
|
+
if (clip) { clip.drop(); }
|
|
658
672
|
encoderReady.then(function (enc) { return enc.output.cancel(); }).then(null, function () {});
|
|
659
673
|
settle.reject(e);
|
|
660
674
|
});
|
|
@@ -670,6 +684,7 @@ function _record(runner, RS, opts) {
|
|
|
670
684
|
return true;
|
|
671
685
|
},
|
|
672
686
|
onStill: function (p, i) {
|
|
687
|
+
if (clip) { clip.sample(i); }
|
|
673
688
|
var handled = p.then(function (still) {
|
|
674
689
|
if (i === 0) {
|
|
675
690
|
scene = still.scene;
|
|
@@ -1,121 +1,137 @@
|
|
|
1
1
|
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* lib/shim/bundle/index.js —
|
|
5
|
-
* The canonical owner of the bundle format
|
|
6
|
-
* (design §2): a folder that zips for download —
|
|
4
|
+
* lib/shim/bundle/index.js — Export and Import of a project folder.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* index.html ← standalone host (@descix/frqtl-web bootProject over the served folder)
|
|
11
|
-
* manifest.json ← Module envelope minus `source` + bundle-only fields (E1)
|
|
12
|
-
* preview.png ← optional golden screenshot
|
|
6
|
+
* A project is a folder (the stored row's owner is
|
|
7
|
+
* lib/shim/storage/folders.js). The Export IS the folder, zipped, plus the host page that boots it:
|
|
13
8
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
9
|
+
* exportFolder(files, renderState, { sdkVersion }) → zipBytes
|
|
10
|
+
* the zip holds EXACTLY the folder's files plus a generated `index.html` (indexHtml.js: bootProject over the
|
|
11
|
+
* served folder, frqtl.json naming the entry, the boot bag inlined by its one writer).
|
|
12
|
+
* importArchive(bytes, fileName) → { files, renderState } (a normalized folder row)
|
|
13
|
+
* a zip → its files minus `index.html`, whose boot bag (indexHtml.js readBootBag) becomes renderState;
|
|
14
|
+
* it must carry frqtl.json at its root (an archive with no index.html imports with
|
|
15
|
+
* renderState null)
|
|
16
|
+
* one `.js` → the one-file project { 'sketch.js': text, 'frqtl.json': manifestFor({ name: <basename>,
|
|
17
|
+
* entry: 'sketch.js' }) }, renderState null — a 3D sketch then fails loud at boot on the
|
|
18
|
+
* renderer (the existing named refusal); nothing guesses a display
|
|
19
|
+
* REFUSED, naming the migration: a zip carrying `experiment.js` or `manifest.json`, or a JSON v4 module
|
|
20
|
+
* envelope — the retired export format. REFUSED by rule: a one-file `.mjs` (frqtl.json's entry rule admits a
|
|
21
|
+
* `.js` entry only), anything else.
|
|
19
22
|
*
|
|
20
|
-
*
|
|
23
|
+
* PURE + DOM-free (Uint8Array in and out). The browser download and file read are lib/features/ide/bundle-export.js.
|
|
24
|
+
* zip.js (the STORE zip) is imported by the Record too; its API is frozen. Layer 2 (physics): none.
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
'use strict';
|
|
24
28
|
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { normalizeSavedRenderState } from '../render-state/index.js';
|
|
29
|
+
import { zipStore, unzipStore, textBytes } from './zip.js';
|
|
30
|
+
import { buildIndexHtml, readBootBag } from './indexHtml.js';
|
|
31
|
+
import { normalizeFolderRow, EXPORT_PAGE } from '../storage/folders.js';
|
|
32
|
+
import { MANIFEST_FILE, MANIFEST_KEYS, parseManifest, serializeManifest, manifestFor } from '../project/manifest.js';
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
export var
|
|
33
|
-
export var FILE_EXPERIMENT_FRQNB = 'experiment.frqnb';
|
|
34
|
-
export var FILE_INDEX_HTML = 'index.html';
|
|
35
|
-
export var FILE_MANIFEST_JSON = 'manifest.json';
|
|
36
|
-
export var FILE_PREVIEW_PNG = 'preview.png';
|
|
34
|
+
/** The files of the retired export format (the v4 envelope's bundle). */
|
|
35
|
+
export var RETIRED_ARCHIVE_FILES = Object.freeze(['experiment.js', 'manifest.json']);
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
* Conceptually the dispatch's `(source, envelopeMeta, renderState, previewPngBytes,
|
|
42
|
-
* sdkVersion)`: here `source` + `renderState` + `envelopeMeta` arrive together AS
|
|
43
|
-
* the Module envelope (the exact shape STUDIO's Save path already builds), so the
|
|
44
|
-
* bundle↔envelope round-trip is a clean identity. `manifest` ferries the envelope
|
|
45
|
-
* minus `source` (E1) — no field re-enumeration.
|
|
46
|
-
*
|
|
47
|
-
* @param {object} envelope — a `lib/shim/storage/modules.js` Module envelope
|
|
48
|
-
* (`{id,name,source,renderState,…}`; `source` = the recipe, REQUIRED).
|
|
49
|
-
* @param {object} opts
|
|
50
|
-
* @param {string} opts.sdkVersion — REQUIRED CDN pin (derived, never a literal).
|
|
51
|
-
* @param {Uint8Array|null} [opts.previewPngBytes] — preview image bytes, or null.
|
|
52
|
-
* @param {string} [opts.stage='600x400'] — canvas stage for the .frqnb projection.
|
|
53
|
-
* @param {string} [opts.prose] — optional teaching blurb for the .frqnb markdown.
|
|
54
|
-
* @returns {{files: {name:string, bytes:Uint8Array}[], zipBytes: Uint8Array}}
|
|
55
|
-
*/
|
|
56
|
-
export function assembleBundle(envelope, opts) {
|
|
57
|
-
if (!envelope || typeof envelope !== 'object') {
|
|
58
|
-
throw new Error('[bundle] assembleBundle(envelope): envelope must be an object.');
|
|
59
|
-
}
|
|
60
|
-
if (typeof envelope.source !== 'string' || envelope.source.length === 0) {
|
|
61
|
-
throw new Error('[bundle] assembleBundle: envelope.source (the recipe) is required.');
|
|
62
|
-
}
|
|
63
|
-
opts = opts || {};
|
|
64
|
-
var preview = (opts.previewPngBytes instanceof Uint8Array && opts.previewPngBytes.length > 0)
|
|
65
|
-
? opts.previewPngBytes : null;
|
|
37
|
+
var RETIRED = 'this is the retired export format; the project folder replaced it (frqtl.json + sketch.js)';
|
|
38
|
+
var SKETCH = 'sketch.js';
|
|
66
39
|
|
|
67
|
-
|
|
68
|
-
sdkVersion: opts.sdkVersion,
|
|
69
|
-
preview: preview ? FILE_PREVIEW_PNG : null
|
|
70
|
-
});
|
|
40
|
+
var _strict = new TextDecoder('utf-8', { fatal: true });
|
|
71
41
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
var files = [
|
|
75
|
-
{ name: FILE_EXPERIMENT_JS, bytes: textBytes(envelope.source) },
|
|
76
|
-
{ name: FILE_EXPERIMENT_FRQNB, bytes: textBytes(buildFrqnb({
|
|
77
|
-
name: envelope.name, source: envelope.source,
|
|
78
|
-
renderState: normalizedRs, stage: opts.stage, prose: opts.prose
|
|
79
|
-
})) },
|
|
80
|
-
{ name: FILE_INDEX_HTML, bytes: textBytes(buildIndexHtml({
|
|
81
|
-
name: envelope.name, sdkVersion: opts.sdkVersion, renderState: normalizedRs,
|
|
82
|
-
scripts: Array.isArray(envelope.scripts) ? envelope.scripts : [] // the envelope's setup-factory delta
|
|
83
|
-
})) },
|
|
84
|
-
{ name: FILE_MANIFEST_JSON, bytes: textBytes(JSON.stringify(manifest, null, 2)) }
|
|
85
|
-
];
|
|
86
|
-
if (preview) { files.push({ name: FILE_PREVIEW_PNG, bytes: preview }); }
|
|
87
|
-
|
|
88
|
-
return { files: files, zipBytes: zipStore(files) };
|
|
42
|
+
function _fail(msg) {
|
|
43
|
+
throw new Error('[bundle] ' + msg + ' (FAIL-LOUD.)');
|
|
89
44
|
}
|
|
90
45
|
|
|
91
46
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* @
|
|
96
|
-
* @returns {{envelope: object, previewPngBytes: Uint8Array|null}}
|
|
47
|
+
* @param {Object<string,string>} files — the folder (frqtl.json + its files)
|
|
48
|
+
* @param {object|null} renderState — the host's boot bag (normalized by its owner through the folder row)
|
|
49
|
+
* @param {{sdkVersion: string}} opts — the `@descix/frqtl-web` pin the page imports (derived, never a literal)
|
|
50
|
+
* @returns {Uint8Array} the zip
|
|
97
51
|
*/
|
|
98
|
-
export function
|
|
99
|
-
var
|
|
100
|
-
var
|
|
101
|
-
|
|
52
|
+
export function exportFolder(files, renderState, opts) {
|
|
53
|
+
var row = normalizeFolderRow({ files: files, renderState: renderState });
|
|
54
|
+
var sdkVersion = opts && opts.sdkVersion;
|
|
55
|
+
var name = parseManifest(row.files[MANIFEST_FILE]).name;
|
|
56
|
+
var entries = Object.keys(row.files).map(function (p) { return { name: p, bytes: textBytes(row.files[p]) }; });
|
|
57
|
+
entries.push({ name: EXPORT_PAGE, bytes: textBytes(buildIndexHtml({ name: name, sdkVersion: sdkVersion, renderState: row.renderState })) });
|
|
58
|
+
return zipStore(entries);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _isZip(bytes) {
|
|
62
|
+
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b &&
|
|
63
|
+
((bytes[2] === 0x03 && bytes[3] === 0x04) || (bytes[2] === 0x05 && bytes[3] === 0x06));
|
|
64
|
+
}
|
|
102
65
|
|
|
103
|
-
|
|
104
|
-
|
|
66
|
+
function _text(bytes, what) {
|
|
67
|
+
try { return _strict.decode(bytes); }
|
|
68
|
+
catch (e) { _fail(what + ' is not UTF-8 text — a project folder holds text files only.'); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _baseName(fileName) {
|
|
72
|
+
var base = String(fileName).split(/[\\/]/).pop();
|
|
73
|
+
return base;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _fromZip(bytes) {
|
|
77
|
+
var entries = unzipStore(bytes).filter(function (e) { return !/\/$/.test(e.name); });
|
|
78
|
+
var names = entries.map(function (e) { return e.name; });
|
|
79
|
+
var retired = RETIRED_ARCHIVE_FILES.filter(function (f) { return names.indexOf(f) !== -1; });
|
|
80
|
+
if (retired.length) {
|
|
81
|
+
_fail('the archive carries ' + retired.join(' and ') + ': ' + RETIRED + '.');
|
|
105
82
|
}
|
|
106
|
-
if (
|
|
107
|
-
|
|
83
|
+
if (names.indexOf(MANIFEST_FILE) === -1) {
|
|
84
|
+
_fail('the archive carries no ' + MANIFEST_FILE + ' at its root — a project archive is the folder zipped ' +
|
|
85
|
+
'(frqtl.json + sketch.js + …), as an Export writes it.');
|
|
108
86
|
}
|
|
87
|
+
var files = {};
|
|
88
|
+
var renderState = null;
|
|
89
|
+
entries.forEach(function (e) {
|
|
90
|
+
var text = _text(e.bytes, '"' + e.name + '"');
|
|
91
|
+
if (e.name === EXPORT_PAGE) { renderState = readBootBag(text); return; }
|
|
92
|
+
files[e.name] = text;
|
|
93
|
+
});
|
|
94
|
+
return normalizeFolderRow({ files: files, renderState: renderState });
|
|
95
|
+
}
|
|
109
96
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
97
|
+
function _fromJson(text) {
|
|
98
|
+
var raw;
|
|
99
|
+
try { raw = JSON.parse(text); }
|
|
100
|
+
catch (e) { _fail('the file is neither a project archive (.zip) nor a sketch (.js), and not valid JSON either.'); }
|
|
101
|
+
if (raw && typeof raw === 'object' && ('schemaVersion' in raw || 'scripts' in raw) && 'source' in raw) {
|
|
102
|
+
_fail('the file is a v4 module envelope: ' + RETIRED + '.');
|
|
115
103
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
104
|
+
_fail('the JSON file is not a project — Import takes a project archive (.zip) or one sketch (.js).');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _fromSketch(text, fileName) {
|
|
108
|
+
var base = _baseName(fileName);
|
|
109
|
+
var name = base.replace(/\.js$/, '');
|
|
110
|
+
if (!name) { _fail('the sketch file "' + base + '" has no name before ".js".'); }
|
|
111
|
+
var files = {};
|
|
112
|
+
files[MANIFEST_FILE] = serializeManifest(manifestFor({ name: name, entry: SKETCH }));
|
|
113
|
+
files[SKETCH] = text;
|
|
114
|
+
return normalizeFolderRow({ files: files, renderState: null });
|
|
115
|
+
}
|
|
119
116
|
|
|
120
|
-
|
|
117
|
+
/**
|
|
118
|
+
* @param {Uint8Array} bytes
|
|
119
|
+
* @param {string} fileName — the imported file's name (a one-file sketch's basename is its project name)
|
|
120
|
+
* @returns {Readonly<{files: Object<string,string>, renderState: object}>}
|
|
121
|
+
*/
|
|
122
|
+
export function importArchive(bytes, fileName) {
|
|
123
|
+
if (!(bytes instanceof Uint8Array)) { _fail('importArchive(bytes, fileName): bytes is a Uint8Array.'); }
|
|
124
|
+
if (typeof fileName !== 'string' || fileName.length === 0) { _fail('importArchive(bytes, fileName): the file\'s name.'); }
|
|
125
|
+
if (_isZip(bytes)) { return _fromZip(bytes); }
|
|
126
|
+
var base = _baseName(fileName);
|
|
127
|
+
if (/\.mjs$/i.test(base)) {
|
|
128
|
+
var entryRule = MANIFEST_KEYS.filter(function (r) { return r.key === 'entry'; })[0].rule;
|
|
129
|
+
_fail('"' + base + '" is a .mjs module — a one-file Import takes a .js sketch, because a project\'s entry is ' +
|
|
130
|
+
entryRule + ' (frqtl.json, lib/shim/project/manifest.js MANIFEST_KEYS). Rename it to .js and import it again.');
|
|
131
|
+
}
|
|
132
|
+
var text = _text(bytes, '"' + base + '"');
|
|
133
|
+
if (/\.js$/.test(base)) { return _fromSketch(text, base); }
|
|
134
|
+
if (/\.json$/i.test(base) || /^\s*\{/.test(text)) { return _fromJson(text); }
|
|
135
|
+
_fail('"' + base + '" is not a project — Import takes a project archive (.zip, as an Export writes it) or one ' +
|
|
136
|
+
'sketch (.js).');
|
|
121
137
|
}
|