@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.2

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 (218) hide show
  1. package/CLAUDE.md +87 -0
  2. package/LICENSE.md +664 -0
  3. package/README.md +84 -2
  4. package/dist/dist-manifest.json +218 -0
  5. package/dist/eyes/index.js +109 -0
  6. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  7. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  8. package/dist/fat/pkg-node/package.json +12 -0
  9. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  10. package/dist/frqtl/compat/boot-node.js +34 -0
  11. package/dist/frqtl/compat/engine.js +127 -0
  12. package/dist/frqtl/compat/package-root.js +24 -0
  13. package/dist/frqtl/compat/providers/wasm-engine.js +2499 -0
  14. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  15. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  16. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  17. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  18. package/dist/frqtl/compat/renderer/RenderFamily.js +84 -0
  19. package/dist/frqtl/compat/renderer/RenderMesh.js +99 -0
  20. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  21. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  22. package/dist/frqtl/compat/renderer/captureStill-common.js +258 -0
  23. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  24. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  25. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  26. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  27. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  28. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  30. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  31. package/dist/frqtl/compat/renderer/providers/three-provider.js +2470 -0
  32. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  33. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  34. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  35. package/dist/frqtl/compat/vendor.js +196 -0
  36. package/dist/frqtl/node-entry.js +17 -0
  37. package/dist/frqtl/objects/FrqtlApp.js +2920 -0
  38. package/dist/frqtl/objects/ObjectFactory.js +296 -0
  39. package/dist/frqtl/objects/RecipeContext.js +322 -0
  40. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  41. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  42. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  43. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  44. package/dist/frqtl/objects/instruments/capture.js +710 -0
  45. package/dist/frqtl/objects/instruments/hosted-eyes.js +984 -0
  46. package/dist/frqtl/objects/kinds-entry.js +14 -0
  47. package/dist/frqtl/objects/kinds.js +190 -0
  48. package/dist/frqtl/objects/objects/Box.js +273 -0
  49. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  50. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  51. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  52. package/dist/frqtl/objects/objects/DirectedTunnel.js +411 -0
  53. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  54. package/dist/frqtl/objects/objects/LightSource.js +351 -0
  55. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  56. package/dist/frqtl/objects/objects/Tube.js +471 -0
  57. package/dist/frqtl/objects/objects/absorbRadiation.js +309 -0
  58. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  59. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  60. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  61. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  62. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  66. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  67. package/dist/frqtl/objects/objects/makeControlsForObject.js +820 -0
  68. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  69. package/dist/frqtl/objects/objects/slitLinerScatter.js +261 -0
  70. package/dist/frqtl/objects/objects/sustainedSource.js +298 -0
  71. package/dist/frqtl/objects/objects/tagger.js +228 -0
  72. package/dist/frqtl/objects/schema/engine-scopes.js +468 -0
  73. package/dist/frqtl/objects/schema/index.js +256 -0
  74. package/dist/frqtl/objects/schema/scopes.js +457 -0
  75. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  76. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  77. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  78. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  79. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  80. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  81. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  82. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  83. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +250 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit.js +326 -0
  85. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  86. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  87. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  88. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  89. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  90. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  91. package/dist/frqtl/objects/setup/setupLightSourceLab.js +277 -0
  92. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  94. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  95. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1258 -0
  96. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  97. package/dist/frqtl/objects/setup/setupTunnelLab.js +240 -0
  98. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  99. package/dist/frqtl/objects/setup/setupWaveInterference.js +177 -0
  100. package/dist/frqtl/objects/v1-replacements.js +29 -0
  101. package/dist/frqtl/package-core.js +23 -0
  102. package/dist/frqtl/parts/CameraControls.js +643 -0
  103. package/dist/frqtl/parts/CameraPath.js +575 -0
  104. package/dist/frqtl/parts/CollisionActions.js +34 -0
  105. package/dist/frqtl/parts/CollisionTypes.js +30 -0
  106. package/dist/frqtl/parts/DetectorWall.js +496 -0
  107. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  108. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  109. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  110. package/dist/frqtl/parts/GraphingDetectorWall.js +489 -0
  111. package/dist/frqtl/parts/LargeObject.js +405 -0
  112. package/dist/frqtl/parts/LightColor.js +255 -0
  113. package/dist/frqtl/parts/LiveChart.js +285 -0
  114. package/dist/frqtl/parts/LogicGate.js +717 -0
  115. package/dist/frqtl/parts/OvenBox.js +611 -0
  116. package/dist/frqtl/parts/PointSource.js +762 -0
  117. package/dist/frqtl/parts/Rectangle.js +200 -0
  118. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  119. package/dist/frqtl/parts/SlitScreen.js +628 -0
  120. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  121. package/dist/frqtl/parts/Trace.js +508 -0
  122. package/dist/frqtl/parts/Tunnel.js +1057 -0
  123. package/dist/frqtl/parts/Wall.js +1211 -0
  124. package/dist/frqtl/parts/index.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  126. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  127. package/dist/frqtl/scenes/atomic-model.js +88 -0
  128. package/dist/frqtl/scenes/big-bang.js +46 -0
  129. package/dist/frqtl/scenes/blackbody.js +81 -0
  130. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  131. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  132. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  133. package/dist/frqtl/scenes/counting-wall.js +38 -0
  134. package/dist/frqtl/scenes/double-slit.js +31 -0
  135. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  136. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  137. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  138. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +243 -0
  139. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  140. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  141. package/dist/frqtl/scenes/empty.js +14 -0
  142. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  143. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  144. package/dist/frqtl/scenes/mirror-route.js +140 -0
  145. package/dist/frqtl/scenes/mirror.js +38 -0
  146. package/dist/frqtl/scenes/omni-3d.js +27 -0
  147. package/dist/frqtl/scenes/omni-source.js +22 -0
  148. package/dist/frqtl/scenes/self-interference.js +39 -0
  149. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  150. package/dist/frqtl/scenes/straight-walk.js +22 -0
  151. package/dist/frqtl/scenes/sun.js +39 -0
  152. package/dist/frqtl/scenes/two-source.js +37 -0
  153. package/dist/frqtl/scenes/wave.js +26 -0
  154. package/dist/shim/bundle/frqnb.js +112 -0
  155. package/dist/shim/bundle/index.js +121 -0
  156. package/dist/shim/bundle/indexHtml.js +107 -0
  157. package/dist/shim/bundle/manifest.js +95 -0
  158. package/dist/shim/bundle/zip.js +243 -0
  159. package/dist/shim/bus.js +67 -0
  160. package/dist/shim/controls/index.js +627 -0
  161. package/dist/shim/controls/registry.js +274 -0
  162. package/dist/shim/engine/artifact-shell.js +211 -0
  163. package/dist/shim/engine/boot-phases.js +44 -0
  164. package/dist/shim/engine/engine-stack-boot.js +59 -0
  165. package/dist/shim/engine/router.js +690 -0
  166. package/dist/shim/experiment-frame/index.js +296 -0
  167. package/dist/shim/expr/ast-schema.js +369 -0
  168. package/dist/shim/field-descriptor/index.js +325 -0
  169. package/dist/shim/frame-data/index.js +224 -0
  170. package/dist/shim/index.js +67 -0
  171. package/dist/shim/notebook/canvas-guard.js +82 -0
  172. package/dist/shim/notebook/catalog.js +35 -0
  173. package/dist/shim/notebook/format.js +736 -0
  174. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  175. package/dist/shim/notebook/kernel.js +671 -0
  176. package/dist/shim/notebook/knob.js +150 -0
  177. package/dist/shim/notebook/module-resolver.js +260 -0
  178. package/dist/shim/notebook/notebook-knobs.js +65 -0
  179. package/dist/shim/notebook/produced-bindings.js +67 -0
  180. package/dist/shim/notebook/realm-registry.js +121 -0
  181. package/dist/shim/notebook/realm-transport.js +73 -0
  182. package/dist/shim/notebook/setup-builder.js +507 -0
  183. package/dist/shim/notebook/store.js +159 -0
  184. package/dist/shim/notebook/vendor-importmap.js +134 -0
  185. package/dist/shim/notebook/view-builder.js +189 -0
  186. package/dist/shim/notebook/view-ctx.js +100 -0
  187. package/dist/shim/notebook/view-handle.js +202 -0
  188. package/dist/shim/notebook/warmup.js +102 -0
  189. package/dist/shim/package.json +4 -0
  190. package/dist/shim/page-params/index.js +213 -0
  191. package/dist/shim/project/identity.js +112 -0
  192. package/dist/shim/project/index.js +19 -0
  193. package/dist/shim/project/link.js +402 -0
  194. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  195. package/dist/shim/project/register-node.mjs +17 -0
  196. package/dist/shim/project/specifiers.js +106 -0
  197. package/dist/shim/registry/copy-gate.js +110 -0
  198. package/dist/shim/registry/exhibits.js +455 -0
  199. package/dist/shim/registry/experiments.js +215 -0
  200. package/dist/shim/registry/index.js +19 -0
  201. package/dist/shim/registry/scene-source.js +157 -0
  202. package/dist/shim/registry/walkthrough.js +804 -0
  203. package/dist/shim/render/capture-envelope.js +279 -0
  204. package/dist/shim/render/heat-colormap.js +92 -0
  205. package/dist/shim/render/render-stages.js +855 -0
  206. package/dist/shim/render/sun-look.js +33 -0
  207. package/dist/shim/render-state/apply.js +262 -0
  208. package/dist/shim/render-state/index.js +74 -0
  209. package/dist/shim/rpc/index.js +207 -0
  210. package/dist/shim/runtime/index.js +1023 -0
  211. package/dist/shim/storage/boot.js +69 -0
  212. package/dist/shim/storage/index.js +169 -0
  213. package/dist/shim/storage/local-storage-provider.js +230 -0
  214. package/dist/shim/storage/make-namespace-store.js +132 -0
  215. package/dist/shim/storage/modules.js +249 -0
  216. package/dist/shim/storage/projects.js +150 -0
  217. package/llms.txt +12 -0
  218. package/package.json +63 -4
@@ -0,0 +1,818 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * FrqtlVision.js — the reusable "video camera that takes stills" sugar object, and the
5
+ * consumer-facing surface of the normalized capture format.
6
+ *
7
+ * Peer of `CameraControls.js` in this directory, and deliberately DISJOINT from it:
8
+ * `CameraControls` drives the VIEWPOINT (`runner.cameraControl` — where the camera looks);
9
+ * `FrqtlVision` reads the PICTURE (`runner.capture` / `runner.captureStill` — what came out).
10
+ * Framing a still is therefore composition, not a parameter: point the camera with
11
+ * `CameraControls`/`cameraControl.setOrbit`, then capture what the canvas shows. A
12
+ * `getStill({azimuth…})` would be a second write path into camera state.
13
+ *
14
+ * ── Vocabulary: the noun is STILL, never `frame` ─────────────────────────────
15
+ * `Frame` is the engine's core noun (the Frame Axiom — every body, every quantum is a
16
+ * Frame; `FrameData`, `makeLeafFrame`, `setFrameProperty`). On this surface a **STILL** is
17
+ * one tap result, a **STREAM** is a tick-ordered sequence of stills, and a **VIDEO** is an
18
+ * encoded stream. There is no `getFrame`, no `frames[]`, no `onFrame`, no `frameRate`
19
+ * anywhere here. The video metaphor survives in prose ("a still is a single frame *of that
20
+ * video*") but never in an identifier — in this codebase a stream-of-frames API would read
21
+ * as a stream of physics `Frame` objects, and the `tick` stage makes that a genuine
22
+ * ambiguity rather than a pedantic one: `getStill('tick').data` literally IS `FrameData[]`.
23
+ *
24
+ * ── Layer 1: API contract ────────────────────────────────────────────────────
25
+ *
26
+ * `FrqtlVision.attach(runner) → handle`
27
+ *
28
+ * @param {object} runner — an FrqtlApp. The coupling: `runner.capture()`,
29
+ * `runner.captureStill()`, `runner.sceneRecord()` and the shim-owned stage table; the
30
+ * stream and the recorder additionally use the per-tick listener fan-out
31
+ * (`addTickListener`) and, for a raster stage, the capture hold (`suspendTicks`,
32
+ * `repaint`, `resumeTicks`); `record` reads `runner.captureHost()` where the runner has
33
+ * one (a node-side capture instrument), and forwards to that host. No renderer-sniffing,
34
+ * no engine, no per-page coupling — this
35
+ * object knows nothing about which experiment mounts it.
36
+ * @returns {{
37
+ * getScene():Promise<object>, // the run-invariant SceneRecord + its content id
38
+ * getStill(stage?):Promise<object>, // stage omitted → the FULL composed stack
39
+ * getStream({stage?, everyTicks}):AsyncIterable<object>, // stills at a TICK cadence
40
+ * record({everyTicks, stopAt, maxFrames, fps, stage?, bitrate?, keyFrameInterval?, signal?})
41
+ * :Promise<object>, // an encoded video: CaptureEnvelope.videoEnvelope
42
+ * stages:string[], // the FERRIED RenderStages.RENDER_STAGES list
43
+ * finalStage():string, // the ferried final-stage derivation
44
+ * toDataURL(still):string, // the BROWSER PNG encoder (data URL)
45
+ * toPNG(still):Buffer // the NODE PNG encoder (bytes)
46
+ * }}
47
+ *
48
+ * ── TWO HOSTS, one contract ──────────────────────────────────────────────────
49
+ *
50
+ * This object runs in a browser and in Node, and the PIXEL ENCODER differs by host — so each
51
+ * one throws naming the other rather than guessing: `toDataURL` is the browser encoder (a
52
+ * scratch canvas) and refuses under Node naming `toPNG`; `toPNG` is the Node encoder (zlib +
53
+ * Buffer) and refuses in a browser naming `toDataURL`. Same picture, same record, the encoder
54
+ * for THIS host.
55
+ *
56
+ * That redirect is only honest if `attach()` is REACHABLE in both hosts: the stage vocabulary
57
+ * is an import (lib/shim/render/render-stages.js) in both. Pinned end to end by
58
+ * `objects/test/frqtl-vision-node-host.test.mjs`, which seeds NO global.
59
+ *
60
+ * ── The normalized format: ONE scene record, MANY light stills ────────────────
61
+ *
62
+ * 3D scenes are high bandwidth, so the format does not repeat the scene on every capture.
63
+ * `getScene()` returns the RUN-INVARIANT regeneration input — `seed`, `sourceRef`, `build`,
64
+ * `physicsW/H`, `projection` — IMMUTABLE and CONTENT-ADDRESSED (`scene = sha256(...)`).
65
+ * `getStill()` returns COORDINATES WITHIN that scene: which tick, which stage, which pose,
66
+ * plus the payload, carrying `scene: <hash>` rather than the embedded record. A corpus
67
+ * stores one scene plus N tiny stills, and every still is verifiable against (and
68
+ * regenerable from) its scene. A self-contained "fat" bag is a JOIN a consumer can perform —
69
+ * never a second stored format.
70
+ *
71
+ * The split is drawn by MUTABILITY, not by category. `filtersApplied`, `pose`,
72
+ * `cssWidth`/`cssHeight` and `renderOptions` all LOOK scene-level and are not — each has a
73
+ * live mid-run mutation surface, and hoisting one would mislabel every subsequent still. The
74
+ * `RenderStages.RUNTIME_MUTATION_SURFACES` table declares that line and a conformance gate
75
+ * enforces it, so a future hoist fails CI instead of rotting a corpus at training time.
76
+ *
77
+ * `getStill(stage)` FORWARDS — it adds nothing:
78
+ * - `getStill()` → `runner.captureStill()` (the last-stage convenience)
79
+ * - `getStill(stage)` → `runner.capture(stage)` (the generic mechanism)
80
+ * - `getScene()` → `runner.sceneRecord()`
81
+ * All dispatch, all throw rules and ALL provenance stamping live in the runner and nowhere
82
+ * else. This object holds no state at all.
83
+ *
84
+ * ── STREAM and VIDEO: the tick IS the frame ─────────────────────────────────
85
+ *
86
+ * `getStream({stage, everyTicks})` is this same tap subscribed across ticks through the
87
+ * runner's per-tick listener fan-out, yielding the SAME canonical still bags in tick order —
88
+ * no envelope, no new noun, no sequence number beside `tick` (a second ordering key would be a
89
+ * two-clocks bug). The cadence is in TICKS; there is no wall-clock rate anywhere on this face.
90
+ * A stream spans ONE scene at ONE size: a still whose `scene` or dimensions differ from the
91
+ * first ENDS the stream with an error rather than yielding a sequence no encoder can hold.
92
+ *
93
+ * A sampled RASTER tick is rendered before it is read because the stream ASKS for it: in the
94
+ * listener it raises the capture HOLD — `suspendTicks()` → `repaint()` → capture →
95
+ * `resumeTicks()` when the capture settles. The listener fires BEFORE the loop draws its
96
+ * tick, a batching loop composites only a batch's last tick, and an fps cap skips draws; the
97
+ * hold stops the clock and forces the draw, and the tap's provenance guard proves it did.
98
+ * The stream never OWNS the clock: a paused runner yields nothing, and a runner driven by
99
+ * `step()` waits for each hold to release. Numeric stages (`tick`, `field`) read the
100
+ * retained TickData directly and need no hold — selected by FORMAT off the stage table.
101
+ *
102
+ * `record(...)` is `getStream`'s sampling plus an encoder, and nothing records anything
103
+ * else: every encoded picture IS a provenance-stamped still, so its manifest row `{i, tick,
104
+ * pose}` is DERIVED from the still. The encoder is mediabunny (vendored unmodified with its
105
+ * licence and pin, `assets/vendor/mediabunny.*`), loaded LAZILY through the page's router on
106
+ * the first `record()` — a page that never records never fetches it. It runs where WebCodecs
107
+ * exists, i.e. in a page; under node the hosted eyes run this SAME `record` inside their page
108
+ * (`eyes.vision.record`), and a node process with no host is refused naming that door.
109
+ *
110
+ * ── Why the PNG is DERIVED here and never in a provider ──────────────────────
111
+ *
112
+ * The pixel array IS the still; a PNG is a lossless ENCODING of it. pixels → PNG is
113
+ * synchronous and lossless, so it is written ONCE, here, and no provider (nor any BYO
114
+ * renderer) ever ships image-encoding code. This is the sanctioned EDGE for encoders: every
115
+ * encoding accretes HERE over records — never below this layer, and never as a format
116
+ * parameter on the tap (that would be format negotiation inside a render capability). The
117
+ * video encoder is the heavyweight one, so it is resolved lazily by `record()` rather than
118
+ * loaded as a script by every page.
119
+ *
120
+ * ── ONE ENCODER PER HOST, both on this ONE face ──────────────────────────────
121
+ *
122
+ * "Get a picture from a script" is the most natural thing to want from this surface, and
123
+ * until V4 the package shipped no node way to do it: `toDataURL` needs a DOM scratch canvas,
124
+ * which is exactly the environment the HOSTED eyes do NOT have. Routing node pixels back
125
+ * INTO a browser to encode them (the earlier design) is ceremony without a compensating
126
+ * invariant when zlib is stdlib — so there are two encoders, one per host environment, and
127
+ * BOTH are members of this face:
128
+ *
129
+ * `toDataURL(still)` → `data:image/png;base64,…` the BROWSER encoder (scratch canvas)
130
+ * `toPNG(still)` → PNG `Buffer` the NODE encoder (zlib, no dependency)
131
+ *
132
+ * Each FAILS LOUD in the wrong host NAMING THE RIGHT ONE — the correct next line in the
133
+ * error message, which is the whole P5-test discipline. What keeps the two honest is a
134
+ * conformance row that encodes ONE known bag through BOTH and asserts the decoded pixels are
135
+ * identical: "two implementations, one derivation" is a contract only when a test says so.
136
+ * Row order and colour space come from the record contract, not a guess: rgba8 is row-major
137
+ * from the TOP-LEFT origin, sRGB, NON-premultiplied — ImageData's own convention, and
138
+ * exactly PNG colour type 6. Both encoders are therefore straight copies with no
139
+ * re-orientation and no alpha correction.
140
+ *
141
+ * `eyes.saveStill(still, path)` (the hosted handle) is the write convenience over `toPNG`.
142
+ *
143
+ * The numbers stay first class: a consumer folding a radial profile, a histogram or a corpus
144
+ * row reads `still.data` directly and never goes near a PNG. Analysis lives in the consumer —
145
+ * an analysis method on this handle is how one primitive quietly becomes an instrument
146
+ * registry. Pixel→universe mapping is likewise a separate published authority
147
+ * (`compat/renderer/unprojectStill.js`), not a method here.
148
+ *
149
+ * ── FAIL-LOUD ────────────────────────────────────────────────────────────────
150
+ *
151
+ * `toDataURL`/`toPNG` THROW on a record whose `format` is not `'rgba8'` (a `framedata` or `r8`
152
+ * field record has no image encoding — a plausible-looking blank PNG would be worse than an
153
+ * error), and each throws in the wrong host naming the other. `attach` throws when handed
154
+ * something that is not a capture-capable runner. Never a null image, never a silent empty
155
+ * record.
156
+ *
157
+ * ── Layer 2: physics behaviour ───────────────────────────────────────────────
158
+ * ZERO physics. This is the output boundary reading its own output — no frame, dimension,
159
+ * tick or op-program is touched, so no canonical-bench golden can move because of it. Its
160
+ * product is stills, goldens, guide captures and corpus rows.
161
+ *
162
+ * Usage:
163
+ * var vision = FrqtlVision.attach(runner);
164
+ * var scene = await vision.getScene(); // store ONCE
165
+ * var still = await vision.getStill(); // the whole composed stack
166
+ * var field = await vision.getStill('field'); // the render-free numeric channel
167
+ *
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
+ *
171
+ * // Aligned multi-channel corpus row: capture BETWEEN ticks and assert equal tick stamps —
172
+ * // the tick equality IS the co-registration guard; `scene` ties them to one header.
173
+ * runner.stop();
174
+ * var rows = await Promise.all(vision.stages
175
+ * .filter(function (s) { return s !== 'scene'; }) // no 'scene' executor in v1
176
+ * .map(function (s) { return vision.getStill(s); }));
177
+ */
178
+
179
+ /** The rgba8 format tag, read from the owner so this file carries no format literal. */
180
+
181
+ import * as RenderStages from '../../shim/render/render-stages.js';
182
+ import * as CaptureEnvelope from '../../shim/render/capture-envelope.js';
183
+ import { CameraPath } from './CameraPath.js';
184
+ import { FrqtlCaptureHost } from '../objects/instruments/capture-host.js';
185
+ import { vendorUrl } from '../compat/vendor.js';
186
+
187
+ function _rgba8Format(RS) { return RS.STAGE_FORMATS[RS.finalStage()]; }
188
+
189
+ function _assertImageable(still, RS, who) {
190
+ if (!still || typeof still !== 'object') {
191
+ throw new Error('[FrqtlVision.' + who + '] expected a still record, got ' + String(still) + '.');
192
+ }
193
+ var want = _rgba8Format(RS);
194
+ if (still.format !== want) {
195
+ throw new Error('[FrqtlVision.' + who + '] this record\'s format is ' +
196
+ JSON.stringify(still.format) + ', not ' + JSON.stringify(want) + ' — stage ' +
197
+ JSON.stringify(still.stage) + ' carries NUMBERS (read still.data), not an image. ' +
198
+ 'Encoding it as a PNG would produce a plausible-looking picture of nothing. (FAIL-LOUD.)');
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The BROWSER encoder: an rgba8 still record → a PNG data URL.
204
+ *
205
+ * FAILS LOUD with no DOM naming `toPNG` — the node counterpart on this same face. An error
206
+ * that only says "no DOM" leaves the reader to guess; an error that names the next line is
207
+ * the whole point (a crash is recoverable curriculum, a silent wrong outcome is not).
208
+ *
209
+ * @param {object} still — a `format:'rgba8'` record from `getStill()`
210
+ * @returns {string} `data:image/png;base64,…`
211
+ */
212
+ function toDataURL(still) {
213
+ var RS = RenderStages;
214
+ _assertImageable(still, RS, 'toDataURL');
215
+ if (typeof document === 'undefined') {
216
+ throw new Error('[FrqtlVision.toDataURL] no DOM: a data URL is encoded through a ' +
217
+ 'scratch canvas, so this is the BROWSER encoder. In node call ' +
218
+ 'FrqtlVision.toPNG(still) for PNG bytes (or eyes.saveStill(still, path) to write ' +
219
+ 'them straight to a file) — same picture, same record, the encoder for THIS host. ' +
220
+ '(FAIL-LOUD.)');
221
+ }
222
+ var scratch = document.createElement('canvas');
223
+ scratch.width = still.width;
224
+ scratch.height = still.height;
225
+ var ctx = scratch.getContext('2d');
226
+ if (!ctx || typeof ctx.putImageData !== 'function') {
227
+ throw new Error('[FrqtlVision.toDataURL] the scratch canvas has no 2D context. (FAIL-LOUD.)');
228
+ }
229
+ // The record's rows are TOP-LEFT origin, sRGB and NON-premultiplied by contract (a GL
230
+ // provider already flipped) — exactly ImageData's own convention, so this is a straight
231
+ // copy with no re-orientation and no alpha correction.
232
+ var img = ctx.createImageData(still.width, still.height);
233
+ img.data.set(still.data);
234
+ ctx.putImageData(img, 0, 0);
235
+ return scratch.toDataURL('image/png');
236
+ }
237
+
238
+ /**
239
+ * The NODE encoder: an rgba8 still record → PNG bytes.
240
+ *
241
+ * The counterpart of `toDataURL`, for the host that has no scratch canvas — which is exactly
242
+ * where the hosted eyes run. PNG here is one filter byte per row plus `zlib.deflate`, so it
243
+ * needs no dependency at all; zlib is node's built-in, reached through
244
+ * `process.getBuiltinModule` when this encoder is called, so a browser that imports this module
245
+ * never touches it.
246
+ *
247
+ * @param {object} still — a `format:'rgba8'` record from `getStill()`
248
+ * @returns {Buffer} PNG bytes
249
+ */
250
+ function toPNG(still) {
251
+ var RS = RenderStages;
252
+ _assertImageable(still, RS, 'toPNG');
253
+ var zlib = (typeof process !== 'undefined' && process.getBuiltinModule) ? process.getBuiltinModule('node:zlib') : null;
254
+ if (!zlib || typeof Buffer === 'undefined') {
255
+ throw new Error('[FrqtlVision.toPNG] this is the NODE encoder (zlib + Buffer) and ' +
256
+ 'neither is available here. In a browser call FrqtlVision.toDataURL(still) — same ' +
257
+ 'picture, same record, the encoder for THIS host. (FAIL-LOUD.)');
258
+ }
259
+ var w = still.width, h = still.height;
260
+ var px = Buffer.from(still.data.buffer, still.data.byteOffset, still.data.length);
261
+ var stride = w * 4 + 1;
262
+ var raw = Buffer.alloc(h * stride);
263
+ for (var y = 0; y < h; y++) {
264
+ raw[y * stride] = 0; // filter type 0 (None) per row
265
+ px.copy(raw, y * stride + 1, y * w * 4, (y + 1) * w * 4);
266
+ }
267
+ // Node's zlib gained crc32 only in v20.15; a local one keeps this version-independent.
268
+ function crc32(b) {
269
+ var c, crc = 0xffffffff;
270
+ for (var n = 0; n < b.length; n++) {
271
+ c = (crc ^ b[n]) & 0xff;
272
+ for (var k = 0; k < 8; k++) { c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); }
273
+ crc = (crc >>> 8) ^ c;
274
+ }
275
+ return (crc ^ 0xffffffff) >>> 0;
276
+ }
277
+ function chunk(type, data) {
278
+ var len = Buffer.alloc(4); len.writeUInt32BE(data.length);
279
+ var body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
280
+ var crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body));
281
+ return Buffer.concat([len, body, crc]);
282
+ }
283
+ var ihdr = Buffer.alloc(13);
284
+ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
285
+ ihdr[8] = 8; ihdr[9] = 6; // 8-bit, colour type 6 (RGBA)
286
+ return Buffer.concat([
287
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
288
+ chunk('IHDR', ihdr),
289
+ chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
290
+ chunk('IEND', Buffer.alloc(0))
291
+ ]);
292
+ }
293
+
294
+ // ── STREAM + VIDEO ────────────────────────────────────────────────────────────
295
+
296
+ /** Named FAIL-LOUD for this face. @private */
297
+ function _fail(who, msg) {
298
+ throw new Error('[FrqtlVision.' + who + '] ' + msg + ' (FAIL-LOUD.)');
299
+ }
300
+
301
+ /** @private */
302
+ function _requirePosInt(value, name, who) {
303
+ if (typeof value !== 'number' || !isFinite(value) || value <= 0 || value % 1 !== 0) {
304
+ _fail(who, name + ' must be a positive whole number, got ' + String(value));
305
+ }
306
+ return value;
307
+ }
308
+
309
+ /** Refuse any option this call does not declare — a typo'd knob is never a silent no-op. @private */
310
+ function _assertKnownKeys(bag, allowed, where, who) {
311
+ var keys = Object.keys(bag);
312
+ for (var i = 0; i < keys.length; i++) {
313
+ if (allowed.indexOf(keys[i]) === -1) {
314
+ _fail(who, where + ' has no option ' + JSON.stringify(keys[i]) + ' — declared: ' +
315
+ allowed.join(', '));
316
+ }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * THE ONE SAMPLING MECHANISM behind `getStream` and `record`.
322
+ *
323
+ * Subscribes to the runner's tick fan-out and samples the FIRST tick after subscription, then
324
+ * every `everyTicks`-th. A sampled raster tick is HELD (suspend → repaint → capture → resume
325
+ * when `cfg.onStill`'s promise settles, so a stop decision taken there lands before another
326
+ * tick can run). `cfg.admit(index)` is asked BEFORE each capture and may refuse it (a bound
327
+ * reached). Every capture promise goes to `cfg.onStill(promise, index)` with the one-scene /
328
+ * one-size continuity check already applied.
329
+ *
330
+ * @returns {{stop:function}} idempotent; releases nothing it does not hold (holds release
331
+ * when their own capture settles).
332
+ * @private
333
+ */
334
+ function _sample(runner, RS, cfg) {
335
+ var who = cfg.who;
336
+ if (typeof runner.addTickListener !== 'function') {
337
+ _fail(who, 'needs the runner\'s per-tick listener fan-out (runner.addTickListener) — the ' +
338
+ 'cadence is counted in TICKS, and nothing else can count them. A node-side capture ' +
339
+ 'instrument has no clock of its own: record inside the page that runs the world ' +
340
+ '(eyes.vision.record from @descix/frqtl-node/eyes)');
341
+ }
342
+ var raster = RS.STAGE_FORMATS[cfg.stage] === _rgba8Format(RS);
343
+ if (raster && (typeof runner.suspendTicks !== 'function' || typeof runner.resumeTicks !== 'function' ||
344
+ typeof runner.repaint !== 'function')) {
345
+ _fail(who, 'a ' + JSON.stringify(cfg.stage) + ' sample needs the capture hold ' +
346
+ '(runner.suspendTicks/repaint/resumeTicks): a capture from the tick listener must see ' +
347
+ 'its tick COMPOSITED before the readback, and on a batching or fps-capped loop only a ' +
348
+ 'held clock plus a forced draw guarantees that');
349
+ }
350
+ var calls = 0;
351
+ var index = 0;
352
+ var first = null;
353
+ var stopped = false;
354
+ var unsubscribe = null;
355
+
356
+ function continuity(still) {
357
+ if (first === null) { first = { scene: still.scene, width: still.width, height: still.height }; return still; }
358
+ if (still.scene !== first.scene) {
359
+ throw new Error('[FrqtlVision.' + who + '] the scene changed mid-sequence (' +
360
+ JSON.stringify(first.scene) + ' → ' + JSON.stringify(still.scene) + ' at tick ' +
361
+ still.tick + '): a sequence spans exactly ONE scene record. Start a new one. (FAIL-LOUD.)');
362
+ }
363
+ if (still.width !== first.width || still.height !== first.height) {
364
+ throw new Error('[FrqtlVision.' + who + '] the ' + still.stage + ' size changed mid-sequence (' +
365
+ first.width + 'x' + first.height + ' → ' + still.width + 'x' + still.height + ' at tick ' +
366
+ still.tick + '): a sequence has constant dimensions (an encoder cannot hold a resize). ' +
367
+ 'Start a new one. (FAIL-LOUD.)');
368
+ }
369
+ return still;
370
+ }
371
+
372
+ function onTick() {
373
+ if (stopped) { return; }
374
+ calls += 1;
375
+ if ((calls - 1) % cfg.everyTicks !== 0) { return; }
376
+ if (!cfg.admit(index)) { return; }
377
+ var i = index;
378
+ index += 1;
379
+ var capture;
380
+ if (raster) {
381
+ runner.suspendTicks();
382
+ try { runner.repaint(); }
383
+ catch (e) { capture = Promise.reject(e); }
384
+ }
385
+ if (!capture) {
386
+ try { capture = Promise.resolve(runner.capture(cfg.stage)); }
387
+ catch (e) { capture = Promise.reject(e); }
388
+ }
389
+ var handled = Promise.resolve(cfg.onStill(capture.then(continuity), i));
390
+ if (raster) {
391
+ var release = function () { runner.resumeTicks(); };
392
+ handled.then(release, release);
393
+ }
394
+ }
395
+
396
+ unsubscribe = runner.addTickListener(onTick);
397
+ return {
398
+ stop: function () {
399
+ if (stopped) { return; }
400
+ stopped = true;
401
+ if (unsubscribe) { unsubscribe(); unsubscribe = null; }
402
+ }
403
+ };
404
+ }
405
+
406
+ /**
407
+ * getStream({stage?, everyTicks}) — the tap subscribed across ticks.
408
+ *
409
+ * @param {object} runner
410
+ * @param {object} RS
411
+ * @param {{stage?:string, everyTicks:number}} opts — `stage` defaults to the final stage.
412
+ * @returns {AsyncIterable<object>} canonical still bags in tick order. Iteration SUBSCRIBES;
413
+ * `break`/`return()` unsubscribes. A capture failure or a continuity break rejects `next()`
414
+ * and ends the stream. The queue is UNBOUNDED: stills sampled faster than the consumer pulls
415
+ * are held in memory — `record`'s `maxFrames` is the bound when one is needed.
416
+ * @private
417
+ */
418
+ function _getStream(runner, RS, opts) {
419
+ var who = 'getStream';
420
+ if (!opts || typeof opts !== 'object') { _fail(who, 'expected {stage?, everyTicks}, got ' + String(opts)); }
421
+ _assertKnownKeys(opts, ['stage', 'everyTicks'], 'getStream', who);
422
+ var everyTicks = _requirePosInt(opts.everyTicks, 'everyTicks', who);
423
+ var stage = (opts.stage === undefined) ? RS.finalStage() : RS.assertStage(opts.stage);
424
+
425
+ var iterable = {};
426
+ iterable[Symbol.asyncIterator] = function () {
427
+ var queue = []; // capture promises, in index order
428
+ var waiter = null; // a pending next() with nothing queued
429
+ var ended = false;
430
+ var session = _sample(runner, RS, {
431
+ who: who, stage: stage, everyTicks: everyTicks,
432
+ admit: function () { return !ended; },
433
+ onStill: function (p) {
434
+ // Handled here so a capture failing while nobody is pulling is never an
435
+ // unhandled rejection; the failure is delivered by next().
436
+ p.then(null, function () {});
437
+ queue.push(p);
438
+ if (waiter) { var w = waiter; waiter = null; w(); }
439
+ return p;
440
+ }
441
+ });
442
+ function end() { ended = true; session.stop(); queue.length = 0; }
443
+ return {
444
+ next: function () {
445
+ if (queue.length) {
446
+ return queue.shift().then(function (still) { return { value: still, done: false }; },
447
+ function (e) { end(); throw e; });
448
+ }
449
+ if (ended) { return Promise.resolve({ value: undefined, done: true }); }
450
+ var self = this;
451
+ return new Promise(function (resolve) { waiter = resolve; }).then(function () { return self.next(); });
452
+ },
453
+ return: function () {
454
+ end();
455
+ if (waiter) { var w = waiter; waiter = null; w(); }
456
+ return Promise.resolve({ value: undefined, done: true });
457
+ }
458
+ };
459
+ };
460
+ return iterable;
461
+ }
462
+
463
+ /** The recorder's declared options. @private */
464
+ var RECORD_OPTIONS = ['everyTicks', 'stopAt', 'maxFrames', 'fps', 'stage', 'bitrate', 'keyFrameInterval', 'signal'];
465
+ var STOP_AT_FORMS = ['tick', 'ticks', 'path'];
466
+ var PATH_OPTIONS = ['waypoints', 'loop', 'waypointTimeoutMs'];
467
+
468
+ /** Validate a record() request; returns the normalized request. @private */
469
+ function _normalizeRecord(RS, opts) {
470
+ var who = 'record';
471
+ if (!opts || typeof opts !== 'object') {
472
+ _fail(who, 'expected {everyTicks, stopAt, maxFrames, fps, stage?, bitrate?, keyFrameInterval?, signal?}, got ' + String(opts));
473
+ }
474
+ _assertKnownKeys(opts, RECORD_OPTIONS, 'record', who);
475
+ var req = {
476
+ everyTicks: _requirePosInt(opts.everyTicks, 'everyTicks (the capture cadence in TICKS — there is no wall-clock cadence)', who),
477
+ maxFrames: _requirePosInt(opts.maxFrames, 'maxFrames (the memory bound; hitting it reports truncated:true)', who),
478
+ fps: _requirePosInt(opts.fps, 'fps (the PLAYBACK rate — it has no physical meaning)', who),
479
+ stage: (opts.stage === undefined) ? RS.finalStage() : RS.assertStage(opts.stage),
480
+ bitrate: (opts.bitrate === undefined) ? null : _requirePosInt(opts.bitrate, 'bitrate (bits per second)', who),
481
+ keyFrameInterval: (opts.keyFrameInterval === undefined) ? 1
482
+ : _requirePosInt(opts.keyFrameInterval, 'keyFrameInterval (frames)', who),
483
+ stopAt: null,
484
+ signal: null
485
+ };
486
+ if (opts.signal !== undefined) {
487
+ var sig = opts.signal;
488
+ if (!sig || typeof sig.aborted !== 'boolean' || typeof sig.addEventListener !== 'function') {
489
+ _fail(who, 'signal must be an AbortSignal (new AbortController().signal) — it is how a recording ' +
490
+ 'stops before its stopAt; got ' + String(sig));
491
+ }
492
+ req.signal = sig;
493
+ }
494
+ if (RS.STAGE_FORMATS[req.stage] !== _rgba8Format(RS)) {
495
+ _fail(who, 'stage ' + JSON.stringify(req.stage) + ' carries NUMBERS (format ' +
496
+ JSON.stringify(RS.STAGE_FORMATS[req.stage]) + '), not an image — a video of it would be a ' +
497
+ 'plausible-looking movie of nothing. Read the numbers with getStream({stage: ' +
498
+ JSON.stringify(req.stage) + ', everyTicks})');
499
+ }
500
+ var s = opts.stopAt;
501
+ if (!s || typeof s !== 'object') {
502
+ _fail(who, 'stopAt is REQUIRED — exactly one of {tick: T}, {ticks: K}, {path: {waypoints, loop?, ' +
503
+ 'waypointTimeoutMs?}}. A recording with no honest stop is stopped by its bound, which is ' +
504
+ 'how a cap silently becomes the length of every video');
505
+ }
506
+ var forms = Object.keys(s);
507
+ _assertKnownKeys(s, STOP_AT_FORMS, 'stopAt', who);
508
+ if (forms.length !== 1) {
509
+ _fail(who, 'stopAt names exactly ONE form, got [' + forms.join(', ') + '] — declared: ' + STOP_AT_FORMS.join(', '));
510
+ }
511
+ if (forms[0] === 'tick') {
512
+ if (typeof s.tick !== 'number' || !isFinite(s.tick) || s.tick < 0 || s.tick % 1 !== 0) {
513
+ _fail(who, 'stopAt.tick is an absolute tick (a non-negative whole number), got ' + String(s.tick));
514
+ }
515
+ req.stopAt = { tick: s.tick };
516
+ } else if (forms[0] === 'ticks') {
517
+ req.stopAt = { ticks: _requirePosInt(s.ticks, 'stopAt.ticks (ticks after the first capture)', who) };
518
+ } else {
519
+ if (!s.path || typeof s.path !== 'object') { _fail(who, 'stopAt.path must be {waypoints, loop?, waypointTimeoutMs?}'); }
520
+ _assertKnownKeys(s.path, PATH_OPTIONS, 'stopAt.path', who);
521
+ req.stopAt = { path: s.path };
522
+ }
523
+ return req;
524
+ }
525
+
526
+ /**
527
+ * Load the vendored encoder and its pin (compat/vendor.js vendorUrl — beside this tree). Asynchronous only —
528
+ * every synchronous refusal is a door `_record` checks first. @private
529
+ */
530
+ function _loadEncoder() {
531
+ return Promise.all([
532
+ import(vendorUrl('mediabunny.min.mjs')),
533
+ fetch(vendorUrl('mediabunny.PINNED.json')).then(function (r) {
534
+ if (!r.ok) { _fail('record', 'the encoder pin mediabunny.PINNED.json did not load (HTTP ' + r.status + ')'); }
535
+ return r.json();
536
+ })
537
+ ]).then(function (mods) {
538
+ var pin = mods[1];
539
+ if (!pin || typeof pin.version !== 'string' || !pin.version) {
540
+ _fail('record', 'mediabunny.PINNED.json carries no version — the muxer label is ferried from ' +
541
+ 'the pin, never typed here');
542
+ }
543
+ return { MB: mods[0], pin: pin };
544
+ });
545
+ }
546
+
547
+ /**
548
+ * record(opts) — the in-page recorder, or the hosted forward.
549
+ * @private
550
+ */
551
+ function _record(runner, RS, opts) {
552
+ var req = _normalizeRecord(RS, opts);
553
+ var CE = CaptureEnvelope;
554
+
555
+ // HOSTED: the world, the clock and the encoder are in the page. The SAME record runs there;
556
+ // this process forwards and VERIFIES the envelope against its own scene identity. The
557
+ // forward is issued synchronously, so a drive issued right after this call cannot run
558
+ // ticks ahead of the page-side subscription.
559
+ var host = (typeof runner.captureHost === 'function') ? runner.captureHost() : null;
560
+ if (host) {
561
+ var pending = Promise.resolve(host.record(opts));
562
+ return Promise.all([runner.sceneRecord(), pending]).then(function (joined) {
563
+ return FrqtlCaptureHost.verifyVideo(joined[0].scene, joined[1]);
564
+ });
565
+ }
566
+ if (typeof document === 'undefined') {
567
+ _fail('record', 'no DOM and no capture host: video is encoded with WebCodecs where the ' +
568
+ 'picture is, i.e. in a page. From node use the hosted eyes — ' +
569
+ 'openHostedEyes from @descix/frqtl-node/eyes, then eyes.vision.record(opts), ' +
570
+ 'which runs this SAME record inside the hosted page');
571
+ }
572
+ if (typeof VideoEncoder === 'undefined') {
573
+ _fail('record', 'this page has no WebCodecs VideoEncoder (it needs a secure context: https or ' +
574
+ 'localhost). The encoder is not optional — there is no second path');
575
+ }
576
+
577
+ // Everything that can THROW synchronously happens BEFORE the sampler subscribes: a
578
+ // CameraPath refused at attach (no pose control, an undeclared pose field) must not leave
579
+ // a subscribed sampler holding the clock behind a rejection nobody can reach.
580
+ var path = null;
581
+ 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
+ });
587
+ }
588
+ var frames = [];
589
+ var inflight = [];
590
+ var truncated = false;
591
+ var closing = false;
592
+ var failure = null;
593
+ var stopTick = (req.stopAt.tick !== undefined) ? req.stopAt.tick : null;
594
+ var session = null;
595
+ var onAbort = null;
596
+ var dims = null;
597
+ var scene = null;
598
+ var encoderReady = _loadEncoder().then(function (enc) {
599
+ var MB = enc.MB;
600
+ var target = new MB.BufferTarget();
601
+ var output = new MB.Output({ format: new MB.Mp4OutputFormat({ fastStart: 'in-memory' }), target: target });
602
+ var source = new MB.VideoSampleSource({
603
+ codec: 'avc',
604
+ bitrate: (req.bitrate === null) ? MB.QUALITY_HIGH : req.bitrate,
605
+ keyFrameInterval: req.keyFrameInterval
606
+ });
607
+ output.addVideoTrack(source, { frameRate: req.fps });
608
+ return output.start().then(function () {
609
+ return { MB: MB, pin: enc.pin, target: target, output: output, source: source };
610
+ });
611
+ });
612
+ var encodeChain = encoderReady;
613
+
614
+ var settle;
615
+ var done = new Promise(function (resolve, reject) { settle = { resolve: resolve, reject: reject }; });
616
+
617
+ function close(err) {
618
+ if (err && !failure) { failure = err; }
619
+ if (closing) { return; }
620
+ closing = true;
621
+ if (session) { session.stop(); }
622
+ if (path) { path.dispose(); path = null; }
623
+ if (onAbort) { req.signal.removeEventListener('abort', onAbort); onAbort = null; }
624
+ // Every capture already requested lands in the video before it is finalized.
625
+ Promise.all(inflight).then(function () {
626
+ if (failure) { throw failure; }
627
+ if (!frames.length) {
628
+ _fail('record', 'the recording stopped before a single still was captured — nothing to ' +
629
+ 'encode. Is the runner advancing ticks?');
630
+ }
631
+ return encodeChain;
632
+ }).then(function (enc) {
633
+ return enc.output.finalize().then(function () { return enc.output.getMimeType(); })
634
+ .then(function (mimeType) {
635
+ var parts = String(mimeType).split(';');
636
+ var mime = parts[0].trim();
637
+ var m = /codecs="([^"]+)"/.exec(mimeType);
638
+ if (!m) {
639
+ _fail('record', 'the muxer reported no codec string (' + JSON.stringify(mimeType) +
640
+ ') — encoded.codec REPORTS what was produced and is never assumed');
641
+ }
642
+ return CE.videoEnvelope({
643
+ track: {
644
+ scene: scene, stage: req.stage, width: dims.width, height: dims.height,
645
+ ticksPerFrame: req.everyTicks, fps: req.fps, frames: frames
646
+ },
647
+ encoded: {
648
+ mime: mime,
649
+ bytes: CE.bytesToBase64(new Uint8Array(enc.target.buffer)),
650
+ encoder: 'FrqtlVision.record',
651
+ codec: m[1],
652
+ muxer: 'mediabunny@' + enc.pin.version
653
+ },
654
+ truncated: truncated
655
+ });
656
+ });
657
+ }).then(settle.resolve, function (e) {
658
+ encoderReady.then(function (enc) { return enc.output.cancel(); }).then(null, function () {});
659
+ settle.reject(e);
660
+ });
661
+ }
662
+
663
+ encoderReady.then(null, function (e) { close(e); });
664
+
665
+ session = _sample(runner, RS, {
666
+ who: 'record', stage: req.stage, everyTicks: req.everyTicks,
667
+ admit: function (i) {
668
+ if (closing) { return false; }
669
+ if (i >= req.maxFrames) { truncated = true; close(null); return false; }
670
+ return true;
671
+ },
672
+ onStill: function (p, i) {
673
+ var handled = p.then(function (still) {
674
+ if (i === 0) {
675
+ scene = still.scene;
676
+ dims = { width: still.width, height: still.height };
677
+ if (req.stopAt.ticks !== undefined) { stopTick = still.tick + req.stopAt.ticks; }
678
+ if (stopTick !== null && still.tick > stopTick) {
679
+ _fail('record', 'stopAt.tick ' + stopTick + ' is already in the past — the first ' +
680
+ 'capture is tick ' + still.tick);
681
+ }
682
+ }
683
+ frames[i] = { i: i, tick: still.tick, pose: still.pose };
684
+ encodeChain = encodeChain.then(function (enc) {
685
+ var sample = new enc.MB.VideoSample(still.data, {
686
+ format: 'RGBA', codedWidth: still.width, codedHeight: still.height,
687
+ timestamp: i / req.fps, duration: 1 / req.fps
688
+ });
689
+ return Promise.resolve(enc.source.add(sample)).then(function () {
690
+ sample.close();
691
+ return enc;
692
+ }, function (e) { sample.close(); throw e; });
693
+ });
694
+ // Handled at close; a rejected encode must not surface as unhandled meanwhile.
695
+ encodeChain.then(null, function (e) { close(e); });
696
+ if (stopTick !== null && still.tick + req.everyTicks > stopTick) { close(null); }
697
+ }, function (e) { close(e); });
698
+ inflight.push(handled);
699
+ return handled;
700
+ }
701
+ });
702
+
703
+ // An abort ends the recording before its declared stop — that IS what `truncated` reports.
704
+ // With no still captured yet, close() refuses with "nothing to encode".
705
+ if (req.signal) {
706
+ onAbort = function () { truncated = true; close(null); };
707
+ if (req.signal.aborted) { onAbort(); return done; }
708
+ req.signal.addEventListener('abort', onAbort, { once: true });
709
+ }
710
+
711
+ try {
712
+ if (path) {
713
+ var last = path.waypoints.length - 1;
714
+ path.onWaypoint(function (evt) { if (evt.index === last) { close(null); } });
715
+ path.start();
716
+ }
717
+ } catch (e) { close(e); }
718
+ return done;
719
+ }
720
+
721
+ /**
722
+ * attach(runner) — bind the handle to a runner. Stateless: the handle is a thin forwarder.
723
+ * @param {object} runner — an FrqtlApp
724
+ * @returns {object} the handle (see the module docstring)
725
+ */
726
+ function attach(runner) {
727
+ if (!runner || typeof runner.capture !== 'function' || typeof runner.captureStill !== 'function' ||
728
+ typeof runner.sceneRecord !== 'function') {
729
+ throw new Error('[FrqtlVision.attach] needs an FrqtlApp exposing capture(stage), ' +
730
+ 'captureStill() and sceneRecord() (the RENDER_STAGES tap contract). (FAIL-LOUD.)');
731
+ }
732
+ var RS = RenderStages;
733
+ return {
734
+ // FERRIED from the owner — a hand-written stage list here would be the
735
+ // schema-mirror bug, and a stage added to the table appears here for free.
736
+ stages: RS.RENDER_STAGES.slice(),
737
+ finalStage: RS.finalStage,
738
+ /**
739
+ * getScene() — the run-invariant SceneRecord plus its own content id. Pure forwarding.
740
+ *
741
+ * **MUST BE AWAITED.** It is a Promise because the scene id is a content hash and
742
+ * `crypto.subtle.digest` — the ONE hashing implementation, a global in both Node and
743
+ * the browser — is Promise-based; a synchronous accessor would need a second,
744
+ * hand-rolled sha256 in the render layer. The failure mode is SILENT and has already
745
+ * been hit once in the realm: an un-awaited Promise has NO own keys and
746
+ * `JSON.stringify(promise)` is `'{}'`, so
747
+ * `JSON.stringify({ scene: vision.getScene() })` // ← writes {} into the corpus
748
+ * looks like an empty SceneRecord rather than a mistake. Always:
749
+ * `const scene = await vision.getScene();`
750
+ *
751
+ * The returned record is a fresh COPY each call — it shares no object identity with
752
+ * the runner's internal memo, so mutating it cannot corrupt the record for any other
753
+ * caller or break the hash other stills already reference.
754
+ * @returns {Promise<object>}
755
+ */
756
+ getScene: function () { return runner.sceneRecord(); },
757
+ /**
758
+ * getStill(stage?) — pure forwarding. Omit `stage` for the fully composed stack.
759
+ * @param {string} [stage]
760
+ * @returns {Promise<object>}
761
+ */
762
+ getStill: function (stage) {
763
+ return (stage === undefined) ? runner.captureStill() : runner.capture(stage);
764
+ },
765
+ /**
766
+ * getStream({stage?, everyTicks}) — stills at a TICK cadence, as an async iterable.
767
+ * Iterating subscribes; `break` unsubscribes. The queue is UNBOUNDED (a slow consumer
768
+ * holds every sampled still in memory); `record`'s `maxFrames` is the bound when one
769
+ * is needed. See the module docstring for the hold and the one-scene/one-size rule.
770
+ * @param {{stage?:string, everyTicks:number}} opts
771
+ * @returns {AsyncIterable<object>}
772
+ */
773
+ getStream: function (opts) { return _getStream(runner, RS, opts); },
774
+ /**
775
+ * record(opts) → Promise<VideoEnvelope> — sample like `getStream`, encode an MP4.
776
+ *
777
+ * everyTicks REQUIRED — capture cadence in TICKS (sampling starts at the first
778
+ * tick after this call, then every `everyTicks`)
779
+ * stopAt REQUIRED — exactly one of `{tick: T}` (absolute, inclusive),
780
+ * `{ticks: K}` (K ticks after the first capture),
781
+ * `{path: {waypoints, loop?, waypointTimeoutMs?}}` (a CameraPath
782
+ * flight started with the recording; stop = arrival at its last
783
+ * waypoint)
784
+ * maxFrames REQUIRED — the memory bound; reaching it before `stopAt` stops the
785
+ * recording and reports `truncated: true`
786
+ * fps REQUIRED — PLAYBACK rate only; frame i is timestamped i/fps
787
+ * stage default `finalStage()`; must be an rgba8 stage
788
+ * signal an AbortSignal: abort ends the recording early with the stills
789
+ * captured so far and `truncated: true` (rejects if none yet)
790
+ * bitrate default mediabunny QUALITY_HIGH (resolution-scaled); bits/second
791
+ * keyFrameInterval default 1 — every frame independently decodable
792
+ *
793
+ * Resolves `CaptureEnvelope.videoEnvelope`: `encoded {mime, bytes (base64), encoder,
794
+ * codec (as PRODUCED), muxer ('mediabunny@<pin>')}`, `track {scene, stage, width,
795
+ * height, ticksPerFrame, fps, firstTick, lastTick, frames[{i, tick, pose}]}` (every row
796
+ * derived from its still), `truncated`. Never throws synchronously.
797
+ * @param {object} opts
798
+ * @returns {Promise<object>}
799
+ */
800
+ record: function (opts) {
801
+ try { return _record(runner, RS, opts); }
802
+ catch (e) { return Promise.reject(e); }
803
+ },
804
+ toDataURL: toDataURL,
805
+ toPNG: toPNG
806
+ };
807
+ }
808
+
809
+ // ── Export ────────────────────────────────────────────────────────────────────
810
+
811
+ var FrqtlVision = { attach: attach, toDataURL: toDataURL, toPNG: toPNG };
812
+
813
+ // The capture-host KIND's in-page recorder is THIS face: registered here, so capture-host
814
+ // never names FrqtlVision (the import runs one way: FrqtlVision → capture-host).
815
+ FrqtlCaptureHost.registerRecorder(FrqtlVision);
816
+
817
+ export { FrqtlVision };
818
+