@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.
- package/CLAUDE.md +87 -0
- package/README.md +84 -2
- package/dist/dist-manifest.json +218 -0
- package/dist/eyes/index.js +109 -0
- package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
- package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
- package/dist/fat/pkg-node/package.json +12 -0
- package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
- package/dist/frqtl/compat/boot-node.js +34 -0
- package/dist/frqtl/compat/engine.js +127 -0
- package/dist/frqtl/compat/package-root.js +24 -0
- package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
- package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
- package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
- package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
- package/dist/frqtl/compat/renderer/Filters.js +163 -0
- package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
- package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
- package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
- package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
- package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
- package/dist/frqtl/compat/renderer/display-slot.js +96 -0
- package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
- package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
- package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
- package/dist/frqtl/compat/renderer/providers/index.js +22 -0
- package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
- package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
- package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
- package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
- package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
- package/dist/frqtl/compat/vendor.js +196 -0
- package/dist/frqtl/node-entry.js +17 -0
- package/dist/frqtl/objects/FrqtlApp.js +2922 -0
- package/dist/frqtl/objects/ObjectFactory.js +297 -0
- package/dist/frqtl/objects/RecipeContext.js +322 -0
- package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
- package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
- package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
- package/dist/frqtl/objects/instruments/capture-host.js +453 -0
- package/dist/frqtl/objects/instruments/capture.js +711 -0
- package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
- package/dist/frqtl/objects/kinds-entry.js +14 -0
- package/dist/frqtl/objects/kinds.js +190 -0
- package/dist/frqtl/objects/objects/Box.js +273 -0
- package/dist/frqtl/objects/objects/Bullet.js +155 -0
- package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
- package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
- package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
- package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
- package/dist/frqtl/objects/objects/LightSource.js +354 -0
- package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
- package/dist/frqtl/objects/objects/Tube.js +471 -0
- package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
- package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
- package/dist/frqtl/objects/objects/controlAction.js +89 -0
- package/dist/frqtl/objects/objects/controlBind.js +278 -0
- package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
- package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
- package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
- package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
- package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
- package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
- package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
- package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
- package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
- package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
- package/dist/frqtl/objects/objects/tagger.js +228 -0
- package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
- package/dist/frqtl/objects/schema/index.js +256 -0
- package/dist/frqtl/objects/schema/scopes.js +457 -0
- package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
- package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
- package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
- package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
- package/dist/frqtl/objects/setup/_worldInit.js +162 -0
- package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
- package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
- package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
- package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
- package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
- package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
- package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
- package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
- package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
- package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
- package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
- package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
- package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
- package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
- package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
- package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
- package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
- package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
- package/dist/frqtl/objects/v1-replacements.js +30 -0
- package/dist/frqtl/package-core.js +23 -0
- package/dist/frqtl/parts/CameraControls.js +643 -0
- package/dist/frqtl/parts/CameraPath.js +575 -0
- package/dist/frqtl/parts/CollisionActions.js +35 -0
- package/dist/frqtl/parts/CollisionTypes.js +31 -0
- package/dist/frqtl/parts/DetectorWall.js +496 -0
- package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
- package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
- package/dist/frqtl/parts/FrqtlVision.js +818 -0
- package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
- package/dist/frqtl/parts/LargeObject.js +406 -0
- package/dist/frqtl/parts/LightColor.js +255 -0
- package/dist/frqtl/parts/LiveChart.js +285 -0
- package/dist/frqtl/parts/LogicGate.js +720 -0
- package/dist/frqtl/parts/OvenBox.js +612 -0
- package/dist/frqtl/parts/PointSource.js +763 -0
- package/dist/frqtl/parts/Rectangle.js +200 -0
- package/dist/frqtl/parts/SlitLightSource.js +234 -0
- package/dist/frqtl/parts/SlitScreen.js +628 -0
- package/dist/frqtl/parts/SphereEmitter.js +946 -0
- package/dist/frqtl/parts/Trace.js +508 -0
- package/dist/frqtl/parts/Tunnel.js +1057 -0
- package/dist/frqtl/parts/Wall.js +1211 -0
- package/dist/frqtl/parts/index.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall.js +32 -0
- package/dist/frqtl/scenes/atomic-model.js +88 -0
- package/dist/frqtl/scenes/big-bang.js +46 -0
- package/dist/frqtl/scenes/blackbody.js +81 -0
- package/dist/frqtl/scenes/circuit-sat.js +127 -0
- package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
- package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
- package/dist/frqtl/scenes/counting-wall.js +38 -0
- package/dist/frqtl/scenes/double-slit.js +31 -0
- package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
- package/dist/frqtl/scenes/dse-one-slit.js +114 -0
- package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
- package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
- package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
- package/dist/frqtl/scenes/dse-two-slits.js +132 -0
- package/dist/frqtl/scenes/empty.js +14 -0
- package/dist/frqtl/scenes/laser-buildup.js +49 -0
- package/dist/frqtl/scenes/mirror-3d.js +56 -0
- package/dist/frqtl/scenes/mirror-route.js +140 -0
- package/dist/frqtl/scenes/mirror.js +38 -0
- package/dist/frqtl/scenes/omni-3d.js +27 -0
- package/dist/frqtl/scenes/omni-source.js +22 -0
- package/dist/frqtl/scenes/self-interference.js +39 -0
- package/dist/frqtl/scenes/smoking-gun.js +122 -0
- package/dist/frqtl/scenes/straight-walk.js +22 -0
- package/dist/frqtl/scenes/sun.js +39 -0
- package/dist/frqtl/scenes/two-source.js +37 -0
- package/dist/frqtl/scenes/wave.js +26 -0
- package/dist/shim/bundle/frqnb.js +112 -0
- package/dist/shim/bundle/index.js +121 -0
- package/dist/shim/bundle/indexHtml.js +107 -0
- package/dist/shim/bundle/manifest.js +95 -0
- package/dist/shim/bundle/zip.js +243 -0
- package/dist/shim/bus.js +67 -0
- package/dist/shim/controls/index.js +627 -0
- package/dist/shim/controls/registry.js +274 -0
- package/dist/shim/engine/artifact-shell.js +212 -0
- package/dist/shim/engine/boot-phases.js +44 -0
- package/dist/shim/engine/engine-stack-boot.js +59 -0
- package/dist/shim/engine/router.js +692 -0
- package/dist/shim/experiment-frame/index.js +296 -0
- package/dist/shim/expr/ast-schema.js +369 -0
- package/dist/shim/field-descriptor/index.js +326 -0
- package/dist/shim/frame-data/index.js +224 -0
- package/dist/shim/index.js +67 -0
- package/dist/shim/notebook/canvas-guard.js +82 -0
- package/dist/shim/notebook/catalog.js +35 -0
- package/dist/shim/notebook/format.js +736 -0
- package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
- package/dist/shim/notebook/kernel.js +672 -0
- package/dist/shim/notebook/knob.js +151 -0
- package/dist/shim/notebook/module-resolver.js +260 -0
- package/dist/shim/notebook/notebook-knobs.js +65 -0
- package/dist/shim/notebook/produced-bindings.js +67 -0
- package/dist/shim/notebook/realm-registry.js +121 -0
- package/dist/shim/notebook/realm-transport.js +73 -0
- package/dist/shim/notebook/setup-builder.js +510 -0
- package/dist/shim/notebook/store.js +159 -0
- package/dist/shim/notebook/vendor-importmap.js +134 -0
- package/dist/shim/notebook/view-builder.js +189 -0
- package/dist/shim/notebook/view-ctx.js +100 -0
- package/dist/shim/notebook/view-handle.js +202 -0
- package/dist/shim/notebook/warmup.js +102 -0
- package/dist/shim/package.json +4 -0
- package/dist/shim/page-params/index.js +214 -0
- package/dist/shim/project/identity.js +112 -0
- package/dist/shim/project/index.js +19 -0
- package/dist/shim/project/link.js +402 -0
- package/dist/shim/project/node-specifier-hook.mjs +35 -0
- package/dist/shim/project/register-node.mjs +17 -0
- package/dist/shim/project/specifiers.js +106 -0
- package/dist/shim/registry/copy-gate.js +110 -0
- package/dist/shim/registry/exhibits.js +455 -0
- package/dist/shim/registry/experiments.js +215 -0
- package/dist/shim/registry/index.js +19 -0
- package/dist/shim/registry/scene-source.js +157 -0
- package/dist/shim/registry/walkthrough.js +807 -0
- package/dist/shim/render/capture-envelope.js +279 -0
- package/dist/shim/render/heat-colormap.js +92 -0
- package/dist/shim/render/render-stages.js +855 -0
- package/dist/shim/render/sun-look.js +33 -0
- package/dist/shim/render-state/apply.js +262 -0
- package/dist/shim/render-state/index.js +74 -0
- package/dist/shim/rpc/index.js +207 -0
- package/dist/shim/runtime/index.js +1023 -0
- package/dist/shim/storage/boot.js +69 -0
- package/dist/shim/storage/index.js +169 -0
- package/dist/shim/storage/local-storage-provider.js +230 -0
- package/dist/shim/storage/make-namespace-store.js +132 -0
- package/dist/shim/storage/modules.js +249 -0
- package/dist/shim/storage/projects.js +150 -0
- package/llms.txt +12 -0
- package/package.json +62 -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 (CEO-named 2026-07-30).
|
|
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
|
+
|