@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.
Files changed (80) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +6 -4
  3. package/dist/dist-manifest.json +71 -65
  4. package/dist/eyes/index.js +5 -2
  5. package/dist/fat/pkg-node/package.json +2 -10
  6. package/dist/frqtl/compat/providers/wasm-engine.js +3 -4
  7. package/dist/frqtl/compat/renderer/providers/p5-provider.js +1 -1
  8. package/dist/frqtl/objects/FrqtlApp.js +33 -6
  9. package/dist/frqtl/objects/RecipeContext.js +284 -13
  10. package/dist/frqtl/objects/constants/WallBehaviors.js +1 -1
  11. package/dist/frqtl/objects/instruments/capture.js +1 -24
  12. package/dist/frqtl/objects/instruments/hosted-eyes.js +216 -29
  13. package/dist/frqtl/objects/objects/makeControlsForObject.js +5 -5
  14. package/dist/frqtl/objects/record/Record.js +346 -0
  15. package/dist/frqtl/objects/record/open-record.js +22 -0
  16. package/dist/frqtl/objects/schema/engine-scopes.js +2 -2
  17. package/dist/frqtl/objects/schema/scopes.js +2 -2
  18. package/dist/frqtl/objects/setup/_worldInit.js +1 -2
  19. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +1 -2
  20. package/dist/frqtl/parts/CameraControls.js +8 -0
  21. package/dist/frqtl/parts/DetectorWall.js +1 -1
  22. package/dist/frqtl/parts/FrqtlVision.js +21 -6
  23. package/dist/frqtl/parts/GraphingDetectorWall.js +7 -7
  24. package/dist/frqtl/parts/LargeObject.js +2 -2
  25. package/dist/frqtl/parts/LiveChart.js +2 -2
  26. package/dist/frqtl/parts/LogicGate.js +3 -3
  27. package/dist/frqtl/parts/OvenBox.js +1 -1
  28. package/dist/frqtl/parts/PointSource.js +1 -1
  29. package/dist/frqtl/parts/SlitLightSource.js +1 -1
  30. package/dist/frqtl/parts/SlitScreen.js +1 -1
  31. package/dist/frqtl/parts/SphereEmitter.js +12 -12
  32. package/dist/frqtl/parts/Trace.js +1 -1
  33. package/dist/frqtl/parts/Tunnel.js +11 -11
  34. package/dist/frqtl/parts/Wall.js +20 -10
  35. package/dist/frqtl/scenes/circuit-sat.js +1 -0
  36. package/dist/frqtl/scenes/dse-one-emitter.js +32 -34
  37. package/dist/frqtl/scenes/dse-two-emitters.js +33 -34
  38. package/dist/frqtl/scenes/mirror-route.js +96 -79
  39. package/dist/shim/bundle/index.js +118 -96
  40. package/dist/shim/bundle/indexHtml.js +66 -39
  41. package/dist/shim/controls/index.js +1 -1
  42. package/dist/shim/engine/artifact-shell.js +1 -1
  43. package/dist/shim/engine/router.js +3 -3
  44. package/dist/shim/host/host-view.js +228 -0
  45. package/dist/shim/host/index.js +18 -0
  46. package/dist/shim/index.js +3 -5
  47. package/dist/shim/notebook/format.js +1 -1
  48. package/dist/shim/notebook/kernel.js +1 -1
  49. package/dist/shim/notebook/setup-builder.js +2 -2
  50. package/dist/shim/notebook/store.js +2 -3
  51. package/dist/shim/package.json +1 -0
  52. package/dist/shim/page-params/index.js +2 -30
  53. package/dist/shim/project/fork.js +293 -0
  54. package/dist/shim/project/identity.js +3 -1
  55. package/dist/shim/project/index.js +16 -3
  56. package/dist/shim/project/link.js +224 -46
  57. package/dist/shim/project/manifest.js +151 -0
  58. package/dist/shim/project/parts-writer.js +68 -7
  59. package/dist/shim/project/read-text.js +43 -0
  60. package/dist/shim/project/record-format.js +345 -0
  61. package/dist/shim/project/specifiers.js +104 -15
  62. package/dist/shim/registry/exhibits.js +15 -34
  63. package/dist/shim/registry/index.js +3 -3
  64. package/dist/shim/registry/scene-roster.js +35 -0
  65. package/dist/shim/registry/scene-source.js +48 -64
  66. package/dist/shim/registry/walkthrough.js +6 -6
  67. package/dist/shim/render-state/apply.js +66 -31
  68. package/dist/shim/render-state/index.js +6 -2
  69. package/dist/shim/runtime/index.js +4 -4
  70. package/dist/shim/storage/codec-chains.js +88 -0
  71. package/dist/shim/storage/folders.js +205 -0
  72. package/dist/shim/storage/index.js +64 -8
  73. package/dist/shim/storage/make-namespace-store.js +5 -5
  74. package/llms.txt +4 -3
  75. package/package.json +19 -1
  76. package/dist/shim/bundle/frqnb.js +0 -112
  77. package/dist/shim/bundle/manifest.js +0 -95
  78. package/dist/shim/registry/experiments.js +0 -215
  79. package/dist/shim/storage/modules.js +0 -249
  80. package/dist/shim/storage/projects.js +0 -150
@@ -0,0 +1,346 @@
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
+ * Both carry `droppedClips`: the clips still filming when the Record closed, which
26
+ * the close drops (0 on a stop, which refuses while a clip films); a refused
27
+ * re-author close carries it as err.droppedClips beside err.closedBy.
28
+ * A refused close nobody READ (the getter records the read) makes the facade's
29
+ * next exp.startRecord() reject, naming it.
30
+ * err.record every rejection of stop() and of `closed` carries { changes, clips } — the
31
+ * Record's rows and clip spans — so a host can name them unheld instead of losing them.
32
+ *
33
+ * `closedBy`, `droppedClips` and the clip spans are the host's (a lab's own `filmed` list); record.json never
34
+ * carries them.
35
+ *
36
+ * An act (toSketch, setDeclared, fork) is ADMITTED only while the Record is open and not stopping (`_admit`, asked
37
+ * before the act changes the held text): an act during an async stop() is refused naming "stopping", and the
38
+ * held text is unchanged.
39
+ *
40
+ * WHERE ITS PARTS COME FROM — nothing here captures a second time:
41
+ * rows the writer's own edits at act end (RecipeContext.toSketch / setDeclared call `_row`); only what
42
+ * was WRITTEN makes a row;
43
+ * clips every FrqtlVision.record on this runner while the Record is open (FrqtlVision asks
44
+ * `openRecordOf(runner)` and hands the recording to `_clipFor`); a recording whose frames / fps
45
+ * falls outside CLIP_SECONDS is refused before it is encoded;
46
+ * parts parts.before / parts.after = linkProject(held text).module.parts at open / at close, each kind
47
+ * by the name the kinds registry resolves (kindNamed);
48
+ * project the held files at close, under project/.
49
+ */
50
+
51
+ import { FrqtlVision } from '../../parts/FrqtlVision.js';
52
+ import { linkProject } from '../../../shim/project/link.js';
53
+ import { serializeRecordFolder, CLIP_SECONDS, BY, CHANGE_KEYS } from '../../../shim/project/record-format.js';
54
+ import { kindNamed } from '../kinds.js';
55
+ import { _setOpenRecord, openRecordOf } from './open-record.js';
56
+
57
+ function _fail(msg) {
58
+ throw new Error('[Record] ' + msg + ' (FAIL-LOUD.)');
59
+ }
60
+
61
+ function _bytesOfBase64(b64) {
62
+ var bin = atob(b64);
63
+ var out = new Uint8Array(bin.length);
64
+ for (var i = 0; i < bin.length; i++) { out[i] = bin.charCodeAt(i); }
65
+ return out;
66
+ }
67
+
68
+ /** linkProject(held).module.parts → { name: [registered kind name, props] } in key order. */
69
+ async function _partsOf(held) {
70
+ var linked = await linkProject(held);
71
+ var parts = linked.module && linked.module.parts;
72
+ if (!parts || typeof parts !== 'object') {
73
+ _fail('the held sketch exports no `parts` block — a Record writes parts.before/after from `export const parts`.');
74
+ }
75
+ var out = {};
76
+ Object.keys(parts).forEach(function (name) {
77
+ var e = parts[name];
78
+ var Kind = Array.isArray(e) ? e[0] : null;
79
+ if (typeof Kind !== 'function') { _fail('parts.' + name + ' carries no kind class.'); }
80
+ kindNamed(Kind.name); // the name the registry resolves (THROWS naming an unregistered kind)
81
+ out[name] = [Kind.name, e[1]];
82
+ });
83
+ return out;
84
+ }
85
+
86
+ /** The held project's files, copied (text values). */
87
+ function _heldFiles(held) {
88
+ return Object.assign({}, held.files);
89
+ }
90
+
91
+ /**
92
+ * @param {object} exp — the RecipeContext that opens it (its runner and its held project)
93
+ * @private — construct through exp.startRecord()
94
+ */
95
+ function Record(exp) {
96
+ var self = this;
97
+ this._exp = exp;
98
+ this._runner = exp.runner;
99
+ this._tick = 0;
100
+ this._rows = [];
101
+ this._clips = [];
102
+ this._inflight = 0;
103
+ this._open = true;
104
+ this._stopping = false;
105
+ this._filmArm = null;
106
+ this._before = null;
107
+ this._closedSettle = null;
108
+ this._closedRead = false;
109
+ this._refused = null;
110
+ this._closed = new Promise(function (resolve, reject) { self._closedSettle = { resolve: resolve, reject: reject }; });
111
+ // Observed here so node does not crash on an unhandled rejection; a refused close nobody read is LOUD at the next
112
+ // act instead (RecipeContext.startRecord refuses until `rec.closed` has been read).
113
+ this._closed.then(null, function () {});
114
+ this._settled = this._closed.then(function () {}, function () {});
115
+ this._onTick = function () { self._tick += 1; };
116
+ this._off = this._runner.addTickListener(this._onTick);
117
+ _setOpenRecord(this._runner, this);
118
+ }
119
+
120
+ Object.defineProperty(Record.prototype, 'tick', { get: function () { return this._tick; } });
121
+ /** A deep-frozen copy. */
122
+ function _frozen(v) {
123
+ if (v === null || typeof v !== 'object') { return v; }
124
+ var o = Array.isArray(v) ? v.map(_frozen) : Object.keys(v).reduce(function (a, k) { a[k] = _frozen(v[k]); return a; }, {});
125
+ return Object.freeze(o);
126
+ }
127
+ /**
128
+ * rec.changes — the rows so far, a NEW deep-frozen copy per read (record.json's `changes` vocabulary), on an open or a
129
+ * closed Record. One act's rows: `const n = rec.changes.length; await exp.toSketch({ by }); rec.changes.slice(n)`.
130
+ */
131
+ Object.defineProperty(Record.prototype, 'changes', { get: function () {
132
+ return _frozen(this._rows.map(function (r) { var o = {}; CHANGE_KEYS.forEach(function (k) { o[k] = r[k]; }); return o; }));
133
+ } });
134
+ /** rec.closed — reading it records that the host has heard how the Record closed. */
135
+ Object.defineProperty(Record.prototype, 'closed', { get: function () { this._closedRead = true; return this._closed; } });
136
+
137
+ /** Attach what the Record holds to a refusal — `err.record = { changes, clips }` — so a host can name it unheld. */
138
+ Record.prototype._carry = function (err) {
139
+ var e = (err instanceof Error) ? err : new Error(String(err));
140
+ e.record = {
141
+ changes: this._rows.map(function (r) { return JSON.parse(JSON.stringify(r)); }),
142
+ clips: this._clips.map(function (c) { return { firstTick: c.firstTick, lastTick: c.lastTick }; })
143
+ };
144
+ return e;
145
+ };
146
+
147
+ /** Read parts.before (the held text at open). @private */
148
+ Record.prototype._ready = async function (held) {
149
+ this._held = held;
150
+ this._before = await _partsOf(held);
151
+ return this;
152
+ };
153
+
154
+ /** Throws unless `by` is one of BY (while a Record is open, every act names who acted). */
155
+ Record.requireBy = function (by, where) {
156
+ if (BY.indexOf(by) === -1) {
157
+ throw new TypeError('[' + where + '] a Record is open: the act names who acted — { by: ' + BY.map(function (b) {
158
+ return JSON.stringify(b); }).join(' | ') + ' } (record-format.js BY); got ' + JSON.stringify(by) + '. (FAIL-LOUD.)');
159
+ }
160
+ return by;
161
+ };
162
+
163
+ /**
164
+ * THE admission check: an act may write into this Record only while it is open and not stopping. The facade asks
165
+ * it BEFORE an act changes the held text (toSketch, setDeclared, fork), and `_row` asks it again: one predicate.
166
+ * @private
167
+ */
168
+ Record.prototype._admit = function (where) {
169
+ var at = where ? where + ': ' : '';
170
+ if (!this._open) { _fail(at + 'this Record is closed — no row is written into it.'); }
171
+ if (this._stopping) { _fail(at + 'this Record is stopping — an act during stop() belongs to the next Record.'); }
172
+ };
173
+
174
+ /** One change row, at the Record's current tick, in the order acted. @private */
175
+ Record.prototype._row = function (row) {
176
+ this._admit();
177
+ this._rows.push({ tick: this._tick, name: row.name, op: row.op, by: row.by, from: row.from, to: row.to });
178
+ };
179
+
180
+ /**
181
+ * The clip hook. FrqtlVision calls it with the normalized record() request, BEFORE it subscribes; it THROWS
182
+ * when the request cannot be a CLIP_SECONDS clip, and otherwise returns the recording's session:
183
+ * sample(i) called as frame i is sampled (inside the tick fan-out), reads this Record's tick
184
+ * check(frames) called before the encoder is finalized; THROWS outside CLIP_SECONDS
185
+ * end(envelope) the encoded clip joins the Record (ignored once the Record is closed)
186
+ * drop() the recording failed
187
+ * @private
188
+ */
189
+ Record.prototype._clipFor = function (req) {
190
+ var self = this;
191
+ var span = CLIP_SECONDS.min + '–' + CLIP_SECONDS.max + ' s';
192
+ var advice = 'film inside an open Record with rec.film({ seconds, fps, everyTicks }), or record ' + span +
193
+ ' (frames / fps) per clip';
194
+ var planned = null;
195
+ if (req.stopAt.ticks !== undefined) { planned = Math.min(req.maxFrames, Math.floor(req.stopAt.ticks / req.everyTicks) + 1); }
196
+ var bound = planned === null ? req.maxFrames : planned;
197
+ var secs = function (n) { return n / req.fps; };
198
+ if ((planned !== null && (secs(planned) < CLIP_SECONDS.min || secs(planned) > CLIP_SECONDS.max)) ||
199
+ (planned === null && secs(bound) < CLIP_SECONDS.min)) {
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 is ' + (planned !== null ? secs(planned) : 'at most ' + secs(bound)) + ' s (' + bound +
202
+ ' frames at ' + req.fps + ' fps) — ' + advice + '.');
203
+ }
204
+ var arm = this._filmArm;
205
+ this._filmArm = null;
206
+ var ticks = [];
207
+ var done = false;
208
+ this._inflight += 1;
209
+ function settle() { if (!done) { done = true; self._inflight -= 1; } }
210
+ return {
211
+ sample: function (i) {
212
+ ticks[i] = self._tick;
213
+ if (arm && i === req.maxFrames - 1) { var next = arm.onLast; arm = null; next(); }
214
+ },
215
+ check: function (frames) {
216
+ if (secs(frames) < CLIP_SECONDS.min || secs(frames) > CLIP_SECONDS.max) {
217
+ settle();
218
+ _fail('a Record is open on this runner, so this recording is one of its clips, and a clip is ' + span +
219
+ ' long: this one stopped at ' + secs(frames) + ' s (' + frames + ' frames at ' + req.fps + ' fps) — ' + advice + '.');
220
+ }
221
+ },
222
+ end: function (env) {
223
+ settle();
224
+ if (!self._open) { return; }
225
+ var n = env.track.frames.length;
226
+ self._clips.push({ bytes: _bytesOfBase64(env.encoded.bytes), firstTick: ticks[0], lastTick: ticks[n - 1] });
227
+ },
228
+ drop: function () { settle(); }
229
+ };
230
+ };
231
+
232
+ /**
233
+ * rec.film({ seconds, fps, everyTicks, stage? }) → Promise<[{ file?, firstTick, lastTick }]> — ceil(seconds/10)
234
+ * back-to-back segments of equal frame count. Each segment's recording is started INSIDE the previous one's last
235
+ * sampled tick (the tick fan-out dispatches a snapshot, so the next subscription fires from the next tick): the
236
+ * segments are contiguous on the world's clock whatever drives it. REFUSES: seconds below CLIP_SECONDS.min, a
237
+ * seconds × fps that does not split into equal whole-frame segments.
238
+ */
239
+ Record.prototype.film = function (opts) {
240
+ var self = this;
241
+ try {
242
+ if (!this._open) { _fail('this Record is closed — open the next one (exp.startRecord()) to film.'); }
243
+ if (!opts || typeof opts !== 'object') { _fail('rec.film({ seconds, fps, everyTicks, stage? })'); }
244
+ Object.keys(opts).forEach(function (k) {
245
+ if (['seconds', 'fps', 'everyTicks', 'stage'].indexOf(k) === -1) { _fail('rec.film takes { seconds, fps, everyTicks, stage? }, not "' + k + '".'); }
246
+ });
247
+ var s = opts.seconds, fps = opts.fps, every = opts.everyTicks;
248
+ if (typeof s !== 'number' || !isFinite(s) || s < CLIP_SECONDS.min) {
249
+ _fail('rec.film: seconds is at least ' + CLIP_SECONDS.min + ' (CLIP_SECONDS), got ' + JSON.stringify(s) + '.');
250
+ }
251
+ if (!Number.isInteger(fps) || fps < 1) { _fail('rec.film: fps is a whole number ≥ 1 (the playback rate), got ' + JSON.stringify(fps) + '.'); }
252
+ if (!Number.isInteger(every) || every < 1) { _fail('rec.film: everyTicks is a whole number ≥ 1 (the capture cadence in ticks), got ' + JSON.stringify(every) + '.'); }
253
+ var n = Math.ceil(s / CLIP_SECONDS.max);
254
+ var total = s * fps;
255
+ if (!Number.isInteger(total) || total % n !== 0) {
256
+ _fail('rec.film: ' + s + ' s at ' + fps + ' fps is ' + total + ' frames, which does not split into ' + n +
257
+ ' equal whole-frame segments of at most ' + CLIP_SECONDS.max + ' s — choose seconds × fps divisible by ' + n + '.');
258
+ }
259
+ var f = total / n;
260
+ var req = { everyTicks: every, fps: fps, maxFrames: f, stopAt: { ticks: (f - 1) * every } };
261
+ if (opts.stage !== undefined) { req.stage = opts.stage; }
262
+ var vision = FrqtlVision.attach(this._runner);
263
+ var first = this._clips.length;
264
+ // One deferred per segment: segment k+1 exists once segment k sampled its last frame.
265
+ var segs = [];
266
+ for (var k = 0; k < n; k++) {
267
+ (function () { var d = {}; d.promise = new Promise(function (res, rej) { d.resolve = res; d.reject = rej; }); segs.push(d); })();
268
+ }
269
+ var started = [];
270
+ var start = function (k) {
271
+ started[k] = true;
272
+ self._filmArm = (k < n - 1) ? { onLast: function () { start(k + 1); } } : null;
273
+ var p = vision.record(req);
274
+ segs[k].resolve(p);
275
+ p.then(function () {
276
+ if (k + 1 < n && !started[k + 1]) {
277
+ segs[k + 1].reject(new Error('[Record.film] segment ' + (k + 1) + ' of ' + n + ' ended without sampling its last frame, ' +
278
+ 'so segment ' + (k + 2) + ' never started. (FAIL-LOUD.)'));
279
+ }
280
+ }, function (e) {
281
+ self._filmArm = null;
282
+ for (var j = k + 1; j < n; j++) { if (!started[j]) { started[j] = true; segs[j].reject(e); } }
283
+ });
284
+ };
285
+ start(0);
286
+ return Promise.all(segs.map(function (d) { return d.promise; })).then(function () {
287
+ return self._clips.slice(first).map(function (c) { return { firstTick: c.firstTick, lastTick: c.lastTick }; });
288
+ });
289
+ } catch (e) { return Promise.reject(e); }
290
+ };
291
+
292
+ /** Serialize this Record's folder from `held` (the files it snapshots). @private */
293
+ Record.prototype._folder = async function (held) {
294
+ var after = await _partsOf(held);
295
+ return serializeRecordFolder({
296
+ changes: this._rows.slice(), clips: this._clips.slice(),
297
+ partsBefore: this._before, partsAfter: after, project: _heldFiles(held)
298
+ });
299
+ };
300
+
301
+ /** Detach from the world (no row, tick or clip after this). @private */
302
+ Record.prototype._detach = function () {
303
+ this._open = false;
304
+ if (this._off) { this._off(); this._off = null; }
305
+ _setOpenRecord(this._runner, null);
306
+ this._exp._record = null;
307
+ };
308
+
309
+ /** rec.stop() — see the module docstring. */
310
+ Record.prototype.stop = async function () {
311
+ var self = this;
312
+ var refuse = function (msg) { try { _fail(msg); } catch (e) { throw self._carry(e); } };
313
+ if (!this._open) { refuse('this Record is already closed' + (this._closedBy ? ' (closedBy: ' + this._closedBy + ')' : '') + ' — read rec.closed.'); }
314
+ if (this._stopping) { refuse('stop() is already running.'); }
315
+ if (this._inflight) { refuse(this._inflight + ' clip(s) are still being filmed — await rec.film (or the record()) before stop().'); }
316
+ this._stopping = true;
317
+ var out;
318
+ try { out = await this._folder(this._exp._project); }
319
+ catch (e) { throw this._carry(e); }
320
+ finally { this._stopping = false; }
321
+ this._closedBy = 'stop';
322
+ this._detach();
323
+ this._closedSettle.resolve(Object.assign({}, out, { closedBy: 'stop', droppedClips: 0 }));
324
+ return out;
325
+ };
326
+
327
+ /**
328
+ * The facade re-authors its world: close with the folder of the world BEFORE (`held` = the held project before
329
+ * the build). Synchronous detach; the folder settles `closed`. @private
330
+ */
331
+ Record.prototype._closeOnReauthor = function (held) {
332
+ var self = this;
333
+ var dropped = this._inflight; // clips still filming: detaching drops them (their end() finds the Record closed)
334
+ this._closedBy = 'reauthor';
335
+ this._detach();
336
+ this._folder(held).then(function (out) {
337
+ self._closedSettle.resolve(Object.assign({}, out, { closedBy: 'reauthor', droppedClips: dropped }));
338
+ }, function (e) {
339
+ self._refused = self._carry(e);
340
+ self._refused.closedBy = 'reauthor';
341
+ self._refused.droppedClips = dropped;
342
+ self._closedSettle.reject(self._refused);
343
+ });
344
+ };
345
+
346
+ 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 };
@@ -94,7 +94,7 @@ import * as FrameDataSchema from '../../../shim/frame-data/index.js';
94
94
  engineVisible: true,
95
95
  description: 'Frame center Z position (depth, physics pixels). ' +
96
96
  'Default 0.0 when active_axes.z is false (2D mode). ' +
97
- 'Bite 3 (ws-3d-discrete-walk): set by the 3D emitter; moved by the ' +
97
+ 'Set by the 3D emitter; moved by the ' +
98
98
  'independent z yo-yo coin when active_axes.z is true. ' +
99
99
  'Cube edge = w (spec §11 — no separate d field; cubes only).'
100
100
  },
@@ -322,7 +322,7 @@ import * as FrameDataSchema from '../../../shim/frame-data/index.js';
322
322
  engineVisible: true,
323
323
  description: 'Derived (scope-read): frame CENTER Z before this tick\'s move() ' +
324
324
  '(physics pixels). Default 0.0 when active_axes.z is false. ' +
325
- 'Bite 3 (ws-3d-discrete-walk): saved by Frame::move_frame alongside prev_x/prev_y. ' +
325
+ 'Saved by Frame::move_frame alongside prev_x/prev_y. ' +
326
326
  'Read-only — write THROWS (engine-owned per-tick snapshot).'
327
327
  },
328
328
  // ── Forbidden: engine-owned topology ──────────────────────────────
@@ -273,7 +273,7 @@ export function shapeValuesMap() {
273
273
  // existing scene is byte-identical. A live edit (updateObjectProperty) sets the
274
274
  // per-instance override that _visibleLargeObjects reads.
275
275
  description: 'When this object\'s visual shell renders (render-only): design = only ' +
276
- 'while authoring (Bite-4b marker default); run = always visible (the "red light ' +
276
+ 'while authoring (the marker default); run = always visible (the "red light ' +
277
277
  'bulb" opt-in); never = hidden. Generalizes the design-only marker suppression ' +
278
278
  'at the single _visibleLargeObjects authority; engine collision is unaffected.'
279
279
  },
@@ -322,7 +322,7 @@ export function shapeValuesMap() {
322
322
  // designer control — the raw JSON must never render in the STUDIO Inspector.
323
323
  // `internal:true` is the ONE authority for "hide from the designer surface"
324
324
  // (consumed by makeControlsForObject + AuthorView's renderInspector); the
325
- // program is still engineVisible + captured for codegen/round-trip.
325
+ // program is still engineVisible + captured (the parts writer's round trip).
326
326
  internal: true,
327
327
  description: 'Op-program attached to this LO (§10.3 SLICE 3). ' +
328
328
  'Shape: { on: "collision"|"tick", ops: [{ set: "<handle>.<prop>", to: ExprAST }] }. ' +
@@ -23,8 +23,7 @@
23
23
  *
24
24
  * The list is derived from what `initArgs()` below ACTUALLY reads. `interQuantumCollisions`
25
25
  * is in it because it is still ACCEPTED (inert — it fed a removed positional slot) and
26
- * STUDIO codegen prints it into every saved recipe: dropping it from the vocabulary
27
- * would make every saved scene throw.
26
+ * saved scenes carry it: dropping it from the vocabulary would make them throw.
28
27
  *
29
28
  * @type {Array<{key:string, effect:string}>}
30
29
  */
@@ -34,8 +34,7 @@
34
34
  * — as `lib/frqtl/scenes/blackbody.js` sets it. ENGINE physics; no new
35
35
  * op-programs. Expected JS↔WASM divergence on parent-frame count (deferred §6).
36
36
  *
37
- * Set any flag to `false` only for progressive test isolation (L1/L2/L3 runs in
38
- * `experiments/blackbody-substrate/run.js`). The default (all true) runs the
37
+ * Set any flag to `false` only for progressive test isolation (L1/L2/L3 runs). The default (all true) runs the
39
38
  * scene's flags (brownian on, inter-quantum collisions on).
40
39
  *
41
40
  * ## Thermostat control loop (as-intended §2.1, Dispatch C2)
@@ -177,6 +177,12 @@ import * as OrbitBag from '../compat/renderer/orbit-bag.js';
177
177
  if (mountEl && mountEl.__frqtlCameraHandle && mountEl.__frqtlCameraHandle.mounted) {
178
178
  return mountEl.__frqtlCameraHandle;
179
179
  }
180
+ // ...and per RUNNER: one runner has one camera controller. A host that mounted the pads before the scene
181
+ // could (STUDIO: the sizeless page builds before its runner has a control host), then a re-run in the same
182
+ // document (a fork, setDeclared) that reaches the scene's own attach with another container, gets the live one.
183
+ if (runner.__frqtlCameraHandle && runner.__frqtlCameraHandle.mounted) {
184
+ return runner.__frqtlCameraHandle;
185
+ }
180
186
 
181
187
  var doc = (mountEl && mountEl.ownerDocument) ? mountEl.ownerDocument
182
188
  : (typeof document !== 'undefined' ? document : null);
@@ -581,6 +587,7 @@ import * as OrbitBag from '../compat/renderer/orbit-bag.js';
581
587
  if (mountEl && mountEl.__frqtlCameraHandle === liveHandle) {
582
588
  mountEl.__frqtlCameraHandle = null;
583
589
  }
590
+ if (runner.__frqtlCameraHandle === liveHandle) { runner.__frqtlCameraHandle = null; }
584
591
  if (rafId !== null) { cAF(rafId); rafId = null; }
585
592
  // Force-release any in-flight joystick drag (detaches its doc listeners).
586
593
  for (var i = 0; i < padCleanups.length; i++) {
@@ -617,6 +624,7 @@ import * as OrbitBag from '../compat/renderer/orbit-bag.js';
617
624
  };
618
625
  // F4: mark the container as owning this live instance (idempotency guard above).
619
626
  if (mountEl) { mountEl.__frqtlCameraHandle = liveHandle; }
627
+ runner.__frqtlCameraHandle = liveHandle;
620
628
  return liveHandle;
621
629
  }
622
630
 
@@ -111,7 +111,7 @@ import { registerKind } from '../objects/kinds.js';
111
111
  var depth = (opts.depth !== undefined && opts.depth !== null) ? opts.depth : null;
112
112
 
113
113
  this.universe = universe;
114
- this._schemaKind = 'DetectorWall';
114
+ this._schemaKind = this.constructor.name;
115
115
 
116
116
  // The world rect, through the ONE normalizer (a zero-high detector counts nothing
117
117
  // and would report it as physics — so a missing dimension throws, never zeroes).
@@ -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;
@@ -24,8 +24,8 @@
24
24
  * so every Wall option, live method and property accessor applies verbatim.
25
25
  * When neither `preset` nor `collisionBehavior` is supplied it defaults to the
26
26
  * `detector-screen` preset (destroy + count) — the counting authority. Extra
27
- * options, all DECLARED descriptor fields (so a codegen Restart round-trip
28
- * rebuilds the heatmap wall from captured state — the W2 declared-field rule):
27
+ * options, all DECLARED descriptor fields (so the parts writer captures them and the
28
+ * sketch rebuilds the heatmap wall from that state — the W2 declared-field rule):
29
29
  *
30
30
  * @param {number} [opts.binPx=12] — face bin size in physics pixels. Bins
31
31
  * are anchored at the FACE ORIGIN (its top / near-z corner), so the map runs
@@ -105,7 +105,7 @@ import { registerKind, kindOf } from '../objects/kinds.js';
105
105
  * painting of the only spatial information a 2D scene carries.
106
106
  *
107
107
  * @param {Array|Object} rect — the LO rect ([l,t,w,h] / [l,t,z,w,h,d] /
108
- * {left,top,w,h[,z,d]} — the shape a codegen round-trip captures).
108
+ * {left,top,w,h[,z,d]} — the shape a live LO's rect reads as).
109
109
  * @param {number} binPx
110
110
  * @returns {{top:number,height:number,z0:number,depth:number,binPx:number,
111
111
  * canvasW:number,canvasH:number}}
@@ -248,9 +248,9 @@ import { registerKind, kindOf } from '../objects/kinds.js';
248
248
  opts = merged;
249
249
  }
250
250
  WallCls.call(this, universe, opts);
251
- this._schemaKind = 'GraphingDetectorWall';
251
+ this._schemaKind = this.constructor.name;
252
252
 
253
- // Declared heatmap fields (own props → codegen captureProps round-trip).
253
+ // Declared heatmap fields (own props → the parts writer's captureProps).
254
254
  this.binPx = _num(opts.binPx, 12);
255
255
  this.refreshMs = _num(opts.refreshMs, 100);
256
256
  this.heatBase = _num(opts.heatBase, 0.35);
@@ -399,8 +399,8 @@ import { registerKind, kindOf } from '../objects/kinds.js';
399
399
  *
400
400
  * FIELDS ARE FERRIED FROM WallDescriptor (never re-listed — the
401
401
  * schema-mirror-drift discipline): this descriptor is Wall's field table plus
402
- * the three heatmap fields. Every one is a DECLARED field, so a codegen
403
- * Restart round-trip rebuilds the heatmap wall from captured state.
402
+ * the three heatmap fields. Every one is a DECLARED field, so the parts writer
403
+ * captures it and the sketch rebuilds the heatmap wall from that state.
404
404
  */
405
405
  var WallDescriptor = kindOf(WallCls).descriptor;
406
406
  var GraphingDetectorWallDescriptor = {
@@ -35,7 +35,7 @@ import { SCOPE_DESCRIPTORS } from '../objects/schema/scopes.js';
35
35
  * Build a LargeObject `.rect` for the 2D (4-element) case: a REAL Rectangle instance
36
36
  * (is_centered=false) so a center write (`.x`/`.y`) auto-recomputes left/top and vice
37
37
  * versa (PR-1 — audit item 3). A non-enumerable `toJSON` is attached so JSON-serializing
38
- * consumers (codegen `_jsonCopy`, bundle export, save-state) still produce the SAME plain
38
+ * consumers (bundle export, save-state, any JSON copy) still produce the SAME plain
39
39
  * `{left,top,w,h}` shape they always did — the Rectangle's own enumerable fields are
40
40
  * `_left/_x/...`, which WOULD drift; `toJSON` pins the wire shape. (`left/top/w/h` are read
41
41
  * via getters by every direct consumer, so those are unaffected.) 6-element z/d rects are
@@ -128,7 +128,7 @@ import { SCOPE_DESCRIPTORS } from '../objects/schema/scopes.js';
128
128
  collectCollided = collectCollided || false;
129
129
 
130
130
  this.universe = universe;
131
- this._schemaKind = 'LargeObject';
131
+ this._schemaKind = this.constructor.name;
132
132
  // rect is [left, top, w, h] (legacy) or [left, top, z, w, h, d] (FRQTL-MAINT
133
133
  // 2026-07-01, explicit z/depth) — is_centered=false convention either way.
134
134
  var ra = rectToArray(rect);
@@ -267,8 +267,8 @@ import { registerKind } from '../objects/kinds.js';
267
267
  };
268
268
  // Mirror the SERIALIZABLE chartSpec fields onto the LO under their DESCRIPTOR
269
269
  // names (restart-bug fix, 2026-07-25): captureProps(lo, LiveChartDescriptor)
270
- // reads lo[field.name], so without these mirrors a codegen round-trip (Save /
271
- // Restart evals the generated module) silently rebuilt every chart UNBOUND
270
+ // reads lo[field.name], so without these mirrors a captured chart (the parts writer's
271
+ // capture of a declared chart) would rebuild UNBOUND
272
272
  // with default projector/label ("detector"). The name string — never the live
273
273
  // sourceHandle — is the captured source (re-resolved here on rebuild).
274
274
  lo.source = sourceName;
@@ -219,7 +219,7 @@ import { registerKind } from '../objects/kinds.js';
219
219
  function LogicGate(universe, opts) {
220
220
  opts = opts || {};
221
221
  this.universe = universe;
222
- this._schemaKind = 'LogicGate';
222
+ this._schemaKind = this.constructor.name;
223
223
 
224
224
  var TunnelCls = _Tunnel;
225
225
  var WallCls = _Wall;
@@ -705,8 +705,8 @@ import { registerKind } from '../objects/kinds.js';
705
705
  var gate = new GateCls(ctx.runner.universe, opts);
706
706
  gate._schemaKind = Class.name;
707
707
  // The comparator is a HOST tick-fn: subscribe it to the runner's per-tick
708
- // fan-out here, off the DECLARED fields, so the switching survives
709
- // codegen → Restart (a recipe-authored tick-fn would be lost). Idempotent.
708
+ // fan-out here, off the DECLARED fields, so the switching belongs to the declared
709
+ // object: every build of it (a sketch entry, a Restart) re-wires it. Idempotent.
710
710
  if (typeof gate.attachToRunner === 'function') { gate.attachToRunner(ctx.runner); }
711
711
  return gate;
712
712
  }
@@ -199,7 +199,7 @@ import { registerKind } from '../objects/kinds.js';
199
199
  options = options || {};
200
200
 
201
201
  this.universe = universe;
202
- this._schemaKind = 'OvenBox';
202
+ this._schemaKind = this.constructor.name;
203
203
  this.name = options.name || 'oven'; // its declared name (the Tunnel/LogicGate idiom)
204
204
  this.temperature = options.temperature || 300;
205
205
 
@@ -169,7 +169,7 @@ import { declaredCount } from '../objects/ObjectFactory.js';
169
169
  var zDepth = opts.zDepth !== undefined ? opts.zDepth : null;
170
170
 
171
171
  this.universe = universe;
172
- this._schemaKind = 'PointSource';
172
+ this._schemaKind = this.constructor.name;
173
173
  this.x = x;
174
174
  this.y = y;
175
175
  this.diameter = diameter;