@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.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 (217) hide show
  1. package/CLAUDE.md +87 -0
  2. package/README.md +84 -2
  3. package/dist/dist-manifest.json +218 -0
  4. package/dist/eyes/index.js +109 -0
  5. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  6. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  7. package/dist/fat/pkg-node/package.json +12 -0
  8. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  9. package/dist/frqtl/compat/boot-node.js +34 -0
  10. package/dist/frqtl/compat/engine.js +127 -0
  11. package/dist/frqtl/compat/package-root.js +24 -0
  12. package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
  13. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  14. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  15. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  16. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  17. package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
  18. package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
  19. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  20. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  21. package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
  22. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  23. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  24. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  25. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  26. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  27. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  28. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  30. package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
  31. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  32. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  33. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  34. package/dist/frqtl/compat/vendor.js +196 -0
  35. package/dist/frqtl/node-entry.js +17 -0
  36. package/dist/frqtl/objects/FrqtlApp.js +2922 -0
  37. package/dist/frqtl/objects/ObjectFactory.js +297 -0
  38. package/dist/frqtl/objects/RecipeContext.js +322 -0
  39. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  40. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  41. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  42. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  43. package/dist/frqtl/objects/instruments/capture.js +711 -0
  44. package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
  45. package/dist/frqtl/objects/kinds-entry.js +14 -0
  46. package/dist/frqtl/objects/kinds.js +190 -0
  47. package/dist/frqtl/objects/objects/Box.js +273 -0
  48. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  49. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  50. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  51. package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
  52. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  53. package/dist/frqtl/objects/objects/LightSource.js +354 -0
  54. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  55. package/dist/frqtl/objects/objects/Tube.js +471 -0
  56. package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
  57. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  58. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  59. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  60. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  61. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  62. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  66. package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
  67. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  68. package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
  69. package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
  70. package/dist/frqtl/objects/objects/tagger.js +228 -0
  71. package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
  72. package/dist/frqtl/objects/schema/index.js +256 -0
  73. package/dist/frqtl/objects/schema/scopes.js +457 -0
  74. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  75. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  76. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  77. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  78. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  79. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  80. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  81. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  82. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
  83. package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  85. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  86. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  87. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  88. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  89. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  90. package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
  91. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  92. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  94. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
  95. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  96. package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
  97. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  98. package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
  99. package/dist/frqtl/objects/v1-replacements.js +30 -0
  100. package/dist/frqtl/package-core.js +23 -0
  101. package/dist/frqtl/parts/CameraControls.js +643 -0
  102. package/dist/frqtl/parts/CameraPath.js +575 -0
  103. package/dist/frqtl/parts/CollisionActions.js +35 -0
  104. package/dist/frqtl/parts/CollisionTypes.js +31 -0
  105. package/dist/frqtl/parts/DetectorWall.js +496 -0
  106. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  107. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  108. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  109. package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
  110. package/dist/frqtl/parts/LargeObject.js +406 -0
  111. package/dist/frqtl/parts/LightColor.js +255 -0
  112. package/dist/frqtl/parts/LiveChart.js +285 -0
  113. package/dist/frqtl/parts/LogicGate.js +720 -0
  114. package/dist/frqtl/parts/OvenBox.js +612 -0
  115. package/dist/frqtl/parts/PointSource.js +763 -0
  116. package/dist/frqtl/parts/Rectangle.js +200 -0
  117. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  118. package/dist/frqtl/parts/SlitScreen.js +628 -0
  119. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  120. package/dist/frqtl/parts/Trace.js +508 -0
  121. package/dist/frqtl/parts/Tunnel.js +1057 -0
  122. package/dist/frqtl/parts/Wall.js +1211 -0
  123. package/dist/frqtl/parts/index.js +33 -0
  124. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  126. package/dist/frqtl/scenes/atomic-model.js +88 -0
  127. package/dist/frqtl/scenes/big-bang.js +46 -0
  128. package/dist/frqtl/scenes/blackbody.js +81 -0
  129. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  130. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  131. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  132. package/dist/frqtl/scenes/counting-wall.js +38 -0
  133. package/dist/frqtl/scenes/double-slit.js +31 -0
  134. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  135. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  136. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  137. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
  138. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  139. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  140. package/dist/frqtl/scenes/empty.js +14 -0
  141. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  142. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  143. package/dist/frqtl/scenes/mirror-route.js +140 -0
  144. package/dist/frqtl/scenes/mirror.js +38 -0
  145. package/dist/frqtl/scenes/omni-3d.js +27 -0
  146. package/dist/frqtl/scenes/omni-source.js +22 -0
  147. package/dist/frqtl/scenes/self-interference.js +39 -0
  148. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  149. package/dist/frqtl/scenes/straight-walk.js +22 -0
  150. package/dist/frqtl/scenes/sun.js +39 -0
  151. package/dist/frqtl/scenes/two-source.js +37 -0
  152. package/dist/frqtl/scenes/wave.js +26 -0
  153. package/dist/shim/bundle/frqnb.js +112 -0
  154. package/dist/shim/bundle/index.js +121 -0
  155. package/dist/shim/bundle/indexHtml.js +107 -0
  156. package/dist/shim/bundle/manifest.js +95 -0
  157. package/dist/shim/bundle/zip.js +243 -0
  158. package/dist/shim/bus.js +67 -0
  159. package/dist/shim/controls/index.js +627 -0
  160. package/dist/shim/controls/registry.js +274 -0
  161. package/dist/shim/engine/artifact-shell.js +212 -0
  162. package/dist/shim/engine/boot-phases.js +44 -0
  163. package/dist/shim/engine/engine-stack-boot.js +59 -0
  164. package/dist/shim/engine/router.js +692 -0
  165. package/dist/shim/experiment-frame/index.js +296 -0
  166. package/dist/shim/expr/ast-schema.js +369 -0
  167. package/dist/shim/field-descriptor/index.js +326 -0
  168. package/dist/shim/frame-data/index.js +224 -0
  169. package/dist/shim/index.js +67 -0
  170. package/dist/shim/notebook/canvas-guard.js +82 -0
  171. package/dist/shim/notebook/catalog.js +35 -0
  172. package/dist/shim/notebook/format.js +736 -0
  173. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  174. package/dist/shim/notebook/kernel.js +672 -0
  175. package/dist/shim/notebook/knob.js +151 -0
  176. package/dist/shim/notebook/module-resolver.js +260 -0
  177. package/dist/shim/notebook/notebook-knobs.js +65 -0
  178. package/dist/shim/notebook/produced-bindings.js +67 -0
  179. package/dist/shim/notebook/realm-registry.js +121 -0
  180. package/dist/shim/notebook/realm-transport.js +73 -0
  181. package/dist/shim/notebook/setup-builder.js +510 -0
  182. package/dist/shim/notebook/store.js +159 -0
  183. package/dist/shim/notebook/vendor-importmap.js +134 -0
  184. package/dist/shim/notebook/view-builder.js +189 -0
  185. package/dist/shim/notebook/view-ctx.js +100 -0
  186. package/dist/shim/notebook/view-handle.js +202 -0
  187. package/dist/shim/notebook/warmup.js +102 -0
  188. package/dist/shim/package.json +4 -0
  189. package/dist/shim/page-params/index.js +214 -0
  190. package/dist/shim/project/identity.js +112 -0
  191. package/dist/shim/project/index.js +19 -0
  192. package/dist/shim/project/link.js +402 -0
  193. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  194. package/dist/shim/project/register-node.mjs +17 -0
  195. package/dist/shim/project/specifiers.js +106 -0
  196. package/dist/shim/registry/copy-gate.js +110 -0
  197. package/dist/shim/registry/exhibits.js +455 -0
  198. package/dist/shim/registry/experiments.js +215 -0
  199. package/dist/shim/registry/index.js +19 -0
  200. package/dist/shim/registry/scene-source.js +157 -0
  201. package/dist/shim/registry/walkthrough.js +807 -0
  202. package/dist/shim/render/capture-envelope.js +279 -0
  203. package/dist/shim/render/heat-colormap.js +92 -0
  204. package/dist/shim/render/render-stages.js +855 -0
  205. package/dist/shim/render/sun-look.js +33 -0
  206. package/dist/shim/render-state/apply.js +262 -0
  207. package/dist/shim/render-state/index.js +74 -0
  208. package/dist/shim/rpc/index.js +207 -0
  209. package/dist/shim/runtime/index.js +1023 -0
  210. package/dist/shim/storage/boot.js +69 -0
  211. package/dist/shim/storage/index.js +169 -0
  212. package/dist/shim/storage/local-storage-provider.js +230 -0
  213. package/dist/shim/storage/make-namespace-store.js +132 -0
  214. package/dist/shim/storage/modules.js +249 -0
  215. package/dist/shim/storage/projects.js +150 -0
  216. package/llms.txt +12 -0
  217. package/package.json +62 -4
@@ -0,0 +1,711 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * capture.js — THE render-pipeline tap MECHANISM, as an INSTRUMENT on the primitive
5
+ * surface (THE_MINIMAL_SIM.md §4; CEO ruling 2026-08-01, home = the primitive surface).
6
+ *
7
+ * `capture(stage)` is the ONE generic read-at-a-named-stage mechanism; `captureStill()` is
8
+ * its last-stage CONVENIENCE; `sceneRecord()` is the run-invariant half of the normalized
9
+ * format. All dispatch, all throw rules and ALL provenance stamping live HERE and nowhere
10
+ * else. Contract text: docs/internal/engine/RENDERER_CONTRACT.md §1a.4 + §10.
11
+ *
12
+ * ── Why this is not on the host ──────────────────────────────────────────────
13
+ *
14
+ * A tap is not an editor concern. This mechanism was resident in `FrqtlApp` (the editor
15
+ * host) purely by history, which made "eyes" a BROWSER privilege: nothing in reading a
16
+ * stage needs a DOM, a page or a canvas — it needs the loop's retained tick, the active
17
+ * provider, the scene identity, and the live render-options bag. Those four are the whole
18
+ * binding, so the instrument constructs in a bare node process with no host at all:
19
+ *
20
+ * FrqtlCapture.create(loop, providerGetter, identity, renderOptionsGetter)
21
+ *
22
+ * @param loop — anything exposing `lastTickData` (the retained TickData).
23
+ * `SimLoop` after the §4 split; the `FrqtlApp` host today.
24
+ * The instrument NEVER advances the clock: it does not call
25
+ * `step()`/`doTick()`, so a tap can never perturb tick order.
26
+ * @param providerGetter — `() => RendererProvider|null`. The ONE renderer authority,
27
+ * read live (a provider that resolves later is picked up on
28
+ * the next capture). `null` is a legitimate state — the
29
+ * `tick` and `field` stages are backend-independent and work
30
+ * with no renderer at all.
31
+ * @param identity — `{ seed, rect, sourceRef? }`. The seed is FERRIED from the
32
+ * party that handed it to the universe (one seed authority,
33
+ * never re-derived here); `rect` is read LIVE so a host that
34
+ * re-rects its canvas cannot leave a stale scene record.
35
+ * @param renderOptionsGetter — `() => renderOptionsBag`. The ONE LIVE bag `setRenderOption`
36
+ * writes, read THROUGH the getter and never copied — a copy
37
+ * here would be a second options authority whose provenance
38
+ * silently stopped tracking the picture.
39
+ *
40
+ * ── One stage the local process genuinely cannot execute ─────────────────────
41
+ *
42
+ * `tick` and `field` are backend-independent, so a bare node process taps them with no
43
+ * renderer at all. `composite` is different in kind: it IS the render stack's output, and
44
+ * re-drawing it with a node raster library would show the agent a different picture than
45
+ * the user sees (CEO ruling, V3 — a god-rays shader turns particles into A SUN, and the
46
+ * shader is part of the experiment, not decoration). So `composite` is executed by a
47
+ * CAPTURE HOST — a remote stage executor running the real stack in a managed headless
48
+ * browser — attached via `attachCaptureHost()`. The KIND, its boundary codec and its
49
+ * per-still verification live in the sibling `instruments/capture-host.js`.
50
+ *
51
+ * Two upward VOCABULARY reads (`RenderStages`, `FieldBuilder`) resolve at CALL time through
52
+ * named FAIL-LOUD guards — the same seam every game-layer object uses for a shim/compat
53
+ * contract. They are leaf tables and executors, never machinery, and they are resolved
54
+ * rather than imported so this file adds no arrow.
55
+ *
56
+ * ── Layer 2: physics behaviour ───────────────────────────────────────────────
57
+ * ZERO physics. This is the output boundary reading its own output — no frame, dimension,
58
+ * tick or op-program is touched, so no canonical-bench golden can move because of it.
59
+ *
60
+ * OBSERVATION ONLY. A tap cannot perturb the pipeline. Pipeline CONFIGURATION stays exactly
61
+ * where it already lives — `setFilterActive` / `setRenderOption` —
62
+ * so "capture with the water shader off" composes existing knobs (setFilterActive('water',
63
+ * false) → repaint() → capture → restore) rather than growing a second filter authority on
64
+ * the capture call.
65
+ */
66
+
67
+ import * as RenderStages from '../../../shim/render/render-stages.js';
68
+ import { FieldBuilder } from '../../compat/renderer/FieldBuilder.js';
69
+ import { FrqtlCaptureHost } from './capture-host.js';
70
+ import { engine, engineState } from '../../compat/engine.js';
71
+ import { loadedWasmSha256 } from '../../compat/providers/wasm-load-browser.js';
72
+ import * as FrqtlDisplay from '../../compat/renderer/display-slot.js';
73
+
74
+
75
+ /**
76
+ * The experiment identity the page ALREADY carries, ferried — not a second authority.
77
+ *
78
+ * The experiments registry DEFINES an experiment id as "the directory name under
79
+ * experiments/" (`experimentById`), and a canonical page is served from
80
+ * `…/experiments/<id>/index.html`. So the path segment IS the registry id, and reading it
81
+ * ferries the existing identity rather than minting a parallel one. The registry module
82
+ * is a registry, not a page fact, which is why the id is taken from the URL the registry's
83
+ * own ids are defined by.
84
+ *
85
+ * Fails SOFT to null (no DOM, a notebook realm, an ad-hoc page): `sourceRef: null` is the
86
+ * honest "this scene has no authored identity" value, and the corpus row then carries the
87
+ * code itself. A caller with a better identity overrides via `setSourceRef()`.
88
+ * @private
89
+ */
90
+ function _defaultSourceRef() {
91
+ try {
92
+ if (typeof location === 'undefined' || !location) { return null; }
93
+ var m = String(location.pathname).match(/\/experiments\/([^/]+)\//);
94
+ return (m && m[1]) ? m[1] : null;
95
+ } catch (e) { return null; }
96
+ }
97
+
98
+ /**
99
+ * The RASTER PATH pin — which GL backend this browser actually rasters with.
100
+ *
101
+ * Measured, not assumed (`docs/internal/physics-lab/composite-determinism/`): a
102
+ * `composite` still is bit-reproducible run-to-run WITHIN a pinned (machine, driver,
103
+ * browser build) — under software SwiftShader AND under hardware ANGLE/Metal alike —
104
+ * but the software and hardware pictures differ (0.42% of pixels raw, 1.75% with a
105
+ * bloom pass). Sub-perceptual, and real. So the policy is bit-exact within a path and
106
+ * tolerance across paths, which only works if a row SAYS which path drew it: rows from
107
+ * different raster paths must refuse to mix BY IDENTITY, not by luck.
108
+ *
109
+ * It rides `build` because it is exactly what `build` is for — a runtime pin DERIVED
110
+ * from the environment, never a caller-set flag (a hand-set `software:true` would be
111
+ * the truth-in-labeling violation this field exists to prevent). The value is the
112
+ * browser's own `UNMASKED_RENDERER_WEBGL` string, which separates `SwiftShader driver`
113
+ * from `ANGLE Metal Renderer: Apple M4 Pro` exactly.
114
+ *
115
+ * MEMOIZED per process: it cannot change within a run, and creating a GL context per
116
+ * `sceneRecord()` call would be an absurd cost on a memoized-by-design record. `null`
117
+ * where there is no WebGL to ask (node, or a browser withholding the debug extension) —
118
+ * honestly absent, exactly like the three sibling pins.
119
+ * @private
120
+ */
121
+ var _rasterPathMemo; // undefined = not yet read (null is a legitimate answer)
122
+ function _rasterPath() {
123
+ if (_rasterPathMemo !== undefined) { return _rasterPathMemo; }
124
+ _rasterPathMemo = null;
125
+ try {
126
+ if (typeof document !== 'undefined' && document.createElement) {
127
+ var c = document.createElement('canvas');
128
+ var gl = c.getContext('webgl2') || c.getContext('webgl');
129
+ var ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
130
+ if (ext) { _rasterPathMemo = String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)); }
131
+ }
132
+ } catch (e) { _rasterPathMemo = null; }
133
+ return _rasterPathMemo;
134
+ }
135
+
136
+ /**
137
+ * The TICK-PROVENANCE GUARD for a raster the provider executes: the stamp is the pixels'
138
+ * tick, or the capture THROWS.
139
+ *
140
+ * `tick` is stamped off the LOOP's retained TickData when the capture is requested; the
141
+ * pixels are whatever the provider LAST composited, read later. The two diverge whenever
142
+ * the clock ticks without a draw — the rAF loop runs a tick batch (listeners included)
143
+ * BEFORE its one render, so a batched frame composites only its last tick, and the first
144
+ * frame's burst composites only the burst's last. A still labelled with a tick its pixels
145
+ * are not is a falsified physics record, so the provider REPORTS the tick it drew
146
+ * (`renderedTick`, §1a.4) and this compares. The dispatcher takes the readback in the
147
+ * stamp's task, so a between-frame caller always agrees; what is left to
148
+ * refuse is a capture from inside a tick batch (or a genuinely asynchronous BYO
149
+ * readback). There is no reconciling branch: the remedy is the caller's clock
150
+ * discipline, named in the message.
151
+ * @private
152
+ */
153
+ function _assertComposited(stage, tick, bag) {
154
+ var rendered = bag.renderedTick;
155
+ if (!(typeof rendered === 'number' && isFinite(rendered))) {
156
+ throw new Error('[FrqtlCapture.capture] the provider\'s captureStill() bag reports ' +
157
+ 'no renderedTick (got ' + String(rendered) + ') — the tick of the TickData its ' +
158
+ 'pixels were drawn from is REQUIRED (RENDERER_CONTRACT §1a.4), because without ' +
159
+ 'it a still stamped tick ' + tick + ' cannot be shown to carry tick ' + tick +
160
+ '\'s picture. (FAIL-LOUD.)');
161
+ }
162
+ if (rendered !== tick) {
163
+ throw new Error('[FrqtlCapture.capture] PROVENANCE MISMATCH at stage \'' + stage +
164
+ '\': the still would be stamped tick ' + tick + ' (the loop\'s retained TickData) ' +
165
+ 'but the provider composited tick ' + rendered + ' — these pixels are not tick ' +
166
+ tick + '\'s. Capture a tick that was RENDERED: drive the clock with runner.step() ' +
167
+ '(it renders, then notifies), or HOLD it from a tick listener — ' +
168
+ 'runner.suspendTicks(), runner.repaint() (forces the draw; an fps cap skips ' +
169
+ 'display frames), capture, runner.resumeTicks() in the capture\'s finally; ' +
170
+ 'outside a listener, runner.repaint() then capture in the same task. ' +
171
+ '(FAIL-LOUD — a still whose label is not its pixels is a falsified record.)');
172
+ }
173
+ }
174
+
175
+ // ── The instrument ────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * @param {{lastTickData: object}} loop
179
+ * @param {function(): object|null} providerGetter
180
+ * @param {{seed: *, rect: object, sourceRef: (string|null|undefined)}} identity
181
+ * @param {function(): object} renderOptionsGetter
182
+ */
183
+ function CaptureInstrument(loop, providerGetter, identity, renderOptionsGetter) {
184
+ if (!loop || typeof loop !== 'object') {
185
+ throw new Error('[FrqtlCapture.create] argument 1 must be the LOOP — anything ' +
186
+ 'exposing `lastTickData` (SimLoop / the FrqtlApp host). (FAIL-LOUD.)');
187
+ }
188
+ if (typeof providerGetter !== 'function') {
189
+ throw new Error('[FrqtlCapture.create] argument 2 must be a providerGetter ' +
190
+ 'function returning the active RendererProvider or null — the provider is read ' +
191
+ 'LIVE, never captured at bind time. (FAIL-LOUD.)');
192
+ }
193
+ if (!identity || typeof identity !== 'object' || identity.seed === undefined || !identity.rect) {
194
+ throw new Error('[FrqtlCapture.create] argument 3 must be the identity bag ' +
195
+ '{ seed, rect, sourceRef? } — `seed` is FERRIED from the party that handed it to ' +
196
+ 'the universe and `rect` is the physics canvas. (FAIL-LOUD.)');
197
+ }
198
+ if (typeof renderOptionsGetter !== 'function') {
199
+ throw new Error('[FrqtlCapture.create] argument 4 must be a renderOptionsGetter ' +
200
+ 'function returning the ONE LIVE render-options bag — read through the getter, ' +
201
+ 'never copied (a copy is a second options authority). (FAIL-LOUD.)');
202
+ }
203
+ this._loop = loop;
204
+ this._providerGetter = providerGetter;
205
+ this._identity = identity;
206
+ this._renderOptionsGetter = renderOptionsGetter;
207
+ // Experiment/rung identity for a SceneRecord, registered ONCE at setup by the page or
208
+ // harness via setSourceRef(). null is HONEST (an ad-hoc universe); the corpus row then
209
+ // carries the code itself. Never per-capture caller-supplied provenance.
210
+ this._sourceRef = (identity.sourceRef === undefined || identity.sourceRef === null)
211
+ ? _defaultSourceRef() : String(identity.sourceRef);
212
+ // The code identity (setSourceSha256) — null until a door that linked a project registers it.
213
+ this._sourceSha256 = null;
214
+ // Memo for the content-addressed SceneRecord — see sceneRecord().
215
+ this._sceneMemo = null;
216
+ // The attached CAPTURE HOST (the remote stage-executor KIND), or null. See
217
+ // attachCaptureHost() and instruments/capture-host.js.
218
+ this._host = null;
219
+ }
220
+
221
+ /**
222
+ * attachCaptureHost(host) — attach a REMOTE stage executor (the capture-host KIND).
223
+ *
224
+ * The KIND, its boundary codec and its verification live in the sibling
225
+ * `instruments/capture-host.js`; this method is the seam that lets a stage the LOCAL
226
+ * process cannot execute be executed elsewhere without a second dispatcher. The
227
+ * canonical implementation is the hosted headless browser (`instruments/hosted-eyes.js`)
228
+ * that runs the REAL render stack, so `capture('composite')` works from node through the
229
+ * real renderer and its shaders, drawing the scene source plus the renderState it was handed.
230
+ *
231
+ * ATTACHING EXTENDS THIS INSTRUMENT'S IDENTITY (a.3): from here on `_buildIdentity()`
232
+ * and `_projection()` report the HOST'S page-resolved values, so the scene identity this
233
+ * process computes and the one the page stamps are equal by construction rather than by
234
+ * a comparison someone later loosens.
235
+ *
236
+ * FAIL-LOUD when the live provider already executes a stage the host claims: two
237
+ * executors for one stage is two truths about one picture, and the honest moment to say
238
+ * so is attach time, not the first mismatch.
239
+ *
240
+ * @param {object} host — see instruments/capture-host.js for the KIND
241
+ * @returns {object} the same host
242
+ */
243
+ CaptureInstrument.prototype.attachCaptureHost = function (host) {
244
+ var CH = FrqtlCaptureHost;
245
+ CH.assertCaptureHost(host);
246
+ var prov = this._provider();
247
+ if (prov && typeof prov.captureStill === 'function' && host.stages.indexOf('composite') !== -1) {
248
+ throw new Error('[FrqtlCapture.attachCaptureHost] this instrument already has a ' +
249
+ 'display provider that implements captureStill(), so the `composite` stage is ' +
250
+ 'already executed locally. A capture host is for a stage this process CANNOT ' +
251
+ 'execute; attaching one here would mint a second executor — two truths about ' +
252
+ 'one picture. (FAIL-LOUD.)');
253
+ }
254
+ this._host = host;
255
+ // The identity just changed (build/projection now come from the page), so the memo
256
+ // computed under the old identity must not survive — a stale memo here would hand
257
+ // out the pre-attachment hash and every verification would fail for the right
258
+ // reason with the wrong explanation.
259
+ this._sceneMemo = null;
260
+ return host;
261
+ };
262
+
263
+ /**
264
+ * captureHost() — the attached capture host, or null when this instrument executes its
265
+ * stages locally. `FrqtlVision.record` reads it to forward a recording to the process
266
+ * where the world and its clock are (the host's `record` member).
267
+ * @returns {object|null}
268
+ */
269
+ CaptureInstrument.prototype.captureHost = function () {
270
+ return this._host;
271
+ };
272
+
273
+ /** The live provider, or null. @private */
274
+ CaptureInstrument.prototype._provider = function () {
275
+ return this._providerGetter() || null;
276
+ };
277
+
278
+ /**
279
+ * setSourceRef(ref) — register the experiment/rung identity for the SceneRecord.
280
+ *
281
+ * Called ONCE at setup by the page or harness (a legitimate authority set at attach
282
+ * time), NEVER per capture — a per-capture identity would be caller-supplied provenance,
283
+ * the hardcoded-label anti-pattern. Absent ⇒ `sourceRef: null`, which is honest for an
284
+ * ad-hoc universe: such a scene is REPLAY-grade, not REGENERATE-grade, and the corpus row
285
+ * must carry the code itself.
286
+ * @param {string|null} ref
287
+ */
288
+ CaptureInstrument.prototype.setSourceRef = function (ref) {
289
+ this._sourceRef = (ref === undefined || ref === null) ? null : String(ref);
290
+ };
291
+
292
+ /**
293
+ * setSourceSha256(sha) — register the CODE identity for the SceneRecord: the project identity
294
+ * (lib/shim/project/identity.js projectIdentity) of the project a door linked and built. Called
295
+ * ONCE by that door (bootProject, the empty page's applyModuleSource), never per capture; null
296
+ * (the default) is honest for a sketch booted as a function. Validated by the record's owner.
297
+ * @param {string|null} sha
298
+ */
299
+ CaptureInstrument.prototype.setSourceSha256 = function (sha) {
300
+ this._sourceSha256 = RenderStages.assertSourceSha256(sha);
301
+ };
302
+
303
+ /**
304
+ * The runtime BUILD pin, DERIVED from the boot authorities — never a literal.
305
+ *
306
+ * Pixel-exact regeneration pins the builds, not just the code+seed. What is genuinely
307
+ * derivable at runtime today is the boot identity: the engine the boot booted (the engine
308
+ * handle's own `backend`, compat/engine.js), the renderer the boot selected (the display slot,
309
+ * compat/renderer/display-slot.js), and the CONTENT hash of the wasm binary the browser loader
310
+ * instantiated (`loadedWasmSha256()`, compat/providers/wasm-load-browser.js — the `PINNED.json`
311
+ * `wasmSha256` definition). Never the package URL: the serving origin is a deployment fact, so the same
312
+ * bytes from two hosts must regenerate ONE scene identity. Each is `null` when the
313
+ * authority is absent (a headless harness) — honestly absent, never invented; a hand-typed
314
+ * version string here would be the truth-in-labeling violation (dep-map rule 4) that this
315
+ * method exists to avoid.
316
+ * @private
317
+ */
318
+ CaptureInstrument.prototype._buildIdentity = function () {
319
+ // A capture host is attached ⇒ the picture is produced in the PAGE, so the page's
320
+ // build pin is the truthful one (a.3 identity convergence — see attachCaptureHost).
321
+ if (this._host) { return this._host.identity().build || null; }
322
+ // The PAGE's facts: present only in a realm where the BROWSER loader instantiated a binary
323
+ // (its content hash is recorded — null until then). Every other realm — node, a notebook
324
+ // test — carries nulls, exactly as its record always has (identity convergence above
325
+ // extends a node record through a host).
326
+ var sha = loadedWasmSha256();
327
+ var paged = sha !== null;
328
+ return {
329
+ engine: (paged && engineState() === 'booted') ? String(engine().backend) : null,
330
+ renderer: (paged && FrqtlDisplay.name()) ? String(FrqtlDisplay.name()) : null,
331
+ wasmSha256: paged ? sha : null,
332
+ gl: _rasterPath()
333
+ };
334
+ };
335
+
336
+ /**
337
+ * The camera intrinsics for the scene, from the provider's OPTIONAL `projection()`.
338
+ *
339
+ * Without them a 3D pixel→world mapping is UNDERDETERMINED offline: an offline corpus
340
+ * consumer has no live `screenToWorld`, and the pose bag plus buffer dims are not enough
341
+ * for a perspective camera. `null` when the provider does not declare the capability (a
342
+ * BYO renderer) — honest, and `unprojectStill` then FAILS LOUD at use rather than
343
+ * computing a wrong point.
344
+ * @private
345
+ */
346
+ CaptureInstrument.prototype._projection = function () {
347
+ // Hosted: the camera that made the picture is the PAGE's, so its intrinsics are the
348
+ // only ones that describe this still's pixel→world map (a.3).
349
+ if (this._host) {
350
+ var ident = this._host.identity();
351
+ return (ident && ident.projection) ? ident.projection : null;
352
+ }
353
+ var prov = this._provider();
354
+ if (!prov || typeof prov.projection !== 'function') { return null; }
355
+ return prov.projection();
356
+ };
357
+
358
+ /**
359
+ * The ONE camera authority, read (never re-derived) — through the PROVIDER capability,
360
+ * which is where the camera handle actually lives. The host's `runner.cameraControl`
361
+ * memo is the same handle wearing a host-facing convenience; reading it back up through
362
+ * the host would be an upward coupling for no extra truth. `null` when the active
363
+ * provider exposes no camera pose control at all (a BYO renderer): without a pose a 3D
364
+ * composite cannot be co-registered to the field, and for VLA data the pose is
365
+ * load-bearing, so absent-and-honest is the only alternative to wrong.
366
+ * @private
367
+ */
368
+ CaptureInstrument.prototype._pose = function () {
369
+ var prov = this._provider();
370
+ if (!prov || typeof prov.getCameraControl !== 'function') { return null; }
371
+ var cam = prov.getCameraControl(); // null in a provider with no pose control
372
+ return cam ? cam.getOrbit() : null;
373
+ };
374
+
375
+ /**
376
+ * sceneRecord() → Promise<SceneRecord + its own content id>
377
+ *
378
+ * The RUN-INVARIANT half of the normalized format: the regeneration input
379
+ * (`seed`, `sourceRef`, `build`, `physicsW/H`, `projection`), IMMUTABLE and
380
+ * CONTENT-ADDRESSED as `scene = sha256(canonicalize(record))`. A corpus stores ONE of
381
+ * these plus N light stills that reference it by hash, instead of repeating a
382
+ * high-bandwidth header on every capture.
383
+ *
384
+ * SELF-INVALIDATING BY CONSTRUCTION: the record is rebuilt from the live authorities on
385
+ * every call (seven field reads) and re-hashed ONLY when its canonical form actually
386
+ * changes. So there is no `invalidateScene()` to forget to call, and a record is never
387
+ * stale. Scene records being immutable, a change mints a NEW identity rather than
388
+ * revising one — under content-addressing those are the same operation.
389
+ *
390
+ * The returned object carries its own `scene` id alongside the record for convenience;
391
+ * `canonicalizeScene` PROJECTS onto `SCENE_KEYS`, so that id cannot perturb its own hash.
392
+ *
393
+ * @returns {Promise<object>} the record plus `scene`
394
+ * @throws {Error} (as a rejection) if the seed authority is unreachable, or
395
+ * `crypto.subtle` is unavailable — never a silently unidentified scene.
396
+ */
397
+ CaptureInstrument.prototype.sceneRecord = function () {
398
+ var RS = RenderStages;
399
+ var self = this;
400
+ var record;
401
+ try {
402
+ var rect = this._identity.rect;
403
+ record = RS.normalizeScene({
404
+ seed: this._identity.seed,
405
+ sourceRef: this._sourceRef,
406
+ sourceSha256: this._sourceSha256,
407
+ build: this._buildIdentity(),
408
+ physicsW: rect.w,
409
+ physicsH: rect.h,
410
+ projection: this._projection()
411
+ });
412
+ } catch (e) { return Promise.reject(e); }
413
+ var canon = RS.canonicalizeScene(record);
414
+ if (this._sceneMemo && this._sceneMemo.canon === canon) {
415
+ // COPY, never the memo object itself. Handing out `_sceneMemo.value` made every
416
+ // caller share ONE mutable record that IS internal instrument state: a single
417
+ // careless consumer (`scene.build.engine = ...`) corrupted the record for every
418
+ // later caller AND changed its canonical form, so the hash silently stopped
419
+ // verifying against stills already written. That is the R8 aliasing class landing
420
+ // on the one record the regenerate-not-replay claim depends on. The memo stays —
421
+ // it exists to avoid re-hashing, not to share an object.
422
+ return Promise.resolve(RS.deepPlainCopy(this._sceneMemo.value));
423
+ }
424
+ return RS.sceneHash(record).then(function (id) {
425
+ var value = Object.assign({}, record, { scene: id });
426
+ self._sceneMemo = { canon: canon, value: value };
427
+ // COPY ON THE WAY OUT — see the memo-hit path above for why.
428
+ return RS.deepPlainCopy(value);
429
+ });
430
+ };
431
+
432
+ /**
433
+ * The tick index the channel derives from — the co-registration key.
434
+ *
435
+ * Stamped by the INSTRUMENT off the loop's retained TickData because the loop is the only
436
+ * party that knows the tick: a caller capturing several stages between ticks asserts
437
+ * EQUAL `tick` stamps to prove the channels are aligned, and a caller-supplied tick would
438
+ * make that assertion vacuous.
439
+ * @private
440
+ */
441
+ CaptureInstrument.prototype._tickIndex = function (tickData) {
442
+ var t = tickData && tickData.universeData && tickData.universeData.tick;
443
+ if (typeof t === 'number' && isFinite(t)) { return t; }
444
+ throw new Error('[FrqtlCapture.capture] the live TickData carries no ' +
445
+ 'universeData.tick — the tick stamp is what co-registers a multi-channel capture, ' +
446
+ 'so it is never defaulted or omitted. (FAIL-LOUD.)');
447
+ };
448
+
449
+ /**
450
+ * The capability descriptors the ACTIVE PROVIDER declares — the provider is the
451
+ * capability authority (`renderCapabilities()` is its OPTIONAL contract method), and the
452
+ * host's `runner.renderCapabilities()` is the host-facing read of that SAME authority.
453
+ * A provider that predates the contract addition, or no provider at all, yields `[]` —
454
+ * never an error (the `cameraControl` capability-check precedent).
455
+ * @private
456
+ */
457
+ CaptureInstrument.prototype._renderCapabilities = function () {
458
+ var prov = this._provider();
459
+ if (!prov || typeof prov.renderCapabilities !== 'function') { return []; }
460
+ return prov.renderCapabilities();
461
+ };
462
+
463
+ /**
464
+ * The live values of the keys the active provider DECLARES via `renderCapabilities()`.
465
+ *
466
+ * DRIVEN OFF the declared descriptor list, so a newly declared R4/R5 knob shows up in
467
+ * capture provenance with zero edits here — a hand-typed key list would be the
468
+ * schema-mirror bug. The VALUES come from the ONE live options bag, read through the
469
+ * bound getter (never copied). `filter.*` capability keys are deliberately EXCLUDED:
470
+ * their authority is the provider-stamped, ORDERED `filtersApplied`, and reading them
471
+ * here as well would create a second (unordered, BYO-blind) filter truth.
472
+ * @private
473
+ */
474
+ CaptureInstrument.prototype._captureRenderOptions = function () {
475
+ var caps = this._renderCapabilities();
476
+ var live = this._renderOptionsGetter() || {};
477
+ var out = {};
478
+ for (var i = 0; i < caps.length; i++) {
479
+ var name = caps[i].name;
480
+ if (!name || name.indexOf('filter.') === 0) { continue; }
481
+ out[name] = (live[name] !== undefined) ? live[name] : caps[i].default;
482
+ }
483
+ return out;
484
+ };
485
+
486
+ /**
487
+ * capture(stage) — THE generic render-pipeline tap (the mechanism).
488
+ *
489
+ * Reads the data at a NAMED stage of the render pipeline and returns a provenance-
490
+ * stamped bag. `stage` is REQUIRED: a default belongs to a convenience, not to the
491
+ * mechanism (see `captureStill()` below).
492
+ *
493
+ * @param {string} stage — a member of `RenderStages.RENDER_STAGES`
494
+ * (`'tick' | 'field' | 'scene' | 'composite'`).
495
+ * @returns {Promise<object>} a bag carrying every `RenderStages.STILL_BAG_KEYS` key:
496
+ * the `data` payload (Array of FrameData / `Uint8Array` / `Uint8ClampedArray` — the
497
+ * NUMBERS are first class, an image is something a consumer derives) plus the
498
+ * provenance that makes a pre-render tap distinguishable from a post-bloom one
499
+ * (`stage`, `tick`, `format`, `filtersApplied`, `renderBaseCanvas`, `pose`,
500
+ * `composedOf`, `renderOptions`). Async for all stages by contract: a WebGPU
501
+ * backend's only readback is `mapAsync`, so a sync signature would structurally
502
+ * exclude WebGPU BYO renderers forever. The shipped stages resolve immediately.
503
+ *
504
+ * @throws {Error} unknown/absent stage (message lists the whole table); no tick has
505
+ * run yet (never an empty buffer); a stage that is a real pipeline point but has no
506
+ * tap executor (`'scene'` in v1 — a DISTINCT error from unknown-stage); `'composite'`
507
+ * on a provider that does not implement the OPTIONAL `captureStill()`. Absence of a
508
+ * CAPABILITY hides a host affordance (a UI capability-checks and shows no button);
509
+ * a PROGRAMMATIC capture never degrades to `null` — a measurement or a golden built
510
+ * on a silently-blank capture is fabricated evidence.
511
+ */
512
+ CaptureInstrument.prototype.capture = function (stage) {
513
+ // A Promise-returning method must NEVER throw synchronously: a mixed error channel
514
+ // (sync throw for a bad argument, rejection for a bad readback) guarantees an
515
+ // uncaught exception at some call site that only wrote `.catch()`. Every failure
516
+ // below therefore arrives as a REJECTION, with the identical message. This wrapper
517
+ // exists in exactly ONE place — a host that forwards to `capture()` must not add a
518
+ // second try/catch of its own.
519
+ try { return this._captureDispatch(stage); }
520
+ catch (e) { return Promise.reject(e); }
521
+ };
522
+
523
+ /** @private — the dispatch body; see `capture` for the contract. */
524
+ CaptureInstrument.prototype._captureDispatch = function (stage) {
525
+ var RS = RenderStages;
526
+ RS.assertStage(stage);
527
+
528
+ // ── HOSTED: a REMOTE executor owns this stage ────────────────────────
529
+ // Checked before every local precondition, because none of them apply: the world,
530
+ // the clock, the canvas and the camera are all in the other process. The local side
531
+ // computes the scene identity, asks, and VERIFIES — it never stamps. (Deliberately
532
+ // NOT a provider branch: see instruments/capture-host.js for why a provider-shaped
533
+ // adapter would have had to invent the provenance it does not hold.)
534
+ if (this._host && this._host.stages.indexOf(stage) !== -1) {
535
+ var CH = FrqtlCaptureHost;
536
+ var host = this._host;
537
+ return this.sceneRecord().then(function (scene) {
538
+ return Promise.resolve(host.capture(stage)).then(function (still) {
539
+ return CH.verifyStill(scene.scene, stage, still);
540
+ });
541
+ });
542
+ }
543
+
544
+ var td = this._loop.lastTickData;
545
+ if (!td) {
546
+ throw new Error('[FrqtlCapture.capture] no tick has run yet (the loop has ' +
547
+ 'retained no TickData), so there is no channel to read. Run start()/step() ' +
548
+ 'first. (FAIL-LOUD — never an empty buffer.)');
549
+ }
550
+ // Reject a non-tappable stage BEFORE doing any work — the diagnosis should not wait
551
+ // on a hash. ('scene' is a real pipeline point with no executor in v1.)
552
+ var prov = this._provider();
553
+ if (stage === 'scene') {
554
+ throw RS.stageNotTappableError(stage, 'the active renderer provider');
555
+ }
556
+ if (stage === 'composite' && (!prov || typeof prov.captureStill !== 'function')) {
557
+ throw RS.stageNotTappableError(stage, 'the active renderer provider ' +
558
+ '(it does not implement the OPTIONAL captureStill() capability), and no capture ' +
559
+ 'host is attached. In a process with no display renderer — a node rig — the ' +
560
+ 'composed picture is produced by the HOSTED headless stack, which runs the real ' +
561
+ 'renderer and its shaders: openHostedEyes({sceneRecord, sceneSource, pageUrl}) ' +
562
+ 'from @descix/frqtl-node/eyes (lib/frqtl/objects/instruments/hosted-eyes.js)');
563
+ }
564
+
565
+ // ONE TASK: the tick is stamped now; a raster readback runs in THIS task's microtask
566
+ // checkpoint, queued before anything is awaited. Both halves of that are load-bearing:
567
+ // - never AFTER the scene hash: sceneRecord() is a real task boundary whenever it
568
+ // hashes (the first capture of a scene, and after every scene change), and a running
569
+ // loop can tick and redraw inside it — the readback would carry a later tick's
570
+ // pixels for a caller that did everything right;
571
+ // - never INSIDE the synchronous call: the rAF loop notifies tick listeners BEFORE the
572
+ // frame's draw in the same task, so a draw of tick t that follows the request in
573
+ // this task (the frame's own) must be what the readback sees.
574
+ // The raster's LABELS (pose, renderOptions) are read in the readback's own synchronous run,
575
+ // right beside captureStill(): a listener registered after the requester (CameraPath's
576
+ // setOrbit, a knob write) can change them later in this task, and the pixels are drawn
577
+ // under the later values — nothing can draw between these reads and the readback.
578
+ // Selected by FORMAT off the stage table, not by stage name: every rgba8 stage is a
579
+ // provider-executed raster and inherits the readback timing, the label timing and the guard.
580
+ var self = this;
581
+ var tick = this._tickIndex(td);
582
+ var labels = { pose: null, renderOptions: null };
583
+ var readback = null;
584
+ if (RS.STAGE_FORMATS[stage] === 'rgba8') {
585
+ readback = Promise.resolve().then(function () {
586
+ labels.pose = self._pose();
587
+ labels.renderOptions = self._captureRenderOptions();
588
+ return prov.captureStill();
589
+ });
590
+ } else {
591
+ // No readback: the channel IS the retained TickData, so the stamp-time labels are the
592
+ // ones it is read under.
593
+ labels.pose = this._pose();
594
+ labels.renderOptions = this._captureRenderOptions();
595
+ }
596
+ // The still records COORDINATES WITHIN a scene, so it needs the scene's content id.
597
+ // sceneRecord() is memoized on the canonical form, so this is a hash only when the
598
+ // scene actually changed. Joined with the readback so neither rejection goes unhandled.
599
+ return Promise.all([this.sceneRecord(), readback]).then(function (joined) {
600
+ var scene = joined[0];
601
+ var base = {
602
+ scene: scene.scene,
603
+ stage: stage,
604
+ tick: tick,
605
+ pose: labels.pose,
606
+ renderOptions: labels.renderOptions
607
+ };
608
+
609
+ if (stage === 'tick') {
610
+ // Stage 0 — the frame-data bubble itself. `width`/`height` are the physics
611
+ // canvas the rect coordinates live in (a framedata payload is not a raster).
612
+ // The payload SHAPE is the frame-data bubble's own published contract
613
+ // (`FrameDataSchema` is its owner) — pointed AT here, never restated.
614
+ return RS.normalizeStill(Object.assign({}, base, {
615
+ data: td.frameData,
616
+ width: scene.physicsW,
617
+ height: scene.physicsH,
618
+ filtersApplied: [], // pre-render by definition
619
+ renderBaseCanvas: null // nothing composited on this channel
620
+ }));
621
+ }
622
+
623
+ if (stage === 'field') {
624
+ // The render-free numeric channel: the ONE canonical field source over the
625
+ // retained TickData. Instrument-side ON PURPOSE — the field stage is
626
+ // backend-independent by construction, so putting it on the provider contract
627
+ // would force every BYO renderer to re-ship a stage it does not own. Every
628
+ // BYO provider gets this tap for free, and a headless process gets it with no
629
+ // renderer at all.
630
+ var FB = FieldBuilder;
631
+ var fopts = Object.assign({}, FB.FIELD_OPTS, {
632
+ canvasW: scene.physicsW, canvasH: scene.physicsH
633
+ });
634
+ return RS.normalizeStill(Object.assign({}, base, {
635
+ data: FB.buildHeightField(td.frameData, fopts),
636
+ width: fopts.w,
637
+ height: fopts.h,
638
+ // The r8 channel is a dimensionless INSTRUMENT RESPONSE (gain-scaled,
639
+ // clamped, optionally blurred soft-splat fullness density), comparable
640
+ // only at equal options — so the options ride the record. Two field
641
+ // captures at different gain would otherwise be indistinguishable.
642
+ fieldOpts: fopts,
643
+ filtersApplied: [],
644
+ renderBaseCanvas: null
645
+ }));
646
+ }
647
+
648
+ // rgba8 ('composite') — the provider returns the §1a.4 render-domain bag (pixels + the
649
+ // fields it alone is the authority for). The instrument normalizes ONCE through
650
+ // the vocabulary owner: `pixels` becomes the stage-agnostic `data` payload and
651
+ // the dispatcher's provenance is merged in. Providers never import the shim — a
652
+ // BYO renderer's whole capture burden stays ONE method. The bag was read in the
653
+ // stamp's task above.
654
+ var bag = joined[1];
655
+ if (!bag) {
656
+ throw new Error('[FrqtlCapture.capture] the provider\'s captureStill() ' +
657
+ 'resolved with nothing. (FAIL-LOUD — never a null still.)');
658
+ }
659
+ _assertComposited(stage, tick, bag);
660
+ return RS.normalizeStill(Object.assign({}, base, {
661
+ data: bag.pixels,
662
+ width: bag.width,
663
+ height: bag.height,
664
+ cssWidth: bag.cssWidth,
665
+ cssHeight: bag.cssHeight,
666
+ filtersApplied: bag.filtersApplied,
667
+ renderBaseCanvas: bag.renderBaseCanvas,
668
+ filterTime: bag.filterTime
669
+ }));
670
+ });
671
+ };
672
+
673
+ /**
674
+ * captureStill() — the LAST-STAGE CONVENIENCE. Capturing the canvas is capturing the
675
+ * fully composed stack of transforms (CEO-ratified 2026-07-29).
676
+ *
677
+ * PURE DELEGATION: it contains no capture logic, no provider call and no provenance
678
+ * stamping of its own — it resolves the final stage name and calls the mechanism. The
679
+ * `setPreset`-over-`setOrbit` shape (a full framing reset that still goes through the
680
+ * one primitive). A convenience that duplicated its own primitive would be the second
681
+ * path that later disagrees, so the conformance gate asserts `captureStill()` and
682
+ * `capture(<last stage>)` return identically-shaped, identically-provenanced bags.
683
+ *
684
+ * The final stage is DERIVED from the exported table, never a hardcoded 'composite'
685
+ * literal: if the pipeline ever grows a later stage, this convenience follows it by
686
+ * construction (truth-in-labeling at the API level — an API that says "still of the
687
+ * whole stack" must not name a stage that has stopped being last). The returned bag
688
+ * carries FULL provenance, including `composedOf` (the ordered upstream stages) and
689
+ * `renderOptions` — "final" alone would be a label, not a statement of what produced
690
+ * the picture.
691
+ *
692
+ * @returns {Promise<object>} see `capture(stage)`.
693
+ */
694
+ CaptureInstrument.prototype.captureStill = function () {
695
+ return this.capture(RenderStages.finalStage());
696
+ };
697
+
698
+ // ── Export ────────────────────────────────────────────────────────────────
699
+
700
+ /**
701
+ * create(loop, providerGetter, identity, renderOptionsGetter) → the instrument.
702
+ * The ONE construction path (a bare `new CaptureInstrument` is not exported).
703
+ */
704
+ function create(loop, providerGetter, identity, renderOptionsGetter) {
705
+ return new CaptureInstrument(loop, providerGetter, identity, renderOptionsGetter);
706
+ }
707
+
708
+ var FrqtlCapture = { create: create };
709
+
710
+ export { FrqtlCapture };
711
+