@descix/frqtl-node 0.6.1-p3.2 → 0.6.1-p3.3

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.
@@ -258,6 +258,13 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
258
258
  description: "What the god-rays are computed from: 'leaf' = the fundamental-layer frames " +
259
259
  "(LOs only occlude), 'all' = the whole lit scene (every frame of every layer and every LO)."
260
260
  }),
261
+ normalizeFieldDescriptor({
262
+ name: 'godrayMode', type: 'enum', default: GodraysLayer.GODRAY_MODE_DEFAULT,
263
+ constraint: { values: GodraysLayer.GODRAY_MODES.reduce(function (m, v) { m[v] = v; return m; }, {}) },
264
+ description: "How the god-rays are computed: 'streak' = each source frame trails a streak behind " +
265
+ "its direction of travel, 'field' = each pixel marches along the local direction of travel, " +
266
+ "'radial' = each pixel marches toward one light point (the source frames' centroid)."
267
+ }),
261
268
  normalizeFieldDescriptor({
262
269
  name: 'glowIntensity', type: 'number', unit: 'intensity', default: 0,
263
270
  constraint: { min: 0, max: 3, integer: false },
@@ -614,6 +621,9 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
614
621
  // ── R4-B god-rays layer (GodraysLayer.makeGodrayPasses; created lazily on first activation) ──
615
622
  var _godrays = null;
616
623
  var _cubeSourceCount = 0; // cube instances [0, n) are god-rays source frames
624
+ // The particle objects LAID this tick but hidden from the lit picture (Particles OFF while
625
+ // god rays consume the particle layer): the source pass shows them, the lit scene never does.
626
+ var _particlesLaidHidden = [];
617
627
 
618
628
  var _initialized = false;
619
629
  var _lastTickData = null;
@@ -1260,14 +1270,14 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1260
1270
  // Position one clone of the loaded mesh per frame (reusing a pool). The loaded scene is
1261
1271
  // unit-normalized; each clone is scaled to the frame's voxel size (w) and placed at the
1262
1272
  // frame's (x,y,z) — the SAME physics coords the cube/points paths use. Extra clones hide.
1263
- function _layoutMeshClones(entry, frameData, drawParticles, scaleMul, assetId) {
1273
+ function _layoutMeshClones(entry, frameData, lay, scaleMul, assetId) {
1264
1274
  if (!_meshGroup) { _meshGroup = new THREE.Group(); _meshGroup.frustumCulled = false; _scene.add(_meshGroup); }
1265
1275
  // Per-ASSET clone sub-pool (spec §2): one pool per asset id so concurrent assets never
1266
1276
  // fight over slots. Callers: the scene-wide useMesh branch (assetId = resolvedMesh) and
1267
1277
  // the per-frame mesh siphon in the Points path (one call per stamped asset).
1268
1278
  var pool = _meshClonesByAsset[assetId] || (_meshClonesByAsset[assetId] = []);
1269
1279
  var mul = (typeof scaleMul === 'number' && scaleMul > 0) ? scaleMul : 1;
1270
- var n = (drawParticles && frameData) ? frameData.length : 0;
1280
+ var n = (lay && frameData) ? frameData.length : 0;
1271
1281
  for (var i = 0; i < n; i++) {
1272
1282
  var f = frameData[i];
1273
1283
  var clone = pool[i];
@@ -1491,10 +1501,19 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1491
1501
 
1492
1502
  // The god-rays SOURCE image → _godrays.rtSource: same scene/camera/paint, ONLY the fundamental-layer
1493
1503
  // frames drawn. LOs write depth but no colour (they shadow, never shine). All flags restored.
1494
- function _renderGodraySource(tickData) {
1504
+ function _renderGodraySource(tickData, leaf) {
1495
1505
  var gs = GodraysLayer, fund = gs.fundamentalLayerOf(tickData);
1496
1506
  var hidden = [], cubeCount = null, colourOff = [];
1497
1507
  function hide(o) { if (o && o.visible) { o.visible = false; hidden.push(o); } }
1508
+ // The rays read the PARTICLE LAYER whether or not the lit picture draws it.
1509
+ for (var p = 0; p < _particlesLaidHidden.length; p++) { _particlesLaidHidden[p].visible = true; }
1510
+ if (!leaf) {
1511
+ // 'all': the lit scene WITH its particles (only reached when Particles are OFF).
1512
+ _renderer.setRenderTarget(_godrays.rtSource);
1513
+ _renderer.render(_scene, _camera);
1514
+ for (var pa = 0; pa < _particlesLaidHidden.length; pa++) { _particlesLaidHidden[pa].visible = false; }
1515
+ return;
1516
+ }
1498
1517
  for (var k in _dimLayers) {
1499
1518
  if (_dimLayers.hasOwnProperty(k) && !gs.isGodraySource(_dimLayers[k].layer, fund)) { hide(_dimLayers[k].mesh); }
1500
1519
  }
@@ -1522,19 +1541,36 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1522
1541
  for (var h = 0; h < hidden.length; h++) { hidden[h].visible = true; }
1523
1542
  if (cubeCount !== null) { _cubeMesh.count = cubeCount; }
1524
1543
  for (var c = 0; c < colourOff.length; c++) { colourOff[c].colorWrite = true; }
1544
+ for (var q = 0; q < _particlesLaidHidden.length; q++) { _particlesLaidHidden[q].visible = false; }
1525
1545
  }
1526
1546
 
1527
- // God-rays to screen: the march reads the SOURCE image (fundamental-layer frames, LOs
1528
- // depth-only) and adds its rays over the full lit scene. Cost paid ONLY when on.
1529
- // `godraySource` 'all' marches the lit scene itself (every frame, every LO).
1547
+ // Are god rays on? THE one predicate: the final composite dispatch and the particle-layer
1548
+ // gate (rays consume the particle layer as their source) both ask it.
1549
+ function _wantGodrays(opts) { return opts.godrays > 0; }
1550
+
1551
+ // God-rays to screen: the rays read the SOURCE image (fundamental-layer frames, LOs
1552
+ // depth-only) and add over the full lit scene. Cost paid ONLY when on.
1553
+ // `godraySource` 'all' reads the lit scene itself (every frame, every LO); `godrayMode`
1554
+ // picks the pass (GodraysLayer owns both values and defaults).
1530
1555
  function _renderGodrays(opts, tickData) {
1531
1556
  var gl = GodraysLayer, leaf = gl.resolveGodraySource(opts.godraySource) === 'leaf';
1557
+ var mode = gl.resolveGodrayMode(opts.godrayMode);
1532
1558
  if (!_godrays) { _godrays = gl.makeGodrayPasses(THREE, QUAD_VERT, _physW(), _physH()); }
1533
1559
  _renderer.setRenderTarget(_rtA); // Pass 1: lit scene → _rtA
1534
1560
  _renderer.render(_scene, _camera);
1535
- if (leaf) { _renderGodraySource(tickData); } // Pass 1b: source → rtSource
1536
- _godrays.march(_renderer, _quadCamera, _rtA, leaf ? _godrays.rtSource : _rtA, _rtB,
1537
- _godrays.lightUV(tickData, _camera, opts.godraySource), opts.godrayDensity, opts.godrays);
1561
+ // Pass 1b: source → rtSource. 'leaf' always; 'all' only when the lit scene lacks its
1562
+ // particles (Particles OFF) — otherwise the lit scene IS the 'all' source.
1563
+ var ownSource = leaf || _particlesLaidHidden.length > 0;
1564
+ if (ownSource) { _renderGodraySource(tickData, leaf); }
1565
+ var src = ownSource ? _godrays.rtSource : _rtA;
1566
+ if (mode === 'radial') {
1567
+ _godrays.march(_renderer, _quadCamera, _rtA, src, _rtB,
1568
+ _godrays.lightUV(tickData, _camera, opts.godraySource), opts.godrayDensity, opts.godrays);
1569
+ } else {
1570
+ var pass = (mode === 'streak') ? _godrays.streak : _godrays.field;
1571
+ pass(_renderer, _quadCamera, _rtA, src, _rtB,
1572
+ gl.godraySourceFrames(tickData, opts.godraySource), _camera, opts.godrayDensity, opts.godrays);
1573
+ }
1538
1574
  }
1539
1575
 
1540
1576
  // ── Aim feedback layer update ─────────────────────────────────────────────────
@@ -1729,7 +1765,10 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1729
1765
  // built ONLY when a consumer (a queued filter) is active — never unconditionally (that was one sugar object hijacking the
1730
1766
  // render loop). Each flag comes from the render OPTIONS bag (setRenderOption).
1731
1767
  var opts = options || {};
1732
- var drawParticles = opts.renderBaseCanvas !== false; // Particles layer
1768
+ var drawParticles = opts.renderBaseCanvas !== false; // Particles layer (the lit picture)
1769
+ // The particle layer is LAID when drawn OR consumed: god rays read it as their source,
1770
+ // so Particles OFF + god rays ON lays it out hidden and the rays still shine.
1771
+ var layParticles = drawParticles || _wantGodrays(opts);
1733
1772
  // SNAPSHOT, not the reference: _renderOptsWithFeedback() returns
1734
1773
  // this._renderOptions ITSELF when there is no aim-feedback bag, so retaining
1735
1774
  // `opts` would let a setRenderOption between ticks silently change what
@@ -1783,7 +1822,7 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1783
1822
  var _me = _meshCache[resolvedMesh];
1784
1823
  var _sceneLaid = 0;
1785
1824
  if (_me && _me.status === 'ready') {
1786
- _sceneLaid = _layoutMeshClones(_me, tickData.frameData, opts.renderBaseCanvas !== false, opts.meshScale, resolvedMesh);
1825
+ _sceneLaid = _layoutMeshClones(_me, tickData.frameData, layParticles, opts.meshScale, resolvedMesh);
1787
1826
  }
1788
1827
  _hideMeshClonesExcept([resolvedMesh]); // drop any stale per-frame/other-asset clones
1789
1828
  if (_meshGroup) { _meshGroup.visible = _sceneLaid > 0; } // loading/empty → hidden (error already overlaid)
@@ -1795,7 +1834,7 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1795
1834
  // bright; back/bottom are dark — so the cube reads as a solid 3D object.
1796
1835
  _hideAllDimLayers();
1797
1836
 
1798
- if (drawParticles && tickData && tickData.frameData) {
1837
+ if (layParticles && tickData && tickData.frameData) {
1799
1838
  var fdC = tickData.frameData;
1800
1839
  var totalC = fdC.length;
1801
1840
  var defaultColorSchemeC = opts.colorScheme || 'warmcool';
@@ -1847,7 +1886,7 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1847
1886
  _cubeMesh.count = cubeCount;
1848
1887
  _cubeMesh.instanceMatrix.needsUpdate = true;
1849
1888
  if (_cubeMesh.instanceColor) { _cubeMesh.instanceColor.needsUpdate = true; }
1850
- _cubeMesh.visible = drawParticles && cubeCount > 0;
1889
+ _cubeMesh.visible = cubeCount > 0;
1851
1890
  } else {
1852
1891
  if (_cubeMesh) { _cubeMesh.visible = false; }
1853
1892
  }
@@ -1858,7 +1897,7 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
1858
1897
  if (_cubeMesh) { _cubeMesh.visible = false; }
1859
1898
  _hideAllDimLayers();
1860
1899
 
1861
- if (drawParticles && tickData && tickData.frameData) {
1900
+ if (layParticles && tickData && tickData.frameData) {
1862
1901
  var fd = tickData.frameData;
1863
1902
  var total = fd.length;
1864
1903
 
@@ -2039,7 +2078,7 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
2039
2078
  }
2040
2079
 
2041
2080
  entry.centroid.x = cx / n; entry.centroid.y = cy / n; entry.centroid.z = cz / n;
2042
- entry.mesh.visible = drawParticles;
2081
+ entry.mesh.visible = true;
2043
2082
  }
2044
2083
 
2045
2084
  // ── Camera-distance sort (painter's algorithm) — farthest
@@ -2070,7 +2109,7 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
2070
2109
  _ensureMeshLoaded(_aid);
2071
2110
  var _pfMe = _meshCache[_aid];
2072
2111
  if (_pfMe && _pfMe.status === 'ready') {
2073
- _pfMeshLaid += _layoutMeshClones(_pfMe, meshByAsset[_aid], drawParticles, opts.meshScale, _aid);
2112
+ _pfMeshLaid += _layoutMeshClones(_pfMe, meshByAsset[_aid], layParticles, opts.meshScale, _aid);
2074
2113
  }
2075
2114
  }
2076
2115
  _hideMeshClonesExcept(_pfMeshAssets);
@@ -2078,6 +2117,16 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
2078
2117
  }
2079
2118
  }
2080
2119
 
2120
+ // ── Particles OFF: what was laid (for the god-rays source) leaves the lit picture ──
2121
+ _particlesLaidHidden = [];
2122
+ if (!drawParticles) {
2123
+ var laid = [_cubeMesh, _meshGroup];
2124
+ for (var lk in _dimLayers) { if (_dimLayers.hasOwnProperty(lk)) { laid.push(_dimLayers[lk].mesh); } }
2125
+ for (var li = 0; li < laid.length; li++) {
2126
+ if (laid[li] && laid[li].visible) { laid[li].visible = false; _particlesLaidHidden.push(laid[li]); }
2127
+ }
2128
+ }
2129
+
2081
2130
  // ── Field layer — build the heightfield ONLY when consumed ──
2082
2131
  if (fieldConsumed) { _updateFieldTex(tickData); }
2083
2132
 
@@ -2098,7 +2147,7 @@ import * as ThreeCameraGeometry from './three-camera-geometry.js';
2098
2147
  opts = opts || {};
2099
2148
  // godrays is a final-composite layer (a declared render option). It owns the final pass when on; it and the 2D water-fx
2100
2149
  // filter are alternative final composites (concurrent use is a phase-2 item).
2101
- var wantGodrays = opts.godrays > 0;
2150
+ var wantGodrays = _wantGodrays(opts);
2102
2151
  var time = _filters.length > 0
2103
2152
  ? RendererProviderContract.filterTime(tickData) : null;
2104
2153
  _lastFilterTime = time; // capture provenance (§1a.4)
@@ -2010,6 +2010,19 @@ import { paintBootFailure } from '../experiments/_shared/boot-failure.js';
2010
2010
  return [x, y];
2011
2011
  };
2012
2012
 
2013
+ /**
2014
+ * isPointable(d) → whether declared object `d` can be under a pointer on the canvas: at least one of
2015
+ * its members is not hidden by its own `visible:false`. The ONE rule both canvas picks read — the 3D
2016
+ * pick below (per member) and the host's 2D box hit-test (per object).
2017
+ */
2018
+ FrqtlApp.prototype.isPointable = function (d) {
2019
+ var ms = this.membersOf(d);
2020
+ for (var i = 0; i < ms.length; i++) { if (_memberPointable(ms[i])) { return true; } }
2021
+ return false;
2022
+ };
2023
+ /** A member is under the pointer unless its own `visible:false` hides it. */
2024
+ function _memberPointable(lo) { return lo.visible !== false; }
2025
+
2013
2026
  /** Screen radius (in the caller's screen units) inside which a click picks an object. */
2014
2027
  FrqtlApp.PICK_RADIUS_PX = 32;
2015
2028
  /** A center-bearing (drivable) object's pick distance is divided by this, so in a dense scene
@@ -2024,7 +2037,9 @@ import { paintBootFailure } from '../experiments/_shared/boot-failure.js';
2024
2037
  * anisotropic on screen and different per plane, which let a wall behind the emitter win inside
2025
2038
  * the emitter's own footprint. A drivable object's distance is divided by PICK_DRIVE_PRIORITY.
2026
2039
  * Objects-layer; consumed downward by AuthorView. Null (fail-soft) with no screenToWorld
2027
- * capability — the host falls back to the Layers panel.
2040
+ * capability — the host falls back to the Layers panel. A member its own `visible:false` hides
2041
+ * is never under the pointer (isPointable): an invisible counter spanning the scene would
2042
+ * otherwise answer every press. The Layers panel still selects it.
2028
2043
  */
2029
2044
  FrqtlApp.prototype.pickObjectAtScreen = function (cssX, cssY, cssW, cssH) {
2030
2045
  var r = this.renderer;
@@ -2035,6 +2050,7 @@ import { paintBootFailure } from '../experiments/_shared/boot-failure.js';
2035
2050
  var lo = los[i];
2036
2051
  var owner = this.declaredOf(lo);
2037
2052
  if (!owner) { continue; } // a primitive-surface object is never picked
2053
+ if (!_memberPointable(lo)) { continue; } // hidden: not under the pointer (isPointable)
2038
2054
  var c = this._objectCentroid(lo);
2039
2055
  if (!c) { continue; }
2040
2056
  var sp = this.screenPointOf(c, cssW, cssH, [cssX, cssY]);
@@ -53,6 +53,9 @@
53
53
  * - setDeclared refuses while a hand is unwritten (await exp.toSketch() first); its `kept` lists what it kept.
54
54
  * - restart() of a linked sketch re-links the CURRENT text (the retained replay) and returns a Promise.
55
55
  * exp.runner / .universe the runner the co-init call built
56
+ * exp.sceneExports the named exports of the scene module the last build linked (its module
57
+ * namespace — the module that built this world, never a second evaluation);
58
+ * reading it THROWS when the scene was built from a function, which has none
56
59
  * exp.is3D world.renderer === 'three'
57
60
  *
58
61
  * FAIL-LOUD: every member that needs the world THROWS before the call; a second call in one
@@ -126,6 +129,7 @@ import { Record, openRecordOf } from './record/Record.js';
126
129
  this._buildFn = null;
127
130
  this._project = null; // THE held project text: { files: {path: text}, entry, mint, bare? } (build(linked))
128
131
  this._urls = null; // the minted URL of each project file, from the last link
132
+ this._module = null; // the entry module's namespace, from the last link (exp.sceneExports)
129
133
  this._parts = new Map(); // declared object → { key, baseline } — the objects in `parts`
130
134
  this._code = new Map(); // declared object → baseline — the code declarations at build end
131
135
  this._declaredOnce = false;
@@ -148,6 +152,18 @@ import { Record, openRecordOf } from './record/Record.js';
148
152
  Object.defineProperty(RecipeContext.prototype, 'is3D', {
149
153
  get: function () { _requireWorld(this); return this._world.renderer === 'three'; }
150
154
  });
155
+ /** READ-ONLY: the named exports of the scene module the last build linked (the module that built this world). */
156
+ Object.defineProperty(RecipeContext.prototype, 'sceneExports', {
157
+ get: function () {
158
+ _requireWorld(this);
159
+ if (!this._module) {
160
+ throw new Error('[RecipeContext] sceneExports: this world was built from a buildExperiment function, ' +
161
+ 'not a linked scene module, so it has no module exports to read — build it from its text: ' +
162
+ 'exp.build(await linkProject({ files, entry, mint })) (lib/shim/project/link.js). (FAIL-LOUD.)');
163
+ }
164
+ return this._module;
165
+ }
166
+ });
151
167
 
152
168
  // ── the co-init call ─────────────────────────────────────────────────────────────
153
169
 
@@ -893,6 +909,7 @@ import { Record, openRecordOf } from './record/Record.js';
893
909
  this._buildFn = sketch;
894
910
  this._project = linked ? linked.project : null;
895
911
  this._urls = linked ? (linked.urls || null) : null;
912
+ this._module = linked ? linked.module : null;
896
913
  if (this._runner) { _teardownSceneForRebuild(this); }
897
914
  this._world = null;
898
915
  this._parts = new Map();
@@ -53,18 +53,12 @@ import { linkProject } from '../../../shim/project/link.js';
53
53
  import { serializeRecordFolder, CLIP_SECONDS, BY, CHANGE_KEYS } from '../../../shim/project/record-format.js';
54
54
  import { kindNamed } from '../kinds.js';
55
55
  import { _setOpenRecord, openRecordOf } from './open-record.js';
56
+ import { base64ToBytes } from '../../../shim/render/capture-envelope.js';
56
57
 
57
58
  function _fail(msg) {
58
59
  throw new Error('[Record] ' + msg + ' (FAIL-LOUD.)');
59
60
  }
60
61
 
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
62
  /** linkProject(held).module.parts → { name: [registered kind name, props] } in key order. */
69
63
  async function _partsOf(held) {
70
64
  var linked = await linkProject(held);
@@ -223,7 +217,7 @@ Record.prototype._clipFor = function (req) {
223
217
  settle();
224
218
  if (!self._open) { return; }
225
219
  var n = env.track.frames.length;
226
- self._clips.push({ bytes: _bytesOfBase64(env.encoded.bytes), firstTick: ticks[0], lastTick: ticks[n - 1] });
220
+ self._clips.push({ bytes: base64ToBytes(env.encoded.bytes), firstTick: ticks[0], lastTick: ticks[n - 1] });
227
221
  },
228
222
  drop: function () { settle(); }
229
223
  };
@@ -257,7 +251,8 @@ Record.prototype.film = function (opts) {
257
251
  ' equal whole-frame segments of at most ' + CLIP_SECONDS.max + ' s — choose seconds × fps divisible by ' + n + '.');
258
252
  }
259
253
  var f = total / n;
260
- var req = { everyTicks: every, fps: fps, maxFrames: f, stopAt: { ticks: (f - 1) * every } };
254
+ var b = FrqtlVision.recordBudget(f, every);
255
+ var req = { everyTicks: every, fps: fps, maxFrames: b.maxFrames, stopAt: b.stopAt };
261
256
  if (opts.stage !== undefined) { req.stage = opts.stage; }
262
257
  var vision = FrqtlVision.attach(this._runner);
263
258
  var first = this._clips.length;
@@ -37,8 +37,10 @@
37
37
  * getScene():Promise<object>, // the run-invariant SceneRecord + its content id
38
38
  * getStill(stage?):Promise<object>, // stage omitted → the FULL composed stack
39
39
  * getStream({stage?, everyTicks}):AsyncIterable<object>, // stills at a TICK cadence
40
- * record({everyTicks, stopAt, maxFrames, fps, stage?, bitrate?, keyFrameInterval?, signal?})
40
+ * record({seconds?, fps?, signal?}) // THE DEFAULT: up to 60 s of what the viewer sees, wall-clock timed
41
41
  * :Promise<object>, // an encoded video: CaptureEnvelope.videoEnvelope
42
+ * record({everyTicks, stopAt, maxFrames, fps, stage?, bitrate?, keyFrameInterval?, signal?})
43
+ * :Promise<object>, // the ADVANCED tick-sampling form (corpus rows, flights)
42
44
  * stages:string[], // the FERRIED RenderStages.RENDER_STAGES list
43
45
  * finalStage():string, // the ferried final-stage derivation
44
46
  * toDataURL(still):string, // the BROWSER PNG encoder (data URL)
@@ -165,8 +167,12 @@
165
167
  * var still = await vision.getStill(); // the whole composed stack
166
168
  * var field = await vision.getStill('field'); // the render-free numeric channel
167
169
  *
168
- * // A video: 1 still per 2 ticks until tick 600, played back at 30 fps.
169
- * var env = await vision.record({ everyTicks: 2, stopAt: { tick: 600 }, maxFrames: 400, fps: 30 });
170
+ * // A video of what the viewer sees (the default form): up to 60 s at 30 fps; Stop = abort.
171
+ * var stop = new AbortController();
172
+ * var env = await vision.record({ seconds: 20, signal: stop.signal });
173
+ *
174
+ * // ADVANCED — tick sampling: 1 still per 2 ticks until tick 600, played back at 30 fps.
175
+ * var env2 = await vision.record({ everyTicks: 2, stopAt: { tick: 600 }, maxFrames: 400, fps: 30 });
170
176
  *
171
177
  * // Aligned multi-channel corpus row: capture BETWEEN ticks and assert equal tick stamps —
172
178
  * // the tick equality IS the co-registration guard; `scene` ties them to one header.
@@ -462,33 +468,43 @@ function _getStream(runner, RS, opts) {
462
468
  }
463
469
 
464
470
  /** The recorder's declared options. @private */
465
- var RECORD_OPTIONS = ['everyTicks', 'stopAt', 'maxFrames', 'fps', 'stage', 'bitrate', 'keyFrameInterval', 'signal'];
471
+ var RECORD_OPTIONS = ['seconds', 'everyTicks', 'stopAt', 'maxFrames', 'fps', 'stage', 'bitrate', 'keyFrameInterval', 'signal'];
472
+ /** The keys only the advanced TICK form takes; any of them selects it. @private */
473
+ var TICK_FORM_KEYS = ['everyTicks', 'stopAt', 'maxFrames'];
474
+ /**
475
+ * The default — HUMAN — form's defaults and cap: a recording of what the viewer sees, `seconds` long (at most
476
+ * RECORD_DEFAULTS.seconds), played back at `fps`.
477
+ */
478
+ var RECORD_DEFAULTS = Object.freeze({ seconds: 60, fps: 30 });
479
+ var HUMAN_SHAPE = '{seconds?, fps?, signal?, stage?, bitrate?, keyFrameInterval?} (the default: seconds of what the viewer sees)';
480
+ var TICK_SHAPE = '{everyTicks, stopAt, maxFrames, fps, stage?, bitrate?, keyFrameInterval?, signal?} (the advanced tick-sampling form)';
466
481
  var STOP_AT_FORMS = ['tick', 'ticks', 'path'];
467
482
  var PATH_OPTIONS = ['waypoints', 'loop', 'waypointTimeoutMs'];
468
483
 
469
- /** Validate a record() request; returns the normalized request. @private */
470
- function _normalizeRecord(RS, opts) {
471
- var who = 'record';
472
- if (!opts || typeof opts !== 'object') {
473
- _fail(who, 'expected {everyTicks, stopAt, maxFrames, fps, stage?, bitrate?, keyFrameInterval?, signal?}, got ' + String(opts));
474
- }
475
- _assertKnownKeys(opts, RECORD_OPTIONS, 'record', who);
476
- var req = {
477
- everyTicks: _requirePosInt(opts.everyTicks, 'everyTicks (the capture cadence in TICKS — there is no wall-clock cadence)', who),
478
- maxFrames: _requirePosInt(opts.maxFrames, 'maxFrames (the memory bound; hitting it reports truncated:true)', who),
479
- fps: _requirePosInt(opts.fps, 'fps (the PLAYBACK rate — it has no physical meaning)', who),
480
- stage: (opts.stage === undefined) ? RS.finalStage() : RS.assertStage(opts.stage),
481
- bitrate: (opts.bitrate === undefined) ? null : _requirePosInt(opts.bitrate, 'bitrate (bits per second)', who),
482
- keyFrameInterval: (opts.keyFrameInterval === undefined) ? 1
483
- : _requirePosInt(opts.keyFrameInterval, 'keyFrameInterval (frames)', who),
484
- stopAt: null,
485
- signal: null
486
- };
484
+ /**
485
+ * THE length budget of a recording (one owner: the human form and Record.film both take it): `frames` pictures at
486
+ * `everyTicks` end `(frames − 1) × everyTicks` ticks after the first capture, ON the last picture — so reaching the
487
+ * end is truncated:false (a stop at `frames × everyTicks` would ask for one picture past the bound and report a
488
+ * truncation). The math of maxi-record.js::segmentTicks.
489
+ * @param {number} frames
490
+ * @param {number} everyTicks
491
+ * @returns {{maxFrames:number, stopAt:{ticks:number}}}
492
+ */
493
+ function recordBudget(frames, everyTicks) {
494
+ return { maxFrames: frames, stopAt: { ticks: (frames - 1) * everyTicks } };
495
+ }
496
+
497
+ /** The stage/signal/encoder options both forms share, validated once. @private */
498
+ function _sharedRecordOptions(RS, opts, req, who) {
499
+ req.stage = (opts.stage === undefined) ? RS.finalStage() : RS.assertStage(opts.stage);
500
+ req.bitrate = (opts.bitrate === undefined) ? null : _requirePosInt(opts.bitrate, 'bitrate (bits per second)', who);
501
+ req.keyFrameInterval = (opts.keyFrameInterval === undefined) ? 1 : _requirePosInt(opts.keyFrameInterval, 'keyFrameInterval (frames)', who);
502
+ req.signal = null;
487
503
  if (opts.signal !== undefined) {
488
504
  var sig = opts.signal;
489
505
  if (!sig || typeof sig.aborted !== 'boolean' || typeof sig.addEventListener !== 'function') {
490
506
  _fail(who, 'signal must be an AbortSignal (new AbortController().signal) — it is how a recording ' +
491
- 'stops before its stopAt; got ' + String(sig));
507
+ 'stops before its end; got ' + String(sig));
492
508
  }
493
509
  req.signal = sig;
494
510
  }
@@ -498,6 +514,92 @@ function _normalizeRecord(RS, opts) {
498
514
  'plausible-looking movie of nothing. Read the numbers with getStream({stage: ' +
499
515
  JSON.stringify(req.stage) + ', everyTicks})');
500
516
  }
517
+ return req;
518
+ }
519
+
520
+ /**
521
+ * recordRefusal(runner) → null when the default (human) form of record() can start on this runner NOW, else
522
+ * `{ reason, message }`: reason 'paused' (no tick will arrive to film) or 'rate-unmeasured' (runner.getRates().tps is
523
+ * not yet a positive number — FrqtlApp resets it on every start and reports after its first second running). The ONE
524
+ * owner of when the default form refuses: record() fails with `message`, and a host that offers a Record control
525
+ * enables it on `null` (reading this on the live runner, never a polled copy of its rates).
526
+ * @param {object} runner
527
+ * @returns {null|{reason:string, message:string}}
528
+ */
529
+ function recordRefusal(runner) {
530
+ if (!runner || typeof runner.getRates !== 'function') {
531
+ throw new TypeError('[FrqtlVision] recordRefusal(runner): runner is a live FrqtlApp (it reads isRunning and getRates()). (FAIL-LOUD.)');
532
+ }
533
+ if (!runner.isRunning) {
534
+ return { reason: 'paused', message: 'the world is paused, so no tick will arrive to film (a recording samples by ' +
535
+ 'TICK, never by the clock) — resume first' };
536
+ }
537
+ var tps = runner.getRates().tps;
538
+ if (typeof tps !== 'number' || !isFinite(tps) || tps <= 0) {
539
+ return { reason: 'rate-unmeasured', message: 'the runner has not measured its tick rate yet (runner.getRates().tps = ' +
540
+ String(tps) + '; it reports after its first second running), and the default form samples at most fps pictures ' +
541
+ 'a second of it. Record once it has run a second, or name the cadence with the tick form ' + TICK_SHAPE };
542
+ }
543
+ return null;
544
+ }
545
+
546
+ /** The HUMAN form: {seconds?, fps?} — the tick cadence is derived from the live runner when recording starts. @private */
547
+ function _normalizeHumanRecord(RS, opts, who) {
548
+ var req = {
549
+ form: 'human',
550
+ seconds: (opts.seconds === undefined) ? RECORD_DEFAULTS.seconds : _requirePosInt(opts.seconds, 'seconds (playback seconds)', who),
551
+ fps: (opts.fps === undefined) ? RECORD_DEFAULTS.fps : _requirePosInt(opts.fps, 'fps (the PLAYBACK rate)', who),
552
+ everyTicks: null, stopAt: null, maxFrames: null
553
+ };
554
+ if (req.seconds > RECORD_DEFAULTS.seconds) {
555
+ _fail(who, 'seconds ' + req.seconds + ' is over the cap of ' + RECORD_DEFAULTS.seconds + ' s (the recording is held ' +
556
+ 'in memory until it ends). A longer capture is the tick form: ' + TICK_SHAPE);
557
+ }
558
+ return _sharedRecordOptions(RS, opts, req, who);
559
+ }
560
+
561
+ /**
562
+ * The human form's request, from the LIVE runner when recording starts. Its TIME is the wall clock: every picture is
563
+ * stamped with its wall-clock time since the first one (_record), and the recording ends on its own once `seconds` of
564
+ * wall clock have been filmed — so the file lasts as long as the viewer watched, whatever the tick rate does while
565
+ * recording (a 1:1-locked scene slows when the recorder draws). The cadence is only a SAMPLING-RATE CEILING: the runner
566
+ * ticks `tps` a second (its own measured rate, runner.getRates().tps), and sampling every
567
+ * everyTicks = max(1, ceil(tps / fps)) ticks never films faster than `fps` at that rate (round or floor would, and a
568
+ * picture closer than 1/fps to the last one has to be pushed later than its wall time). The memory bound is
569
+ * recordBudget(seconds × fps, everyTicks).maxFrames; reaching it (the scene sped up) is truncated:true. @private
570
+ */
571
+ function _deriveHumanRecord(runner, req) {
572
+ var refusal = recordRefusal(runner);
573
+ if (refusal) { _fail('record', refusal.message); }
574
+ var tps = runner.getRates().tps;
575
+ req.everyTicks = Math.max(1, Math.ceil(tps / req.fps));
576
+ req.maxFrames = recordBudget(req.seconds * req.fps, req.everyTicks).maxFrames;
577
+ req.stopAt = {}; // no tick stop: the wall clock ends it (_record)
578
+ return req;
579
+ }
580
+
581
+ /** Validate a record() request; returns the normalized request. @private */
582
+ function _normalizeRecord(RS, opts) {
583
+ var who = 'record';
584
+ if (opts === undefined) { opts = {}; }
585
+ if (!opts || typeof opts !== 'object') {
586
+ _fail(who, 'expected ' + HUMAN_SHAPE + ' or ' + TICK_SHAPE + ', got ' + String(opts));
587
+ }
588
+ _assertKnownKeys(opts, RECORD_OPTIONS, 'record', who);
589
+ var tickKeys = TICK_FORM_KEYS.filter(function (k) { return opts[k] !== undefined; });
590
+ if (opts.seconds !== undefined && tickKeys.length) {
591
+ _fail(who, 'a call mixes the two shapes (seconds with ' + tickKeys.join(', ') + '). Pass EITHER ' + HUMAN_SHAPE +
592
+ ' OR ' + TICK_SHAPE);
593
+ }
594
+ if (!tickKeys.length) { return _normalizeHumanRecord(RS, opts, who); }
595
+ var req = {
596
+ form: 'tick',
597
+ everyTicks: _requirePosInt(opts.everyTicks, 'everyTicks (the capture cadence in TICKS — there is no wall-clock cadence)', who),
598
+ maxFrames: _requirePosInt(opts.maxFrames, 'maxFrames (the memory bound; hitting it reports truncated:true)', who),
599
+ fps: _requirePosInt(opts.fps, 'fps (the PLAYBACK rate — it has no physical meaning)', who),
600
+ stopAt: null
601
+ };
602
+ _sharedRecordOptions(RS, opts, req, who);
501
603
  var s = opts.stopAt;
502
604
  if (!s || typeof s !== 'object') {
503
605
  _fail(who, 'stopAt is REQUIRED — exactly one of {tick: T}, {ticks: K}, {path: {waypoints, loop?, ' +
@@ -575,6 +677,8 @@ function _record(runner, RS, opts) {
575
677
  'localhost). The encoder is not optional — there is no second path');
576
678
  }
577
679
 
680
+ if (req.form === 'human') { _deriveHumanRecord(runner, req); }
681
+
578
682
  // THE CLIP HOOK: while a Record is open on this runner, every recording is one of its clips. The Record
579
683
  // refuses a request that cannot be a CLIP_SECONDS clip (before anything subscribes) and keeps the clip's
580
684
  // ticks and bytes; this recorder holds no Record state of its own (objects/record/Record.js _clipFor).
@@ -600,6 +704,12 @@ function _record(runner, RS, opts) {
600
704
  var closing = false;
601
705
  var failure = null;
602
706
  var stopTick = (req.stopAt.tick !== undefined) ? req.stopAt.tick : null;
707
+ // The human form's clock: picture i's wall time since picture 0 as a SLOT of the 1/fps grid the muxer writes
708
+ // (whole numbers, so two pictures can never share a slot: a picture landing in or before the last one's slot
709
+ // takes the next), and whether it is the last one the `seconds` of wall clock admit.
710
+ var human = (req.form === 'human');
711
+ var wallT0 = null;
712
+ var slotOf = [];
603
713
  var session = null;
604
714
  var onAbort = null;
605
715
  var dims = null;
@@ -685,6 +795,14 @@ function _record(runner, RS, opts) {
685
795
  },
686
796
  onStill: function (p, i) {
687
797
  if (clip) { clip.sample(i); }
798
+ var lastByWall = false;
799
+ if (human) {
800
+ var now = performance.now();
801
+ if (i === 0) { wallT0 = now; }
802
+ var slot = Math.round((now - wallT0) / 1000 * req.fps);
803
+ slotOf[i] = (i === 0) ? 0 : Math.max(slot, slotOf[i - 1] + 1);
804
+ lastByWall = slotOf[i] + 1 >= req.seconds * req.fps;
805
+ }
688
806
  var handled = p.then(function (still) {
689
807
  if (i === 0) {
690
808
  scene = still.scene;
@@ -699,7 +817,7 @@ function _record(runner, RS, opts) {
699
817
  encodeChain = encodeChain.then(function (enc) {
700
818
  var sample = new enc.MB.VideoSample(still.data, {
701
819
  format: 'RGBA', codedWidth: still.width, codedHeight: still.height,
702
- timestamp: i / req.fps, duration: 1 / req.fps
820
+ timestamp: (human ? slotOf[i] : i) / req.fps, duration: 1 / req.fps
703
821
  });
704
822
  return Promise.resolve(enc.source.add(sample)).then(function () {
705
823
  sample.close();
@@ -709,6 +827,7 @@ function _record(runner, RS, opts) {
709
827
  // Handled at close; a rejected encode must not surface as unhandled meanwhile.
710
828
  encodeChain.then(null, function (e) { close(e); });
711
829
  if (stopTick !== null && still.tick + req.everyTicks > stopTick) { close(null); }
830
+ if (lastByWall) { close(null); }
712
831
  }, function (e) { close(e); });
713
832
  inflight.push(handled);
714
833
  return handled;
@@ -787,8 +906,19 @@ function attach(runner) {
787
906
  */
788
907
  getStream: function (opts) { return _getStream(runner, RS, opts); },
789
908
  /**
790
- * record(opts) → Promise<VideoEnvelope> — sample like `getStream`, encode an MP4.
909
+ * record(opts?) → Promise<VideoEnvelope> — film what the viewer sees, encode an MP4.
910
+ *
911
+ * THE DEFAULT (human) FORM — record({seconds?, fps?, signal?, stage?, bitrate?, keyFrameInterval?}):
912
+ * seconds default and cap RECORD_DEFAULTS.seconds (60) — the recording ends on its own
913
+ * after this many seconds of WALL CLOCK (truncated:false)
914
+ * fps default RECORD_DEFAULTS.fps (30) — the most pictures a second it films
915
+ * signal an AbortSignal: abort is Stop — what was filmed, truncated:true
916
+ * Each picture is stamped with its wall-clock time since the first, so the file lasts as long as the
917
+ * viewer watched whatever the tick rate does; picture i is NOT tick i / fps in this form (the tick form
918
+ * keeps that). Refused on a paused runner, or before the runner has measured its rate (FrqtlVision.recordRefusal).
791
919
  *
920
+ * THE ADVANCED (tick) FORM — any of everyTicks/stopAt/maxFrames selects it; mixing it with
921
+ * `seconds` throws naming both shapes:
792
922
  * everyTicks REQUIRED — capture cadence in TICKS (sampling starts at the first
793
923
  * tick after this call, then every `everyTicks`)
794
924
  * stopAt REQUIRED — exactly one of `{tick: T}` (absolute, inclusive),
@@ -798,7 +928,7 @@ function attach(runner) {
798
928
  * waypoint)
799
929
  * maxFrames REQUIRED — the memory bound; reaching it before `stopAt` stops the
800
930
  * recording and reports `truncated: true`
801
- * fps REQUIRED — PLAYBACK rate only; frame i is timestamped i/fps
931
+ * fps REQUIRED — PLAYBACK rate only; frame i is timestamped i/fps (tick-exact timing)
802
932
  * stage default `finalStage()`; must be an rgba8 stage
803
933
  * signal an AbortSignal: abort ends the recording early with the stills
804
934
  * captured so far and `truncated: true` (rejects if none yet)
@@ -823,7 +953,8 @@ function attach(runner) {
823
953
 
824
954
  // ── Export ────────────────────────────────────────────────────────────────────
825
955
 
826
- var FrqtlVision = { attach: attach, toDataURL: toDataURL, toPNG: toPNG };
956
+ var FrqtlVision = { attach: attach, toDataURL: toDataURL, toPNG: toPNG, RECORD_DEFAULTS: RECORD_DEFAULTS, recordBudget: recordBudget,
957
+ recordRefusal: recordRefusal };
827
958
 
828
959
  // The capture-host KIND's in-page recorder is THIS face: registered here, so capture-host
829
960
  // never names FrqtlVision (the import runs one way: FrqtlVision → capture-host).
@@ -76,7 +76,7 @@ import { registerKind } from '../objects/kinds.js';
76
76
  'source fails loud.',
77
77
  // STUDIO UX: a chart panel has a real draggable/resizable extent, and it is
78
78
  // VISIBLE during Run (its whole point), so shellRender:'run' (NOT a Design-only
79
- // marker). 2D drag; 3D reposition via the camera.
79
+ // marker).
80
80
  ux: {
81
81
  resizable: true,
82
82
  draggable: { '2d': true },
@@ -617,8 +617,8 @@ import { CollisionTypes } from './CollisionTypes.js';
617
617
  // UX_DRAGGABLE_MODES / UX_SHELL_RENDER); this block is read via
618
618
  // ObjectSchema.ux('SphereEmitter'). resizable:false — a SphereEmitter has
619
619
  // TWO radii (inner+outer); a single-box resize gesture is ambiguous, so
620
- // it is NOT resizable (individually, per object). draggable 2D (a 3D
621
- // scene never cursor-drags). handles: ['move'] — it only moves, never scales.
620
+ // it is NOT resizable (individually, per object). handles: ['move'] — the
621
+ // Hand only moves it (2D and 3D), never scales it.
622
622
  // shellRender:'design' — the marker paints only while authoring (the
623
623
  // default; the "red light bulb" flips a per-instance shellRender override).
624
624
  ux: {
@@ -800,7 +800,7 @@ import { setupTunnelPointExit } from '../objects/setup/setupTunnelPointExit.js';
800
800
  'reflects off the two facing mirrors and stays collimated (no random scatter, ' +
801
801
  'no leak through the walls). The group is the single author-surface entity; ' +
802
802
  'the two mirror Walls it composes carry its `_owner` up-link. Zero physics of its own.',
803
- // A tunnel has a genuine draggable/resizable extent (like a Wall). 2D drag only.
803
+ // A tunnel has a genuine draggable/resizable extent (like a Wall).
804
804
  ux: {
805
805
  resizable: true,
806
806
  draggable: { '2d': true },