@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.
Files changed (35) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +3 -3
  3. package/dist/dist-manifest.json +27 -25
  4. package/dist/eyes/index.js +5 -2
  5. package/dist/frqtl/objects/FrqtlApp.js +28 -1
  6. package/dist/frqtl/objects/RecipeContext.js +178 -12
  7. package/dist/frqtl/objects/instruments/hosted-eyes.js +200 -29
  8. package/dist/frqtl/objects/record/Record.js +326 -0
  9. package/dist/frqtl/objects/record/open-record.js +22 -0
  10. package/dist/frqtl/parts/FrqtlVision.js +21 -6
  11. package/dist/shim/bundle/index.js +112 -96
  12. package/dist/shim/bundle/indexHtml.js +66 -39
  13. package/dist/shim/engine/router.js +2 -2
  14. package/dist/shim/index.js +1 -1
  15. package/dist/shim/notebook/format.js +1 -1
  16. package/dist/shim/notebook/store.js +2 -3
  17. package/dist/shim/project/index.js +16 -3
  18. package/dist/shim/project/link.js +181 -43
  19. package/dist/shim/project/manifest.js +151 -0
  20. package/dist/shim/project/parts-writer.js +9 -5
  21. package/dist/shim/project/record-format.js +345 -0
  22. package/dist/shim/project/specifiers.js +1 -1
  23. package/dist/shim/registry/index.js +1 -1
  24. package/dist/shim/registry/scene-source.js +44 -30
  25. package/dist/shim/registry/walkthrough.js +4 -3
  26. package/dist/shim/storage/codec-chains.js +88 -0
  27. package/dist/shim/storage/folders.js +205 -0
  28. package/dist/shim/storage/index.js +64 -8
  29. package/dist/shim/storage/make-namespace-store.js +5 -5
  30. package/llms.txt +2 -2
  31. package/package.json +1 -1
  32. package/dist/shim/bundle/frqnb.js +0 -112
  33. package/dist/shim/bundle/manifest.js +0 -95
  34. package/dist/shim/storage/modules.js +0 -249
  35. 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
- path = CameraPath.attach(runner, {
583
- waypoints: req.stopAt.path.waypoints,
584
- loop: !!req.stopAt.path.loop,
585
- waypointTimeoutMs: req.stopAt.path.waypointTimeoutMs
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(settle.resolve, function (e) {
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 — the recipe-bundle assembler + importer
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
- * experiment.js ← THE source of truth (the buildExperiment(exp) recipe)
9
- * experiment.frqnb ← teaching/gallery projection (notebook/format.serialize)
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
- * PURE + DOM-free: emits/consumes `Uint8Array`. STUDIO's export button and any
15
- * future DeSciX publisher CONSUME this — passing the generated `experiment.js`
16
- * source + envelope + renderState DOWN (this module never imports codegen/
17
- * save-state — those live above the shim; the arrow stays one-way). The browser
18
- * `Blob`+`a.click` download trigger is a THIN feature-side helper.
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
- * Layer 2 (physics): none — pure format/packaging.
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 { buildManifest, manifestToEnvelope } from './manifest.js';
26
- import { buildFrqnb } from './frqnb.js';
27
- import { buildIndexHtml } from './indexHtml.js';
28
- import { zipStore, unzipStore, textBytes, bytesText } from './zip.js';
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
- // ── Canonical bundle file names ──────────────────────────────────────────────
32
- export var FILE_EXPERIMENT_JS = 'experiment.js';
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
- * Assemble a recipe bundle from a Module envelope + bundle inputs.
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
- var manifest = buildManifest(envelope, {
68
- sdkVersion: opts.sdkVersion,
69
- preview: preview ? FILE_PREVIEW_PNG : null
70
- });
40
+ var _strict = new TextDecoder('utf-8', { fatal: true });
71
41
 
72
- var normalizedRs = normalizeSavedRenderState(envelope.renderState);
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
- * Import a recipe bundle: recover the Module envelope (source re-attached from
93
- * `experiment.js` per E1) + the preview bytes.
94
- *
95
- * @param {Uint8Array} zipBytes — a bundle `.zip` (from `assembleBundle`).
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 importBundle(zipBytes) {
99
- var entries = unzipStore(zipBytes);
100
- var byName = Object.create(null);
101
- for (var i = 0; i < entries.length; i++) { byName[entries[i].name] = entries[i].bytes; }
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
- if (!byName[FILE_MANIFEST_JSON]) {
104
- throw new Error('[bundle] importBundle: ' + FILE_MANIFEST_JSON + ' missing from the archive.');
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 (!byName[FILE_EXPERIMENT_JS]) {
107
- throw new Error('[bundle] importBundle: ' + FILE_EXPERIMENT_JS + ' missing from the archive.');
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
- var manifest;
111
- try {
112
- manifest = JSON.parse(bytesText(byName[FILE_MANIFEST_JSON]));
113
- } catch (e) {
114
- throw new Error('[bundle] importBundle: ' + FILE_MANIFEST_JSON + ' is not valid JSON — ' + (e && e.message));
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
- var source = bytesText(byName[FILE_EXPERIMENT_JS]);
117
- var envelope = manifestToEnvelope(manifest, source);
118
- var previewPngBytes = byName[FILE_PREVIEW_PNG] || null;
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
- return { envelope: envelope, previewPngBytes: previewPngBytes };
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
  }