@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,985 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * hosted-eyes.js — THE CAPTURE HOST: the eyes see through the SAME stack the user sees.
5
+ *
6
+ * The V3 implementation of the capture-host KIND (`instruments/capture-host.js`): it boots
7
+ * the canonical experiment page in a managed headless browser, regenerates the scene from
8
+ * its `{sceneRecord, sceneSource}` pair, drives the clock to an exact tick, points the one
9
+ * camera, and captures IN-PAGE through the same `FrqtlCapture` surface a browser user has.
10
+ * `composite` therefore works from node, through the real renderer and its shaders.
11
+ *
12
+ * What it draws is the SOURCE plus the renderState it is handed — nothing else. A walk-through
13
+ * rung's LOOK (render rows, filters, camera) lives on its registry row, not in its sketch, so
14
+ * the sketch alone records the PLAIN render: pass the rung's `effectiveRenderState(rung, mode)`
15
+ * as `renderState` to record it as the GYM shows it. The contract is
16
+ * SOURCE + declared renderState → PAGE resolves → PROVENANCE reports.
17
+ *
18
+ * **The browser is the ENGINE ROOM, never the interface.** Nothing above this file mentions
19
+ * a page, a selector or a screenshot: a consumer opens a handle, drives it, and reads the
20
+ * same normalized still bags `FrqtlVision` hands out everywhere else. That is the whole
21
+ * point of the ruling that produced this file — a raster backend that RE-DREW the scene in
22
+ * node would have shown the agent a different picture than the end user sees, which defeats
23
+ * the purpose (a god-rays shader turns a bunch of particles into A SUN, and the shader is
24
+ * part of the experiment, not decoration).
25
+ *
26
+ * ── Everything here CONSUMES an existing authority. There is no new machinery. ──
27
+ *
28
+ * the page `lib/frqtl/experiments/empty/index.html` + `FrqtlPage.applyModuleSource` —
29
+ * the same blank load-by-eval target STUDIO's saved projects and the
30
+ * public site's hero already boot. NEVER the IDE page (a driver that
31
+ * hunts an iframe and clicks chrome by label is a harness, not a library)
32
+ * and never a bespoke HTML harness (that would be a boot-manifest schema
33
+ * mirror plus a second serving context). Any other page is REFUSED by
34
+ * name at once (no `window.FrqtlPage` after DOMContentLoaded), as is a
35
+ * URL that did not serve, a canonical page whose stack failed (its boot
36
+ * record's `data-egpt-boot-failed`) and one that stalled (the pending
37
+ * phase) — never a generic wait-for-function timeout.
38
+ * the URL keys `lib/shim/page-params` — `seed` (the record's, half the regeneration
39
+ * triple) and `renderer` (the declared renderState's display on the first
40
+ * boot, or the SCENE's own required display on a reload — never a record
41
+ * field), each CHECKED against its own `FORWARDABLE_PARAMS` list
42
+ * before it is set. (`engine` is deliberately absent: the contract
43
+ * declares it non-forwardable, because the host's own engine selector is
44
+ * the sole authority for that axis.)
45
+ * the clock `lib/shim/notebook/warmup.js` `warmUpRealm` — one AWAITED `step()` per
46
+ * tick over a control-plane handle, which is exactly what this boundary
47
+ * is. It is transport-agnostic by design: the handle below is
48
+ * driven over page-evaluate, a consumer, not a fork. A wall-clock "wait for ~N frames at 60fps" poll —
49
+ * the old capture-script shape — is BOTH incorrect (it lands on whatever
50
+ * tick the render loop reached) and slower, because its minutes are spent
51
+ * watching a render loop rather than computing.
52
+ * the look `FrqtlPage.applyRenderState` — the page's door to the ONE renderState
53
+ * procedure (`lib/shim/render-state/apply.js` `RenderState.apply`: rows,
54
+ * camera, backdrop), called in-page after the scene is applied and before
55
+ * the establishing step. This file restates none of it; it only refuses
56
+ * what a hosted eye cannot accept (a RETAINED row or preset — see `open`).
57
+ * the pose `runner.cameraControl` — the ONE camera authority, set and read back
58
+ * in-page. This file re-derives no orbit geometry.
59
+ * the format the page's own `capture(stage)` dispatcher stamps every field; the
60
+ * KIND's codec ferries the bag; this file adds NO provenance.
61
+ * the world the scene's co-init call (`exp.createCanvas/createScene`) — the ONE size
62
+ * writer. `physicsW/H` are read back from the page's SceneRecord like
63
+ * `build`/`projection`; a caller who states them states an EXPECTATION.
64
+ *
65
+ * ── The REGENERATE gate (a.4) ────────────────────────────────────────────────
66
+ *
67
+ * A SceneRecord alone cannot open a host: it is identity plus reproduction pins, with no
68
+ * scene spec in it. So the API takes the PAIR — record + `{kind:'project', files, entry}`, the
69
+ * project whose entry's default export is `buildExperiment(exp)` (a one-string sketch, such as the
70
+ * module `designToModule` emits, is `{ files: { 'sketch.js': text }, entry: 'sketch.js' }`). The
71
+ * page links it through its one door and stamps the project's identity (projectIdentity) as the
72
+ * record's `sourceSha256`; this process computes the same identity from the same files, and the
73
+ * open-time record check holds the two equal. A caller without one is REPLAY-grade
74
+ * (an ad-hoc tick-fn scene, a live-edited scene, a bare raster row) and is REFUSED in
75
+ * RENDERER_CONTRACT §10.3's own vocabulary: there is nothing to regenerate FROM, and a
76
+ * silently approximate re-run is the one failure a content-addressed corpus cannot detect
77
+ * after the fact.
78
+ *
79
+ * ── The raster path is RECORDED, never assumed ───────────────────────────────
80
+ *
81
+ * The default launch flags select HARDWARE GL, per the measured policy
82
+ * (`docs/internal/physics-lab/composite-determinism/`): hardware is bit-reproducible on a
83
+ * pinned machine/driver/build, so a software default would trade V3's premise for a
84
+ * property we measured we already have. But the flags are a REQUEST — headless chromium
85
+ * silently falls back to SwiftShader, which is how a naive rig measures the software path
86
+ * without announcing it. What makes the policy safe is not the flags: every still's
87
+ * `build.gl` carries the browser's OWN `UNMASKED_RENDERER_WEBGL` answer, so rows from
88
+ * different raster paths refuse to mix by identity whatever was asked for.
89
+ *
90
+ * ── Lifecycle ────────────────────────────────────────────────────────────────
91
+ *
92
+ * ONE managed browser per handle, closed by `dispose()`, which is symmetric with `open()`
93
+ * and idempotent. Every entry point after disposal FAILS LOUD rather than resurrecting a
94
+ * browser silently. `open()` cleans up its own partial acquisition if boot fails — a
95
+ * half-opened handle that leaked a browser process would poison the zero-orphans gate for
96
+ * every later run in the same shell.
97
+ *
98
+ * ── Layer 2: physics behaviour ───────────────────────────────────────────────
99
+ * ZERO physics, and NO second clock: this file never calls `doTick()`, and its only tick
100
+ * driver is the page's own `step()`, awaited one tick at a time through the existing
101
+ * warm-up authority.
102
+ *
103
+ * @example <caption>The library call. Published consumers reach the SAME open() through the
104
+ * sdk's "./eyes" entry, which supplies the packaged browser and adds nothing else.</caption>
105
+ * const HostedEyes = require('./instruments/hosted-eyes.js');
106
+ * const eyes = await HostedEyes.open({
107
+ * // No display or size fields: the SOURCE declares its world and what kind of scene it
108
+ * // is, the PAGE resolves them, and `build`/`projection`/`physicsW/H` REPORT what happened.
109
+ * sceneRecord: { seed: 42, sourceRef: 'walkthrough:r7-3d' },
110
+ * sceneSource: { kind: 'project', files: { 'sketch.js': rungSource }, entry: 'sketch.js' },
111
+ * pageUrl: 'http://localhost:5534/frqtl/experiments/empty/index.html',
112
+ * browserType: chromium
113
+ * });
114
+ * try {
115
+ * await eyes.driveToTick(300);
116
+ * // A pose set AFTER open overrides the camera a `renderState` declared: the bag is the
117
+ * // rung's declared view, a pose is a still coordinate.
118
+ * await eyes.setPose({ azimuth: 35, elevation: -15, distance: 420, target: [420, 200, 0] });
119
+ * const still = await eyes.vision.getStill('composite'); // the SAME normalized bag
120
+ * eyes.saveStill(still, 'out.png'); // …and the picture, in one line
121
+ * } finally { await eyes.dispose(); }
122
+ */
123
+
124
+ import fs from 'node:fs';
125
+ import path from 'node:path';
126
+ import { fileURLToPath } from 'node:url';
127
+ import * as RenderStages from '../../../shim/render/render-stages.js';
128
+ import * as CaptureEnvelope from '../../../shim/render/capture-envelope.js';
129
+ import * as PageParams from '../../../shim/page-params/index.js';
130
+ import * as RenderState from '../../../shim/render-state/apply.js';
131
+ import * as Warmup from '../../../shim/notebook/warmup.js';
132
+ import { FrqtlCaptureHost } from './capture-host.js';
133
+ import { FrqtlVision } from '../../parts/FrqtlVision.js';
134
+ import { FrqtlCapture } from './capture.js';
135
+ import { projectIdentity, projectBytes } from '../../../shim/project/identity.js';
136
+
137
+
138
+ /** The contracts this instrument consumes, by static import (it is a node-only module). */
139
+ function _contracts() {
140
+ return { RS: RenderStages, CH: FrqtlCaptureHost, PP: PageParams, RST: RenderState };
141
+ }
142
+
143
+ /** `warmUpRealm` (lib/shim/notebook/warmup.js) — the one clock driver. @private */
144
+ function _warmup() {
145
+ return Promise.resolve(Warmup);
146
+ }
147
+
148
+ /**
149
+ * The URL, AS THE PAGE SEES IT, of a module beside this file. The canonical page sits at
150
+ * `frqtl/experiments/empty/index.html` of the same lib-identity tree in every host (source
151
+ * placement is identity), so a module's page URL is its path relative to that page's
152
+ * directory, resolved against the page's own URL. The in-page routines import the PAGE's
153
+ * module instances from these URLs — never a copy. @private
154
+ */
155
+ var EMPTY_PAGE_DIR = path.dirname(fileURLToPath(new URL('../../experiments/empty/index.html', import.meta.url)));
156
+ function _pageModuleUrl(pageUrl, moduleUrl) {
157
+ var rel = path.relative(EMPTY_PAGE_DIR, fileURLToPath(moduleUrl)).split(path.sep).join('/');
158
+ return new URL(rel, pageUrl).href;
159
+ }
160
+ /** The modules the capture-host KIND's in-page routines import, for the page at `pageUrl`. @private */
161
+ function _inPageModules(pageUrl) {
162
+ return {
163
+ captureHost: _pageModuleUrl(pageUrl, import.meta.resolve('./capture-host.js')),
164
+ captureEnvelope: _pageModuleUrl(pageUrl, import.meta.resolve('../../../shim/render/capture-envelope.js'))
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The DEFAULT raster-path request: hardware GL, per the measured policy. `--use-angle`
170
+ * names the platform's own backend only where the measurement was taken; elsewhere ANGLE
171
+ * picks its default. Overridable wholesale via `launchArgs` (a BYO browser stack is
172
+ * sanctioned — what is NOT optional is that the result is recorded, which `build.gl`
173
+ * does regardless of what was asked for).
174
+ * @private
175
+ */
176
+ function _defaultLaunchArgs() {
177
+ var args = ['--enable-gpu', '--ignore-gpu-blocklist', '--use-gl=angle'];
178
+ if (typeof process !== 'undefined' && process.platform === 'darwin') {
179
+ args.push('--use-angle=metal');
180
+ }
181
+ return args;
182
+ }
183
+
184
+ // ── The REGENERATE gate ───────────────────────────────────────────────────
185
+
186
+ /**
187
+ * assertHostable({sceneRecord, sceneSource}) — throw-or-return, in §10.3's vocabulary.
188
+ * @private
189
+ */
190
+ function _assertHostable(opts) {
191
+ var src = opts.sceneSource;
192
+ if (!opts.sceneRecord || typeof opts.sceneRecord !== 'object') {
193
+ throw new Error('[FrqtlHostedEyes.open] `sceneRecord` is REQUIRED — it is this ' +
194
+ 'process\'s half of the scene identity (seed, sourceRef), and ' +
195
+ 'the thing every returned still is verified against. (FAIL-LOUD.)');
196
+ }
197
+ if (typeof opts.sceneRecord.seed !== 'number' || !isFinite(opts.sceneRecord.seed)) {
198
+ throw new Error('[FrqtlHostedEyes.open] the sceneRecord carries no finite `seed`. ' +
199
+ 'The seed is half of the regeneration triple (code, seed, tick) and is what the ' +
200
+ 'hosted page is booted with — without it a "regenerated" scene is a different ' +
201
+ 'scene wearing the same name. (FAIL-LOUD.)');
202
+ }
203
+ if (src && typeof src === 'object' && src.kind === 'module') {
204
+ throw new Error('[FrqtlHostedEyes.open] sceneSource {kind:"module", source} is retired — a scene ' +
205
+ 'is a project: sceneSource: { kind: "project", files: { "sketch.js": source }, entry: "sketch.js" } ' +
206
+ '(more files beside it for a project with forked parts). (FAIL-LOUD.)');
207
+ }
208
+ if (!src || typeof src !== 'object' || src.kind !== 'project' ||
209
+ !src.files || typeof src.files !== 'object' || Object.keys(src.files).length === 0 ||
210
+ typeof src.entry !== 'string' || !src.entry) {
211
+ throw new Error('[FrqtlHostedEyes.open] this scene is REPLAY-grade, not ' +
212
+ 'REGENERATE-grade: hosting re-RUNS the scene in another process, so a ' +
213
+ 'SceneRecord alone is not enough (it is identity + reproduction pins, with no ' +
214
+ 'scene spec in it). Pass the pair — sceneSource: { kind: "project", files, entry } — ' +
215
+ 'where the entry\'s default export is buildExperiment(exp) and the canonical empty ' +
216
+ 'page links it. An ad-hoc tick-fn scene, a live-edited scene or a ' +
217
+ 'bare raster row has nothing to regenerate FROM, and a silently approximate ' +
218
+ 're-run is the one failure a content-addressed corpus cannot detect afterwards. ' +
219
+ '(FAIL-LOUD — see RENDERER_CONTRACT.md §10.3.)');
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The declared renderState, normalized by its OWNER (`RenderState.normalizeSavedRenderState`),
225
+ * or null when the caller declared none. Throw-or-return, BEFORE a browser is acquired.
226
+ *
227
+ * Its two load-time fields must not open a second identity for one scene: a `seed` that is
228
+ * not the record's would boot one scene and file it under another, and an `engine` other than
229
+ * wasm names an engine FRQTL does not run.
230
+ * @private
231
+ */
232
+ function _declaredRenderState(opts, record, RST) {
233
+ var raw = opts.renderState;
234
+ if (raw === undefined || raw === null) { return null; }
235
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
236
+ throw new Error('[FrqtlHostedEyes.open] `renderState` must be a renderState bag (a plain ' +
237
+ 'object — for a rung, effectiveRenderState(rung, mode) from the walkthrough registry); ' +
238
+ 'got ' + (Array.isArray(raw) ? 'an array' : typeof raw) + '. (FAIL-LOUD.)');
239
+ }
240
+ var bag = RST.normalizeSavedRenderState(raw);
241
+ if (bag.seed !== null && bag.seed !== String(record.seed)) {
242
+ throw new Error('[FrqtlHostedEyes.open] renderState.seed ' + JSON.stringify(bag.seed) +
243
+ ' is not the sceneRecord\'s seed ' + String(record.seed) + ' — two seeds for one scene ' +
244
+ 'is two identities. Drop the bag\'s seed, or open the record with it. (FAIL-LOUD.)');
245
+ }
246
+ if (bag.engine !== null && bag.engine !== 'wasm') {
247
+ throw new Error('[FrqtlHostedEyes.open] renderState.engine ' + JSON.stringify(bag.engine) +
248
+ ' — the JS reference engine is archived; FRQTL is WASM-only. Drop the bag\'s engine ' +
249
+ 'or set it to "wasm". (FAIL-LOUD.)');
250
+ }
251
+ return bag;
252
+ }
253
+
254
+ /**
255
+ * The page URL, with the forwardable params this boot NEEDS.
256
+ *
257
+ * `seed` comes from the record — it is half the regeneration triple and the page cannot
258
+ * derive it. THE DISPLAY DOES NOT: `renderer` is NOT set from the record.
259
+ * They are boot RESULTS (RENDERER_CONTRACT §10.3 lists them as never caller-supplied),
260
+ * and steering a boot with them made them caller REQUESTS wearing provenance names —
261
+ * whose absent case silently booted a 2D default under a 3D scene. The SOURCE declares
262
+ * what kind of scene it is (its co-init call names the renderer); the PAGE reports it;
263
+ * this function carries a DECLARED display on the first boot (the renderState's `renderer`,
264
+ * verified against the built world afterwards) or the scene's OWN answer back on a reload
265
+ * (`renderer`). Each key is checked against the contract's forwardable list before it is set.
266
+ *
267
+ * @param {string} [renderer] — the declared renderState's display, or the renderer the
268
+ * SCENE's co-init call requires (see `_IN_PAGE_DISPLAY`) — never a record field.
269
+ * @private
270
+ */
271
+ function _pageUrlFor(pageUrl, record, PP, renderer) {
272
+ var u = new URL(pageUrl);
273
+ var set = function (key, value) {
274
+ if (PP.FORWARDABLE_PARAMS.indexOf(key) === -1) {
275
+ throw new Error('[FrqtlHostedEyes] page param ' + JSON.stringify(key) + ' is ' +
276
+ 'declared NON-forwardable by lib/shim/page-params — a host may not force ' +
277
+ 'it into an embedded page. (FAIL-LOUD.)');
278
+ }
279
+ u.searchParams.set(key, String(value));
280
+ };
281
+ set(PP.KEYS.SEED, record.seed);
282
+ if (renderer) { set(PP.KEYS.RENDERER, renderer); }
283
+ return u.href;
284
+ }
285
+
286
+ /**
287
+ * The caller's `build`/`projection` are an EXPECTATION, never a request.
288
+ *
289
+ * They are boot RESULTS, so a caller who states them is asserting what the source will
290
+ * resolve to. Agreement is silent; a disagreement THROWS naming BOTH sides — the CEO's
291
+ * "caller forces 2D onto a 3D source" case. The one thing never allowed is the third
292
+ * outcome: quietly running the caller's display over the source's world.
293
+ * @private
294
+ */
295
+ function _assertExpectedDisplay(record, pageScene) {
296
+ var bad = [];
297
+ if (record.build && record.build.renderer !== undefined && record.build.renderer !== null &&
298
+ record.build.renderer !== pageScene.build.renderer) {
299
+ bad.push('build.renderer — you expected ' + JSON.stringify(record.build.renderer) +
300
+ ', the scene source resolved ' + JSON.stringify(pageScene.build.renderer));
301
+ }
302
+ if (record.projection && record.projection.kind !== undefined &&
303
+ record.projection.kind !== null &&
304
+ record.projection.kind !== pageScene.projection.kind) {
305
+ bad.push('projection.kind — you expected ' + JSON.stringify(record.projection.kind) +
306
+ ', the scene source resolved ' + JSON.stringify(pageScene.projection.kind));
307
+ }
308
+ if (bad.length) {
309
+ throw new Error('[FrqtlHostedEyes.open] the display you declared is not the display ' +
310
+ 'this scene requires: ' + bad.join('; ') + '. `build` and `projection` are boot ' +
311
+ 'RESULTS, not requests — the scene SOURCE is the one authority on what kind of ' +
312
+ 'scene it is (its co-init call: exp.createCanvas ⇒ 2D, exp.createScene ⇒ 3D), the PAGE ' +
313
+ 'resolves the display it needs, and these fields REPORT what happened. Drop them ' +
314
+ 'from the record to accept the source\'s own answer. (FAIL-LOUD — a 3D scene ' +
315
+ 'running in a 2D display would look fine and be wrong.)');
316
+ }
317
+ }
318
+
319
+ /** Was this bound stated at all? (`undefined`/`null` is "the source decides".) @private */
320
+ function _stated(record, k) { return record[k] !== undefined && record[k] !== null; }
321
+
322
+ /**
323
+ * A STATED physics bound is validated by its owner's rule (`RenderStages.assertSceneBound`)
324
+ * at the FRONT DOOR — before a browser is acquired, never after a boot. @private
325
+ */
326
+ function _assertBoundShape(record, RS) {
327
+ RS.SCENE_BOUND_KEYS.forEach(function (k) {
328
+ if (!_stated(record, k)) { return; }
329
+ try { RS.assertSceneBound(record[k], k); }
330
+ catch (e) {
331
+ throw new Error('[FrqtlHostedEyes.open] sceneRecord.' + k + ' ' + String(record[k]) +
332
+ ' is refused before a browser is acquired — ' + e.message + ' The bounds are the scene SOURCE\'s ' +
333
+ '(its exp.createCanvas/createScene call) and are read back from the page — omit ' +
334
+ k + ' to accept the source\'s own answer; state it only as an expectation. ' +
335
+ '(FAIL-LOUD.)');
336
+ }
337
+ });
338
+ }
339
+
340
+ /**
341
+ * The caller's `physicsW/H` are an EXPECTATION, never a request — the `build`/`projection`
342
+ * rule (`_assertExpectedDisplay`) applied to the world size. The scene's co-init call is the
343
+ * ONE size writer; agreement is silent, a disagreement THROWS naming both sides by field.
344
+ * @private
345
+ */
346
+ function _assertExpectedBounds(record, pageScene, RS) {
347
+ var bad = [];
348
+ RS.SCENE_BOUND_KEYS.forEach(function (k) {
349
+ if (_stated(record, k) && record[k] !== pageScene[k]) {
350
+ bad.push(k + ' — you expected ' + String(record[k]) + ', the scene source resolved ' +
351
+ String(pageScene[k]));
352
+ }
353
+ });
354
+ if (bad.length) {
355
+ throw new Error('[FrqtlHostedEyes.open] the world you declared is not the world this ' +
356
+ 'scene builds: ' + bad.join('; ') + '. The physics bounds are a boot RESULT: the ' +
357
+ 'scene SOURCE states its world in its first statement (exp.createCanvas(w, h, cfg) / ' +
358
+ 'exp.createScene(w, h, d, cfg)), the page builds it, and `physicsW/H` REPORT it. Drop ' +
359
+ 'them from the record to accept the source\'s own answer. (FAIL-LOUD.)');
360
+ }
361
+ }
362
+
363
+ // ── The boot wait: every way it can fail is a NAMED refusal ─────────────────
364
+
365
+ /**
366
+ * Where a published dist at `url`'s origin serves the one page. `url` is always
367
+ * `_pageUrlFor`'s absolute href, so its origin is known. @private
368
+ */
369
+ function _distEmptyPage(url) {
370
+ return new URL(url).origin + '/experiments/empty/index.html';
371
+ }
372
+
373
+ /** D — the URL did not serve (an HTTP status other than 2xx, or a navigation error). @private */
374
+ function _refuseUnserved(url, what) {
375
+ return new Error('[FrqtlHostedEyes.open] ' + url + ' did not serve: ' + what + '. That is ' +
376
+ 'a deployment fact, not a scene problem — the hosted eyes boot ONLY the canonical empty ' +
377
+ 'experiment page lib/frqtl/experiments/empty/index.html as your deployment serves it ' +
378
+ '(a published dist at this origin serves it at ' + _distEmptyPage(url) + '); point ' +
379
+ '`pageUrl` at it, or serve it. Nothing was booted. (FAIL-LOUD.)');
380
+ }
381
+
382
+ /** A — it served, and it is not the canonical page (no `window.FrqtlPage`). @private */
383
+ function _refuseNotCanonical(url, status) {
384
+ return new Error('[FrqtlHostedEyes.open] ' + url + ' is not the canonical empty experiment ' +
385
+ 'page: it loaded (HTTP ' + status + ') and defines no window.FrqtlPage. The hosted eyes ' +
386
+ 'boot ONLY lib/frqtl/experiments/empty/index.html (in a published dist: ' +
387
+ _distEmptyPage(url) + ') and apply your scene into it. They never host an app page, the ' +
388
+ 'IDE, or a page of your own: a page that boots and edits its own scene cannot be ' +
389
+ 'regenerated from {sceneRecord, sceneSource}. To see your own page, capture IN it ' +
390
+ 'through FrqtlVision (the in-page face). To drive it from node, use your own browser; ' +
391
+ 'pass that browser type as `browserType` to share one process. (FAIL-LOUD.)');
392
+ }
393
+
394
+ /** B — the canonical page's stack failed: its boot record stamped a phase. @private */
395
+ function _refuseBootFailed(url, state) {
396
+ return new Error('[FrqtlHostedEyes.open] the canonical page at ' + url + ' booted and its ' +
397
+ 'stack did not come up: boot phase ' + JSON.stringify(state.failed) + ' FAILED ' +
398
+ '(<html data-egpt-boot-failed>). The page\'s own failure text: ' +
399
+ (state.text ? JSON.stringify(state.text)
400
+ : '(none: no [data-egpt-boot-error] overlay text was present when the failure was read)') +
401
+ '. This is the deployment\'s stack, not your scene — nothing was applied. (FAIL-LOUD.)');
402
+ }
403
+
404
+ /** C — the canonical page never became ready within `timeoutMs`. @private */
405
+ function _refuseStalled(url, timeoutMs, state) {
406
+ var pending = state.phase
407
+ ? 'boot phase ' + JSON.stringify(state.phase) + ' (' + state.done + '/' + state.total +
408
+ ') is pending'
409
+ : 'the page published no boot record (<html data-egpt-boot-phase> is absent)';
410
+ return new Error('[FrqtlHostedEyes.open] the canonical page at ' + url + ' never became ' +
411
+ 'ready within ' + timeoutMs + ' ms: ' + pending + '. FrqtlPage.isReady() stayed false, ' +
412
+ 'so nothing was applied. (FAIL-LOUD.)');
413
+ }
414
+
415
+ /**
416
+ * A hosted eye accepts NO retained render state. STUDIO retains a row or preset because its
417
+ * display toggle can switch back and its Render Controls SHOW what was retained; a hosted eye
418
+ * has no later and no chrome, so a declared look that did not land is a picture that differs
419
+ * from its declaration — the silent class a corpus cannot detect afterwards.
420
+ * @param {object|null} report — `FrqtlPage.applyRenderState`'s report, or null (none declared).
421
+ * @private
422
+ */
423
+ function _assertLanded(report) {
424
+ if (!report) { return; }
425
+ if (report.retained.length > 0) {
426
+ throw new Error('[FrqtlHostedEyes.open] the renderState rows ' +
427
+ JSON.stringify(report.retained) + ' were retained, not applied: the booted display ' +
428
+ 'does not declare them (another renderer does). A hosted eye records the look it was ' +
429
+ 'given or nothing. (FAIL-LOUD.)');
430
+ }
431
+ if (report.camera === 'retained') {
432
+ throw new Error('[FrqtlHostedEyes.open] the renderState camera preset ' +
433
+ JSON.stringify(report.preset) + ' is not offered by the booted camera, so it was ' +
434
+ 'retained, not applied. A hosted eye records the view it was given or nothing. ' +
435
+ '(FAIL-LOUD.)');
436
+ }
437
+ }
438
+
439
+ // ── IN-PAGE routines (each closes over NOTHING — they run in the browser) ──
440
+
441
+ /**
442
+ * Apply the regenerated scene, then its declared renderState (through the page's door), then
443
+ * take the clock: the loop is the host's from here. Returns the door's report for the host to
444
+ * judge (`renderState: null` when none was declared).
445
+ */
446
+ var _IN_PAGE_APPLY = async function (args) {
447
+ if (!window.FrqtlPage || typeof window.FrqtlPage.applyModuleSource !== 'function') {
448
+ throw new Error('[FrqtlHostedEyes/in-page] this page has no ' +
449
+ 'FrqtlPage.applyModuleSource — the host boots the CANONICAL empty experiment page ' +
450
+ '(lib/frqtl/experiments/empty/index.html), never an arbitrary URL.');
451
+ }
452
+ try {
453
+ await window.FrqtlPage.applyModuleSource({ files: args.files, entry: args.entry });
454
+ } catch (e) {
455
+ // The scene's co-init call named a renderer this page did not boot: report it, so the
456
+ // host reloads with the scene's own answer (displayResolution reads it back).
457
+ if (e && e.frqtlRequiredRenderer) { return { requiredRenderer: e.frqtlRequiredRenderer }; }
458
+ throw e;
459
+ }
460
+ var r = window.FrqtlPage.runner;
461
+ if (!r) { throw new Error('[FrqtlHostedEyes/in-page] no FrqtlPage.runner after apply'); }
462
+ r.stop(); // the RAF loop must not advance behind us
463
+ if (args.sourceRef !== null) { r.setSourceRef(args.sourceRef); }
464
+ // The declared look, BEFORE the establishing step, so tick 1 is already drawn under it.
465
+ // The door paints the page's error overlay and rethrows on any owner throw.
466
+ var applied = null;
467
+ if (args.renderState !== null) {
468
+ if (typeof window.FrqtlPage.applyRenderState !== 'function') {
469
+ throw new Error('[FrqtlHostedEyes/in-page] this page has no FrqtlPage.applyRenderState — ' +
470
+ 'the host boots the CANONICAL empty experiment page, never an arbitrary URL.');
471
+ }
472
+ applied = window.FrqtlPage.applyRenderState(args.renderState);
473
+ }
474
+ // ESTABLISH THE CLOCK with one step, deliberately. Applying a recipe RE-INITS the
475
+ // universe, and neither the host's retained TickData nor the compat universe's own
476
+ // `tick` field is cleared by that re-init — both keep reporting the blank page's last
477
+ // value until the next tick overwrites them (observed: a freshly regenerated scene
478
+ // read as "tick 10"). Every later read here is exact BECAUSE this process is the only
479
+ // stepper (the RAF loop is stopped above), so the fix is to make the first reading a
480
+ // real one rather than to trust a cache the re-init left behind. It costs nothing the
481
+ // contract was not already charging: a capture before the first tick is REFUSED
482
+ // anyway ("no tick has run yet" — never an empty buffer), so tick 1 is the earliest
483
+ // capturable coordinate of any scene.
484
+ r.step();
485
+ return { renderState: applied };
486
+ };
487
+
488
+ /** Is this the canonical page? Its public front door is published by its module script, so
489
+ it exists once the page has loaded — its absence then is decisive, with nothing to wait for. */
490
+ var _IN_PAGE_FRONT_DOOR = function () {
491
+ return !!(window.FrqtlPage && typeof window.FrqtlPage.isReady === 'function');
492
+ };
493
+
494
+ /** Settled = ready, or FAILED per the boot record (with the overlay's own text); else null. */
495
+ var _IN_PAGE_BOOT_SETTLED = function () {
496
+ var failed = document.documentElement.getAttribute('data-egpt-boot-failed');
497
+ if (failed) {
498
+ var box = document.querySelector('[data-egpt-boot-error]');
499
+ return { failed: failed, text: box ? box.textContent : null };
500
+ }
501
+ return window.FrqtlPage.isReady() ? { ready: true } : null;
502
+ };
503
+
504
+ /** The boot record's pending phase, read after a stall. */
505
+ var _IN_PAGE_BOOT_STATE = function () {
506
+ var el = document.documentElement;
507
+ return {
508
+ phase: el.getAttribute('data-egpt-boot-phase'),
509
+ done: el.getAttribute('data-egpt-boot-done'),
510
+ total: el.getAttribute('data-egpt-boot-total')
511
+ };
512
+ };
513
+
514
+ /** The page's own SceneRecord — the identity half this process cannot resolve. */
515
+ var _IN_PAGE_SCENE = function () {
516
+ return window.FrqtlPage.runner.sceneRecord();
517
+ };
518
+
519
+ /** What display does the BUILT WORLD require, and what did this page boot? */
520
+ var _IN_PAGE_DISPLAY = function () {
521
+ return window.FrqtlPage.displayResolution();
522
+ };
523
+
524
+ /**
525
+ * One tick, awaited by the caller: the control-plane step warmUpRealm drives.
526
+ *
527
+ * It HONOURS THE HOLD rather than racing it: an in-page recorder holds the clock from a
528
+ * sampled tick until that capture settles, and `step()` refuses to advance a held clock. So
529
+ * the step waits (a macrotask at a time) for `ticksSuspended` to clear, bounded by the
530
+ * handle's timeout, and throws naming the hold if it never does.
531
+ */
532
+ var _IN_PAGE_STEP = function (args) {
533
+ var r = window.FrqtlPage.runner;
534
+ var deadline = Date.now() + args.timeoutMs;
535
+ return new Promise(function (resolve, reject) {
536
+ (function attempt() {
537
+ if (r.ticksSuspended) {
538
+ if (Date.now() > deadline) {
539
+ reject(new Error('[FrqtlHostedEyes/in-page] the tick clock stayed HELD ' +
540
+ '(runner.ticksSuspended) for ' + args.timeoutMs + 'ms — a capture or ' +
541
+ 'waypoint handler in the page never settled, so the drive cannot ' +
542
+ 'advance. (FAIL-LOUD.)'));
543
+ return;
544
+ }
545
+ setTimeout(attempt, 0);
546
+ return;
547
+ }
548
+ try {
549
+ var td = r.step();
550
+ resolve((td && td.universeData) ? td.universeData.tick : null);
551
+ } catch (e) { reject(e); }
552
+ })();
553
+ });
554
+ };
555
+
556
+ /** The current tick, read (never advanced). */
557
+ var _IN_PAGE_TICK = function () {
558
+ var td = window.FrqtlPage.runner._lastTickData;
559
+ return (td && td.universeData) ? td.universeData.tick : null;
560
+ };
561
+
562
+ /** Point the ONE camera and redraw — pose is a still coordinate, set before capture. */
563
+ var _IN_PAGE_POSE = function (pose) {
564
+ var r = window.FrqtlPage.runner;
565
+ var cam = r.cameraControl;
566
+ if (!cam) {
567
+ throw new Error('[FrqtlHostedEyes/in-page] this page\'s active provider exposes no ' +
568
+ 'camera pose control, so a pose cannot be applied (a 2D stack has no orbit). ' +
569
+ 'Capture without one — the still will report pose honestly.');
570
+ }
571
+ cam.setOrbit(pose);
572
+ r.repaint();
573
+ return cam.getOrbit();
574
+ };
575
+
576
+ // ── open() ────────────────────────────────────────────────────────────────
577
+
578
+ /**
579
+ * open(opts) → Promise<handle> — boot a hosted eye on a regenerable scene.
580
+ *
581
+ * @param {object} opts
582
+ * @param {object} opts.sceneRecord — this process's identity half (seed, sourceRef).
583
+ * `build`/`projection`/`physicsW`/`physicsH` are RESOLVED from the page (the bounds from the
584
+ * world the source's co-init call built). Supplying any of them is an EXPECTATION, checked
585
+ * after boot and THROWN on mismatch — never a request; a stated bound that is not a
586
+ * positive integer is refused before a browser is acquired.
587
+ * @param {{kind:'project', files:Object<string,string>, entry:string}} opts.sceneSource — the
588
+ * REGENERATE-grade scene: a project, linked in the page by its one door.
589
+ * @param {object} [opts.renderState] — the declared LOOK, ferried: a renderState bag (for a
590
+ * rung, `effectiveRenderState(rung, mode)`; or a saved project's bag). Its `renderer` boots
591
+ * the page; its rows, camera and backdrop are applied by `FrqtlPage.applyRenderState` before
592
+ * the establishing step. FAIL-LOUD: a row no renderer declares (the owner's throw); a row or
593
+ * camera preset the booted display cannot take (RETAINED — a hosted eye has no later and no
594
+ * chrome, so a declared look that did not land is refused, not recorded); a `renderer` the
595
+ * source does not build; a `seed` that is not the record's; an `engine` other than wasm.
596
+ * `setPose` after open overrides its camera. Omit it for a scene's PLAIN render.
597
+ * @param {string} opts.pageUrl — the canonical empty experiment page in YOUR deployment
598
+ * (dev server: `<origin>/frqtl/experiments/empty/index.html`; published dist:
599
+ * `<origin>/experiments/empty/index.html`). Required rather than guessed: the address of
600
+ * a deployment is not something a library may invent.
601
+ * @param {object} opts.browserType — a Playwright browser type (`chromium`). The
602
+ * `@descix/frqtl-node/eyes` entry supplies the packaged default; passing your own IS the
603
+ * BYO seam.
604
+ * @param {string[]} [opts.launchArgs] — overrides the hardware-GL default wholesale.
605
+ * @param {{width:number,height:number}} [opts.viewport]
606
+ * @param {number} [opts.timeoutMs=60000] — the boot budget. Only a STALL waits it out: a
607
+ * page that is not the canonical one, a URL that did not serve and a stack that failed are
608
+ * refused by name as soon as they are decidable.
609
+ * @returns {Promise<object>} the handle (see the module docstring).
610
+ */
611
+ function open(opts) {
612
+ // A Promise-returning entry point must NEVER throw synchronously: a mixed error
613
+ // channel (sync throw for a bad argument, rejection for a bad boot) guarantees an
614
+ // uncaught exception at some call site that only wrote `.catch()`. Same wrapper, same
615
+ // reason, as the tap mechanism's `capture()`.
616
+ try { return _open(opts || {}); }
617
+ catch (e) { return Promise.reject(e); }
618
+ }
619
+
620
+ /** @private — the body; see `open` for the contract. */
621
+ function _open(opts) {
622
+ var C = _contracts();
623
+ _assertHostable(opts);
624
+ _assertBoundShape(opts.sceneRecord, C.RS);
625
+ var declared = _declaredRenderState(opts, opts.sceneRecord, C.RST);
626
+ if (!opts.pageUrl || typeof opts.pageUrl !== 'string') {
627
+ throw new Error('[FrqtlHostedEyes.open] `pageUrl` is REQUIRED: the URL of the ' +
628
+ 'CANONICAL empty experiment page (lib/frqtl/experiments/empty/index.html) in your ' +
629
+ 'deployment — a dev server exposes it at its /frqtl mount, a published dist at ' +
630
+ '<origin>/experiments/empty/index.html. A library may not invent the address of ' +
631
+ 'your deployment, and it must not ship a second copy of that page. (FAIL-LOUD.)');
632
+ }
633
+ if (!opts.browserType || typeof opts.browserType.launch !== 'function') {
634
+ throw new Error('[FrqtlHostedEyes.open] `browserType` is REQUIRED — a Playwright ' +
635
+ 'browser type (chromium). The dependency is declared by the eyes ENTRY that ' +
636
+ 'ships it (@descix/frqtl-node/eyes), never by this layer, so the core library ' +
637
+ 'stays dependency-free and a BYO browser stack is a first-class option. ' +
638
+ '(FAIL-LOUD.)');
639
+ }
640
+ var timeout = (typeof opts.timeoutMs === 'number') ? opts.timeoutMs : 60000;
641
+ var record = opts.sceneRecord;
642
+ var sourceRef = (record.sourceRef === undefined || record.sourceRef === null)
643
+ ? null : String(record.sourceRef);
644
+
645
+ var browser = null, page = null;
646
+ return Promise.resolve(opts.browserType.launch({
647
+ headless: true,
648
+ args: Array.isArray(opts.launchArgs) ? opts.launchArgs : _defaultLaunchArgs()
649
+ })).then(function (b) {
650
+ browser = b;
651
+ return b.newPage({ viewport: opts.viewport || { width: 900, height: 700 } });
652
+ }).then(function (p) {
653
+ page = p;
654
+ /**
655
+ * Boot the canonical page at `url`, wait for the runner, apply the source, and
656
+ * report the display the BUILT WORLD requires. @private
657
+ */
658
+ function bootAndApply(url) {
659
+ return page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeout })
660
+ .then(function (resp) {
661
+ // D: a non-2xx response is decisive now (goto RESOLVES on a 404).
662
+ if (resp && !resp.ok()) {
663
+ throw _refuseUnserved(url, 'HTTP ' + resp.status() +
664
+ (resp.statusText() ? ' ' + resp.statusText() : ''));
665
+ }
666
+ return resp ? resp.status() : null;
667
+ }, function (navErr) {
668
+ throw _refuseUnserved(url, 'navigation error: ' + ((navErr && navErr.message) || String(navErr)));
669
+ })
670
+ .then(function (status) {
671
+ // A: the canonical page's front door exists by DOMContentLoaded.
672
+ return page.evaluate(_IN_PAGE_FRONT_DOOR).then(function (isCanonical) {
673
+ if (!isCanonical) { throw _refuseNotCanonical(url, status === null ? '(no response)' : status); }
674
+ });
675
+ })
676
+ .then(function () {
677
+ // The page boots ENGINE then RENDERER then p5 (which calls setup()); the
678
+ // runner exists only after that chain completes. Waiting on the page's
679
+ // own boot record — ready, or FAILED (B) — not on a timer, is what makes
680
+ // this deterministic; only a STALL (C) waits out the budget.
681
+ return page.waitForFunction(_IN_PAGE_BOOT_SETTLED, undefined, { timeout: timeout })
682
+ .then(function (h) { return h.jsonValue(); }, function (e) {
683
+ if (!e || e.name !== 'TimeoutError') { throw e; }
684
+ return page.evaluate(_IN_PAGE_BOOT_STATE).then(function (st) {
685
+ throw _refuseStalled(url, timeout, st);
686
+ });
687
+ })
688
+ .then(function (settled) {
689
+ if (settled.failed) { throw _refuseBootFailed(url, settled); }
690
+ });
691
+ })
692
+ .then(function () {
693
+ return page.evaluate(_IN_PAGE_APPLY,
694
+ { files: opts.sceneSource.files, entry: opts.sceneSource.entry, sourceRef: sourceRef, renderState: declared });
695
+ })
696
+ .then(function (applied) {
697
+ return page.evaluate(_IN_PAGE_DISPLAY).then(function (disp) {
698
+ return { disp: disp, renderState: (applied && applied.renderState) || null };
699
+ });
700
+ });
701
+ }
702
+
703
+ // ── SOURCE declares → PAGE resolves → PROVENANCE reports ─────────
704
+ // The first boot takes the page's own default display, because nothing outside
705
+ // the source knows what kind of scene this is. The page then reads the 3D-ness
706
+ // off the world the source just BUILT. If that disagrees with what booted, the
707
+ // page is reloaded with ITS OWN answer — the sanctioned RELOAD semantics of the
708
+ // display slot (the display provider is boot-fixed; a re-selection is a reload),
709
+ // and the reason this is a reload rather than a live swap is that a canvas owns
710
+ // ONE rendering context and the runner memoizes the provider it started under.
711
+ // One extra boot, only when the default was wrong, and never a caller knob.
712
+ // A DECLARED renderState renderer boots the first page instead of the default, and
713
+ // is then a declaration to verify, not a guess to correct: a source that builds a
714
+ // different display THROWS rather than reloading over it.
715
+ var declaredRenderer = declared ? declared.renderer : null;
716
+ return bootAndApply(_pageUrlFor(opts.pageUrl, record, C.PP, declaredRenderer)).then(function (boot) {
717
+ var disp = boot.disp;
718
+ if (disp.required.renderer === disp.booted.renderer) { return boot; }
719
+ if (declaredRenderer) {
720
+ throw new Error('[FrqtlHostedEyes.open] the renderState declares the ' +
721
+ JSON.stringify(declaredRenderer) + ' display and this scene\'s source requires the ' +
722
+ JSON.stringify(disp.required.renderer) + ' display. The bag and the source name ' +
723
+ 'two different worlds; a rung\'s bag belongs to the rung\'s own scene text. ' +
724
+ '(FAIL-LOUD — a 3D scene drawn by a 2D display looks fine and is wrong.)');
725
+ }
726
+ return bootAndApply(_pageUrlFor(opts.pageUrl, record, C.PP, disp.required.renderer));
727
+ }).then(function (boot) {
728
+ var disp = boot.disp;
729
+ if (disp.required.renderer !== disp.booted.renderer) {
730
+ throw new Error('[FrqtlHostedEyes.open] this scene\'s source requires the ' +
731
+ JSON.stringify(disp.required.renderer) + ' display and the page booted ' +
732
+ JSON.stringify(disp.booted.renderer) + ' even after a reload carrying that ' +
733
+ 'answer. A 3D scene drawn by a 2D display looks fine and is wrong, so this ' +
734
+ 'refuses rather than returning it. (FAIL-LOUD.)');
735
+ }
736
+ _assertLanded(boot.renderState);
737
+ });
738
+ }).then(function () {
739
+ return page.evaluate(_IN_PAGE_SCENE);
740
+ }).then(function (pageScene) {
741
+ // The caller's `build`/`projection`, if any, were an EXPECTATION about what the
742
+ // source would resolve to — checked here, against what it actually did.
743
+ _assertExpectedDisplay(record, pageScene);
744
+ _assertExpectedBounds(record, pageScene, C.RS);
745
+ // ── The KIND, over this page ────────────────────────────────────
746
+ var identity = { build: pageScene.build, projection: pageScene.projection };
747
+ var disposed = false;
748
+ // The in-page recorder subscribes when its BEGIN lands; a drive waits on this gate so
749
+ // no tick runs between a record() call and the page-side subscription.
750
+ var gate = { opened: Promise.resolve(), seq: 0 };
751
+ var host = {
752
+ kind: C.CH.CAPTURE_HOST_KIND,
753
+ // FERRIED, never a local list: the PAGE's dispatcher is the authority on which
754
+ // stages are tappable, so a stage that gains an executor there works here the
755
+ // same day, and one that has none fails loud with the page's own message.
756
+ stages: C.RS.RENDER_STAGES.slice(),
757
+ identity: function () { return identity; },
758
+ capture: function (stage) {
759
+ if (disposed) {
760
+ throw new Error('[FrqtlHostedEyes] this handle is DISPOSED — its browser ' +
761
+ 'is closed. Open a new one; a capture surface that silently ' +
762
+ 'resurrected a browser would leak processes by design. (FAIL-LOUD.)');
763
+ }
764
+ return page.evaluate(C.CH.IN_PAGE_CAPTURE, { stage: stage, modules: _inPageModules(page.url()) })
765
+ .then(function (env) { return C.CH.decodeStillEnvelope(env); });
766
+ },
767
+ record: function (recordOpts) {
768
+ if (disposed) {
769
+ return Promise.reject(new Error('[FrqtlHostedEyes] this handle is DISPOSED — ' +
770
+ 'its browser is closed. Open a new one. (FAIL-LOUD.)'));
771
+ }
772
+ gate.seq += 1;
773
+ var id = gate.seq;
774
+ // A signal cannot cross `evaluate`: the page-side record gets its own
775
+ // controller (parked by BEGIN), and this signal is ferried to it by ABORT.
776
+ var signal = recordOpts.signal || null;
777
+ var pageOpts = {};
778
+ for (var k in recordOpts) { if (k !== 'signal') { pageOpts[k] = recordOpts[k]; } }
779
+ var begun = page.evaluate(C.CH.IN_PAGE_RECORD_BEGIN, { id: id, opts: pageOpts, modules: _inPageModules(page.url()) });
780
+ gate.opened = begun.then(null, function () {});
781
+ var onAbort = null;
782
+ var aborted = null;
783
+ if (signal) {
784
+ var sendAbort = function () {
785
+ return begun.then(function () {
786
+ return page.evaluate(C.CH.IN_PAGE_RECORD_ABORT, { id: id, modules: _inPageModules(page.url()) });
787
+ });
788
+ };
789
+ if (signal.aborted) { aborted = sendAbort(); }
790
+ else {
791
+ onAbort = function () { aborted = sendAbort(); };
792
+ signal.addEventListener('abort', onAbort, { once: true });
793
+ }
794
+ }
795
+ var detach = function () { if (onAbort) { signal.removeEventListener('abort', onAbort); } };
796
+ return begun.then(function () {
797
+ return page.evaluate(C.CH.IN_PAGE_RECORD_END, { id: id, modules: _inPageModules(page.url()) });
798
+ }).then(function (env) {
799
+ detach();
800
+ // A failed ABORT delivery is surfaced, never swallowed.
801
+ return Promise.resolve(aborted).then(function () { return env; });
802
+ }, function (e) { detach(); throw e; });
803
+ },
804
+ dispose: function () {
805
+ if (disposed) { return Promise.resolve(); }
806
+ disposed = true;
807
+ return Promise.resolve(browser.close());
808
+ }
809
+ };
810
+
811
+ // The node-side instrument: no world, no provider, no DOM. Attaching the host
812
+ // EXTENDS its identity with the page's build/projection (a.3), and its physics
813
+ // bounds are the page's too — the world the source's co-init call built, never a
814
+ // caller's restatement — so the record it computes and the one the page stamps are
815
+ // the same record.
816
+ var inst = FrqtlCapture.create(
817
+ { lastTickData: null },
818
+ function () { return null; },
819
+ { seed: record.seed, rect: { w: pageScene.physicsW, h: pageScene.physicsH }, sourceRef: sourceRef },
820
+ function () { return {}; }
821
+ );
822
+ inst.attachCaptureHost(host);
823
+
824
+ return projectIdentity({ files: projectBytes(opts.sceneSource.files) }).then(function (sha) {
825
+ inst.setSourceSha256(sha);
826
+ return inst.sceneRecord();
827
+ }).then(function (nodeScene) {
828
+ // Prove a.3 AT OPEN, by field — not at the first capture, by hash.
829
+ var diff = C.CH.diffSceneRecords(nodeScene, pageScene);
830
+ if (diff.length) {
831
+ throw new Error('[FrqtlHostedEyes.open] the hosted page is not running the ' +
832
+ 'scene this handle was opened for: [' + diff.join(', ') + '] disagree. ' +
833
+ 'Node ' + JSON.stringify(_pick(nodeScene, diff)) + ' vs page ' +
834
+ JSON.stringify(_pick(pageScene, diff)) + '. The page was booted with ' +
835
+ 'this record\'s seed (?seed=) and handed its sourceRef, and did not take ' +
836
+ 'them. (FAIL-LOUD — a hosted still filed under the wrong scene verifies ' +
837
+ 'forever against the wrong header.)');
838
+ }
839
+ return _handle(browser, page, host, inst, nodeScene, gate, timeout);
840
+ });
841
+ }).catch(function (e) {
842
+ // A half-opened handle that leaked a browser would poison the zero-orphans gate
843
+ // for every later run in the same shell — so open() cleans up its OWN acquisition.
844
+ if (browser) { return Promise.resolve(browser.close()).then(function () { throw e; },
845
+ function () { throw e; }); }
846
+ throw e;
847
+ });
848
+ }
849
+
850
+ /** @private */
851
+ function _pick(rec, keys) {
852
+ var out = {};
853
+ for (var i = 0; i < keys.length; i++) { out[keys[i]] = rec[keys[i]]; }
854
+ return out;
855
+ }
856
+
857
+ /** The consumer-facing handle. @private */
858
+ function _handle(browser, page, host, inst, scene, gate, timeout) {
859
+ var self = {
860
+ /**
861
+ * The SAME face as everywhere else: getScene / getStill / stages / toPNG, and
862
+ * `record(opts)`, which runs the one recorder IN the hosted page. Issue the drive
863
+ * after it: `const p = eyes.vision.record(opts); await eyes.driveToTick(n); await p`.
864
+ */
865
+ vision: FrqtlVision.attach(inst),
866
+ /** The tap MECHANISM, for a consumer that wants `capture(stage)` directly. */
867
+ capture: inst,
868
+ /** The node-side SceneRecord (page-extended) + its content id. */
869
+ scene: scene,
870
+
871
+ /** @returns {Promise<number|null>} the page's current tick — READ, never advanced. */
872
+ tick: function () { return page.evaluate(_IN_PAGE_TICK); },
873
+
874
+ /**
875
+ * driveToTick(n) — advance the hosted world to EXACTLY tick `n`, or throw.
876
+ *
877
+ * One awaited `step()` per tick through `warmUpRealm` (the existing authority),
878
+ * then a verify: "approximately tick n" is not a coordinate, and a still stamped
879
+ * with a tick nobody asked for is worse than an error because it looks fine.
880
+ * @param {number} n
881
+ * @returns {Promise<number>} n
882
+ */
883
+ driveToTick: function (n) {
884
+ if (!Number.isInteger(n) || n < 0) {
885
+ // REJECTION, never a sync throw — see open()'s wrapper for why.
886
+ return Promise.reject(new Error('[FrqtlHostedEyes.driveToTick] a tick is a ' +
887
+ 'non-negative integer coordinate; got ' + String(n) + '. (FAIL-LOUD.)'));
888
+ }
889
+ return gate.opened.then(function () { return self.tick(); }).then(function (now) {
890
+ var at = (typeof now === 'number') ? now : 0;
891
+ if (at > n) {
892
+ throw new Error('[FrqtlHostedEyes.driveToTick] the hosted world is ' +
893
+ 'already at tick ' + at + ', past ' + n + '. A world runs forward ' +
894
+ 'only: open a fresh handle to reach an earlier tick (that is what ' +
895
+ 'the seed is for). (FAIL-LOUD.)');
896
+ }
897
+ return _warmup().then(function (W) {
898
+ return W.warmUpRealm({ step: function () { return page.evaluate(_IN_PAGE_STEP, { timeoutMs: timeout }); } },
899
+ n - at, 0, W.MAX_PRODUCE_AFTER_TICKS);
900
+ });
901
+ }).then(function () {
902
+ return self.tick();
903
+ }).then(function (landed) {
904
+ if (landed !== n) {
905
+ throw new Error('[FrqtlHostedEyes.driveToTick] asked for tick ' + n +
906
+ ' and the hosted world is at ' + String(landed) + '. Exact-or-throw: ' +
907
+ 'the tick is a co-registration key, and a still stamped with a tick ' +
908
+ 'nobody asked for is worse than an error. (FAIL-LOUD.)');
909
+ }
910
+ return n;
911
+ });
912
+ },
913
+
914
+ /**
915
+ * setPose(orbit) — point the ONE camera authority and redraw.
916
+ * @param {object} orbit — an orbit bag (`{azimuth, elevation, distance, roll?}`)
917
+ * @returns {Promise<object>} the pose the camera actually took (read back)
918
+ */
919
+ setPose: function (orbit) { return page.evaluate(_IN_PAGE_POSE, orbit); },
920
+
921
+ /**
922
+ * saveStill(still, file) — write a still to disk as a PNG.
923
+ *
924
+ * The write convenience over `FrqtlVision.toPNG` (the NODE encoder on the ONE
925
+ * face) — "get a picture from a script" in one line, which is the most natural
926
+ * thing to want from this surface and, until V4, the one thing the package could
927
+ * not do (`toDataURL` needs a DOM, and a hosted eye has none).
928
+ *
929
+ * SYNCHRONOUS on purpose: unlike every other method here it crosses NO boundary —
930
+ * the pixels are already in this process, and a Promise would imply a page round
931
+ * trip that does not happen.
932
+ *
933
+ * @param {object} still — an rgba8 still (`getStill('composite')`)
934
+ * @param {string} file — the destination path (directories created)
935
+ * @returns {string} `file`
936
+ */
937
+ saveStill: function (still, file) {
938
+ if (typeof file !== 'string' || !file) {
939
+ throw new Error('[FrqtlHostedEyes.saveStill] a destination path is REQUIRED ' +
940
+ '— saveStill(still, "out.png"). (FAIL-LOUD.)');
941
+ }
942
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
943
+ fs.writeFileSync(file, FrqtlVision.toPNG(still));
944
+ return file;
945
+ },
946
+
947
+ /**
948
+ * saveVideo(envelope, file) — write a recording's MP4 bytes to disk.
949
+ *
950
+ * The twin of `saveStill`: the envelope is re-validated by its OWNER
951
+ * (`CaptureEnvelope.videoEnvelope`) and its base64 bytes are decoded to the file.
952
+ * SYNCHRONOUS — the bytes are already in this process.
953
+ *
954
+ * @param {object} envelope — what `vision.record` resolved
955
+ * @param {string} file — the destination path (directories created)
956
+ * @returns {string} `file`
957
+ */
958
+ saveVideo: function (envelope, file) {
959
+ if (typeof file !== 'string' || !file) {
960
+ throw new Error('[FrqtlHostedEyes.saveVideo] a destination path is REQUIRED ' +
961
+ '— saveVideo(envelope, "out.mp4"). (FAIL-LOUD.)');
962
+ }
963
+ if (!envelope || envelope.kind !== 'video') {
964
+ throw new Error('[FrqtlHostedEyes.saveVideo] expected the video envelope ' +
965
+ 'vision.record resolves, got kind ' + JSON.stringify(envelope && envelope.kind) +
966
+ '. (FAIL-LOUD.)');
967
+ }
968
+ var env = CaptureEnvelope.videoEnvelope({ track: envelope.track, encoded: envelope.encoded, truncated: envelope.truncated });
969
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
970
+ fs.writeFileSync(file, Buffer.from(env.encoded.bytes, 'base64'));
971
+ return file;
972
+ },
973
+
974
+ /** Close the managed browser. Symmetric with open(), idempotent. */
975
+ dispose: function () { return host.dispose(); }
976
+ };
977
+ return self;
978
+ }
979
+
980
+ // ── Export ────────────────────────────────────────────────────────────────
981
+
982
+ var FrqtlHostedEyes = { open: open };
983
+
984
+ export { FrqtlHostedEyes };
985
+