Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a19053dfd4 | ||
|
|
2548bb5740 | ||
|
|
fc5cbdff8a | ||
|
|
c9b1009259 | ||
|
|
6290fcb2ce | ||
|
|
81761067e9 | ||
|
|
7fba39a629 | ||
|
|
230cba4b21 | ||
|
|
e6dd8c2cf1 | ||
|
|
23181c867b | ||
|
|
cfc7b062da | ||
|
|
5a29a536a2 | ||
|
|
082b7057da | ||
|
|
10019a454a | ||
|
|
f1f8e21b78 | ||
|
|
ae2ce055c8 | ||
|
|
a7babf60db | ||
|
|
90450ae320 | ||
|
|
ecd95a22f0 | ||
|
|
9f712bf353 | ||
|
|
ee15160862 | ||
|
|
3a64f54dea | ||
|
|
a0706fd5d8 | ||
|
|
b53d6d5a45 | ||
|
|
ada2a55ee6 | ||
|
|
2b53168149 | ||
|
|
31fc4f6567 | ||
|
|
648d5bced4 | ||
|
|
75d2e5da4b | ||
|
|
57b2208cae | ||
|
|
fecb5885d0 | ||
|
|
71c85e9894 | ||
|
|
ffffee1879 | ||
|
|
d7a2c22faf | ||
|
|
7b7b5d6cad | ||
|
|
36f0c93d2a | ||
|
|
a29b38a0d7 | ||
|
|
eddc09008e | ||
|
|
a4accf0fb6 | ||
|
|
ea811ff370 | ||
|
|
d7749208a7 | ||
|
|
af9319a33b | ||
|
|
0ac6424c46 | ||
|
|
574a494759 | ||
|
|
aa3680948f | ||
|
|
9d51080d2e | ||
|
|
24a47318f2 | ||
|
|
f9ffb7bd1c | ||
|
|
e94c64eebd | ||
|
|
007ff9fff2 | ||
|
|
656f0c524d | ||
|
|
aa2df560b7 | ||
|
|
2bda1986bd | ||
|
|
a281faf923 | ||
|
|
05bdab24a5 | ||
|
|
e6f7648b84 | ||
|
|
19ab973110 | ||
|
|
63dd8c8790 | ||
|
|
27bf7527df | ||
|
|
9225227421 | ||
|
|
8459bb03fb |
+1
-1
@@ -9,4 +9,4 @@
|
||||
*.xbin binary
|
||||
*.las binary
|
||||
*.lcc binary
|
||||
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -12,6 +12,7 @@ __pycache__/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
apps/control-station/dist-shadow/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
@@ -21,10 +22,14 @@ build/
|
||||
!.env.example
|
||||
config/local.toml
|
||||
private/
|
||||
.runtime/
|
||||
|
||||
# Real laboratory captures and decoded artifacts are sensitive and large.
|
||||
captures/
|
||||
sessions/
|
||||
# The host runtime package shares the domain name but is source, not evidence.
|
||||
!src/k1link/sessions/
|
||||
!src/k1link/sessions/**/*.py
|
||||
artifacts/raw/
|
||||
artifacts/decoded/
|
||||
*.pcap
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Repository operating rules
|
||||
|
||||
This repository investigates an owner-controlled XGRIDS/LixelKity K1 as a
|
||||
black-box sensor. Preserve the device, the Mac, the working LAN, and raw
|
||||
evidence.
|
||||
NODEDC MISSION CORE is a vendor-neutral mission-control monorepo. Its first
|
||||
real device plugin investigates an owner-controlled XGRIDS/LixelKity K1 as a
|
||||
black-box sensor. Preserve the device, the host, the working LAN, raw evidence,
|
||||
and the boundary between Mission Core and vendor-specific integration code.
|
||||
|
||||
## Non-negotiable safety boundaries
|
||||
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
# NDC XGRIDS K1 Connector
|
||||
# NODEDC MISSION CORE
|
||||
|
||||
Pre-production research project for connecting an owner-controlled
|
||||
XGRIDS/LixelKity K1 to a Mac without LixelGO, firmware changes, device opening,
|
||||
or speculative writes.
|
||||
NODEDC MISSION CORE is the vendor-neutral control, observation, mission-planning,
|
||||
recording, and integration platform for NODEDC autonomous systems. This
|
||||
repository is the canonical early-stage monorepo: it contains the Mission Core
|
||||
Control Station, the current local control plane, shared contracts as they are
|
||||
extracted, and device plugins.
|
||||
|
||||
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
|
||||
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
|
||||
the scan-correlated point-cloud and pose streams, and decoded both successfully.
|
||||
The repository also contains a local React control console, an automatic Rerun
|
||||
gRPC bridge for the verified point-cloud and trajectory streams, and a
|
||||
self-hosted Rerun Web Viewer embedded in that console. The former Foxglove
|
||||
bridge remains only as a legacy regression module.
|
||||
The first proven hardware vertical is the XGRIDS/LixelKity K1 plugin. On firmware
|
||||
3.0.2 the host provisions the scanner onto an existing LAN without LixelGO,
|
||||
connects to its MQTT broker, persists each raw frame before preview work, decodes
|
||||
point cloud and pose, and renders the real cloud plus trajectory through an
|
||||
embedded self-hosted Rerun Web Viewer. Native MQTT persistence uses bounded
|
||||
group commit (at most 0.5 seconds, 4 MiB or 32 messages), and camera archives
|
||||
commit complete fMP4 segments before their index rows. These are explicit
|
||||
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
|
||||
Foxglove bridge remains only as a legacy regression module.
|
||||
|
||||
The backend vendor implementation is physically isolated below
|
||||
`src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility
|
||||
profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers
|
||||
archive sources, recovery hooks and recording exporters only through the plugin
|
||||
runtime contribution. The verified wire protocol and raw evidence format are
|
||||
unchanged.
|
||||
|
||||
The K1 acquisition now also requires an operator project name. Frontend and
|
||||
backend normalize it with NFKC plus surrounding-whitespace trimming, reject
|
||||
control/surrogate characters and values above 96 Unicode characters, and
|
||||
preserve it as local session/catalog display metadata. It is never used as a
|
||||
filesystem path. The physically accepted interactive control path carries the
|
||||
same validated value in its single canonical START; there is no separate
|
||||
project-name write.
|
||||
|
||||
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
|
||||
operation, runtime-action, stream, evidence and compatibility contracts. Every
|
||||
backend runtime must now pass a versioned descriptor/handshake against its
|
||||
manifest before actions are admitted. Every action then crosses immutable SDK
|
||||
`RuntimeActionInvocation` and `RuntimeActionResult` validation through a
|
||||
replaceable runtime transport seam. The meanings remain a local experimental
|
||||
vocabulary rather than a mutation of NODE.DC Platform Ontology. The current
|
||||
transport is deliberately in process: it uses an explicitly injected K1
|
||||
normalizer to produce transport-neutral local consumer views; portable SDK
|
||||
stream envelopes, process isolation, durable operations, multi-device routing
|
||||
and the remote Edge split remain later gates.
|
||||
|
||||
The application-control bootstrap reads and correlates live `DeviceInfo` before
|
||||
START. The exact profile requires model `LixelKity K1`, observed platform type
|
||||
`A4`, activated state, firmware `3.0.2`, and direct-LAN topology. The UI selects
|
||||
that profile but does not ask the operator to attest firmware manually.
|
||||
|
||||
The repository now contains one narrowly gated state-changing command:
|
||||
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
|
||||
@@ -18,12 +54,21 @@ profile and requires explicit `--confirm-write`; the Wi-Fi password is collected
|
||||
through a hidden local macOS dialog. MQTT capture and decoding are read-only.
|
||||
Nothing changes router settings, firmware or global Python packages.
|
||||
|
||||
## Available stand
|
||||
## Verified XGRIDS K1 stand
|
||||
|
||||
- one XGRIDS/LixelKity K1;
|
||||
- one Apple Silicon MacBook running macOS;
|
||||
- one ordinary TP-Link Deco/mesh network used by other devices;
|
||||
- no LixelGO, phone, Linux host, dedicated AP, OpenWrt, or vendor SDK.
|
||||
- the proven baseline used no LixelGO, phone, Linux host, dedicated AP,
|
||||
OpenWrt, or vendor SDK.
|
||||
|
||||
An owner-controlled iPhone with LixelGO is now available for a separate,
|
||||
evidence-only observation stage. It does not invalidate the no-phone baseline
|
||||
and is not a runtime dependency. The decision and gated runbook are
|
||||
[`ADR 0005`](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md) and
|
||||
[`docs/08_LIXELGO_IPHONE_OBSERVATION.md`](docs/08_LIXELGO_IPHONE_OBSERVATION.md).
|
||||
The Mac capture environment is isolated under
|
||||
`plugins/xgrids-k1/lab/iphone-capture/` and does not require full Xcode.
|
||||
|
||||
The ordinary router is sufficient for the first gates. We first observe the
|
||||
existing LAN without changing it. A Guest/IoT SSID is optional and may be
|
||||
@@ -39,10 +84,11 @@ The project has three independent gates:
|
||||
3. Without LixelGO, K1 can be associated with Wi-Fi and a proprietary data
|
||||
session can be opened.
|
||||
|
||||
All three gates are now proven on the tested unit. The external stream is plain
|
||||
All three gates are now proven on the tested unit. The spatial stream is plain
|
||||
MQTT 3.1.1 on TCP 1883. Firmware-3 `lio_pcl` is protobuf wrapped in a raw LZ4
|
||||
block, and `lio_pose` is an uncompressed protobuf. Raw panoramic camera access is
|
||||
still unproven and is not implied by point-cloud success.
|
||||
block, and `lio_pose` is an uncompressed protobuf. Owner-operated LixelGO capture
|
||||
also proved separate left/right RTSP/H.264 camera previews on TCP 8554. This is a
|
||||
compressed preview contract, not proof of full-resolution raw camera access.
|
||||
|
||||
## Local environment
|
||||
|
||||
@@ -51,26 +97,76 @@ This does not install Python packages globally and does not modify neighboring
|
||||
repositories.
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
|
||||
uv sync --group dev
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
|
||||
uv sync --frozen --group dev
|
||||
uv run k1link doctor
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## NODE.DC Control Station and visualization adapters
|
||||
## External perception worker
|
||||
|
||||
The browser application is now a universal NODE.DC Control Station rather than
|
||||
Mission Core can now package one sealed camera epoch as an immutable,
|
||||
content-addressed `missioncore.compute-job/v1` without changing the source
|
||||
evidence. The first recorded TEST007 job was digest-verified and decoded on a
|
||||
separate Windows RTX 4090 worker, executed through pinned Triton/YOLOX-S and
|
||||
returned a content-addressed `missioncore.compute-result/v1` containing 56
|
||||
session-timestamped generic COCO detections. The worker never connects to K1 and
|
||||
does not own the raw archive.
|
||||
|
||||
Mission Core now validates and projects that optional result into the same saved
|
||||
Rerun recording. Open TEST007 and use **Распознавание** to see the recorded
|
||||
left-camera frames and boxes; **Облако точек** returns to 3D. This remains a
|
||||
recorded camera-only acceptance, not a navigation stack: large-epoch
|
||||
performance, direct worker transport and bounded live fan-out remain open. See
|
||||
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
|
||||
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
|
||||
|
||||
## Simulation Polygon
|
||||
|
||||
Mission Core now has a parallel Polygon product branch for reproducible
|
||||
autonomy qualification. Mission Core owns scenarios, qualification-run
|
||||
lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
|
||||
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
|
||||
digital twin, HIL and physical shadow keep distinct causal run kinds.
|
||||
|
||||
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
|
||||
loopback-only process isolation, exact provider pins, a 2 ms physics step,
|
||||
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
|
||||
accepted-profile stock-rover runs and nine digest-bound evidence claims passed;
|
||||
the target doctor returned `GO`. The doctor remains read-only and a development
|
||||
host without the private evidence pack still returns `INCOMPLETE`:
|
||||
|
||||
```bash
|
||||
uv run missioncore-sim s0 doctor \
|
||||
--evidence /path/to/private/evidence.yaml \
|
||||
--json
|
||||
```
|
||||
|
||||
This accepts simulation infrastructure, not navigation behavior, safety
|
||||
behavior, S1 command authority or real actuator control.
|
||||
|
||||
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
|
||||
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md) and the
|
||||
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md).
|
||||
|
||||
## Mission Core Control Station and visualization adapters
|
||||
|
||||
The browser application is the universal Mission Core Control Station rather than
|
||||
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
||||
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
|
||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
|
||||
React provisioning, acquisition/replay and diagnostic blocks live beside the
|
||||
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
|
||||
mounts them through one reviewed composition import.
|
||||
|
||||
Install, type-check, build and serve the complete local application from the
|
||||
repository root:
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
uv sync --frozen --group dev
|
||||
cd apps/control-station
|
||||
npm ci
|
||||
npm run test:unit
|
||||
npm run typecheck
|
||||
npm run build
|
||||
cd ../..
|
||||
@@ -80,45 +176,146 @@ uv run k1link serve
|
||||
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
|
||||
plane and credential endpoint bind to loopback only. The current K1 adapter
|
||||
still provides real CoreBluetooth discovery, one operator-triggered reviewed
|
||||
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
|
||||
reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
|
||||
Physical K1 scanning is still started and stopped by the verified double-click;
|
||||
the connector publishes no modeling command.
|
||||
BLE Wi-Fi provisioning write, a one-shot CoreWLAN K1-AP association path,
|
||||
read-only MQTT live capture, native `.k1mqtt` and
|
||||
reviewed-TSV replay, raw-first evidence storage, device-reported scan
|
||||
time/distance/speed and measured preview metrics. Its frontend contribution now
|
||||
also owns an optional spatial-scene control block, including acquisition phase,
|
||||
telemetry and the stop action. Operator-manual acquisition still finalizes only
|
||||
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
||||
canonical K1 START/STOP dialogue.
|
||||
|
||||
Plugin v0.6.0 makes the local connection direction explicit. Bridge remains the
|
||||
default and accepted product path; Direct Connect sends the reviewed station
|
||||
provisioning frame for an already-running controller hotspot. Quick Connect
|
||||
sends one separately reviewed AP-enable frame and can associate a prepared Mac
|
||||
through CoreWLAN. Its device activation and prepared-host association were
|
||||
physically accepted, but credential bootstrap is not portable: a clean host
|
||||
cannot acquire the firmware-defined AP material from the reviewed BLE protocol.
|
||||
Quick Connect therefore remains laboratory functionality, with no automatic
|
||||
firmware download, iPhone extraction or hard-coded fallback. Each mode has a
|
||||
distinct topology attestation and no automatic retry; see
|
||||
[ADR 0013](docs/adr/0013-k1-local-connection-matrix.md).
|
||||
|
||||
The exact recovered `ModelingRequest` start/stop encoder, response correlator
|
||||
and device-status state machine are installed in the operator-gated interactive
|
||||
control path.
|
||||
The plugin also contains the exact ten-request pre-START bootstrap recovered
|
||||
from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID;
|
||||
the response supplies live vendor identity, serial, model, activation and
|
||||
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
|
||||
as one private application-level value in the observed client, not a manual
|
||||
per-scanner profile. The whole pre-START order and START/STOP payloads are
|
||||
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
|
||||
application authority without env/file/browser fallback, and a five-batch
|
||||
orchestrator passes all retained response barriers. The legacy shadow publisher
|
||||
boundary remains structurally write-disabled and cannot call its sink. Its
|
||||
dormant coordinator can hold authority
|
||||
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
|
||||
it together with the orchestrator. Plugin v0.5.0 installs a separate
|
||||
physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`,
|
||||
keepalive 60, exact
|
||||
response subscriptions, QoS2 completion and the five response-gated batches.
|
||||
It consumes operation keys before publish and poisons unknown outcomes without
|
||||
retry. One background owner services that socket through response-gated
|
||||
connection, workspace, project, START, STOP and device-standby stages. The normal
|
||||
UI collapses only the pre-START controls into one explicit operator intent;
|
||||
navigation cannot emit a command. The first operator-present physical attempt on 2026-07-18 emitted only
|
||||
the first six bootstrap requests, then failed closed while correlating the third
|
||||
response batch; START was not emitted. A second one-shot attempt correlated all
|
||||
ten pre-START requests and the START acknowledgement, but incorrectly collapsed
|
||||
the whole exchange into 365 ms and ended the control lifecycle before the
|
||||
retained post-START reads. K1 stayed in `SCAN_STARTING` for about 24 seconds,
|
||||
reported `ALGORITHM_ERROR` twice, emitted no point/pose data and showed steady
|
||||
red until an operator power cycle restored steady green. The collapsed executor
|
||||
is now disabled. The offline acceptance executor maps connection requests 1–6,
|
||||
workspace-entry request 7, project-prompt reads 8–10, user-confirmed START 11,
|
||||
immediate status read 12 and state-gated reads 13–14 onto one continuously owned
|
||||
MQTT session. No captured operator delay is a protocol timer: the final reads
|
||||
wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits
|
||||
for live unbound `READY`. Standalone START and STOP
|
||||
are both rejected. The same socket is serviced throughout launch and STOP
|
||||
confirmation, and no captured human delay is replayed as a protocol timer.
|
||||
Plugin v0.5.0 wires this executor to the facade and plugin UI. The full staged
|
||||
START/live/STOP/save cycle is physically accepted on the reviewed unit. START
|
||||
and STOP each retain an explicit operator action; final local sealing now follows
|
||||
the scanner's protocol-reported unbound READY without a redundant second click.
|
||||
|
||||
This locked bootstrap is repeatable in the current workspace, not yet a
|
||||
standalone release install. The frontend consumes sibling `file:` packages from
|
||||
`NODEDC_DESIGN_GUIDELINE`; `package-lock.json` does not pin that checkout's Git
|
||||
revision or content hash. Publishing/vendoring those packages or enforcing an
|
||||
immutable donor revision remains a packaging and CI prerequisite.
|
||||
|
||||
Polygon is planned as a seventh section, but UI delivery is gated on the
|
||||
accepted clock/frame/authority contracts and a proven S1 backend lifecycle with
|
||||
persisted run history. It is not present in the current shell.
|
||||
|
||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||
inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
|
||||
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
||||
use an external hosted viewer UI.
|
||||
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
||||
HTTP or a Rerun gRPC/proxy source such as
|
||||
`rerun+http://127.0.0.1:9876/proxy`; no external hosted viewer UI is used.
|
||||
Dynamic point-cloud/camera composition and the live source contract are fixed in
|
||||
[`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
|
||||
The durable session catalog, recorded `session_time` scrubber and versioned
|
||||
workspace-layout profile are fixed in
|
||||
[`ADR 0008`](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md).
|
||||
|
||||
The first K1 live session or replay in a `k1link serve` process creates one local
|
||||
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
||||
the resulting URL through control-plane state. Later sessions reset their
|
||||
session-local scene and metrics and reuse that process-wide stream; this avoids
|
||||
restarting the native listener while the embedded browser remains connected.
|
||||
The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) in a
|
||||
`k1link serve` process creates one local Rerun `RecordingStream`, starts its
|
||||
gRPC/proxy server on TCP 9876 and publishes the resulting URL through
|
||||
control-plane state. Later live/file-replay sessions reset their session-local
|
||||
scene and metrics and reuse that process-wide stream. Saved observation sessions
|
||||
do not reuse this listener: they open a private digest-bound cache-v9 RRD
|
||||
generation over same-origin HTTP through Rerun's native incremental receiver.
|
||||
Unless an operator has entered a manual source, the React application assigns
|
||||
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence
|
||||
capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders →
|
||||
Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer.
|
||||
the applicable source to the embedded viewer. A generic host action clears a
|
||||
previous manual/archive selection only after a plugin-owned live or file-replay
|
||||
start succeeds; a failed start leaves the current scene mounted. A fail-closed
|
||||
guard blocks operator source switches for every nonterminal acquisition. The
|
||||
complete visual live runtime path is K1 MQTT → raw-first evidence capture →
|
||||
bounded four-message latest-wins preview queue → explicitly injected K1
|
||||
protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
|
||||
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
|
||||
not inspect K1 topics or raw payloads. These local decoded views are not yet the
|
||||
portable Plugin SDK wire envelopes. K1 `ModelingReport` status is consumed by an
|
||||
explicitly injected pre-preview observer, so scan telemetry cannot evict point
|
||||
or pose frames from that four-message queue.
|
||||
|
||||
The default Rerun blueprint shows a 12-second sliding accumulation of real point
|
||||
frames. Product controls are connected for point size, intensity/height/distance
|
||||
or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
|
||||
point and trajectory visibility, and the scene grid. Projection, custom
|
||||
timeline transport and saved layout remain later product work. No synthetic
|
||||
point cloud, trajectory, camera frame or latency value is generated.
|
||||
point and trajectory visibility, and the scene grid. The first disk action saves
|
||||
and restores the versioned spatial layout without mutating sensor evidence.
|
||||
Saved native point/pose sessions are materialized losslessly into private,
|
||||
digest-bound RRD recordings and can be played, paused and scrubbed on a
|
||||
zero-based `session_time` timeline. New captures durably publish a clock origin
|
||||
before camera production, retain a transport-scoped provisional envelope and
|
||||
atomically point their summary to a content-addressed session envelope after all
|
||||
producers stop. That session envelope becomes the real RRD origin/end range;
|
||||
archived `ModelingReport` distance, speed and scan time are logged as scalar
|
||||
time series. No synthetic point cloud, trajectory, camera frame or latency
|
||||
value is generated.
|
||||
|
||||
A powered-device checkpoint passed 80 real MQTT messages through the current
|
||||
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
|
||||
cloud and zero decode errors. Raw panoramic camera frames remain absent. Rerun
|
||||
`capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or
|
||||
photon-to-screen measurement.
|
||||
cloud and zero decode errors. The later RTSP camera preview is available through
|
||||
the generic floating observation windows and new acquisitions archive its fMP4
|
||||
segments independently of browser delivery. Historical sessions recorded before
|
||||
that archive contract contain no video. A real archived session containing one
|
||||
camera plus point cloud has not yet passed physical shared-timeline playback;
|
||||
the implementation and test contract do not close that hardware gate. Rerun
|
||||
`capture_time` and the camera index use Mac receive/arrival timestamps, not
|
||||
proven K1 sensor timestamps or a photon-to-screen measurement.
|
||||
|
||||
The old Foxglove implementation is retained only in
|
||||
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
|
||||
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current
|
||||
live/replay runtime does not start it or use TCP 8765. The
|
||||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
|
||||
its timing/security boundaries; the frontend contract is documented in
|
||||
[`apps/k1-viewer/README.md`](apps/k1-viewer/README.md).
|
||||
[`apps/control-station/README.md`](apps/control-station/README.md).
|
||||
The host storage layout, recovery rules, replay API and operator path are in
|
||||
[`docs/09_OBSERVATION_SESSIONS.md`](docs/09_OBSERVATION_SESSIONS.md).
|
||||
|
||||
The FastAPI application and credential endpoint bind to loopback, but the Rerun
|
||||
gRPC server currently binds TCP 9876 on all network interfaces even though its
|
||||
@@ -170,7 +367,23 @@ present.
|
||||
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
||||
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
|
||||
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
|
||||
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
|
||||
- [K1 application-control physical acceptance](docs/lab/003_K1_CONTROL_ACCEPTANCE_20260718.redacted.md)
|
||||
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
|
||||
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
|
||||
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
|
||||
- [Plugin SDK v0alpha2 and experimental device lifecycle](docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md)
|
||||
- [Owner-controlled LixelGO observation decision](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md)
|
||||
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
|
||||
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
|
||||
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
|
||||
- [Device-plugin observation runtime and K1 extraction](docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md)
|
||||
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
|
||||
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
|
||||
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
|
||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
- [Reference input provenance](docs/reference/README.md)
|
||||
|
||||
@@ -186,13 +399,15 @@ capture of traffic to or from the confirmed K1 address, and offline analysis of
|
||||
owned artifacts.
|
||||
|
||||
The reviewed provisioning write requires its named profile and explicit operator
|
||||
confirmation. Application command publishing remains disabled: physical
|
||||
double-click is the verified start/stop mechanism. Any future MQTT publisher,
|
||||
router configuration change or new BLE write requires its own evidence and
|
||||
confirmation. The interactive application-control publisher is enabled only for
|
||||
the exact K1/A4/FW 3.0.2 direct-LAN profile and only inside one operator-opened,
|
||||
response-gated session; physical double-click remains the independent fallback.
|
||||
The legacy shadow publisher remains structurally disabled. Any broader firmware,
|
||||
topology, scanner-family or command surface requires its own evidence and
|
||||
reviewed step. Random writes, fuzzing, brute force, firmware operations,
|
||||
destructive file access and credential guessing remain out of scope.
|
||||
|
||||
Real captures, projects, router metadata, serials, credentials, maps, images,
|
||||
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
|
||||
are committed; encrypted artifact storage will be selected only when real data
|
||||
exists.
|
||||
and logs under `.runtime/`, canonical evidence roots and legacy `sessions/` are
|
||||
ignored by normal Git. Redacted manifests and SHA-256 inventories are committed;
|
||||
encrypted/replicated artifact storage remains a deployment decision.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Mission Core applications
|
||||
|
||||
- `control-station/` — the current browser operator application.
|
||||
|
||||
Applications consume Mission Core contracts and capabilities. Device-specific
|
||||
transport and codecs belong under `plugins/`, not inside application routing
|
||||
or navigation.
|
||||
@@ -0,0 +1,353 @@
|
||||
# NODEDC MISSION CORE · Control Station
|
||||
|
||||
Универсальный браузерный пункт управления Mission Core на React 19, TypeScript и
|
||||
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
|
||||
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
|
||||
device adapter, но структура интерфейса от него не зависит.
|
||||
|
||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||
переход во внешний облачный интерфейс. Для K1 live и adapter file-replay backend
|
||||
сам создаёт Rerun gRPC/proxy source. Saved observation sessions открывают
|
||||
проверенную digest-bound RRD generation через same-origin HTTP; ручной адрес
|
||||
нужен только для другого совместимого Rerun source.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
| Контур | Состояние | Что это означает |
|
||||
| --- | --- | --- |
|
||||
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
||||
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
|
||||
| Plugin-owned device UI | Реализован | XGRIDS provisioning, project/acquisition/replay, diagnostics, metrics, optional spatial controls и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет frontend SDK, model catalog и host slots. |
|
||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и device-reported scan time/distance/speed. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
|
||||
| Сохранённые observation sessions | Реализованы для point/pose/device metrics | Три последние сессии, background preparation, capture-clock-bound cache v9, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
|
||||
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
|
||||
|
||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
|
||||
показывают `—`.
|
||||
|
||||
## Архитектура данных
|
||||
|
||||
```text
|
||||
K1 MQTT :1883, read-only
|
||||
└── raw .k1mqtt + metadata + durable clock origin сохраняются первыми
|
||||
├── injected ModelingReport observer -> device scan time/distance/speed
|
||||
└── bounded latest-wins visual preview queue (4 сообщения)
|
||||
└── явно внедрённый K1 protobuf/LZ4 point/pose normalizer
|
||||
└── transport-neutral DecodedPointCloudView / DecodedPoseView
|
||||
└── Rerun Points3D + Transform3D + LineStrips3D
|
||||
└── gRPC/proxy TCP 9876
|
||||
└── rerun_grpc_url в REST/WebSocket state
|
||||
└── встроенный @rerun-io/web-viewer
|
||||
└── пространственная сцена Mission Core
|
||||
|
||||
Mission Core Control Station ←→ REST /api/v1/device-plugins/*
|
||||
+ plugin-scoped WebSocket events
|
||||
FastAPI на 127.0.0.1:8000
|
||||
CoreBluetooth + live/replay runtime
|
||||
|
||||
sealed/recovered observation session
|
||||
└── SQLite catalog + bounded background preparation
|
||||
└── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
|
||||
└── generation-bound same-origin HTTP
|
||||
└── aggregate admission
|
||||
└── Rerun native receiver + recorded fMP4 player
|
||||
```
|
||||
|
||||
В момент готовности live/file-replay `RerunBridge` backend публикует адрес вида
|
||||
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
|
||||
если оператор не указал ручной source. После остановки приёма URL и встроенный
|
||||
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
|
||||
траекторию и метрики и использует тот же listener. Он закрывается вместе с
|
||||
процессом `k1link serve`.
|
||||
|
||||
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
|
||||
совместимость со старым контрактом, но текущий runtime держит их пустыми.
|
||||
|
||||
## Фиксированная оболочка
|
||||
|
||||
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
|
||||
всех функциональных модулей:
|
||||
|
||||
1. `AppHeader` — марка NODE DC, выбор архитектурного раздела и состояние
|
||||
локального backend.
|
||||
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
|
||||
выбранного раздела.
|
||||
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
|
||||
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
|
||||
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
|
||||
раскрытия внутренних панелей визуального движка.
|
||||
|
||||
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
||||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
||||
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
|
||||
Rerun Blueprint. Host timeline управляет сохранённым `session_time`, а spatial
|
||||
layout сохраняет display settings, tool windows и source-window geometry через
|
||||
revisioned API. Смена 2D/3D/карты и семантические слои пока остаются
|
||||
интерфейсным контрактом.
|
||||
|
||||
## Архитектурные разделы
|
||||
|
||||
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
|
||||
страницы.
|
||||
|
||||
| Раздел | Назначение |
|
||||
| --- | --- |
|
||||
| Центр | Оперативный обзор, состояние контура и активность оператора. |
|
||||
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
|
||||
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
||||
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
|
||||
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
|
||||
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
|
||||
|
||||
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
|
||||
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
|
||||
как утверждение, что для каждой карточки уже существует backend.
|
||||
|
||||
## Установка, проверка, сборка и запуск
|
||||
|
||||
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
|
||||
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
|
||||
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
||||
|
||||
Канонический путь от корня репозитория:
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
|
||||
uv sync --frozen --group dev
|
||||
|
||||
cd apps/control-station
|
||||
npm ci
|
||||
npm run test:unit
|
||||
npm run typecheck
|
||||
npm run build
|
||||
|
||||
cd ../..
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
|
||||
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
|
||||
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
|
||||
Wi-Fi.
|
||||
|
||||
Этот locked bootstrap повторяем в текущем workspace, но он ещё не является
|
||||
standalone release install. Локальные `file:` зависимости берутся из соседнего
|
||||
`NODEDC_DESIGN_GUIDELINE`, а lockfile не фиксирует Git revision или content hash
|
||||
этого checkout. До CI/portable packaging эти пакеты нужно опубликовать,
|
||||
завендорить либо проверять по неизменяемой donor revision.
|
||||
|
||||
Для разработки интерфейса после запуска backend:
|
||||
|
||||
```bash
|
||||
cd apps/control-station
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Для полного live-viewer контура Vite должен открыться на
|
||||
`http://127.0.0.1:5173`: только 5173, production preview 4173 и backend 8000
|
||||
входят в текущий Rerun CORS allowlist. Если Vite сообщает fallback на 5174 или
|
||||
выше, освободите 5173 и перезапустите `npm run dev`; REST/WebSocket proxy на
|
||||
fallback-порту может работать, но прямой browser → Rerun 9876 будет отклонён.
|
||||
Vite проксирует весь `/api` (включая WebSocket) на `http://127.0.0.1:8000`.
|
||||
Другой локальный backend можно указать переменной `VITE_API_TARGET`. Preview
|
||||
production-сборки запускается командой `npm run preview` на
|
||||
`http://127.0.0.1:4173`.
|
||||
|
||||
## Операторский путь для текущего K1 adapter
|
||||
|
||||
1. Запустить `uv run k1link serve` и открыть Mission Core Control Station.
|
||||
2. Выбрать **Парк → Локальное устройство**.
|
||||
3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
|
||||
активируется runtime и монтируется custom UI XGRIDS-плагина.
|
||||
4. Включить K1, дождаться стабильного индикатора, вручную сверить firmware
|
||||
`3.0.2` и direct-LAN топологию, затем подтвердить это в форме. Mission Core
|
||||
не читает firmware с устройства; такое подтверждение остаётся
|
||||
`operator-attested`, а не device-derived evidence.
|
||||
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
|
||||
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
|
||||
оператор.
|
||||
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||
reviewed provisioning-запись без автоматических повторов.
|
||||
7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится,
|
||||
проверяется на control/surrogate characters и лимит 96 Unicode characters,
|
||||
сохраняется как display metadata и не используется в filesystem path.
|
||||
8. Запустить локальный live-приём по определённому адресу K1 либо replay
|
||||
локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается
|
||||
и останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block
|
||||
показывает local acquisition phase, stop local reception и полученные от K1
|
||||
scan time/distance/speed; облако, траектория и preview metrics появляются
|
||||
только после реальных сообщений.
|
||||
11. Физически остановить K1, дождаться steady green, затем остановить локальный
|
||||
приём из device workflow или spatial block, чтобы запечатать evidence.
|
||||
12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
|
||||
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
|
||||
сохраняется автоматически; disk action сохраняет только workspace layout.
|
||||
|
||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
||||
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
|
||||
0 ошибок декодирования.
|
||||
|
||||
## Источники Rerun
|
||||
|
||||
Live/replay runtime автоматически назначает первый URL из примера. В
|
||||
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
|
||||
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
|
||||
зафиксированной в `package.json` версии:
|
||||
|
||||
```text
|
||||
rerun+http://127.0.0.1:9876/proxy
|
||||
http://127.0.0.1:8080/recording.rrd
|
||||
https://example.internal/recording.rrd
|
||||
```
|
||||
|
||||
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
|
||||
proxy;
|
||||
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
|
||||
- версия RRD должна быть совместима с версией Web Viewer;
|
||||
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
|
||||
автоматическим source;
|
||||
- nonterminal acquisition fail-closed блокирует manual source apply/reset,
|
||||
saved session switch и persisted replay reattach до завершения текущего
|
||||
приёма; automatic-source action очищает старый source и открывает сцену только
|
||||
после successful start, failed start сохраняет текущую сцену, а guard не
|
||||
останавливает устройство;
|
||||
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||
|
||||
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||
оболочку. Backend принимает и применяет к Rerun размер точки `0.5–12.0`, режимы
|
||||
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
|
||||
Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака,
|
||||
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
|
||||
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
|
||||
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
|
||||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
||||
а не готовую семантическую классификацию.
|
||||
|
||||
Для saved session host timeline вызывает Rerun play/pause/seek только после
|
||||
полного admission. Accumulation и display settings применяются через отдельный
|
||||
маленький blueprint RRD и не заменяют основную запись. Versioned
|
||||
`observation.spatial` layout сохраняется на host с optimistic revision; он не
|
||||
содержит sensor evidence. Переключатель 2D/3D/карты и семантические слои пока не
|
||||
вызывают готовый presentation backend.
|
||||
|
||||
## Контракт локального API
|
||||
|
||||
| Метод | Route | Тело / назначение |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Проверка локального сервиса. |
|
||||
| `GET` | `/api/v1/device-plugins` | Валидированные manifests установленных device plugins. |
|
||||
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
|
||||
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
|
||||
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
|
||||
| `GET` | `/api/v1/observation-sessions` | Последние cataloged sessions и authoritative preparation state. |
|
||||
| `POST` | `/api/v1/observation-sessions/{id}/replay` | Verified replay launch либо HTTP 202 preparation handle. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/recording-preparation` | Exact-job polling с `If-Match`. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/recording.rrd` | Immutable generation-bound RRD; arbitrary chunked `send_rrd` не используется. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/media/{artifact}/...` | Manifest/init/segments записанных камер по opaque identifiers. |
|
||||
| `GET`, `PUT` | `/api/v1/workspace-layouts/observation.spatial` | Versioned host layout с optimistic revision. |
|
||||
|
||||
Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
|
||||
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
|
||||
`/api/viewer/settings` сохранены как deprecated compatibility shims внутри
|
||||
XGRIDS backend contribution.
|
||||
|
||||
В v1alpha1/v1alpha2 backend composition загружается из manifests, а frontend plugins
|
||||
статически включаются в сборку через `src/composition/devicePlugins.ts`.
|
||||
Автоматической runtime-сверки двух installed sets пока нет: их соответствие —
|
||||
проверяемое требование сборки до появления подписанных plugin bundles и startup
|
||||
compatibility handshake. Поля manifest `permissions`, `mutating` и
|
||||
`secretFields` пока являются декларативными метаданными; generic host валидирует
|
||||
их форму и action allowlist, но ещё не реализует на их основе RBAC, подтверждения
|
||||
оператора или secret-vault substitution.
|
||||
Точный K1 compatibility profile остаётся неактивным, пока оператор явно не
|
||||
подтвердит firmware `3.0.2` и direct-LAN топологию. Runtime не получает версию
|
||||
firmware с устройства и явно помечает основание как `operator-attested`.
|
||||
Ошибки валидации namespaced action endpoint и deprecated `/api/connect`
|
||||
возвращаются как общий `422` без исходных значений запроса, поэтому отклонённое
|
||||
значение credential-поля не отражается клиенту.
|
||||
|
||||
Legacy `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
|
||||
как `{ "state": { ... } }`. v1alpha2 snapshot отдельно содержит `device_ref`,
|
||||
`device_session`, `acquisition`, `operations`, compatibility/calibration/sensor
|
||||
facts и старые presentation-поля `phase`, `message`, `devices`,
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
|
||||
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
|
||||
пустыми. OpenAPI доступен по `/api/docs`.
|
||||
|
||||
## Карта исходников frontend
|
||||
|
||||
| Файл | Ответственность |
|
||||
| --- | --- |
|
||||
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
||||
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
|
||||
| `src/core/runtime/` | Нормализованное состояние активного устройства, spatial source и fail-closed source-switch guard. |
|
||||
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
|
||||
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
|
||||
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, project/acquisition/replay UI, optional spatial controls, API client, runtime mapper и scoped styles. |
|
||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
|
||||
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
|
||||
| `src/components/RecordedFmp4Player.tsx` | Проверенный generation-bound MSE playback архивных камер. |
|
||||
| `src/core/observation/sessionArchive.ts` | Строгий wire contract catalog/preparation/replay/media API. |
|
||||
| `src/core/observation/recordedSessionAdmission.ts` | Общий RRD/camera gate до публикации recorded workspace. |
|
||||
| `src/core/observation/workspaceLayout.ts` | Versioned spatial layout и optimistic revision. |
|
||||
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
||||
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
|
||||
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
||||
|
||||
## Safety и чувствительные данные
|
||||
|
||||
- Mission Core Control Station и credential endpoint доступны только на loopback.
|
||||
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
|
||||
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
|
||||
connector-level authentication или TLS. Этот порт допустим только в доверенной
|
||||
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
|
||||
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную provisioning-
|
||||
запись; Quick Connect не пишет в GATT и передаёт пароль короткоживущему
|
||||
CoreWLAN helper только через stdin. Случайные GATT writes и автоматические
|
||||
повторы запрещены.
|
||||
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
|
||||
plugin-owned canonical control session и доступны только через отдельные
|
||||
operator-present UI checkpoints.
|
||||
- Exact start/stop codec, response correlator и device-status state machine не
|
||||
владеют transport. Plugin v0.5.0 связывает их отдельным single-owner MQTT
|
||||
контуром без wall-clock переходов, reconnect или automatic retry; после STOP
|
||||
обязательны live READY и подтверждённый постоянный зелёный индикатор.
|
||||
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
|
||||
формируют disposable preview. При перегрузке preview может быть отброшен,
|
||||
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
|
||||
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
|
||||
- Новая session summary hash-bind-ит durable clock origin и атомарно переключает
|
||||
pointer с provisional `mqtt.timeline.json` на content-addressed
|
||||
`mqtt.timeline.session-<sha256>.json`: только owner-sealed `session` scope
|
||||
может рекламировать combined media replay. Cache v9 создаёт реальные
|
||||
origin/end RRD rows; provisional `transport` scope fails closed.
|
||||
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
|
||||
накопления viewer использует session-local `stream_time`. `capture_time` — не
|
||||
доказанный timestamp сенсора K1 и не photon-to-screen latency.
|
||||
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
|
||||
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
|
||||
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
|
||||
Физический archived playback point cloud + одна выбранная камера ещё не принят.
|
||||
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
|
||||
могут содержать адреса, изображения, идентификаторы, траекторию и карту
|
||||
помещения. В репозиторий попадают только код, тесты, redacted manifests и
|
||||
безопасная документация.
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Пункт управления NODE.DC — наблюдение, данные и планирование миссий беспилотных аппаратов."
|
||||
content="NODEDC MISSION CORE — наблюдение, данные и планирование миссий беспилотных аппаратов."
|
||||
/>
|
||||
<meta name="theme-color" content="#08090b" />
|
||||
<title>NODE.DC · Пункт управления</title>
|
||||
<title>NODEDC MISSION CORE</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
+17
-3
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/k1-viewer",
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@nodedc/k1-viewer",
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
@@ -25,7 +27,7 @@
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
||||
@@ -834,6 +836,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/tokens": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"link": true
|
||||
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"name": "@nodedc/k1-viewer",
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test test/*.test.mjs",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
@@ -27,6 +30,6 @@
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 322 B |
@@ -0,0 +1,111 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
||||
|
||||
const sha256File = (path) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
|
||||
if (manifest.version !== "0.34.1") {
|
||||
throw new Error(
|
||||
`Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const vendorRuntime = [
|
||||
{
|
||||
label: "wasm runtime",
|
||||
packagePath: resolve(packageRoot, "re_viewer_bg.wasm"),
|
||||
vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"),
|
||||
publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35",
|
||||
previousNodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2",
|
||||
nodedcSha256: "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469",
|
||||
},
|
||||
{
|
||||
label: "wasm JavaScript glue",
|
||||
packagePath: resolve(packageRoot, "re_viewer.js"),
|
||||
vendorPath: resolve(vendorRoot, "re_viewer.nodedc.js"),
|
||||
publishedSha256: "7c4ba900820137a6ba23e0f7f57d56e3b007db0de3825d5c8bf0d7684b3cc6a6",
|
||||
nodedcSha256: "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339",
|
||||
},
|
||||
];
|
||||
|
||||
for (const runtime of vendorRuntime) {
|
||||
const vendorSha256 = sha256File(runtime.vendorPath);
|
||||
if (vendorSha256 !== runtime.nodedcSha256) {
|
||||
throw new Error(
|
||||
`Refusing corrupt NODE.DC Rerun ${runtime.label}: ${vendorSha256} != ${runtime.nodedcSha256}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installedSha256 = sha256File(runtime.packagePath);
|
||||
if (
|
||||
![
|
||||
runtime.publishedSha256,
|
||||
runtime.previousNodedcSha256,
|
||||
runtime.nodedcSha256,
|
||||
].includes(installedSha256)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to replace unexpected Rerun ${runtime.label}: ${installedSha256}`,
|
||||
);
|
||||
}
|
||||
|
||||
copyFileSync(runtime.vendorPath, runtime.packagePath);
|
||||
if (sha256File(runtime.packagePath) !== runtime.nodedcSha256) {
|
||||
throw new Error(`NODE.DC Rerun ${runtime.label} verification failed after copy`);
|
||||
}
|
||||
}
|
||||
|
||||
const patches = [
|
||||
{
|
||||
label: "compiled watchdog",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "source watchdog",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "compiled singleton global listener",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
{
|
||||
label: "source singleton global listener",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const patch of patches) {
|
||||
const source = readFileSync(patch.path, "utf8");
|
||||
if (source.includes(patch.after)) continue;
|
||||
if (!source.includes(patch.before)) {
|
||||
throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`);
|
||||
}
|
||||
writeFileSync(patch.path, source.replace(patch.before, patch.after));
|
||||
}
|
||||
|
||||
for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1;
|
||||
if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) {
|
||||
throw new Error(`Vendor singleton global listener verification failed for ${path}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Patched @rerun-io/web-viewer 0.34.1 pointer navigation, camera continuity, watchdog teardown, and singleton global keyup listener.",
|
||||
);
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const [inputArgument, outputArgument] = process.argv.slice(2);
|
||||
if (!inputArgument || !outputArgument) {
|
||||
throw new Error("Usage: node transform-rerun-web-viewer-glue.mjs INPUT OUTPUT");
|
||||
}
|
||||
|
||||
const inputPath = resolve(inputArgument);
|
||||
const outputPath = resolve(outputArgument);
|
||||
const expectedGeneratedSha256 =
|
||||
"cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874";
|
||||
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
let code = readFileSync(inputPath, "utf8");
|
||||
|
||||
if (sha256(code) !== expectedGeneratedSha256) {
|
||||
throw new Error(`Refusing to transform unexpected wasm-bindgen output: ${inputPath}`);
|
||||
}
|
||||
|
||||
// This is the same no-modules-base transformation used by Rerun 0.34.1's
|
||||
// rerun_js/web-viewer/build-wasm.mjs. Each factory call gets isolated closure state.
|
||||
const wrapperStart = "let wasm_bindgen = (function(exports) {";
|
||||
const wrapperEnd = `return Object.assign(__wbg_init, { initSync }, exports);
|
||||
})({ __proto__: null });`;
|
||||
|
||||
if (!code.includes(wrapperStart) || !code.includes(wrapperEnd)) {
|
||||
throw new Error("Rerun wasm-bindgen wrapper markers no longer match");
|
||||
}
|
||||
code = code.replace(wrapperStart, "").replace(wrapperEnd, "");
|
||||
|
||||
code = `
|
||||
export default function() {
|
||||
const exports = { __proto__: null };
|
||||
${code}
|
||||
|
||||
function deinit() {
|
||||
__wbg_init.__wbindgen_wasm_module = null;
|
||||
wasmModule = null;
|
||||
wasm = null;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedFloat32ArrayMemory0 = null;
|
||||
cachedInt16ArrayMemory0 = null;
|
||||
cachedInt32ArrayMemory0 = null;
|
||||
cachedInt8ArrayMemory0 = null;
|
||||
cachedUint16ArrayMemory0 = null;
|
||||
cachedUint32ArrayMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
}
|
||||
|
||||
return Object.assign(__wbg_init, { initSync, deinit }, exports);
|
||||
}
|
||||
`;
|
||||
|
||||
const closureDtorsOriginal = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));`;
|
||||
const closureDtorsPatch = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => {
|
||||
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
});`;
|
||||
|
||||
if (!code.includes(closureDtorsOriginal)) {
|
||||
throw new Error("Rerun CLOSURE_DTORS block no longer matches");
|
||||
}
|
||||
code = code.replace(closureDtorsOriginal, closureDtorsPatch);
|
||||
|
||||
const makeMutClosureOriginal = `function makeMutClosure(arg0, arg1, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1 };
|
||||
const real = (...args) => {
|
||||
|
||||
// First up with a closure we increment the internal reference
|
||||
// count. This ensures that the Rust closure environment won't
|
||||
// be deallocated while we're invoking it.
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}`;
|
||||
const makeMutClosurePatch = `function makeMutClosure(arg0, arg1, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1 };
|
||||
const real = (...args) => {
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
if (!wasm) return;
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}`;
|
||||
|
||||
if (!code.includes(makeMutClosureOriginal)) {
|
||||
throw new Error("Rerun makeMutClosure block no longer matches");
|
||||
}
|
||||
code = code.replace(makeMutClosureOriginal, makeMutClosurePatch);
|
||||
|
||||
writeFileSync(outputPath, code);
|
||||
console.log(`Transformed Rerun wasm glue: ${sha256(code)} ${outputPath}`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
||||
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const WINDOW_WIDTH = 336;
|
||||
const WINDOW_HEIGHT = 210;
|
||||
const WINDOW_GAP = 16;
|
||||
const WINDOW_INSET = 18;
|
||||
const TIMELINE_CLEARANCE = 64;
|
||||
|
||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||
|
||||
export function shouldCaptureWorkspacePointer(
|
||||
button: number,
|
||||
target: ClosestTarget | null,
|
||||
): boolean {
|
||||
if (button !== 0 || !target) return false;
|
||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||
return !target.closest("button, input, select, textarea, a");
|
||||
}
|
||||
|
||||
export function initialObservationWindowRect(
|
||||
index: number,
|
||||
count = 1,
|
||||
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
||||
): ObservationWindowRect {
|
||||
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
||||
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
||||
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
||||
const maxColumns = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
||||
);
|
||||
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
||||
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
||||
const rowHeight = Math.max(
|
||||
1,
|
||||
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
||||
);
|
||||
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
||||
const row = Math.floor(index / columns);
|
||||
const column = index % columns;
|
||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||
|
||||
return {
|
||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||
y: Math.max(
|
||||
0,
|
||||
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function FloatingObservationWindow({
|
||||
source,
|
||||
index,
|
||||
count,
|
||||
boundsRef,
|
||||
rect,
|
||||
maximized,
|
||||
active,
|
||||
hidden = false,
|
||||
onRectChange,
|
||||
onMaximizedChange,
|
||||
onActivate,
|
||||
onClose,
|
||||
playback,
|
||||
prepareRecorded,
|
||||
recordedSessionGate,
|
||||
recordedAdmissionKey,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
index: number;
|
||||
count: number;
|
||||
boundsRef: RefObject<HTMLElement | null>;
|
||||
rect?: ObservationWindowRect;
|
||||
maximized: boolean;
|
||||
active: boolean;
|
||||
hidden?: boolean;
|
||||
onRectChange: (rect: ObservationWindowRect) => void;
|
||||
onMaximizedChange: (maximized: boolean) => void;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = boundsRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const measure = () => {
|
||||
const next = { width: element.clientWidth, height: element.clientHeight };
|
||||
setBounds((current) => (
|
||||
current?.width === next.width && current.height === next.height ? current : next
|
||||
));
|
||||
};
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [boundsRef]);
|
||||
|
||||
const initialRect = bounds ? initialObservationWindowRect(index, count, bounds) : null;
|
||||
const windowRect = rect ?? initialRect;
|
||||
if (!windowRect) return null;
|
||||
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
boundsRef={boundsRef}
|
||||
rect={windowRect}
|
||||
onRectChange={onRectChange}
|
||||
maximized={maximized}
|
||||
onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
title={source.label}
|
||||
subtitle={source.description}
|
||||
status={(
|
||||
<span className="floating-observation-window__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
)}
|
||||
footer={(
|
||||
<span className="floating-observation-window__footer">
|
||||
<span>{source.endpointLabel || source.transport}</span>
|
||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||
</span>
|
||||
)}
|
||||
minWidth={Math.min(280, windowRect.width)}
|
||||
minHeight={Math.min(190, windowRect.height)}
|
||||
resizable={source.capabilities.resizable}
|
||||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture can fail if the browser ended the pointer between
|
||||
// dispatch and capture. The donor's window listeners remain fallback.
|
||||
}
|
||||
}}
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepareRecorded={prepareRecorded}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { RootDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
export interface LandingStageProps {
|
||||
root: RootDefinition | null;
|
||||
backendStatus: BackendStatus;
|
||||
phase?: string | null;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
onOpenObservation: () => void;
|
||||
onOpenDevice: () => void;
|
||||
@@ -24,8 +24,8 @@ export function LandingStage({
|
||||
return (
|
||||
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span>
|
||||
<h1>{root?.title ?? "Пункт управления"}</h1>
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
||||
<h1>{root?.title ?? "Mission Core"}</h1>
|
||||
<p>
|
||||
{root?.statement ??
|
||||
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
|
||||
|
||||
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
|
||||
|
||||
export interface CameraLeaseRetryBudget {
|
||||
deliveryId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
|
||||
|
||||
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
|
||||
return { deliveryId, count: 0 };
|
||||
}
|
||||
|
||||
export function consumeCameraLeaseRetry(
|
||||
current: CameraLeaseRetryBudget,
|
||||
deliveryId: string,
|
||||
): { budget: CameraLeaseRetryBudget; delay: number | null } {
|
||||
const count = current.deliveryId === deliveryId ? current.count : 0;
|
||||
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
|
||||
return {
|
||||
budget: { deliveryId, count: delay === null ? count : count + 1 },
|
||||
delay,
|
||||
};
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const url = new URL(path, window.location.href);
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error("Адаптер вернул неподдерживаемый адрес видеопотока.");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function MseFmp4WebSocketPlayer({
|
||||
delivery,
|
||||
label,
|
||||
}: {
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
|
||||
label: string;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [status, setStatus] = useState<PlayerStatus>("connecting");
|
||||
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let sourceBuffer: SourceBuffer | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (disposed || failed) return;
|
||||
failed = true;
|
||||
setStatus("error");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(1011, "Live video buffer reset");
|
||||
}
|
||||
} catch {
|
||||
// The manual reconnect button will create a fresh transport and MSE buffer.
|
||||
}
|
||||
};
|
||||
|
||||
const retryLease = () => {
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
leaseRetryRef.current = retry.budget;
|
||||
if (retry.delay === null) {
|
||||
fail("Camera adapter ещё занят предыдущим окном. Подключитесь повторно.");
|
||||
return;
|
||||
}
|
||||
setStatus("connecting");
|
||||
setMessage("Освобождение предыдущего окна камеры");
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (!disposed) setAttempt((value) => value + 1);
|
||||
}, retry.delay);
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
if (disposed) return;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
setStatus("playing");
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
video.addEventListener("playing", onPlaying);
|
||||
|
||||
const appendNext = () => {
|
||||
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
|
||||
const chunk = queue.shift();
|
||||
if (!chunk) return;
|
||||
queuedBytes -= chunk.byteLength;
|
||||
try {
|
||||
sourceBuffer.appendBuffer(chunk);
|
||||
} catch (error) {
|
||||
fail(error instanceof DOMException && error.name === "QuotaExceededError"
|
||||
? "Live-буфер переполнен и сброшен, чтобы не накапливать задержку."
|
||||
: "Не удалось добавить видеосегмент. Повторите подключение.");
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (chunk: ArrayBuffer) => {
|
||||
if (disposed || chunk.byteLength === 0) return;
|
||||
// Never drop arbitrary fMP4 fragments: the following samples may depend
|
||||
// on them. A bounded reset is safer and keeps live latency deterministic.
|
||||
if (queuedBytes + chunk.byteLength > 2 * 1024 * 1024) {
|
||||
fail("Видеодекодер не успевает за эфиром. Live-буфер сброшен.");
|
||||
return;
|
||||
}
|
||||
queue.push(chunk);
|
||||
queuedBytes += chunk.byteLength;
|
||||
appendNext();
|
||||
};
|
||||
|
||||
const mediaType = delivery.mediaType?.trim();
|
||||
if (typeof MediaSource === "undefined") {
|
||||
fail("Этот браузер не поддерживает Media Source Extensions.");
|
||||
return;
|
||||
}
|
||||
if (!mediaType || !MediaSource.isTypeSupported(mediaType)) {
|
||||
fail(mediaType
|
||||
? `Браузер не поддерживает ${mediaType}`
|
||||
: "Адаптер не сообщил MIME/codec fMP4-потока.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("connecting");
|
||||
setMessage("Подключение к локальному видеопотоку");
|
||||
const mediaSource = new MediaSource();
|
||||
objectUrl = URL.createObjectURL(mediaSource);
|
||||
video.src = objectUrl;
|
||||
|
||||
const onSourceOpen = () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
|
||||
} catch {
|
||||
fail("Не удалось создать MSE-буфер для указанного кодека.");
|
||||
return;
|
||||
}
|
||||
|
||||
sourceBuffer.addEventListener("updateend", () => {
|
||||
if (disposed || !sourceBuffer) return;
|
||||
const buffered = sourceBuffer.buffered;
|
||||
if (buffered.length > 0) {
|
||||
const end = buffered.end(buffered.length - 1);
|
||||
const start = buffered.start(0);
|
||||
if (end - video.currentTime > 1) video.currentTime = Math.max(0, end - 0.1);
|
||||
if (!receivedMedia) {
|
||||
receivedMedia = true;
|
||||
setStatus("buffering");
|
||||
setMessage("Запуск первого декодированного кадра");
|
||||
void video.play().catch(() => undefined);
|
||||
}
|
||||
const removeBefore = end - 3;
|
||||
if (removeBefore > start && !sourceBuffer.updating) {
|
||||
try {
|
||||
sourceBuffer.remove(0, removeBefore);
|
||||
return;
|
||||
} catch {
|
||||
// Continue appending; quota handling performs a clean reset.
|
||||
}
|
||||
}
|
||||
}
|
||||
appendNext();
|
||||
});
|
||||
sourceBuffer.addEventListener("error", () => {
|
||||
fail("MSE сообщил об ошибке декодирования видеосегмента.");
|
||||
});
|
||||
|
||||
try {
|
||||
socket = new WebSocket(websocketUrl(delivery.url));
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока.");
|
||||
return;
|
||||
}
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (disposed) return;
|
||||
setStatus("buffering");
|
||||
setMessage("Ожидание первого видеокадра");
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
enqueue(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
void event.data.arrayBuffer().then(enqueue).catch(() => {
|
||||
fail("Получен повреждённый видеосегмент.");
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
fail("Соединение с локальным video adapter потеряно.");
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (!disposed && !failed && event.code === 1008) {
|
||||
retryLease();
|
||||
} else if (!disposed) {
|
||||
fail(event.code === 1000
|
||||
? "Видеопоток завершён. Можно подключиться повторно."
|
||||
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Источник скрыт оператором");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
try {
|
||||
if (sourceBuffer?.updating) sourceBuffer.abort();
|
||||
} catch {
|
||||
// The MediaSource can already be closing.
|
||||
}
|
||||
try {
|
||||
if (mediaSource.readyState === "open") mediaSource.endOfStream();
|
||||
} catch {
|
||||
// Cleanup must continue even if the browser has already detached MSE.
|
||||
}
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
|
||||
|
||||
return (
|
||||
<div className="mse-fmp4-player" data-status={status}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="observation-media__asset"
|
||||
aria-label={label}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
{status !== "playing" ? (
|
||||
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
||||
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
||||
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
|
||||
<span>{message}</span>
|
||||
{status === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
Подключиться повторно
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState } from "react";
|
||||
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../core/observation/useObservationSessions";
|
||||
import type {
|
||||
ObservationPreparationPhase,
|
||||
ObservationReplayOutcome,
|
||||
} from "../core/observation/useObservationSessions";
|
||||
import "../styles/observation-sessions.css";
|
||||
|
||||
type SessionVisualState = "ready" | "processing" | "cold" | "error";
|
||||
|
||||
export function observationSessionVisualState(
|
||||
session: ObservationSessionSummary,
|
||||
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
|
||||
): SessionVisualState {
|
||||
if (failed) return "error";
|
||||
if (pending) return "processing";
|
||||
if (session.preparation !== null) {
|
||||
if (["queued", "validating", "exporting", "finalizing"].includes(
|
||||
session.preparation.state,
|
||||
)) return "processing";
|
||||
if (session.preparation.state === "ready" && session.replayable) return "ready";
|
||||
return "error";
|
||||
}
|
||||
if (session.status === "recording") return "processing";
|
||||
return session.replayable ? "cold" : "error";
|
||||
}
|
||||
|
||||
export function observationSessionVisualLabel(state: SessionVisualState): string {
|
||||
if (state === "ready") return "Готово";
|
||||
if (state === "processing") return "Обработка";
|
||||
if (state === "cold") return "Подготовить";
|
||||
return "Ошибка";
|
||||
}
|
||||
|
||||
const modalityLabel: Record<string, string> = {
|
||||
"point-cloud": "облако точек",
|
||||
pose: "траектория",
|
||||
trajectory: "траектория",
|
||||
video: "видео",
|
||||
image: "изображения",
|
||||
depth: "глубина",
|
||||
telemetry: "телеметрия",
|
||||
};
|
||||
|
||||
const preparationLabel: Record<ObservationPreparationPhase, string> = {
|
||||
requesting: "Запрашиваем подготовку",
|
||||
queued: "В очереди",
|
||||
validating: "Проверяем запись",
|
||||
exporting: "Готовим операторскую сцену",
|
||||
finalizing: "Завершаем подготовку",
|
||||
failed: "Подготовка не выполнена",
|
||||
cancelled: "Подготовка отменена",
|
||||
};
|
||||
|
||||
function progressCopy(phase: ObservationPreparationPhase): string {
|
||||
// Backend progress values are phase markers and heartbeat revisions, not a
|
||||
// measured fraction of bytes or frames. Presenting 0.5 as "50%" made a
|
||||
// healthy long export look stalled, especially after a browser reload.
|
||||
return preparationLabel[phase];
|
||||
}
|
||||
|
||||
function formatStartedAt(value: string): string {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
|
||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function sessionDescription(session: ObservationSessionSummary): string {
|
||||
const modalities = session.modalities.length
|
||||
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
|
||||
: "каналы не зафиксированы";
|
||||
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
|
||||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 100,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplayAccepted?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplaySettled?: (
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
});
|
||||
const triggerCopy = sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase)
|
||||
: sessions.state === "loading"
|
||||
? "Загружаем сессии…"
|
||||
: "Сохранённые сессии";
|
||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||
|
||||
return <>
|
||||
<Dropdown
|
||||
className="observation-session-select"
|
||||
placement="bottom-end"
|
||||
width={390}
|
||||
offset={10}
|
||||
disabled={disabled}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-session-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="observation-session-select__trigger"
|
||||
data-active={open ? "true" : undefined}
|
||||
aria-label="Открыть сохранённые сессии наблюдения"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
disabled={disabled}
|
||||
title={blockedReason ?? undefined}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={15} />
|
||||
<span>{presentedTriggerCopy}</span>
|
||||
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="observation-session-menu__content">
|
||||
<header className="observation-session-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
|
||||
<strong>Сохранённые сессии</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Обновить каталог сессий"
|
||||
disabled={sessions.state === "loading"}
|
||||
onClick={() => void sessions.refresh()}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{sessions.items.length > 0 ? (
|
||||
<div className="observation-session-menu__list">
|
||||
{sessions.items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const deleting = sessions.deletingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="observation-session-option"
|
||||
data-deleting={deleting ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option__open"
|
||||
disabled={pending || deleting || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">
|
||||
{session.lab &&
|
||||
!session.label.startsWith(`${session.lab.labId} ·`) ? (
|
||||
<span className="observation-session-option__lab">
|
||||
{session.lab.labId}
|
||||
</span>
|
||||
) : null}
|
||||
{session.label}
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-option__delete"
|
||||
aria-label={`Удалить сохранённую сессию ${session.label}`}
|
||||
disabled={pending || deleting}
|
||||
onClick={() => setDeleteTarget(session)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : sessions.state === "loading" ? (
|
||||
<div className="observation-session-menu__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Читаем каталог</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-session-menu__empty">
|
||||
<Icon name={sessions.error ? "alert" : "database"} size={18} />
|
||||
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
|
||||
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.error && sessions.items.length > 0 ? (
|
||||
<footer className="observation-session-menu__error" role="alert">
|
||||
<Icon name="alert" size={13} />
|
||||
<span>{sessions.error}</span>
|
||||
{sessions.failedSessionId ? (
|
||||
<button type="button" onClick={() => void sessions.retry()}>
|
||||
Повторить
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить сохранённую сессию?"
|
||||
description={deleteTarget ? <>
|
||||
<strong>{deleteTarget.label}</strong>
|
||||
{deleteTarget.lab ? (
|
||||
<p>
|
||||
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
|
||||
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
|
||||
лабораторный результат останутся неизменными.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
)}
|
||||
{sessions.error && sessions.deletingSessionId === null ? (
|
||||
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
|
||||
) : null}
|
||||
</> : null}
|
||||
confirmLabel="Удалить сессию"
|
||||
pendingLabel="Удаление…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (sessions.deletingSessionId === null) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
video: "video",
|
||||
image: "image",
|
||||
depth: "image",
|
||||
};
|
||||
|
||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||
unverified: "Не подтверждён",
|
||||
declared: "Канал объявлен",
|
||||
available: "Доступен",
|
||||
connecting: "Подключение",
|
||||
streaming: "Эфир",
|
||||
degraded: "Нестабильно",
|
||||
unavailable: "Недоступен",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
||||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({
|
||||
source,
|
||||
playback,
|
||||
prepareRecorded = true,
|
||||
recordedSessionGate = "ready",
|
||||
recordedAdmissionKey = null,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const sourceSelected = source.activation ? source.activation.selected : true;
|
||||
const deliveryActive = Boolean(source.delivery && sourceSelected);
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "mse-fmp4-websocket" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
if (!prepareRecorded) {
|
||||
return (
|
||||
<div className="observation-media__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ожидает подготовки</strong>
|
||||
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepare
|
||||
sessionGate={recordedSessionGate}
|
||||
admissionKey={recordedAdmissionKey}
|
||||
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
className="observation-media__asset"
|
||||
src={source.delivery.url}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "image-url" &&
|
||||
(source.modality === "image" || source.modality === "depth")
|
||||
) {
|
||||
return <img className="observation-media__asset" src={source.delivery.url} alt={source.label} />;
|
||||
}
|
||||
|
||||
if (sourceSelected && source.previewUrl && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
className="observation-media__asset"
|
||||
src={source.previewUrl}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
sourceSelected &&
|
||||
source.previewUrl &&
|
||||
(source.modality === "image" || source.modality === "depth")
|
||||
) {
|
||||
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="observation-media__empty">
|
||||
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||
<strong>
|
||||
{source.modality === "video" && source.activation && !source.activation.selected
|
||||
? "Источник отключён"
|
||||
: observationSourceStatusLabel(source)}
|
||||
</strong>
|
||||
<span>
|
||||
{source.modality === "video"
|
||||
? source.activation?.controllable
|
||||
? source.activation.selected
|
||||
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||
: "Канал остановлен оператором; при необходимости его можно открыть снова"
|
||||
: "Канал известен, browser-preview сейчас недоступен"
|
||||
: source.description}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationSourcePicker({
|
||||
sources,
|
||||
visibleSourceIds,
|
||||
pendingSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
sources: readonly ObservationSourceDescriptor[];
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
pendingSourceIds?: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
||||
}) {
|
||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="scene-source-picker"
|
||||
placement="bottom-start"
|
||||
width={340}
|
||||
offset={10}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-source-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="scene-source-picker__trigger"
|
||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||
aria-label="Источники данных сцены"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={17} />
|
||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<header className="observation-source-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||
<strong>Источники данных</strong>
|
||||
</div>
|
||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||
</header>
|
||||
{sources.length ? (
|
||||
<div className="observation-source-menu__list">
|
||||
{sources.map((source) => {
|
||||
const selected = visibleSourceIds.has(source.id);
|
||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
||||
const canOpen = Boolean(
|
||||
selected ||
|
||||
source.modality === "point-cloud" ||
|
||||
source.previewUrl ||
|
||||
source.delivery ||
|
||||
source.activation?.controllable,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-source-option"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
disabled={pending || !canOpen}
|
||||
onClick={() => void onToggle(source.id)}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__check">
|
||||
{selected ? <Icon name="check" size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-source-menu__empty">
|
||||
<Icon name="database" size={18} />
|
||||
<strong>Каталог пока пуст</strong>
|
||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||
модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
export function ObservationTimeline({
|
||||
active,
|
||||
sourceCount,
|
||||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
rangeNs = null,
|
||||
currentNs = null,
|
||||
playing = false,
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
sourceCount: number;
|
||||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
rangeNs?: { min: number; max: number } | null;
|
||||
currentNs?: number | null;
|
||||
playing?: boolean;
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||
const buffered = Boolean(
|
||||
seekable &&
|
||||
(mode === "buffered" || mode === "recorded") &&
|
||||
playbackRange,
|
||||
);
|
||||
const elapsedSeconds = playbackRange
|
||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||
: 0;
|
||||
const durationSeconds = playbackRange
|
||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||
: 0;
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
const accumulationValue = accumulationSeconds === undefined
|
||||
? null
|
||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||
>
|
||||
{accumulationValue !== null ? (
|
||||
<div className="observation-timeline__accumulation">
|
||||
<span>Накопление</span>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||
onChange={(event) =>
|
||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||
onPointerUp={onAccumulationCommit}
|
||||
onTouchEnd={onAccumulationCommit}
|
||||
onKeyUp={onAccumulationCommit}
|
||||
onBlur={onAccumulationCommit}
|
||||
/>
|
||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="observation-timeline__playback">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Перейти к началу"
|
||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(durationSeconds, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Позиция воспроизведения"
|
||||
onChange={(event) => {
|
||||
if (!playbackRange) return;
|
||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>
|
||||
{buffered
|
||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||
: active ? "LIVE" : "—:—:—.———"}
|
||||
</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
range: { min: number; max: number } | null | undefined,
|
||||
): { min: number; max: number } | null {
|
||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||
if (range.max <= range.min) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
export function timelineOffsetSeconds(
|
||||
range: { min: number; max: number },
|
||||
currentNs: number,
|
||||
): number {
|
||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||
}
|
||||
|
||||
export function formatTimelineDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||
const wholeMinutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - wholeMinutes * 60;
|
||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationRecordedMediaManifest,
|
||||
ObservationSessionContractError,
|
||||
type ObservationRecordedMediaEpoch,
|
||||
type ObservationRecordedMediaManifest,
|
||||
type ObservationRecordedMediaSource,
|
||||
type ObservationSessionFetch,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import {
|
||||
type RecordedAdmissionPhase,
|
||||
type RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
|
||||
export interface RecordedObservationPlayback {
|
||||
currentSeconds: number;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
export interface RecordedMediaArchive {
|
||||
manifest: ObservationRecordedMediaManifest;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
|
||||
|
||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||
let recordedMediaWorkerGeneration = 0;
|
||||
|
||||
export function recordedMediaPresentationState(
|
||||
state: "loading" | "ready" | "error",
|
||||
readyGeneration: string | null,
|
||||
selectedGeneration: string | null,
|
||||
waitingForEpoch: boolean,
|
||||
sessionGate: RecordedAdmissionPhase = "ready",
|
||||
): RecordedMediaPresentationState {
|
||||
if (state === "error" || sessionGate === "error") return "error";
|
||||
if (sessionGate !== "ready") return "loading";
|
||||
if (waitingForEpoch) return "waiting";
|
||||
return state === "ready" &&
|
||||
selectedGeneration !== null &&
|
||||
readyGeneration === selectedGeneration
|
||||
? "ready"
|
||||
: "loading";
|
||||
}
|
||||
|
||||
export function selectRecordedMediaEpoch(
|
||||
epochs: readonly ObservationRecordedMediaEpoch[],
|
||||
currentSeconds: number,
|
||||
): ObservationRecordedMediaEpoch | null {
|
||||
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
|
||||
let selected: ObservationRecordedMediaEpoch | null = null;
|
||||
for (const epoch of epochs) {
|
||||
if (epoch.timelineStartSeconds > currentSeconds) break;
|
||||
selected = epoch;
|
||||
}
|
||||
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
|
||||
}
|
||||
|
||||
export function recordedMediaSeekableCoverage(
|
||||
durationSeconds: number,
|
||||
seekableEndSeconds: number,
|
||||
declaredDurationSeconds: number,
|
||||
toleranceSeconds = RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
||||
seekableStartSeconds = 0,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(durationSeconds) &&
|
||||
Number.isFinite(seekableEndSeconds) &&
|
||||
Number.isFinite(declaredDurationSeconds) &&
|
||||
Number.isFinite(seekableStartSeconds) &&
|
||||
declaredDurationSeconds > 0 &&
|
||||
toleranceSeconds >= 0 &&
|
||||
durationSeconds + toleranceSeconds >= declaredDurationSeconds &&
|
||||
seekableStartSeconds <= toleranceSeconds &&
|
||||
seekableStartSeconds >= -toleranceSeconds &&
|
||||
seekableEndSeconds + toleranceSeconds >= declaredDurationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function recordedMediaLocalTime(
|
||||
epochStartSeconds: number,
|
||||
currentSeconds: number,
|
||||
durationSeconds = Number.POSITIVE_INFINITY,
|
||||
): number {
|
||||
if (!Number.isFinite(epochStartSeconds) || !Number.isFinite(currentSeconds)) return 0;
|
||||
const local = Math.max(0, currentSeconds - epochStartSeconds);
|
||||
return Number.isFinite(durationSeconds)
|
||||
? Math.min(local, Math.max(0, durationSeconds))
|
||||
: local;
|
||||
}
|
||||
|
||||
function sourceContract(source: ObservationSourceDescriptor): ObservationRecordedMediaSource | null {
|
||||
const delivery = source.delivery;
|
||||
if (!delivery || delivery.kind !== "recorded-fmp4-manifest") return null;
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
modality: "video",
|
||||
manifestUrl: delivery.url,
|
||||
manifestGenerationSha256: delivery.manifestGenerationSha256,
|
||||
byteLength: delivery.byteLength,
|
||||
mediaType: delivery.mediaType,
|
||||
timelineStartSeconds: delivery.timelineStartSeconds,
|
||||
timelineEndSeconds: delivery.timelineEndSeconds,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRecordedMediaArchive(
|
||||
source: ObservationRecordedMediaSource,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
} = {},
|
||||
): Promise<RecordedMediaArchive> {
|
||||
const manifest = await fetchObservationRecordedMediaManifest(source, {
|
||||
signal,
|
||||
fetcher,
|
||||
expectedGenerationSha256: source.manifestGenerationSha256,
|
||||
});
|
||||
const totalBytes = manifest.epochs.reduce((total, epoch) => total + epoch.byteLength, 0);
|
||||
if (
|
||||
!Number.isSafeInteger(totalBytes) ||
|
||||
totalBytes < 1 ||
|
||||
totalBytes !== manifest.byteLength ||
|
||||
totalBytes !== source.byteLength
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Размеры потоков записанного медиаканала не совпадают с manifest.",
|
||||
);
|
||||
}
|
||||
return { manifest, byteLength: totalBytes };
|
||||
}
|
||||
|
||||
function videoHasSeekableArchive(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
): boolean {
|
||||
if (video.readyState < 1 || video.seekable.length < 1) return false;
|
||||
return recordedMediaSeekableCoverage(
|
||||
video.duration,
|
||||
video.seekable.end(video.seekable.length - 1),
|
||||
declaredDurationSeconds,
|
||||
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
||||
video.seekable.start(0),
|
||||
);
|
||||
}
|
||||
|
||||
function waitForSeekableArchive(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
|
||||
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
const armStallTimer = () => {
|
||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||
stallTimer = globalThis.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera stream stalled"));
|
||||
}, 45_000);
|
||||
};
|
||||
const cleanup = () => {
|
||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||
for (const event of events) video.removeEventListener(event, onProgress);
|
||||
video.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onProgress = () => {
|
||||
armStallTimer();
|
||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera decode failed"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
for (const event of events) video.addEventListener(event, onProgress);
|
||||
video.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
armStallTimer();
|
||||
});
|
||||
}
|
||||
|
||||
async function mountRecordedEpochStream(
|
||||
video: HTMLVideoElement,
|
||||
descriptor: ObservationRecordedMediaEpoch,
|
||||
signal: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
|
||||
throw new Error("Archived camera codec is not supported");
|
||||
}
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// The generation token binds the native media request to the exact manifest.
|
||||
// The browser range-streams the virtual init+fragment file from the server;
|
||||
// no complete camera archive is copied into JavaScript memory.
|
||||
const cleanup = () => {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
};
|
||||
video.preload = "auto";
|
||||
video.src = descriptor.streamUrl;
|
||||
video.load();
|
||||
try {
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
signal,
|
||||
);
|
||||
return cleanup;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function RecordedFmp4Player({
|
||||
source,
|
||||
playback,
|
||||
prepare = true,
|
||||
sessionGate = "ready",
|
||||
admissionKey = null,
|
||||
onAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepare?: boolean;
|
||||
sessionGate?: RecordedAdmissionPhase;
|
||||
admissionKey?: string | null;
|
||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||
onAdmissionChangeRef.current = onAdmissionChange;
|
||||
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
|
||||
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
|
||||
recordedMediaWorkerGeneration += 1;
|
||||
workerRef.current = { admissionKey, generation: recordedMediaWorkerGeneration };
|
||||
}
|
||||
const workerGeneration = workerRef.current.generation;
|
||||
const reportAdmission = (next: RecordedCameraAdmissionState) => {
|
||||
onAdmissionChangeRef.current?.({
|
||||
...next,
|
||||
admissionKey,
|
||||
workerGeneration,
|
||||
});
|
||||
};
|
||||
const recordedDelivery = source.delivery?.kind === "recorded-fmp4-manifest"
|
||||
? source.delivery
|
||||
: null;
|
||||
const contract = useMemo(
|
||||
() => sourceContract(source),
|
||||
[
|
||||
source.id,
|
||||
source.label,
|
||||
recordedDelivery?.id,
|
||||
recordedDelivery?.url,
|
||||
recordedDelivery?.mediaType,
|
||||
recordedDelivery?.manifestGenerationSha256,
|
||||
recordedDelivery?.byteLength,
|
||||
recordedDelivery?.timelineStartSeconds,
|
||||
recordedDelivery?.timelineEndSeconds,
|
||||
],
|
||||
);
|
||||
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const epoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
);
|
||||
const waitingForEpoch = Boolean(archive && !epoch);
|
||||
const selectedGeneration = contract && epoch
|
||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
||||
: null;
|
||||
const visualState = recordedMediaPresentationState(
|
||||
state,
|
||||
readyGeneration,
|
||||
selectedGeneration,
|
||||
waitingForEpoch,
|
||||
sessionGate,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contract) {
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: null,
|
||||
message: "Некорректный descriptor записанной камеры.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!prepare) return;
|
||||
const abort = new AbortController();
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
reportAdmission({
|
||||
phase: "loading",
|
||||
byteLength: contract.byteLength,
|
||||
message: null,
|
||||
});
|
||||
void fetchRecordedMediaArchive(contract, { signal: abort.signal })
|
||||
.then((loaded) => {
|
||||
if (abort.signal.aborted) return;
|
||||
setArchive(loaded);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
|
||||
return;
|
||||
}
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: contract.byteLength,
|
||||
message: "Архив записанной камеры не прошёл проверку.",
|
||||
});
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [admissionKey, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!archive || !contract || !prepare) return;
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
for (const candidate of archive.manifest.epochs) {
|
||||
const probe = document.createElement("video");
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
|
||||
cleanup();
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
}
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
byteLength: archive.byteLength,
|
||||
message: null,
|
||||
});
|
||||
})().catch((error: unknown) => {
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) return;
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive.byteLength,
|
||||
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
};
|
||||
}, [admissionKey, archive, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !epoch) return;
|
||||
const epochDescriptor = epoch;
|
||||
const generation = contract
|
||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||
: null;
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
const loadEpoch = async () => {
|
||||
try {
|
||||
cleanup = await mountRecordedEpochStream(video, epochDescriptor, abort.signal);
|
||||
if (disposed || abort.signal.aborted) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
return;
|
||||
}
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
setReadyGeneration(generation);
|
||||
setState("ready");
|
||||
} catch (error) {
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Записанная камера не стала seekable.",
|
||||
});
|
||||
}
|
||||
};
|
||||
void loadEpoch();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
cleanup?.();
|
||||
};
|
||||
}, [archive?.byteLength, contract, epoch]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !epoch || visualState !== "ready") return;
|
||||
const target = recordedMediaLocalTime(
|
||||
epoch.timelineStartSeconds,
|
||||
currentSeconds,
|
||||
video.duration,
|
||||
);
|
||||
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
|
||||
try {
|
||||
video.currentTime = target;
|
||||
} catch {
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Seek записанной камеры завершился ошибкой.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (playback?.playing) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="recorded-media-player"
|
||||
data-state={visualState}
|
||||
aria-busy={visualState === "loading"}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="observation-media__asset"
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
aria-label={source.label}
|
||||
/>
|
||||
{visualState !== "ready" ? (
|
||||
<div
|
||||
className="recorded-media-player__notice"
|
||||
role={visualState === "error" ? "alert" : "status"}
|
||||
>
|
||||
{visualState === "waiting"
|
||||
? "Камера на этой позиции ещё не записывалась"
|
||||
: visualState === "error"
|
||||
? "Записанное видео недоступно"
|
||||
: archive
|
||||
? "Проверяем seek и codec записанного видео…"
|
||||
: "Читаем manifest записанного видео…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
|
||||
// Composition root: this is the only place where Mission Core chooses which
|
||||
// statically reviewed device plugins are shipped in the current build.
|
||||
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||
xgridsK1Plugin,
|
||||
]);
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
||||
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
||||
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
||||
|
||||
interface DevicePluginHostValue {
|
||||
registry: DevicePluginRegistry;
|
||||
selection: RegisteredDeviceModel | null;
|
||||
selectionTransitionPending: boolean;
|
||||
selectionTransitionError: string | null;
|
||||
selectModel: (modelId: string) => Promise<boolean>;
|
||||
clearSelection: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
children,
|
||||
}: {
|
||||
plugins: readonly DeviceUiPlugin[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
|
||||
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
|
||||
const transitionInFlight = useRef(false);
|
||||
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
|
||||
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
|
||||
|
||||
const registerDeactivation = useCallback(
|
||||
(pluginId: string, handler: () => Promise<boolean>) => {
|
||||
deactivationHandlers.current.set(pluginId, handler);
|
||||
return () => {
|
||||
if (deactivationHandlers.current.get(pluginId) === handler) {
|
||||
deactivationHandlers.current.delete(pluginId);
|
||||
}
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const transitionTo = useCallback(
|
||||
async (nextModelId: string | null): Promise<boolean> => {
|
||||
if (transitionInFlight.current) return false;
|
||||
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
|
||||
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
|
||||
}
|
||||
if (nextModelId === selectedModelId) return true;
|
||||
|
||||
transitionInFlight.current = true;
|
||||
setSelectionTransitionPending(true);
|
||||
setSelectionTransitionError(null);
|
||||
try {
|
||||
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
|
||||
const deactivate = current
|
||||
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
|
||||
: undefined;
|
||||
if (current && !deactivate) {
|
||||
setSelectionTransitionError(
|
||||
"Активный плагин не зарегистрировал безопасное завершение сессии.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (deactivate) {
|
||||
try {
|
||||
if (!(await deactivate())) {
|
||||
setSelectionTransitionError(
|
||||
"Плагин не подтвердил завершение текущей сессии.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setSelectionTransitionError(
|
||||
"Не удалось безопасно завершить текущую сессию плагина.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setSelectedModelId(nextModelId);
|
||||
return true;
|
||||
} finally {
|
||||
transitionInFlight.current = false;
|
||||
setSelectionTransitionPending(false);
|
||||
}
|
||||
},
|
||||
[registry, selectedModelId],
|
||||
);
|
||||
|
||||
const deactivationRegistrars = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
registry.plugins.map((plugin) => [
|
||||
plugin.manifest.metadata.id,
|
||||
(handler: () => Promise<boolean>) =>
|
||||
registerDeactivation(plugin.manifest.metadata.id, handler),
|
||||
]),
|
||||
),
|
||||
[registerDeactivation, registry.plugins],
|
||||
);
|
||||
|
||||
const value = useMemo<DevicePluginHostValue>(
|
||||
() => ({
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionPending,
|
||||
selectionTransitionError,
|
||||
selectModel: (modelId) => transitionTo(modelId),
|
||||
clearSelection: () => transitionTo(null),
|
||||
}),
|
||||
[
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionError,
|
||||
selectionTransitionPending,
|
||||
transitionTo,
|
||||
],
|
||||
);
|
||||
|
||||
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
|
||||
const RuntimeProvider = plugin.RuntimeProvider;
|
||||
return (
|
||||
<RuntimeProvider
|
||||
key={plugin.manifest.metadata.id}
|
||||
activeModel={
|
||||
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
|
||||
? selection.model
|
||||
: null
|
||||
}
|
||||
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
|
||||
>
|
||||
{child}
|
||||
</RuntimeProvider>
|
||||
);
|
||||
}, children);
|
||||
|
||||
return (
|
||||
<DevicePluginHostContext.Provider value={value}>
|
||||
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
|
||||
</DevicePluginHostContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDevicePluginHost(): DevicePluginHostValue {
|
||||
const value = useContext(DevicePluginHostContext);
|
||||
if (!value) {
|
||||
throw new Error("DevicePluginHostProvider не подключён в composition root.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const;
|
||||
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const;
|
||||
export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
|
||||
] as const);
|
||||
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
|
||||
|
||||
export type DevicePluginApiVersion =
|
||||
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
|
||||
|
||||
export interface DeviceCapability {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DevicePluginActionDefinition {
|
||||
id: string;
|
||||
mutating: boolean;
|
||||
secretFields: readonly string[];
|
||||
}
|
||||
|
||||
export interface DeviceModelDefinition {
|
||||
id: string;
|
||||
vendor: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
description: string;
|
||||
verified: boolean;
|
||||
capabilities: readonly DeviceCapability[];
|
||||
ui: {
|
||||
slot: "device.connection";
|
||||
componentKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeviceCompatibilityProfileDefinition {
|
||||
profileId: string;
|
||||
path: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
interface DevicePluginManifestBase {
|
||||
apiVersion: DevicePluginApiVersion;
|
||||
kind: "DevicePlugin";
|
||||
metadata: {
|
||||
id: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1";
|
||||
runtime: {
|
||||
backendEntrypoint: string;
|
||||
isolation: "transitional-in-process";
|
||||
};
|
||||
permissions: readonly string[];
|
||||
actions: readonly DevicePluginActionDefinition[];
|
||||
models: readonly DeviceModelDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
spec: {
|
||||
hostApiRange: "v1alpha2";
|
||||
runtime: {
|
||||
backendEntrypoint: string;
|
||||
isolation: "transitional-in-process";
|
||||
};
|
||||
permissions: readonly string[];
|
||||
actions: readonly DevicePluginActionDefinition[];
|
||||
models: readonly DeviceModelDefinition[];
|
||||
compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export type DevicePluginManifest =
|
||||
| DevicePluginManifestV1Alpha1
|
||||
| DevicePluginManifestV1Alpha2;
|
||||
|
||||
export function isDevicePluginManifestV1Alpha2(
|
||||
manifest: DevicePluginManifest,
|
||||
): manifest is DevicePluginManifestV1Alpha2 {
|
||||
return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
}
|
||||
|
||||
export interface DevicePluginHostActions {
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
model: DeviceModelDefinition;
|
||||
host: DevicePluginHostActions;
|
||||
}
|
||||
|
||||
export interface DeviceUiPlugin {
|
||||
manifest: DevicePluginManifest;
|
||||
RuntimeProvider: ComponentType<{
|
||||
activeModel: DeviceModelDefinition | null;
|
||||
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
|
||||
export interface RegisteredDeviceModel {
|
||||
plugin: DeviceUiPlugin;
|
||||
model: DeviceModelDefinition;
|
||||
ConnectionView: ComponentType<DevicePluginConnectionProps>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Public frontend host surface for statically reviewed device plugins.
|
||||
*
|
||||
* Plugin source may depend on this module through the
|
||||
* `@mission-core/plugin-sdk` alias. It must not reach into Control Station
|
||||
* implementation paths directly.
|
||||
*/
|
||||
export * from "./contracts";
|
||||
export { parseDevicePluginManifest, requirePluginAction } from "./manifestParser";
|
||||
export {
|
||||
MissionRuntimeProvider,
|
||||
useMissionRuntime,
|
||||
} from "../runtime/MissionRuntimeContext";
|
||||
export type * from "../runtime/contracts";
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
|
||||
type DeviceCapability,
|
||||
type DeviceCompatibilityProfileDefinition,
|
||||
type DeviceModelDefinition,
|
||||
type DevicePluginActionDefinition,
|
||||
type DevicePluginManifest,
|
||||
type DevicePluginManifestV1Alpha1,
|
||||
type DevicePluginManifestV1Alpha2,
|
||||
} from "./contracts";
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
path: string,
|
||||
allowed: readonly string[],
|
||||
): void {
|
||||
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (extras.length) {
|
||||
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maxLength = 160): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
throw new Error(
|
||||
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, path: string): string {
|
||||
const candidate = text(value, path, 192);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) {
|
||||
throw new Error(`Некорректный manifest: ${path} не является идентификатором.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function flag(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function list(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
|
||||
return strictIdentifiers ? identifier(value, path) : text(value, path);
|
||||
}
|
||||
|
||||
function capability(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DeviceCapability {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "label"]);
|
||||
return {
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
label: text(item.label, `${path}.label`),
|
||||
};
|
||||
}
|
||||
|
||||
function action(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DevicePluginActionDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "mutating", "secretFields"]);
|
||||
return {
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
mutating: flag(item.mutating, `${path}.mutating`),
|
||||
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
|
||||
contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function model(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DeviceModelDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, [
|
||||
"id",
|
||||
"vendor",
|
||||
"displayName",
|
||||
"category",
|
||||
"description",
|
||||
"verified",
|
||||
"capabilities",
|
||||
"ui",
|
||||
]);
|
||||
const ui = record(item.ui, `${path}.ui`);
|
||||
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
|
||||
const slot = text(ui.slot, `${path}.ui.slot`);
|
||||
if (slot !== "device.connection") {
|
||||
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
|
||||
}
|
||||
return {
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
vendor: text(item.vendor, `${path}.vendor`),
|
||||
displayName: text(item.displayName, `${path}.displayName`),
|
||||
category: text(item.category, `${path}.category`),
|
||||
description: text(item.description, `${path}.description`, 1024),
|
||||
verified: flag(item.verified, `${path}.verified`),
|
||||
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
|
||||
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
|
||||
),
|
||||
ui: {
|
||||
slot,
|
||||
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compatibilityProfile(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): DeviceCompatibilityProfileDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["profileId", "path", "modelId"]);
|
||||
const profilePath = text(item.path, `${path}.path`, 512);
|
||||
const pathSegments = profilePath.split("/");
|
||||
if (
|
||||
profilePath.startsWith("/") ||
|
||||
profilePath.includes("\\") ||
|
||||
!profilePath.endsWith(".json") ||
|
||||
pathSegments.some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
throw new Error(
|
||||
`Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
profileId: identifier(item.profileId, `${path}.profileId`),
|
||||
path: profilePath,
|
||||
modelId: identifier(item.modelId, `${path}.modelId`),
|
||||
};
|
||||
}
|
||||
|
||||
function validateV1Alpha2Profiles(
|
||||
models: readonly DeviceModelDefinition[],
|
||||
profiles: readonly DeviceCompatibilityProfileDefinition[],
|
||||
): void {
|
||||
if (!profiles.length) {
|
||||
throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles.");
|
||||
}
|
||||
const modelIds = new Set(models.map((modelItem) => modelItem.id));
|
||||
const profileIds = new Set<string>();
|
||||
const profilePaths = new Set<string>();
|
||||
const coveredModels = new Set<string>();
|
||||
for (const profile of profiles) {
|
||||
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
|
||||
throw new Error(
|
||||
`Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`,
|
||||
);
|
||||
}
|
||||
profileIds.add(profile.profileId);
|
||||
profilePaths.add(profile.path);
|
||||
if (!modelIds.has(profile.modelId)) {
|
||||
throw new Error(
|
||||
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
|
||||
);
|
||||
}
|
||||
coveredModels.add(profile.modelId);
|
||||
}
|
||||
const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId));
|
||||
if (uncovered.length) {
|
||||
throw new Error(
|
||||
`Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
|
||||
const root = record(document, "root");
|
||||
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
|
||||
const apiVersion = root.apiVersion;
|
||||
if (
|
||||
(apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
|
||||
apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) ||
|
||||
root.kind !== "DevicePlugin"
|
||||
) {
|
||||
throw new Error("Manifest использует несовместимую версию или kind.");
|
||||
}
|
||||
const metadata = record(root.metadata, "metadata");
|
||||
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
|
||||
const spec = record(root.spec, "spec");
|
||||
const specKeys = [
|
||||
"hostApiRange",
|
||||
"runtime",
|
||||
"permissions",
|
||||
"actions",
|
||||
"models",
|
||||
];
|
||||
exactKeys(
|
||||
spec,
|
||||
"spec",
|
||||
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2
|
||||
? [...specKeys, "compatibilityProfiles"]
|
||||
: specKeys,
|
||||
);
|
||||
const expectedHostApiRange =
|
||||
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1";
|
||||
if (spec.hostApiRange !== expectedHostApiRange) {
|
||||
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
|
||||
}
|
||||
const runtime = record(spec.runtime, "spec.runtime");
|
||||
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
|
||||
const isolation = text(runtime.isolation, "spec.runtime.isolation");
|
||||
if (isolation !== "transitional-in-process") {
|
||||
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
|
||||
}
|
||||
|
||||
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
const models = list(spec.models, "spec.models").map((entry, index) =>
|
||||
model(entry, `spec.models[${index}]`, strictIdentifiers),
|
||||
);
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) {
|
||||
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
|
||||
}
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) {
|
||||
throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель.");
|
||||
}
|
||||
|
||||
const version = text(metadata.version, "metadata.version");
|
||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
|
||||
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
|
||||
}
|
||||
|
||||
const common = {
|
||||
kind: "DevicePlugin",
|
||||
metadata: {
|
||||
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
|
||||
version,
|
||||
displayName: text(metadata.displayName, "metadata.displayName"),
|
||||
},
|
||||
} as const;
|
||||
const commonSpec = {
|
||||
runtime: {
|
||||
backendEntrypoint: text(
|
||||
runtime.backendEntrypoint,
|
||||
"spec.runtime.backendEntrypoint",
|
||||
256,
|
||||
),
|
||||
isolation,
|
||||
},
|
||||
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
|
||||
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
|
||||
),
|
||||
actions: list(spec.actions, "spec.actions").map((entry, index) =>
|
||||
action(entry, `spec.actions[${index}]`, strictIdentifiers),
|
||||
),
|
||||
models,
|
||||
} as const;
|
||||
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
|
||||
return {
|
||||
...common,
|
||||
apiVersion,
|
||||
spec: {
|
||||
...commonSpec,
|
||||
hostApiRange: "v1alpha1",
|
||||
},
|
||||
} satisfies DevicePluginManifestV1Alpha1;
|
||||
}
|
||||
|
||||
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
|
||||
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
|
||||
);
|
||||
validateV1Alpha2Profiles(models, profiles);
|
||||
return {
|
||||
...common,
|
||||
apiVersion,
|
||||
spec: {
|
||||
...commonSpec,
|
||||
hostApiRange: "v1alpha2",
|
||||
compatibilityProfiles: profiles,
|
||||
},
|
||||
} satisfies DevicePluginManifestV1Alpha2;
|
||||
}
|
||||
|
||||
export function requirePluginAction(
|
||||
manifest: DevicePluginManifest,
|
||||
actionId: string,
|
||||
): string {
|
||||
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
|
||||
if (!action) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
|
||||
}
|
||||
return action.id;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_STATE_READ_ACTION_ID,
|
||||
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
|
||||
isDevicePluginManifestV1Alpha2,
|
||||
type DeviceUiPlugin,
|
||||
type RegisteredDeviceModel,
|
||||
} from "./contracts";
|
||||
|
||||
export interface DevicePluginRegistry {
|
||||
readonly plugins: readonly DeviceUiPlugin[];
|
||||
readonly models: readonly RegisteredDeviceModel[];
|
||||
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
||||
}
|
||||
|
||||
export function createDevicePluginRegistry(
|
||||
installedPlugins: readonly DeviceUiPlugin[],
|
||||
): DevicePluginRegistry {
|
||||
const pluginIds = new Set<string>();
|
||||
const modelIds = new Set<string>();
|
||||
const models: RegisteredDeviceModel[] = [];
|
||||
|
||||
for (const plugin of installedPlugins) {
|
||||
const { manifest } = plugin;
|
||||
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
|
||||
);
|
||||
}
|
||||
if (manifest.kind !== "DevicePlugin") {
|
||||
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
|
||||
}
|
||||
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
|
||||
throw new Error(
|
||||
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
pluginIds.add(manifest.metadata.id);
|
||||
|
||||
if (
|
||||
manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
|
||||
manifest.spec.models.length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
|
||||
);
|
||||
}
|
||||
if (!manifest.spec.models.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
|
||||
}
|
||||
|
||||
const permissions = new Set(manifest.spec.permissions);
|
||||
if (permissions.size !== manifest.spec.permissions.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
|
||||
}
|
||||
|
||||
const actionIds = new Set<string>();
|
||||
for (const action of manifest.spec.actions) {
|
||||
if (!action.id.trim() || actionIds.has(action.id)) {
|
||||
throw new Error(
|
||||
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
actionIds.add(action.id);
|
||||
if (new Set(action.secretFields).size !== action.secretFields.length) {
|
||||
throw new Error(`Действие ${action.id} повторяет secret field.`);
|
||||
}
|
||||
}
|
||||
const stateRead = manifest.spec.actions.find(
|
||||
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
|
||||
);
|
||||
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const model of manifest.spec.models) {
|
||||
if (!model.id.trim() || modelIds.has(model.id)) {
|
||||
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
|
||||
}
|
||||
modelIds.add(model.id);
|
||||
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
|
||||
if (capabilityIds.size !== model.capabilities.length) {
|
||||
throw new Error(`Модель ${model.id} повторяет capability.`);
|
||||
}
|
||||
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
|
||||
if (!ConnectionView) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
|
||||
);
|
||||
}
|
||||
models.push({
|
||||
plugin,
|
||||
model,
|
||||
ConnectionView,
|
||||
...(plugin.SpatialControlsView
|
||||
? { SpatialControlsView: plugin.SpatialControlsView }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (isDevicePluginManifestV1Alpha2(manifest)) {
|
||||
const profileIds = new Set<string>();
|
||||
const profilePaths = new Set<string>();
|
||||
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
|
||||
const coveredModelIds = new Set<string>();
|
||||
if (!manifest.spec.compatibilityProfiles.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`);
|
||||
}
|
||||
for (const profile of manifest.spec.compatibilityProfiles) {
|
||||
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`);
|
||||
}
|
||||
profileIds.add(profile.profileId);
|
||||
profilePaths.add(profile.path);
|
||||
if (!declaredModelIds.has(profile.modelId)) {
|
||||
throw new Error(
|
||||
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
|
||||
);
|
||||
}
|
||||
coveredModelIds.add(profile.modelId);
|
||||
}
|
||||
const uncovered = [...declaredModelIds].filter(
|
||||
(modelId) => !coveredModelIds.has(modelId),
|
||||
);
|
||||
if (uncovered.length) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||
return {
|
||||
plugins: Object.freeze([...installedPlugins]),
|
||||
models: Object.freeze(models),
|
||||
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
|
||||
export interface ObservationVisibilityChange {
|
||||
visibleIds: string[];
|
||||
removedIds: string[];
|
||||
}
|
||||
|
||||
export function shouldRestartObservationSource(
|
||||
source: ObservationSourceDescriptor,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
source.activation?.selected &&
|
||||
source.activation.controllable &&
|
||||
(source.availability === "error" || !source.delivery),
|
||||
);
|
||||
}
|
||||
|
||||
export function openObservationSource(
|
||||
currentIds: readonly string[],
|
||||
sourceId: string,
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
): ObservationVisibilityChange {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) return { visibleIds: [...currentIds], removedIds: [] };
|
||||
|
||||
const uniqueCurrent = [...new Set(currentIds)];
|
||||
const activation = source.activation;
|
||||
if (!activation) {
|
||||
return {
|
||||
visibleIds: uniqueCurrent.includes(sourceId) ? uniqueCurrent : [...uniqueCurrent, sourceId],
|
||||
removedIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const groupSourceIds = new Set(
|
||||
sources
|
||||
.filter((candidate) => candidate.activation?.groupId === activation.groupId)
|
||||
.map((candidate) => candidate.id),
|
||||
);
|
||||
const capacity = Math.max(1, Math.floor(activation.maxActive));
|
||||
const currentPeers = uniqueCurrent.filter(
|
||||
(candidate) => candidate !== sourceId && groupSourceIds.has(candidate),
|
||||
);
|
||||
const retainedPeerCount = Math.max(0, capacity - 1);
|
||||
const retainedPeers = new Set(
|
||||
retainedPeerCount > 0 ? currentPeers.slice(-retainedPeerCount) : [],
|
||||
);
|
||||
const visibleIds = uniqueCurrent.filter(
|
||||
(candidate) => !groupSourceIds.has(candidate) || retainedPeers.has(candidate),
|
||||
);
|
||||
if (!visibleIds.includes(sourceId)) visibleIds.push(sourceId);
|
||||
const visibleSet = new Set(visibleIds);
|
||||
return {
|
||||
visibleIds,
|
||||
removedIds: uniqueCurrent.filter((candidate) => !visibleSet.has(candidate)),
|
||||
};
|
||||
}
|
||||
|
||||
export function closeObservationSource(
|
||||
currentIds: readonly string[],
|
||||
sourceId: string,
|
||||
): ObservationVisibilityChange {
|
||||
const visibleIds = currentIds.filter((candidate) => candidate !== sourceId);
|
||||
return {
|
||||
visibleIds,
|
||||
removedIds: visibleIds.length === currentIds.length ? [] : [sourceId],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
|
||||
|
||||
export function recordedObservationSources(
|
||||
launch: ObservationSessionReplayLaunch | null,
|
||||
): ObservationSourceDescriptor[] {
|
||||
if (!launch) return [];
|
||||
const spatial: ObservationSourceDescriptor = {
|
||||
id: "recorded.spatial.primary",
|
||||
sourceId: "recorded.spatial.primary",
|
||||
semanticChannelId: "spatial.point-cloud.recorded",
|
||||
label: "Сохранённая пространственная сцена",
|
||||
description: "Облако точек и траектория из записи Rerun",
|
||||
modality: "point-cloud",
|
||||
role: "primary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "RRD · session_time",
|
||||
previewUrl: launch.sourceUrl,
|
||||
delivery: null,
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: "recorded-spatial",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: false,
|
||||
fullscreen: true,
|
||||
resizable: false,
|
||||
defaultVisible: true,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: launch.timeline,
|
||||
spatialRegistration: "native",
|
||||
},
|
||||
};
|
||||
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => {
|
||||
const perception = source.id.startsWith("recorded.perception.");
|
||||
return {
|
||||
id: source.id,
|
||||
sourceId: source.id,
|
||||
semanticChannelId: perception
|
||||
? "camera.perception.panoptic.recorded"
|
||||
: "camera.video.recorded",
|
||||
label: source.label,
|
||||
description: perception
|
||||
? "Покадровая instance + semantic сегментация на общей временной шкале"
|
||||
: "Сохранённый видеоканал на общей временной шкале сессии",
|
||||
modality: "video",
|
||||
role: "auxiliary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "Сохранённая сессия",
|
||||
previewUrl: null,
|
||||
delivery: {
|
||||
id: `${launch.sessionId}:${source.id}`,
|
||||
kind: "recorded-fmp4-manifest",
|
||||
url: source.manifestUrl,
|
||||
mediaType: source.mediaType,
|
||||
manifestGenerationSha256: source.manifestGenerationSha256,
|
||||
byteLength: source.byteLength,
|
||||
timelineStartSeconds: source.timelineStartSeconds,
|
||||
timelineEndSeconds: source.timelineEndSeconds,
|
||||
},
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: perception ? "recorded-panoptic-perception" : "recorded-media",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: true,
|
||||
fullscreen: true,
|
||||
resizable: true,
|
||||
// AI presentation belongs to the unified Rerun composition. Keep the
|
||||
// pre-rendered perception video as an explicit fallback instead of
|
||||
// opening it as a second, independently controlled stream.
|
||||
defaultVisible: !perception && index < 2,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: "session_time",
|
||||
spatialRegistration: perception ? "calibrated" : "unresolved",
|
||||
},
|
||||
};
|
||||
});
|
||||
return [spatial, ...media];
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Camera count and preparation concurrency remain device-agnostic scheduling
|
||||
// policy. Recorded duration and aggregate bytes are deliberately not admission
|
||||
// criteria: sealed media is presented through a generation-bound HTTP stream,
|
||||
// so a one-, three- or ten-hour recording never has to fit in browser memory.
|
||||
export const MAX_RECORDED_CAMERA_SOURCES = 16;
|
||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||
|
||||
export type RecordedAdmissionPhase = "loading" | "ready" | "error";
|
||||
export type RecordedCameraAdmissionPhase = "pending" | "loading" | "ready" | "error";
|
||||
|
||||
export interface RecordedCameraAdmissionState {
|
||||
phase: RecordedCameraAdmissionPhase;
|
||||
byteLength: number | null;
|
||||
message: string | null;
|
||||
admissionKey?: string | null;
|
||||
workerGeneration?: number;
|
||||
}
|
||||
|
||||
export type RecordedCameraAdmissionMap = Readonly<Record<string, RecordedCameraAdmissionState>>;
|
||||
|
||||
export interface RecordedCameraAdmissionDescriptor {
|
||||
id: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export function recordedCameraDescriptorPreflight(
|
||||
sources: readonly RecordedCameraAdmissionDescriptor[],
|
||||
): RecordedAdmissionPhase {
|
||||
if (sources.length > MAX_RECORDED_CAMERA_SOURCES) return "error";
|
||||
if (new Set(sources.map(({ id }) => id)).size !== sources.length) return "error";
|
||||
for (const source of sources) {
|
||||
if (
|
||||
!source.id ||
|
||||
!Number.isSafeInteger(source.byteLength) ||
|
||||
source.byteLength < 1
|
||||
) return "error";
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
export function initialRecordedCameraAdmissions(
|
||||
sourceIds: readonly string[],
|
||||
declaredByteLengths: Readonly<Record<string, number>> = {},
|
||||
): Record<string, RecordedCameraAdmissionState> {
|
||||
return Object.fromEntries(sourceIds.map((sourceId) => [sourceId, {
|
||||
phase: "pending" as const,
|
||||
byteLength: declaredByteLengths[sourceId] ?? null,
|
||||
message: null,
|
||||
}]));
|
||||
}
|
||||
|
||||
export function recordedSessionAdmissionPhase(
|
||||
spatialPhase: RecordedAdmissionPhase,
|
||||
sourceIds: readonly string[],
|
||||
cameras: RecordedCameraAdmissionMap,
|
||||
): RecordedAdmissionPhase {
|
||||
if (sourceIds.length > MAX_RECORDED_CAMERA_SOURCES || spatialPhase === "error") {
|
||||
return "error";
|
||||
}
|
||||
for (const sourceId of sourceIds) {
|
||||
const camera = cameras[sourceId];
|
||||
if (!camera || camera.phase === "error") return "error";
|
||||
if (camera.phase === "ready" && camera.byteLength === null) return "error";
|
||||
if (camera.byteLength !== null) {
|
||||
if (
|
||||
!Number.isSafeInteger(camera.byteLength) ||
|
||||
camera.byteLength < 1
|
||||
) return "error";
|
||||
}
|
||||
}
|
||||
if (spatialPhase !== "ready") return "loading";
|
||||
return sourceIds.every((sourceId) => cameras[sourceId]?.phase === "ready")
|
||||
? "ready"
|
||||
: "loading";
|
||||
}
|
||||
|
||||
export function nextRecordedCameraPreparationIds(
|
||||
sourceIds: readonly string[],
|
||||
cameras: RecordedCameraAdmissionMap,
|
||||
preferredSourceIds: ReadonlySet<string> = new Set(),
|
||||
concurrency = MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS,
|
||||
): string[] {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) return [];
|
||||
const pending = sourceIds.filter((sourceId) => {
|
||||
const phase = cameras[sourceId]?.phase;
|
||||
return phase === "pending" || phase === "loading" || phase === undefined;
|
||||
});
|
||||
pending.sort((left, right) => (
|
||||
Number(cameras[right]?.phase === "loading") - Number(cameras[left]?.phase === "loading") ||
|
||||
Number(preferredSourceIds.has(right)) - Number(preferredSourceIds.has(left)) ||
|
||||
sourceIds.indexOf(left) - sourceIds.indexOf(right)
|
||||
));
|
||||
return pending.slice(0, concurrency);
|
||||
}
|
||||
|
||||
export function mergeRecordedCameraAdmission(
|
||||
current: RecordedCameraAdmissionState,
|
||||
next: RecordedCameraAdmissionState,
|
||||
): RecordedCameraAdmissionState {
|
||||
const normalized = next.byteLength === null && current.byteLength !== null
|
||||
? { ...next, byteLength: current.byteLength }
|
||||
: next;
|
||||
const currentWorker = current.workerGeneration ?? 0;
|
||||
const nextWorker = normalized.workerGeneration ?? currentWorker;
|
||||
if (nextWorker < currentWorker) return current;
|
||||
if (current.phase === "error") return current;
|
||||
if (nextWorker > currentWorker) return normalized;
|
||||
if (normalized.phase === "error") return normalized;
|
||||
if (current.phase === "ready") return current;
|
||||
return normalized;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,470 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
import {
|
||||
closeObservationSource,
|
||||
openObservationSource,
|
||||
shouldRestartObservationSource,
|
||||
} from "./layoutPolicy";
|
||||
import {
|
||||
normalizeObservationWindowRect,
|
||||
projectObservationLayoutSnapshot,
|
||||
validateObservationLayoutSnapshot,
|
||||
validateObservationViewportSize,
|
||||
type ObservationLayoutSnapshot,
|
||||
type ObservationViewportSize,
|
||||
type ObservationWindowRect,
|
||||
} from "./workspaceLayout";
|
||||
|
||||
export type { ObservationWindowRect } from "./workspaceLayout";
|
||||
|
||||
export type ObservationLayoutPresentationMode = "preserve" | "reset";
|
||||
|
||||
export function observationPresentationSourceAfterLayoutApply(
|
||||
currentSourceId: string | null,
|
||||
mode: ObservationLayoutPresentationMode,
|
||||
): string | null {
|
||||
return mode === "preserve" ? currentSourceId : null;
|
||||
}
|
||||
|
||||
export function visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
currentIds: readonly string[],
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
): string[] {
|
||||
let visibleIds = [...currentIds];
|
||||
for (const source of sources) {
|
||||
if (source.transport !== "recording" || !canOpenByDefault(source)) continue;
|
||||
visibleIds = openObservationSource(visibleIds, source.id, sources).visibleIds;
|
||||
}
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
focusedSourceId: string | null;
|
||||
activeFloatingSourceId: string | null;
|
||||
maximizedFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
viewportSize: ObservationViewportSize | null;
|
||||
pendingSourceIds: ReadonlySet<string>;
|
||||
toggleSource: (sourceId: string) => Promise<boolean>;
|
||||
hideSource: (sourceId: string) => Promise<boolean>;
|
||||
setFocusedSourceId: (sourceId: string | null) => void;
|
||||
activateFloatingSource: (sourceId: string) => void;
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
setViewportSize: (size: ObservationViewportSize) => void;
|
||||
snapshot: () => ObservationLayoutSnapshot | null;
|
||||
restore: (snapshot: ObservationLayoutSnapshot) => void;
|
||||
}
|
||||
|
||||
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
if (!source.capabilities.defaultVisible) return false;
|
||||
if (source.availability === "unavailable" || source.availability === "error") return false;
|
||||
return (
|
||||
source.modality === "point-cloud" ||
|
||||
Boolean(source.previewUrl) ||
|
||||
Boolean(source.delivery && (!source.activation || source.activation.selected))
|
||||
);
|
||||
}
|
||||
|
||||
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||
return sources
|
||||
.map((source) => [
|
||||
source.id,
|
||||
source.binding.deviceSessionId,
|
||||
source.binding.deviceId,
|
||||
].filter(Boolean).join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function cloneSnapshot(snapshot: ObservationLayoutSnapshot): ObservationLayoutSnapshot {
|
||||
return {
|
||||
visibleSourceIds: [...snapshot.visibleSourceIds],
|
||||
activeFloatingSourceId: snapshot.activeFloatingSourceId,
|
||||
windowRects: Object.fromEntries(
|
||||
Object.entries(snapshot.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
|
||||
),
|
||||
viewportSize: { ...snapshot.viewportSize },
|
||||
};
|
||||
}
|
||||
|
||||
export function useObservationLayout(
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
|
||||
): ObservationLayoutController {
|
||||
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||
const visibleIdsRef = useRef<string[]>([]);
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
||||
const [activeFloatingSourceId, setActiveFloatingSourceIdState] = useState<string | null>(null);
|
||||
const activeFloatingSourceIdRef = useRef<string | null>(null);
|
||||
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
|
||||
const [windowRects, setWindowRectsState] = useState<Record<string, ObservationWindowRect>>({});
|
||||
const windowRectsRef = useRef<Record<string, ObservationWindowRect>>({});
|
||||
const [viewportSize, setViewportSizeState] = useState<ObservationViewportSize | null>(null);
|
||||
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
|
||||
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
|
||||
const restoredLayoutAuthorityRef = useRef(false);
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
||||
const sourceIdsRef = useRef<ReadonlySet<string>>(sourceIds);
|
||||
sourceIdsRef.current = sourceIds;
|
||||
const sourcesRef = useRef<readonly ObservationSourceDescriptor[]>(sources);
|
||||
sourcesRef.current = sources;
|
||||
const identity = catalogIdentity(sources);
|
||||
|
||||
const commitVisibleIds = useCallback((next: readonly string[]) => {
|
||||
const unique = [...new Set(next)];
|
||||
visibleIdsRef.current = unique;
|
||||
setVisibleIds(unique);
|
||||
}, []);
|
||||
|
||||
const commitActiveFloatingSourceId = useCallback((next: string | null) => {
|
||||
activeFloatingSourceIdRef.current = next;
|
||||
setActiveFloatingSourceIdState(next);
|
||||
}, []);
|
||||
|
||||
const commitWindowRects = useCallback((next: Record<string, ObservationWindowRect>) => {
|
||||
windowRectsRef.current = next;
|
||||
setWindowRectsState(next);
|
||||
}, []);
|
||||
|
||||
const persistLiveLayout = useCallback(() => {
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
if (!currentViewport) return;
|
||||
const currentKnownIds = sourceIdsRef.current;
|
||||
const previous = desiredSnapshotRef.current;
|
||||
const unknownVisibleIds = previous?.visibleSourceIds.filter(
|
||||
(sourceId) => !currentKnownIds.has(sourceId),
|
||||
) ?? [];
|
||||
const visibleSourceIds = [...new Set([...unknownVisibleIds, ...visibleIdsRef.current])];
|
||||
const unknownRects = Object.entries(previous?.windowRects ?? {}).filter(
|
||||
([sourceId]) => !currentKnownIds.has(sourceId),
|
||||
);
|
||||
const knownRects = Object.entries(windowRectsRef.current).map(([sourceId, rect]) => [
|
||||
sourceId,
|
||||
normalizeObservationWindowRect(rect, currentViewport),
|
||||
] as const);
|
||||
const previousActive = previous?.activeFloatingSourceId ?? null;
|
||||
const activeFloatingSourceId = activeFloatingSourceIdRef.current ?? (
|
||||
previousActive && !currentKnownIds.has(previousActive) ? previousActive : null
|
||||
);
|
||||
desiredSnapshotRef.current = validateObservationLayoutSnapshot({
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId: activeFloatingSourceId && visibleSourceIds.includes(activeFloatingSourceId)
|
||||
? activeFloatingSourceId
|
||||
: null,
|
||||
windowRects: Object.fromEntries([...unknownRects, ...knownRects]),
|
||||
viewportSize: currentViewport,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyDesiredSnapshot = useCallback((
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
presentationMode: ObservationLayoutPresentationMode,
|
||||
) => {
|
||||
const targetViewport = viewportSizeRef.current ?? snapshot.viewportSize;
|
||||
const projected = projectObservationLayoutSnapshot(
|
||||
snapshot,
|
||||
sourceIdsRef.current,
|
||||
targetViewport,
|
||||
);
|
||||
let visibleSourceIds = [...projected.visibleSourceIds];
|
||||
let activeFloatingSourceId = projected.activeFloatingSourceId;
|
||||
if (visibleSourceIds.length === 0 && sourcesRef.current.length > 0) {
|
||||
for (const source of sourcesRef.current.filter(canOpenByDefault)) {
|
||||
visibleSourceIds = openObservationSource(
|
||||
visibleSourceIds,
|
||||
source.id,
|
||||
sourcesRef.current,
|
||||
).visibleIds;
|
||||
}
|
||||
activeFloatingSourceId = sourcesRef.current.find(
|
||||
(source) => visibleSourceIds.includes(source.id) && source.capabilities.overlay,
|
||||
)?.id ?? null;
|
||||
}
|
||||
commitVisibleIds(visibleSourceIds);
|
||||
commitActiveFloatingSourceId(activeFloatingSourceId);
|
||||
commitWindowRects({ ...projected.windowRects });
|
||||
setFocusedSourceIdState((current) =>
|
||||
observationPresentationSourceAfterLayoutApply(current, presentationMode));
|
||||
setMaximizedFloatingSourceId((current) =>
|
||||
observationPresentationSourceAfterLayoutApply(current, presentationMode));
|
||||
}, [commitActiveFloatingSourceId, commitVisibleIds, commitWindowRects]);
|
||||
|
||||
const clearPresentation = useCallback((removedIds: readonly string[], persist = true) => {
|
||||
if (!removedIds.length) return;
|
||||
const removed = new Set(removedIds);
|
||||
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
|
||||
if (
|
||||
activeFloatingSourceIdRef.current &&
|
||||
removed.has(activeFloatingSourceIdRef.current)
|
||||
) {
|
||||
commitActiveFloatingSourceId(null);
|
||||
}
|
||||
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
|
||||
if (persist) persistLiveLayout();
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
applyDesiredSnapshot(desired, "reset");
|
||||
return;
|
||||
}
|
||||
const currentVisible = visibleIdsRef.current;
|
||||
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
|
||||
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
|
||||
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
||||
if (activeFloatingSourceIdRef.current && !sourceIds.has(activeFloatingSourceIdRef.current)) {
|
||||
commitActiveFloatingSourceId(null);
|
||||
}
|
||||
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
const entries = Object.entries(windowRectsRef.current);
|
||||
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||
if (nextEntries.length !== entries.length) commitWindowRects(Object.fromEntries(nextEntries));
|
||||
}, [
|
||||
applyDesiredSnapshot,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
commitWindowRects,
|
||||
sourceIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity) {
|
||||
if (!desiredSnapshotRef.current) initializedCatalog.current = null;
|
||||
return;
|
||||
}
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
const previousIdentity = initializedCatalog.current;
|
||||
initializedCatalog.current = identity;
|
||||
if (
|
||||
previousIdentity !== identity &&
|
||||
sources.some((source) => source.transport === "recording")
|
||||
) {
|
||||
const nextVisible = visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
visibleIdsRef.current,
|
||||
sources,
|
||||
);
|
||||
commitVisibleIds(nextVisible);
|
||||
const firstRecordedOverlay = sources.find((source) => (
|
||||
source.transport === "recording" &&
|
||||
source.capabilities.overlay &&
|
||||
nextVisible.includes(source.id)
|
||||
));
|
||||
commitActiveFloatingSourceId(firstRecordedOverlay?.id ?? null);
|
||||
persistLiveLayout();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
initializedCatalog.current = identity;
|
||||
let defaults: string[] = [];
|
||||
for (const source of sources.filter(canOpenByDefault)) {
|
||||
defaults = openObservationSource(defaults, source.id, sources).visibleIds;
|
||||
}
|
||||
commitVisibleIds(defaults);
|
||||
const firstFloating = sources.find(
|
||||
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
||||
);
|
||||
commitActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||
persistLiveLayout();
|
||||
}, [
|
||||
applyDesiredSnapshot,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
identity,
|
||||
persistLiveLayout,
|
||||
sources,
|
||||
]);
|
||||
|
||||
const selectedDeliveryIdentity = sources
|
||||
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
||||
.map((source) => [
|
||||
source.id,
|
||||
source.delivery?.id,
|
||||
source.activation?.groupId,
|
||||
source.activation?.maxActive,
|
||||
].join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredLayoutAuthorityRef.current) return;
|
||||
const selected = sources.filter(
|
||||
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
||||
);
|
||||
if (!selected.length) return;
|
||||
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
|
||||
const removed = new Set<string>();
|
||||
for (const source of selected) {
|
||||
change = openObservationSource(change.visibleIds, source.id, sources);
|
||||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
}
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation([...removed], false);
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
|
||||
|
||||
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
|
||||
const groupId = source.activation?.groupId;
|
||||
const affected = groupId
|
||||
? sources.filter((candidate) => candidate.activation?.groupId === groupId).map(({ id }) => id)
|
||||
: [source.id];
|
||||
setPendingIds((current) => pending
|
||||
? [...new Set([...current, ...affected])]
|
||||
: current.filter((candidate) => !affected.includes(candidate)));
|
||||
}, [sources]);
|
||||
|
||||
const hideSource = useCallback(async (sourceId: string) => {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) return false;
|
||||
if (source.activation?.selected && source.activation.controllable) {
|
||||
if (!setSourceActive) return false;
|
||||
markPending(source, true);
|
||||
try {
|
||||
if (!await setSourceActive(source.sourceId, false)) return false;
|
||||
} finally {
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = closeObservationSource(visibleIdsRef.current, sourceId);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation(change.removedIds, false);
|
||||
persistLiveLayout();
|
||||
return true;
|
||||
}, [clearPresentation, commitVisibleIds, markPending, persistLiveLayout, setSourceActive, sources]);
|
||||
|
||||
const toggleSource = useCallback(async (sourceId: string) => {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) return false;
|
||||
const restart = shouldRestartObservationSource(source);
|
||||
if (visibleIdsRef.current.includes(sourceId) && !restart) return hideSource(sourceId);
|
||||
if (source.activation && (!source.activation.selected || restart)) {
|
||||
if (!source.activation.controllable || !setSourceActive) return false;
|
||||
markPending(source, true);
|
||||
try {
|
||||
if (!await setSourceActive(source.sourceId, true)) return false;
|
||||
} finally {
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation(change.removedIds, false);
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
return true;
|
||||
}, [
|
||||
clearPresentation,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
hideSource,
|
||||
markPending,
|
||||
persistLiveLayout,
|
||||
setSourceActive,
|
||||
sources,
|
||||
]);
|
||||
|
||||
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
||||
setFocusedSourceIdState(sourceId);
|
||||
if (sourceId) {
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const activateFloatingSource = useCallback((sourceId: string) => {
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
|
||||
setMaximizedFloatingSourceId(maximized ? sourceId : null);
|
||||
if (maximized) {
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
|
||||
if (
|
||||
!Object.values(rect).every((value) => Number.isFinite(value)) ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
if (currentViewport) normalizeObservationWindowRect(rect, currentViewport);
|
||||
commitWindowRects({ ...windowRectsRef.current, [sourceId]: { ...rect } });
|
||||
persistLiveLayout();
|
||||
}, [commitWindowRects, persistLiveLayout]);
|
||||
|
||||
const setViewportSize = useCallback((size: ObservationViewportSize) => {
|
||||
const next = validateObservationViewportSize(size);
|
||||
const current = viewportSizeRef.current;
|
||||
if (current && current.width === next.width && current.height === next.height) return;
|
||||
viewportSizeRef.current = next;
|
||||
setViewportSizeState(next);
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
desiredSnapshotRef.current = { ...desired, viewportSize: next };
|
||||
// Entering fullscreen changes the shell geometry, which triggers this
|
||||
// ResizeObserver path. Reproject persistent window rectangles without
|
||||
// clearing the transient fullscreen/focus state that caused the resize.
|
||||
applyDesiredSnapshot(desiredSnapshotRef.current, "preserve");
|
||||
} else if (initializedCatalog.current) {
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [applyDesiredSnapshot, persistLiveLayout]);
|
||||
|
||||
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
|
||||
if (!viewportSizeRef.current) return null;
|
||||
persistLiveLayout();
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (!desired) return null;
|
||||
return cloneSnapshot(desired);
|
||||
}, [persistLiveLayout]);
|
||||
|
||||
const restore = useCallback((saved: ObservationLayoutSnapshot) => {
|
||||
const validated = validateObservationLayoutSnapshot(saved);
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
desiredSnapshotRef.current = cloneSnapshot({
|
||||
...validated,
|
||||
viewportSize: currentViewport ?? validated.viewportSize,
|
||||
});
|
||||
restoredLayoutAuthorityRef.current = true;
|
||||
applyDesiredSnapshot(desiredSnapshotRef.current, "reset");
|
||||
}, [applyDesiredSnapshot]);
|
||||
|
||||
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
|
||||
|
||||
return {
|
||||
visibleSourceIds,
|
||||
focusedSourceId,
|
||||
activeFloatingSourceId,
|
||||
maximizedFloatingSourceId,
|
||||
windowRects,
|
||||
viewportSize,
|
||||
pendingSourceIds,
|
||||
toggleSource,
|
||||
hideSource,
|
||||
setFocusedSourceId,
|
||||
activateFloatingSource,
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
setViewportSize,
|
||||
snapshot,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeObservationSessionPreparation,
|
||||
deleteObservationSession,
|
||||
fetchObservationSessionCatalog,
|
||||
fetchObservationSessionPreparation,
|
||||
replayObservationSession,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionPreparation,
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionSummary,
|
||||
} from "./sessionArchive";
|
||||
|
||||
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
|
||||
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
|
||||
export type ObservationPreparationPhase =
|
||||
| "requesting"
|
||||
| ObservationSessionPreparation["state"];
|
||||
|
||||
export interface ObservationReplayProgress {
|
||||
readonly sessionId: string;
|
||||
readonly phase: ObservationPreparationPhase;
|
||||
readonly progress: number | null;
|
||||
readonly cancellable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationSessionsController {
|
||||
items: readonly ObservationSessionSummary[];
|
||||
state: ObservationSessionsLoadState;
|
||||
error: string | null;
|
||||
replayingSessionId: string | null;
|
||||
preparation: ObservationSessionPreparation | null;
|
||||
replayProgress: ObservationReplayProgress | null;
|
||||
failedSessionId: string | null;
|
||||
deletingSessionId: string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
replay: (sessionId: string) => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
remove: (sessionId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ObservationReplayAttempt {
|
||||
readonly signal: AbortSignal;
|
||||
isCurrent: () => boolean;
|
||||
finish: () => boolean;
|
||||
}
|
||||
|
||||
export interface ObservationReplayCoordinator {
|
||||
begin: () => ObservationReplayAttempt;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface ObservationPreparationPollingOptions {
|
||||
signal: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
onUpdate?: (preparation: ObservationSessionPreparation) => void;
|
||||
requestTimeoutMs?: number;
|
||||
heartbeatStallMs?: number;
|
||||
maximumWaitMs?: number;
|
||||
initialPollIntervalMs?: number;
|
||||
maximumPollIntervalMs?: number;
|
||||
now?: () => number;
|
||||
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
||||
}
|
||||
|
||||
const PREPARATION_STORAGE_KEY = "missioncore.observation-session-preparation/v1";
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_HEARTBEAT_STALL_MS = 45_000;
|
||||
const DEFAULT_MAXIMUM_WAIT_MS = 30 * 60_000;
|
||||
const DEFAULT_INITIAL_POLL_INTERVAL_MS = 750;
|
||||
const DEFAULT_MAXIMUM_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
export class ObservationPreparationStalledError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservationPreparationStalledError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest selection wins, even if an obsolete server job finishes later. */
|
||||
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
||||
let sequence = 0;
|
||||
let active: AbortController | null = null;
|
||||
|
||||
return {
|
||||
begin() {
|
||||
active?.abort();
|
||||
const controller = new AbortController();
|
||||
const attemptSequence = ++sequence;
|
||||
active = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => (
|
||||
!controller.signal.aborted &&
|
||||
active === controller &&
|
||||
sequence === attemptSequence
|
||||
),
|
||||
finish: () => {
|
||||
if (active !== controller || sequence !== attemptSequence) return false;
|
||||
active = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
active?.abort();
|
||||
// Keep ownership until the cancelled attempt reaches `finish()`. This
|
||||
// lets its finally block settle a replacement that already passed
|
||||
// onReplayBegin, while `isCurrent()` still fails immediately because the
|
||||
// signal is aborted. A later `begin()` replaces and invalidates it.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция с сохранёнными сессиями завершилась ошибкой.";
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
type PendingPreparationState = Exclude<
|
||||
ObservationSessionPreparation["state"],
|
||||
"failed" | "cancelled"
|
||||
>;
|
||||
|
||||
function pendingPreparation(
|
||||
preparation: ObservationSessionPreparation,
|
||||
): preparation is ObservationSessionPreparation & { state: PendingPreparationState } {
|
||||
return preparation.state !== "failed" && preparation.state !== "cancelled";
|
||||
}
|
||||
|
||||
const PREPARATION_PHASE_ORDER: Record<
|
||||
PendingPreparationState,
|
||||
number
|
||||
> = {
|
||||
queued: 0,
|
||||
validating: 1,
|
||||
exporting: 2,
|
||||
finalizing: 3,
|
||||
};
|
||||
|
||||
function terminalPreparationError(preparation: ObservationSessionPreparation): Error {
|
||||
if (preparation.error) return new Error(preparation.error);
|
||||
return new Error(
|
||||
preparation.state === "cancelled"
|
||||
? "Подготовка записи отменена."
|
||||
: "Сервер не смог подготовить сохранённую сессию.",
|
||||
);
|
||||
}
|
||||
|
||||
function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
};
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function withRequestTimeout<T>(
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const forwardAbort = () => controller.abort();
|
||||
signal.addEventListener("abort", forwardAbort, { once: true });
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
return await operation(controller.signal);
|
||||
} catch (error) {
|
||||
if (timedOut && isAbortError(error)) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Сервер слишком долго не отвечает. Подготовку можно повторить.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timer);
|
||||
signal.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForObservationReplayPreparation(
|
||||
initial: ObservationSessionPreparation,
|
||||
options: ObservationPreparationPollingOptions,
|
||||
): Promise<ObservationSessionReplayLaunch> {
|
||||
if (!pendingPreparation(initial)) throw terminalPreparationError(initial);
|
||||
const requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
const heartbeatStallMs = Math.max(250, options.heartbeatStallMs ?? DEFAULT_HEARTBEAT_STALL_MS);
|
||||
const maximumWaitMs = Math.max(heartbeatStallMs, options.maximumWaitMs ?? DEFAULT_MAXIMUM_WAIT_MS);
|
||||
const initialInterval = Math.max(
|
||||
50,
|
||||
options.initialPollIntervalMs ?? DEFAULT_INITIAL_POLL_INTERVAL_MS,
|
||||
);
|
||||
const maximumInterval = Math.max(
|
||||
initialInterval,
|
||||
options.maximumPollIntervalMs ?? DEFAULT_MAXIMUM_POLL_INTERVAL_MS,
|
||||
);
|
||||
const now = options.now ?? Date.now;
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
const startedAt = now();
|
||||
let lastHeartbeatAt = startedAt;
|
||||
let lastUpdatedAt = Date.parse(initial.updatedAtUtc);
|
||||
let current: ObservationSessionPreparation = initial;
|
||||
let interval = initialInterval;
|
||||
options.onUpdate?.(current);
|
||||
|
||||
while (true) {
|
||||
await sleep(interval, options.signal);
|
||||
const response = await withRequestTimeout(
|
||||
options.signal,
|
||||
requestTimeoutMs,
|
||||
(signal) => fetchObservationSessionPreparation(current, {
|
||||
signal,
|
||||
fetcher: options.fetcher,
|
||||
}),
|
||||
);
|
||||
if (response.kind === "ready") return response.launch;
|
||||
|
||||
const next = response.preparation;
|
||||
const nextUpdatedAt = Date.parse(next.updatedAtUtc);
|
||||
if (nextUpdatedAt < lastUpdatedAt) {
|
||||
throw new Error("Сервер вернул устаревшее состояние подготовки записи.");
|
||||
}
|
||||
if (nextUpdatedAt > lastUpdatedAt) {
|
||||
lastUpdatedAt = nextUpdatedAt;
|
||||
lastHeartbeatAt = now();
|
||||
} else if (
|
||||
next.state !== current.state ||
|
||||
next.progress !== current.progress ||
|
||||
next.cancellable !== current.cancellable
|
||||
) {
|
||||
throw new Error("Сервер изменил подготовку без обновления heartbeat timestamp.");
|
||||
}
|
||||
if (pendingPreparation(next) && pendingPreparation(current)) {
|
||||
if (PREPARATION_PHASE_ORDER[next.state] < PREPARATION_PHASE_ORDER[current.state]) {
|
||||
throw new Error("Сервер вернул подготовку на уже завершённую фазу.");
|
||||
}
|
||||
if (
|
||||
next.progress !== null &&
|
||||
current.progress !== null &&
|
||||
next.progress + Number.EPSILON < current.progress
|
||||
) {
|
||||
throw new Error("Прогресс подготовки не может уменьшаться.");
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
options.onUpdate?.(current);
|
||||
if (!pendingPreparation(current)) throw terminalPreparationError(current);
|
||||
if (current.state !== "queued" && now() - lastHeartbeatAt > heartbeatStallMs) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Подготовка перестала обновляться. Текущая сцена сохранена; повторите запуск.",
|
||||
);
|
||||
}
|
||||
if (now() - startedAt > maximumWaitMs) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Подготовка превысила допустимое время. Текущая сцена сохранена; повторите запуск.",
|
||||
);
|
||||
}
|
||||
interval = Math.min(maximumInterval, Math.round(interval * 1.35));
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveObservationSessionReplay(
|
||||
sessionId: string,
|
||||
options: ObservationPreparationPollingOptions,
|
||||
): Promise<ObservationSessionReplayLaunch> {
|
||||
// The initial replay request may restore and integrity-check large, already
|
||||
// published artifacts before it can return either a launch descriptor or a
|
||||
// background-preparation handle. Its duration therefore scales with the
|
||||
// recording package and must not be confused with a stalled status poll.
|
||||
// Keep it cancellable by the owning UI attempt, but do not impose the short
|
||||
// per-poll timeout used once the server has returned a preparation handle.
|
||||
const response = await replayObservationSession(sessionId, {
|
||||
signal: options.signal,
|
||||
fetcher: options.fetcher,
|
||||
});
|
||||
if (response.kind === "ready") return response.launch;
|
||||
if (!pendingPreparation(response.preparation)) {
|
||||
options.onUpdate?.(response.preparation);
|
||||
throw terminalPreparationError(response.preparation);
|
||||
}
|
||||
return waitForObservationReplayPreparation(response.preparation, options);
|
||||
}
|
||||
|
||||
function preparationStoragePayload(preparation: ObservationSessionPreparation): unknown {
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-preparation/v1",
|
||||
preparation: {
|
||||
preparation_id: preparation.preparationId,
|
||||
session_id: preparation.sessionId,
|
||||
state: preparation.state,
|
||||
progress: preparation.progress,
|
||||
updated_at_utc: preparation.updatedAtUtc,
|
||||
status_url: preparation.statusUrl,
|
||||
cancellable: preparation.cancellable,
|
||||
...(preparation.state === "failed" || preparation.state === "cancelled"
|
||||
? { retryable: preparation.retryable, error: preparation.error }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function storeObservationReplayPreparation(
|
||||
preparation: ObservationSessionPreparation,
|
||||
storage: Storage = window.localStorage,
|
||||
): void {
|
||||
if (!pendingPreparation(preparation)) {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(PREPARATION_STORAGE_KEY, JSON.stringify(preparationStoragePayload(preparation)));
|
||||
}
|
||||
|
||||
export function loadObservationReplayPreparation(
|
||||
storage: Storage = window.localStorage,
|
||||
): ObservationSessionPreparation | null {
|
||||
const serialized = storage.getItem(PREPARATION_STORAGE_KEY);
|
||||
if (!serialized) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("preparation" in parsed) ||
|
||||
typeof parsed.preparation !== "object" ||
|
||||
parsed.preparation === null ||
|
||||
!("session_id" in parsed.preparation) ||
|
||||
typeof parsed.preparation.session_id !== "string"
|
||||
) {
|
||||
throw new Error("invalid persisted preparation");
|
||||
}
|
||||
const preparation = decodeObservationSessionPreparation(
|
||||
parsed,
|
||||
parsed.preparation.session_id,
|
||||
);
|
||||
return pendingPreparation(preparation) ? preparation : null;
|
||||
} catch {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearObservationReplayPreparation(
|
||||
storage: Storage = window.localStorage,
|
||||
): void {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 100,
|
||||
replayEnabled = true,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
replayEnabled?: boolean;
|
||||
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplayAccepted?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplaySettled?: (
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
} = {}): ObservationSessionsController {
|
||||
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
|
||||
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replayingSessionId, setReplayingSessionId] = useState<string | null>(null);
|
||||
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
||||
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
||||
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const replayEnabledRef = useRef(replayEnabled);
|
||||
replayEnabledRef.current = replayEnabled;
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (replayEnabled) return;
|
||||
// Cancels only this browser's polling/selection attempt. The shared
|
||||
// backend preparation remains untouched and can be selected again later.
|
||||
reattachStarted.current = true;
|
||||
replayCoordinator.current?.cancel();
|
||||
setReplayingSessionId(null);
|
||||
setReplayProgress(null);
|
||||
}, [replayEnabled]);
|
||||
const safeLimit = Number.isFinite(limit)
|
||||
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||
: 100;
|
||||
|
||||
const loadCatalog = useCallback(async (foreground: boolean) => {
|
||||
const sequence = ++catalogSequence.current;
|
||||
if (foreground) setState("loading");
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
setState("ready");
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
if (foreground) setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return false;
|
||||
}
|
||||
}, [safeLimit]);
|
||||
|
||||
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
catalogSequence.current += 1;
|
||||
replayCoordinator.current?.cancel();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready") return;
|
||||
let disposed = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
if (disposed) return;
|
||||
await loadCatalog(false);
|
||||
if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(), 2_000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [loadCatalog, state]);
|
||||
|
||||
const executeReplay = useCallback(async (
|
||||
session: ObservationSessionSummary,
|
||||
resumedPreparation?: ObservationSessionPreparation,
|
||||
) => {
|
||||
if (!replayEnabledRef.current) return false;
|
||||
const attempt = replayCoordinator.current!.begin();
|
||||
setReplayingSessionId(session.id);
|
||||
setFailedSessionId(null);
|
||||
setError(null);
|
||||
setReplayProgress({
|
||||
sessionId: session.id,
|
||||
phase: resumedPreparation?.state ?? "requesting",
|
||||
progress: resumedPreparation?.progress ?? null,
|
||||
cancellable: resumedPreparation?.cancellable ?? false,
|
||||
});
|
||||
let outcome: ObservationReplayOutcome = "cancelled";
|
||||
try {
|
||||
const onUpdate = (next: ObservationSessionPreparation) => {
|
||||
if (!mounted.current || !attempt.isCurrent()) return;
|
||||
setPreparation(next);
|
||||
setReplayProgress({
|
||||
sessionId: next.sessionId,
|
||||
phase: next.state,
|
||||
progress: next.progress,
|
||||
cancellable: next.cancellable,
|
||||
});
|
||||
try {
|
||||
storeObservationReplayPreparation(next);
|
||||
} catch {
|
||||
// Private browsing/storage quota must not break replay preparation.
|
||||
}
|
||||
};
|
||||
const launch = resumedPreparation
|
||||
? await waitForObservationReplayPreparation(resumedPreparation, {
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
})
|
||||
: await resolveObservationSessionReplay(session.id, {
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
});
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
|
||||
// The current scene stays mounted throughout preparation. Only now that
|
||||
// the launch descriptor exists do we release the previous viewer.
|
||||
await onReplayBegin?.(session, launch);
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
await onReplayAccepted?.(session, launch);
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
outcome = "accepted";
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage is optional; an accepted replay must not turn into an error.
|
||||
}
|
||||
setPreparation(null);
|
||||
setReplayProgress(null);
|
||||
return true;
|
||||
} catch (replayError) {
|
||||
const aborted = isAbortError(replayError);
|
||||
outcome = aborted ? "cancelled" : "error";
|
||||
if (mounted.current && attempt.isCurrent() && !aborted) {
|
||||
setError(errorMessage(replayError));
|
||||
setFailedSessionId(session.id);
|
||||
setReplayProgress(null);
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage is optional for the current browser lifetime.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
const current = attempt.finish();
|
||||
if (mounted.current && current) {
|
||||
setReplayingSessionId(null);
|
||||
await onReplaySettled?.(session, outcome);
|
||||
}
|
||||
}
|
||||
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
|
||||
|
||||
const replay = useCallback(async (sessionId: string) => {
|
||||
if (!replayEnabledRef.current) return false;
|
||||
const session = items.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !session.replayable) return false;
|
||||
reattachStarted.current = true;
|
||||
return executeReplay(session);
|
||||
}, [executeReplay, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
|
||||
reattachStarted.current = true;
|
||||
let stored: ObservationSessionPreparation | null = null;
|
||||
try {
|
||||
stored = loadObservationReplayPreparation();
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
if (!stored) return;
|
||||
const session = items.find((candidate) => candidate.id === stored?.sessionId);
|
||||
if (!session?.replayable) {
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage may be unavailable in hardened browser profiles.
|
||||
}
|
||||
return;
|
||||
}
|
||||
void executeReplay(session, stored);
|
||||
}, [executeReplay, items, replayEnabled, state]);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
if (!failedSessionId) return false;
|
||||
return replay(failedSessionId);
|
||||
}, [failedSessionId, replay]);
|
||||
|
||||
const remove = useCallback(async (sessionId: string) => {
|
||||
if (deletingSessionId !== null || replayingSessionId === sessionId) return false;
|
||||
setDeletingSessionId(sessionId);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteObservationSession(sessionId);
|
||||
if (!mounted.current) return false;
|
||||
setItems((current) => current.filter((item) => item.id !== sessionId));
|
||||
setFailedSessionId((current) => current === sessionId ? null : current);
|
||||
return true;
|
||||
} catch (deleteError) {
|
||||
if (mounted.current) setError(errorMessage(deleteError));
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted.current) setDeletingSessionId(null);
|
||||
}
|
||||
}, [deletingSessionId, replayingSessionId]);
|
||||
|
||||
return {
|
||||
items,
|
||||
state,
|
||||
error,
|
||||
replayingSessionId,
|
||||
preparation,
|
||||
replayProgress,
|
||||
failedSessionId,
|
||||
deletingSessionId,
|
||||
refresh,
|
||||
replay,
|
||||
retry,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
|
||||
import {
|
||||
initialRecordedCameraAdmissions,
|
||||
mergeRecordedCameraAdmission,
|
||||
nextRecordedCameraPreparationIds,
|
||||
recordedCameraDescriptorPreflight,
|
||||
recordedSessionAdmissionPhase,
|
||||
type RecordedAdmissionPhase,
|
||||
type RecordedCameraAdmissionMap,
|
||||
type RecordedCameraAdmissionState,
|
||||
} from "./recordedSessionAdmission";
|
||||
|
||||
interface AdmissionSnapshot {
|
||||
key: string | null;
|
||||
spatialPhase: RecordedAdmissionPhase;
|
||||
cameras: Record<string, RecordedCameraAdmissionState>;
|
||||
}
|
||||
|
||||
export interface RecordedSessionAdmissionController {
|
||||
key: string;
|
||||
phase: RecordedAdmissionPhase;
|
||||
cameraSourceIds: readonly string[];
|
||||
cameras: RecordedCameraAdmissionMap;
|
||||
activeCameraSourceIds: ReadonlySet<string>;
|
||||
reportSpatial: (key: string, phase: RecordedAdmissionPhase) => void;
|
||||
reportCamera: (
|
||||
key: string,
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function replayAdmissionKey(replay: ObservationSessionReplayLaunch): string {
|
||||
return [
|
||||
replay.sessionId,
|
||||
replay.sha256,
|
||||
...replay.mediaSources.map((source) => (
|
||||
[
|
||||
source.id,
|
||||
source.manifestGenerationSha256,
|
||||
source.byteLength,
|
||||
source.timelineStartSeconds,
|
||||
source.timelineEndSeconds,
|
||||
].join(":")
|
||||
)),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function useRecordedSessionAdmission(
|
||||
replay: ObservationSessionReplayLaunch | null,
|
||||
): RecordedSessionAdmissionController | null {
|
||||
const key = replay ? replayAdmissionKey(replay) : null;
|
||||
const cameraSourceIds = useMemo(
|
||||
() => replay?.mediaSources.map(({ id }) => id) ?? [],
|
||||
[replay],
|
||||
);
|
||||
const declaredByteLengths = useMemo(
|
||||
() => Object.fromEntries(
|
||||
(replay?.mediaSources ?? []).map(({ id, byteLength }) => [id, byteLength]),
|
||||
),
|
||||
[replay],
|
||||
);
|
||||
const [snapshot, setSnapshot] = useState<AdmissionSnapshot>(() => ({
|
||||
key,
|
||||
spatialPhase: "loading",
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot({
|
||||
key,
|
||||
spatialPhase: "loading",
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
});
|
||||
}, [cameraSourceIds, declaredByteLengths, key]);
|
||||
|
||||
const reportSpatial = useCallback((reportedKey: string, phase: RecordedAdmissionPhase) => {
|
||||
setSnapshot((current) => current.key !== reportedKey
|
||||
? current
|
||||
: { ...current, spatialPhase: phase });
|
||||
}, []);
|
||||
|
||||
const reportCamera = useCallback((
|
||||
reportedKey: string,
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
setSnapshot((current) => {
|
||||
if (current.key !== reportedKey || !(sourceId in current.cameras)) return current;
|
||||
const merged = mergeRecordedCameraAdmission(current.cameras[sourceId], state);
|
||||
if (merged === current.cameras[sourceId]) return current;
|
||||
return { ...current, cameras: { ...current.cameras, [sourceId]: merged } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!replay || !key) return null;
|
||||
const current = snapshot.key === key
|
||||
? snapshot
|
||||
: {
|
||||
key,
|
||||
spatialPhase: "loading" as const,
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
};
|
||||
const descriptorPhase = recordedCameraDescriptorPreflight(replay.mediaSources);
|
||||
const phase = descriptorPhase === "error"
|
||||
? "error"
|
||||
: recordedSessionAdmissionPhase(
|
||||
current.spatialPhase,
|
||||
cameraSourceIds,
|
||||
current.cameras,
|
||||
);
|
||||
const activeCameraSourceIds = new Set(phase === "error"
|
||||
? []
|
||||
: nextRecordedCameraPreparationIds(cameraSourceIds, current.cameras));
|
||||
return {
|
||||
key,
|
||||
phase,
|
||||
cameraSourceIds,
|
||||
cameras: current.cameras,
|
||||
activeCameraSourceIds,
|
||||
reportSpatial,
|
||||
reportCamera,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationWorkspaceLayoutProfile,
|
||||
saveObservationWorkspaceLayoutProfile,
|
||||
WorkspaceLayoutApiError,
|
||||
type ObservationWorkspaceLayoutProfile,
|
||||
} from "./workspaceLayout";
|
||||
|
||||
export type WorkspaceLayoutProfileState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "saving"
|
||||
| "error"
|
||||
| "conflict";
|
||||
|
||||
export interface WorkspaceLayoutProfileController {
|
||||
profile: ObservationWorkspaceLayoutProfile | null;
|
||||
state: WorkspaceLayoutProfileState;
|
||||
error: string | null;
|
||||
refresh: () => Promise<ObservationWorkspaceLayoutProfile | null>;
|
||||
save: (
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
) => Promise<ObservationWorkspaceLayoutProfile | null>;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция с профилем рабочей поверхности завершилась ошибкой.";
|
||||
}
|
||||
|
||||
export function useWorkspaceLayoutProfile(): WorkspaceLayoutProfileController {
|
||||
const [profile, setProfile] = useState<ObservationWorkspaceLayoutProfile | null>(null);
|
||||
const profileRef = useRef<ObservationWorkspaceLayoutProfile | null>(null);
|
||||
const [state, setState] = useState<WorkspaceLayoutProfileState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const requestSequence = useRef(0);
|
||||
const saveInFlight = useRef(false);
|
||||
|
||||
const commitProfile = useCallback((next: ObservationWorkspaceLayoutProfile | null) => {
|
||||
profileRef.current = next;
|
||||
setProfile(next);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const loaded = await fetchObservationWorkspaceLayoutProfile();
|
||||
if (!mounted.current || sequence !== requestSequence.current) return null;
|
||||
commitProfile(loaded);
|
||||
setState("ready");
|
||||
return loaded;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== requestSequence.current) return null;
|
||||
setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return null;
|
||||
}
|
||||
}, [commitProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
requestSequence.current += 1;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "error") return;
|
||||
// The desktop shell can become ready before its loopback API. Keep the
|
||||
// last-layout restore self-healing instead of requiring a page reload once
|
||||
// the local service finishes starting.
|
||||
const timer = window.setTimeout(() => void refresh(), 5_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [refresh, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshAfterNetworkRecovery = () => void refresh();
|
||||
window.addEventListener("online", refreshAfterNetworkRecovery);
|
||||
return () => window.removeEventListener("online", refreshAfterNetworkRecovery);
|
||||
}, [refresh]);
|
||||
|
||||
const save = useCallback(async (draft: ObservationWorkspaceLayoutProfile) => {
|
||||
if (saveInFlight.current) return null;
|
||||
const current = profileRef.current;
|
||||
if (current && draft.revision !== current.revision) {
|
||||
setState("conflict");
|
||||
setError("Профиль изменился после открытия. Обновите данные перед сохранением.");
|
||||
return null;
|
||||
}
|
||||
saveInFlight.current = true;
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await saveObservationWorkspaceLayoutProfile(draft);
|
||||
if (!mounted.current) return null;
|
||||
commitProfile(saved);
|
||||
setState("ready");
|
||||
return saved;
|
||||
} catch (saveError) {
|
||||
if (!mounted.current) return null;
|
||||
const conflict = saveError instanceof WorkspaceLayoutApiError && saveError.conflict;
|
||||
setState(conflict ? "conflict" : "error");
|
||||
setError(errorMessage(saveError));
|
||||
return null;
|
||||
} finally {
|
||||
saveInFlight.current = false;
|
||||
}
|
||||
}, [commitProfile]);
|
||||
|
||||
return { profile, state, error, refresh, save };
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
|
||||
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 2 as const;
|
||||
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
|
||||
"/api/v1/workspace-layouts/observation.spatial" as const;
|
||||
|
||||
export interface ObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface NormalizedObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutSnapshot {
|
||||
visibleSourceIds: readonly string[];
|
||||
activeFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, NormalizedObservationWindowRect>>;
|
||||
viewportSize: ObservationViewportSize;
|
||||
}
|
||||
|
||||
export interface ProjectedObservationLayout {
|
||||
visibleSourceIds: readonly string[];
|
||||
activeFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
}
|
||||
|
||||
export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnapshot {
|
||||
version: typeof OBSERVATION_WORKSPACE_LAYOUT_VERSION;
|
||||
revision: number;
|
||||
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
|
||||
sceneSettings: SceneSettings;
|
||||
}
|
||||
|
||||
export type WorkspaceLayoutFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
type WireProfile = {
|
||||
version: number;
|
||||
revision: number;
|
||||
workspace_id: string;
|
||||
scene_settings: {
|
||||
projection: string;
|
||||
point_size: number;
|
||||
color_mode: string;
|
||||
palette: string;
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
show_labels: boolean;
|
||||
show_camera_frustums: boolean;
|
||||
};
|
||||
visible_source_ids: string[];
|
||||
active_floating_source_id: string | null;
|
||||
window_rects: Record<string, NormalizedObservationWindowRect>;
|
||||
viewport_size: ObservationViewportSize;
|
||||
};
|
||||
|
||||
const PROFILE_KEYS = new Set([
|
||||
"version",
|
||||
"revision",
|
||||
"workspace_id",
|
||||
"scene_settings",
|
||||
"visible_source_ids",
|
||||
"active_floating_source_id",
|
||||
"window_rects",
|
||||
"viewport_size",
|
||||
]);
|
||||
const SCENE_KEYS = new Set([
|
||||
"projection",
|
||||
"point_size",
|
||||
"color_mode",
|
||||
"palette",
|
||||
"custom_color",
|
||||
"accumulation_seconds",
|
||||
"show_points",
|
||||
"show_trajectory",
|
||||
"show_grid",
|
||||
"show_labels",
|
||||
"show_camera_frustums",
|
||||
]);
|
||||
const VIEWPORT_KEYS = new Set(["width", "height"]);
|
||||
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
|
||||
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
|
||||
const COLOR_MODES = new Set<SceneSettings["colorMode"]>([
|
||||
"intensity",
|
||||
"height",
|
||||
"distance",
|
||||
"rgb",
|
||||
"class",
|
||||
]);
|
||||
const PALETTES = new Set<SceneSettings["palette"]>([
|
||||
"turbo",
|
||||
"viridis",
|
||||
"plasma",
|
||||
"grayscale",
|
||||
"custom",
|
||||
]);
|
||||
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const MAX_SOURCES = 256;
|
||||
const MAX_VIEWPORT_EDGE = 100_000;
|
||||
const NORMALIZED_EPSILON = 1e-9;
|
||||
|
||||
export class WorkspaceLayoutContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WorkspaceLayoutContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceLayoutApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "WorkspaceLayoutApiError";
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
get conflict(): boolean {
|
||||
return this.status === 409 || this.status === 412;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, field: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть объектом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
field: string,
|
||||
): void {
|
||||
const actual = Object.keys(value);
|
||||
const unknown = actual.filter((key) => !expected.has(key));
|
||||
const missing = [...expected].filter((key) => !(key in value));
|
||||
if (unknown.length || missing.length) {
|
||||
const details = [
|
||||
unknown.length ? `неизвестные: ${unknown.sort().join(", ")}` : "",
|
||||
missing.length ? `отсутствуют: ${missing.sort().join(", ")}` : "",
|
||||
].filter(Boolean).join("; ");
|
||||
throw new WorkspaceLayoutContractError(`${field}: неверная схема (${details}).`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireFiniteInRange(
|
||||
value: unknown,
|
||||
field: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
{ integer = false, minimumExclusive = false }: { integer?: boolean; minimumExclusive?: boolean } = {},
|
||||
): number {
|
||||
const belowMinimum = minimumExclusive
|
||||
? typeof value === "number" && value <= minimum
|
||||
: typeof value === "number" && value < minimum;
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
belowMinimum ||
|
||||
value > maximum ||
|
||||
(integer && !Number.isInteger(value))
|
||||
) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле ${field} должно быть конечным ${integer ? "целым " : ""}числом в допустимом диапазоне.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStableId(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !SAFE_STABLE_ID.test(value)) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле ${field} должно содержать безопасный стабильный идентификатор источника.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeViewport(value: unknown, field = "viewport_size"): ObservationViewportSize {
|
||||
const record = requireRecord(value, field);
|
||||
assertExactKeys(record, VIEWPORT_KEYS, field);
|
||||
return {
|
||||
width: requireFiniteInRange(record.width, `${field}.width`, 1, MAX_VIEWPORT_EDGE),
|
||||
height: requireFiniteInRange(record.height, `${field}.height`, 1, MAX_VIEWPORT_EDGE),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateObservationViewportSize(
|
||||
value: ObservationViewportSize,
|
||||
): ObservationViewportSize {
|
||||
return decodeViewport(value, "viewport");
|
||||
}
|
||||
|
||||
function decodeNormalizedRect(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): NormalizedObservationWindowRect {
|
||||
const record = requireRecord(value, field);
|
||||
assertExactKeys(record, RECT_KEYS, field);
|
||||
const rect = {
|
||||
x: requireFiniteInRange(record.x, `${field}.x`, 0, 1),
|
||||
y: requireFiniteInRange(record.y, `${field}.y`, 0, 1),
|
||||
width: requireFiniteInRange(record.width, `${field}.width`, 0, 1, { minimumExclusive: true }),
|
||||
height: requireFiniteInRange(record.height, `${field}.height`, 0, 1, { minimumExclusive: true }),
|
||||
};
|
||||
if (
|
||||
rect.x + rect.width > 1 + NORMALIZED_EPSILON ||
|
||||
rect.y + rect.height > 1 + NORMALIZED_EPSILON
|
||||
) {
|
||||
throw new WorkspaceLayoutContractError(`${field} выходит за нормализованные границы viewport.`);
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
function decodeStableIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_SOURCES) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле visible_source_ids должно быть массивом не более ${MAX_SOURCES} элементов.`,
|
||||
);
|
||||
}
|
||||
const result = value.map((entry, index) => requireStableId(entry, `visible_source_ids[${index}]`));
|
||||
if (new Set(result).size !== result.length) {
|
||||
throw new WorkspaceLayoutContractError("Поле visible_source_ids содержит повторяющиеся id.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeWindowRects(value: unknown): Record<string, NormalizedObservationWindowRect> {
|
||||
const record = requireRecord(value, "window_rects");
|
||||
if (Object.keys(record).length > MAX_SOURCES) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле window_rects содержит более ${MAX_SOURCES} элементов.`,
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(record).map(([sourceId, rect]) => [
|
||||
requireStableId(sourceId, "window_rects key"),
|
||||
decodeNormalizedRect(rect, `window_rects.${sourceId}`),
|
||||
]));
|
||||
}
|
||||
|
||||
function requireEnum<T extends string>(
|
||||
value: unknown,
|
||||
allowed: ReadonlySet<T>,
|
||||
field: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !allowed.has(value as T)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} содержит неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function decodeSceneSettings(value: unknown): SceneSettings {
|
||||
const record = requireRecord(value, "scene_settings");
|
||||
assertExactKeys(record, SCENE_KEYS, "scene_settings");
|
||||
if (typeof record.custom_color !== "string" || !HEX_COLOR.test(record.custom_color)) {
|
||||
throw new WorkspaceLayoutContractError("Поле scene_settings.custom_color должно быть цветом #RRGGBB.");
|
||||
}
|
||||
return {
|
||||
projection: requireEnum(record.projection, PROJECTIONS, "scene_settings.projection"),
|
||||
pointSize: requireFiniteInRange(record.point_size, "scene_settings.point_size", 0.1, 32),
|
||||
colorMode: requireEnum(record.color_mode, COLOR_MODES, "scene_settings.color_mode"),
|
||||
palette: requireEnum(record.palette, PALETTES, "scene_settings.palette"),
|
||||
customColor: record.custom_color,
|
||||
accumulationSeconds: requireFiniteInRange(
|
||||
record.accumulation_seconds,
|
||||
"scene_settings.accumulation_seconds",
|
||||
0,
|
||||
3_600,
|
||||
),
|
||||
showPoints: requireBoolean(record.show_points, "scene_settings.show_points"),
|
||||
showTrajectory: requireBoolean(record.show_trajectory, "scene_settings.show_trajectory"),
|
||||
showGrid: requireBoolean(record.show_grid, "scene_settings.show_grid"),
|
||||
showLabels: requireBoolean(record.show_labels, "scene_settings.show_labels"),
|
||||
showCameraFrustums: requireBoolean(
|
||||
record.show_camera_frustums,
|
||||
"scene_settings.show_camera_frustums",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeObservationWorkspaceLayoutProfile(
|
||||
value: unknown,
|
||||
): ObservationWorkspaceLayoutProfile {
|
||||
const record = requireRecord(value, "workspace layout");
|
||||
assertExactKeys(record, PROFILE_KEYS, "workspace layout");
|
||||
if (record.version !== OBSERVATION_WORKSPACE_LAYOUT_VERSION) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Неподдерживаемая версия workspace layout: ${String(record.version)}.`,
|
||||
);
|
||||
}
|
||||
if (record.workspace_id !== OBSERVATION_WORKSPACE_ID) {
|
||||
throw new WorkspaceLayoutContractError("Профиль относится к другой рабочей поверхности.");
|
||||
}
|
||||
const visibleSourceIds = decodeStableIds(record.visible_source_ids);
|
||||
const activeFloatingSourceId = record.active_floating_source_id === null
|
||||
? null
|
||||
: requireStableId(record.active_floating_source_id, "active_floating_source_id");
|
||||
if (activeFloatingSourceId && !visibleSourceIds.includes(activeFloatingSourceId)) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
"Активное плавающее окно должно относиться к видимому источнику.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: requireFiniteInRange(record.revision, "revision", 0, Number.MAX_SAFE_INTEGER, {
|
||||
integer: true,
|
||||
}),
|
||||
workspaceId: OBSERVATION_WORKSPACE_ID,
|
||||
sceneSettings: decodeSceneSettings(record.scene_settings),
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId,
|
||||
windowRects: decodeWindowRects(record.window_rects),
|
||||
viewportSize: decodeViewport(record.viewport_size),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeObservationWorkspaceLayoutProfile(
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
): WireProfile {
|
||||
const wire: WireProfile = {
|
||||
version: profile.version,
|
||||
revision: profile.revision,
|
||||
workspace_id: profile.workspaceId,
|
||||
scene_settings: {
|
||||
projection: profile.sceneSettings.projection,
|
||||
point_size: profile.sceneSettings.pointSize,
|
||||
color_mode: profile.sceneSettings.colorMode,
|
||||
palette: profile.sceneSettings.palette,
|
||||
custom_color: profile.sceneSettings.customColor,
|
||||
accumulation_seconds: profile.sceneSettings.accumulationSeconds,
|
||||
show_points: profile.sceneSettings.showPoints,
|
||||
show_trajectory: profile.sceneSettings.showTrajectory,
|
||||
show_grid: profile.sceneSettings.showGrid,
|
||||
show_labels: profile.sceneSettings.showLabels,
|
||||
show_camera_frustums: profile.sceneSettings.showCameraFrustums,
|
||||
},
|
||||
visible_source_ids: [...profile.visibleSourceIds],
|
||||
active_floating_source_id: profile.activeFloatingSourceId,
|
||||
window_rects: Object.fromEntries(
|
||||
Object.entries(profile.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
|
||||
),
|
||||
viewport_size: { ...profile.viewportSize },
|
||||
};
|
||||
// The decoder is the single runtime schema authority for inbound and outbound documents.
|
||||
decodeObservationWorkspaceLayoutProfile(wire);
|
||||
return wire;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
export function normalizeObservationWindowRect(
|
||||
rect: ObservationWindowRect,
|
||||
viewport: ObservationViewportSize,
|
||||
): NormalizedObservationWindowRect {
|
||||
const safeViewport = decodeViewport(viewport, "viewport");
|
||||
for (const [field, value] of Object.entries(rect)) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле rect.${field} должно быть конечным числом.`);
|
||||
}
|
||||
}
|
||||
const x = clamp(rect.x, 0, Math.max(0, safeViewport.width - 1));
|
||||
const y = clamp(rect.y, 0, Math.max(0, safeViewport.height - 1));
|
||||
const width = clamp(rect.width, 1, Math.max(1, safeViewport.width - x));
|
||||
const height = clamp(rect.height, 1, Math.max(1, safeViewport.height - y));
|
||||
return {
|
||||
x: x / safeViewport.width,
|
||||
y: y / safeViewport.height,
|
||||
width: width / safeViewport.width,
|
||||
height: height / safeViewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function denormalizeObservationWindowRect(
|
||||
rect: NormalizedObservationWindowRect,
|
||||
viewport: ObservationViewportSize,
|
||||
): ObservationWindowRect {
|
||||
const normalized = decodeNormalizedRect(rect, "rect");
|
||||
const safeViewport = decodeViewport(viewport, "viewport");
|
||||
return {
|
||||
x: normalized.x * safeViewport.width,
|
||||
y: normalized.y * safeViewport.height,
|
||||
width: normalized.width * safeViewport.width,
|
||||
height: normalized.height * safeViewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateObservationLayoutSnapshot(
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
): ObservationLayoutSnapshot {
|
||||
const wire = {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: 0,
|
||||
workspace_id: OBSERVATION_WORKSPACE_ID,
|
||||
scene_settings: {
|
||||
projection: "3d",
|
||||
point_size: 1,
|
||||
color_mode: "intensity",
|
||||
palette: "turbo",
|
||||
custom_color: "#ffffff",
|
||||
accumulation_seconds: 0,
|
||||
show_points: true,
|
||||
show_trajectory: true,
|
||||
show_grid: true,
|
||||
show_labels: false,
|
||||
show_camera_frustums: true,
|
||||
},
|
||||
visible_source_ids: [...snapshot.visibleSourceIds],
|
||||
active_floating_source_id: snapshot.activeFloatingSourceId,
|
||||
window_rects: snapshot.windowRects,
|
||||
viewport_size: snapshot.viewportSize,
|
||||
};
|
||||
const decoded = decodeObservationWorkspaceLayoutProfile(wire);
|
||||
return {
|
||||
visibleSourceIds: decoded.visibleSourceIds,
|
||||
activeFloatingSourceId: decoded.activeFloatingSourceId,
|
||||
windowRects: decoded.windowRects,
|
||||
viewportSize: decoded.viewportSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectObservationLayoutSnapshot(
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
knownSourceIds: ReadonlySet<string>,
|
||||
viewport: ObservationViewportSize,
|
||||
): ProjectedObservationLayout {
|
||||
const validated = validateObservationLayoutSnapshot(snapshot);
|
||||
const targetViewport = decodeViewport(viewport, "viewport");
|
||||
const visibleSourceIds = validated.visibleSourceIds.filter((sourceId) => knownSourceIds.has(sourceId));
|
||||
const visibleSet = new Set(visibleSourceIds);
|
||||
return {
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId: validated.activeFloatingSourceId && visibleSet.has(validated.activeFloatingSourceId)
|
||||
? validated.activeFloatingSourceId
|
||||
: null,
|
||||
windowRects: Object.fromEntries(
|
||||
Object.entries(validated.windowRects)
|
||||
.filter(([sourceId]) => knownSourceIds.has(sourceId))
|
||||
.map(([sourceId, rect]) => [
|
||||
sourceId,
|
||||
denormalizeObservationWindowRect(rect, targetViewport),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||
return body.detail.trim();
|
||||
}
|
||||
} catch {
|
||||
// A non-JSON error body is represented by the stable fallback.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function fetchObservationWorkspaceLayoutProfile({
|
||||
fetcher = fetch,
|
||||
}: { fetcher?: WorkspaceLayoutFetch } = {}): Promise<ObservationWorkspaceLayoutProfile | null> {
|
||||
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new WorkspaceLayoutApiError(
|
||||
await responseMessage(response, "Не удалось загрузить профиль рабочей поверхности."),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new WorkspaceLayoutContractError("Сервер вернул профиль не в формате JSON.");
|
||||
}
|
||||
return decodeObservationWorkspaceLayoutProfile(body);
|
||||
}
|
||||
|
||||
export async function saveObservationWorkspaceLayoutProfile(
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
{ fetcher = fetch }: { fetcher?: WorkspaceLayoutFetch } = {},
|
||||
): Promise<ObservationWorkspaceLayoutProfile> {
|
||||
const wire = encodeObservationWorkspaceLayoutProfile(profile);
|
||||
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"If-Match": `"${profile.revision}"`,
|
||||
},
|
||||
body: JSON.stringify(wire),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new WorkspaceLayoutApiError(
|
||||
await responseMessage(response, response.status === 409 || response.status === 412
|
||||
? "Профиль уже изменён в другом окне. Обновите данные и повторите сохранение."
|
||||
: "Не удалось сохранить профиль рабочей поверхности."),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new WorkspaceLayoutContractError("Сервер не вернул сохранённый профиль в формате JSON.");
|
||||
}
|
||||
const saved = decodeObservationWorkspaceLayoutProfile(body);
|
||||
if (saved.revision <= profile.revision) {
|
||||
throw new WorkspaceLayoutContractError("Сервер не увеличил revision сохранённого профиля.");
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
import type { MissionRuntimeController } from "./contracts";
|
||||
|
||||
const idleRuntime: MissionRuntimeController = {
|
||||
state: {
|
||||
phase: "unconfigured",
|
||||
message: "Сначала выберите модель локального устройства.",
|
||||
sourceMode: "idle",
|
||||
},
|
||||
backendStatus: "unconfigured",
|
||||
pendingAction: null,
|
||||
refresh: () => undefined,
|
||||
updateViewerSettings: async () => false,
|
||||
setObservationSourceActive: async () => false,
|
||||
};
|
||||
|
||||
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
|
||||
|
||||
export function MissionRuntimeProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: MissionRuntimeController;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <MissionRuntimeContext.Provider value={value}>{children}</MissionRuntimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function IdleMissionRuntimeProvider({ children }: { children: ReactNode }) {
|
||||
return <MissionRuntimeProvider value={idleRuntime}>{children}</MissionRuntimeProvider>;
|
||||
}
|
||||
|
||||
export function useMissionRuntime(): MissionRuntimeController {
|
||||
return useContext(MissionRuntimeContext);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MissionRuntimeState } from "./contracts";
|
||||
|
||||
const TERMINAL_ACQUISITION_STATES = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
export const SPATIAL_SOURCE_SWITCH_BLOCKED_REASON =
|
||||
"Завершите текущий приём перед сменой источника.";
|
||||
|
||||
/**
|
||||
* An acquisition snapshot is blocking unless its state is explicitly known to
|
||||
* be terminal. Unknown future states therefore fail closed.
|
||||
*/
|
||||
export function isSpatialSourceSwitchBlocked(
|
||||
state: MissionRuntimeState | null | undefined,
|
||||
): boolean {
|
||||
const acquisition = state?.acquisition;
|
||||
return Boolean(
|
||||
acquisition &&
|
||||
(acquisition.cleanupPending === true ||
|
||||
!TERMINAL_ACQUISITION_STATES.has(acquisition.state)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
export type BackendStatus = "unconfigured" | "checking" | "online" | "degraded" | "offline";
|
||||
|
||||
export type RuntimePhase =
|
||||
| "unconfigured"
|
||||
| "idle"
|
||||
| "configuring"
|
||||
| "connected"
|
||||
| "starting"
|
||||
| "streaming"
|
||||
| "replaying"
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type SourceMode = "idle" | "live" | "replay";
|
||||
|
||||
export interface ViewerSettings {
|
||||
point_size: number;
|
||||
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
show_detections_2d: boolean;
|
||||
show_segmentation: boolean;
|
||||
show_cuboids_3d: boolean;
|
||||
}
|
||||
|
||||
export interface StreamMetrics {
|
||||
latencyMs?: number | null;
|
||||
frameRateHz?: number | null;
|
||||
pointCount?: number | null;
|
||||
droppedPreviewFrames?: number | null;
|
||||
aiLatencyMs?: number | null;
|
||||
aiFrameRateHz?: number | null;
|
||||
aiDroppedFrames?: number | null;
|
||||
aiStaleMs?: number | null;
|
||||
elapsedSeconds?: number | null;
|
||||
routeDistanceMeters?: number | null;
|
||||
speedMetersPerSecond?: number | null;
|
||||
}
|
||||
|
||||
export interface ActiveDeviceSnapshot {
|
||||
pluginId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
instanceId?: string | null;
|
||||
endpointLabel?: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeDeviceSessionSnapshot {
|
||||
sessionId: string;
|
||||
deviceId: string;
|
||||
compatibilityProfileId?: string | null;
|
||||
connectivity?: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeAcquisitionSnapshot {
|
||||
acquisitionId: string;
|
||||
deviceId: string;
|
||||
deviceSessionId: string;
|
||||
compatibilityProfileId: string;
|
||||
controlMode: string;
|
||||
state: string;
|
||||
stateRevision: number;
|
||||
operatorInstructions: readonly string[];
|
||||
cleanupPending?: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeOperationSnapshot {
|
||||
operationId: string;
|
||||
action: string;
|
||||
status: string;
|
||||
stageCode?: string | null;
|
||||
messageCode?: string | null;
|
||||
}
|
||||
|
||||
export interface SpatialSourceDescriptor {
|
||||
id: string;
|
||||
url: string;
|
||||
label: string;
|
||||
kind: "rerun-grpc" | "rrd" | "other";
|
||||
}
|
||||
|
||||
export type ObservationSourceModality = "point-cloud" | "video" | "image" | "depth";
|
||||
|
||||
export type ObservationSourceAvailability =
|
||||
| "unverified"
|
||||
| "declared"
|
||||
| "available"
|
||||
| "connecting"
|
||||
| "streaming"
|
||||
| "degraded"
|
||||
| "unavailable"
|
||||
| "error";
|
||||
|
||||
export type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||
|
||||
export interface ObservationSourceProvider {
|
||||
pluginId: string;
|
||||
pluginVersion: string;
|
||||
modelId: string;
|
||||
compatibilityProfileId?: string | null;
|
||||
}
|
||||
|
||||
export interface ObservationSourceBinding {
|
||||
deviceId?: string | null;
|
||||
deviceSessionId?: string | null;
|
||||
acquisitionId?: string | null;
|
||||
}
|
||||
|
||||
export interface ObservationSourceCapabilities {
|
||||
overlay: boolean;
|
||||
fullscreen: boolean;
|
||||
resizable: boolean;
|
||||
defaultVisible: boolean;
|
||||
timelineMode: ObservationTimelineMode;
|
||||
seekable: boolean;
|
||||
sessionRecording: boolean;
|
||||
clockId?: string | null;
|
||||
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
|
||||
}
|
||||
|
||||
export type ObservationSourceDelivery =
|
||||
| {
|
||||
id: string;
|
||||
kind: "mse-fmp4-websocket";
|
||||
url: string;
|
||||
mediaType: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "video-url" | "image-url";
|
||||
url: string;
|
||||
mediaType?: string | null;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "recorded-fmp4-manifest";
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
manifestGenerationSha256: string;
|
||||
byteLength: number;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
};
|
||||
|
||||
export interface ObservationSourceActivation {
|
||||
groupId: string;
|
||||
maxActive: number;
|
||||
selected: boolean;
|
||||
controllable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationSourceDescriptor {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
semanticChannelId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
modality: ObservationSourceModality;
|
||||
role: "primary" | "auxiliary";
|
||||
availability: ObservationSourceAvailability;
|
||||
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
|
||||
endpointLabel?: string | null;
|
||||
previewUrl?: string | null;
|
||||
delivery?: ObservationSourceDelivery | null;
|
||||
activation?: ObservationSourceActivation | null;
|
||||
provider: ObservationSourceProvider;
|
||||
binding: ObservationSourceBinding;
|
||||
capabilities: ObservationSourceCapabilities;
|
||||
}
|
||||
|
||||
export interface ObservationTimelineSnapshot {
|
||||
mode: ObservationTimelineMode;
|
||||
seekable: boolean;
|
||||
sessionRecording: boolean;
|
||||
synchronization: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
range: { startSeconds: number; endSeconds: number } | null;
|
||||
}
|
||||
|
||||
export interface MissionRuntimeState {
|
||||
phase: RuntimePhase;
|
||||
message?: string | null;
|
||||
activeDevice?: ActiveDeviceSnapshot | null;
|
||||
deviceSession?: RuntimeDeviceSessionSnapshot | null;
|
||||
acquisition?: RuntimeAcquisitionSnapshot | null;
|
||||
operations?: readonly RuntimeOperationSnapshot[];
|
||||
spatialSource?: SpatialSourceDescriptor | null;
|
||||
observationSources?: readonly ObservationSourceDescriptor[];
|
||||
observationTimeline?: ObservationTimelineSnapshot;
|
||||
viewerSettings?: ViewerSettings | null;
|
||||
sourceMode: SourceMode;
|
||||
metrics?: StreamMetrics;
|
||||
}
|
||||
|
||||
export interface MissionRuntimeController {
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
pendingAction: string | null;
|
||||
refresh: () => void | Promise<void>;
|
||||
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
|
||||
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export interface LatestAsyncCommitter<T> {
|
||||
enqueue: (value: T) => void;
|
||||
hasPending: () => boolean;
|
||||
isBusy: () => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface LatestAsyncCommitResult<T> {
|
||||
value: T;
|
||||
applied: boolean;
|
||||
superseded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an expensive settings commit while retaining only the newest
|
||||
* value submitted during the active request. This keeps slider and color
|
||||
* interactions from creating an unbounded backend queue.
|
||||
*/
|
||||
export function createLatestAsyncCommitter<T>({
|
||||
commit,
|
||||
onSettled,
|
||||
}: {
|
||||
commit: (value: T) => Promise<boolean>;
|
||||
onSettled?: (result: LatestAsyncCommitResult<T>) => void;
|
||||
}): LatestAsyncCommitter<T> {
|
||||
let pending: T | undefined;
|
||||
let running = false;
|
||||
let disposed = false;
|
||||
const idleWaiters = new Set<() => void>();
|
||||
|
||||
const settleIdleWaiters = () => {
|
||||
if (running || pending !== undefined) return;
|
||||
for (const resolve of idleWaiters) resolve();
|
||||
idleWaiters.clear();
|
||||
};
|
||||
|
||||
const drain = async () => {
|
||||
if (running || disposed) return;
|
||||
running = true;
|
||||
|
||||
try {
|
||||
while (!disposed && pending !== undefined) {
|
||||
const value = pending;
|
||||
pending = undefined;
|
||||
|
||||
let applied = false;
|
||||
try {
|
||||
applied = await commit(value);
|
||||
} catch {
|
||||
applied = false;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
onSettled?.({
|
||||
value,
|
||||
applied,
|
||||
superseded: pending !== undefined,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
if (!disposed && pending !== undefined) {
|
||||
void drain();
|
||||
} else {
|
||||
settleIdleWaiters();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue(value) {
|
||||
if (disposed) return;
|
||||
pending = value;
|
||||
void drain();
|
||||
},
|
||||
hasPending: () => pending !== undefined,
|
||||
isBusy: () => running || pending !== undefined,
|
||||
waitForIdle() {
|
||||
if (!running && pending === undefined) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => idleWaiters.add(resolve));
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
pending = undefined;
|
||||
settleIdleWaiters();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import "@nodedc/tokens/themes.css";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
|
||||
import App from "./App";
|
||||
import { installedDevicePlugins } from "./composition/devicePlugins";
|
||||
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
@@ -20,6 +22,8 @@ applyNodedcTheme(rootElement, { theme: "dark" });
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
||||
<App />
|
||||
</DevicePluginHostProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
BackendStatus,
|
||||
RuntimePhase,
|
||||
SourceMode,
|
||||
StreamMetrics,
|
||||
} from "./core/runtime/contracts";
|
||||
|
||||
const phaseLabels: Record<RuntimePhase, string> = {
|
||||
unconfigured: "Модель не выбрана",
|
||||
idle: "Ожидание",
|
||||
configuring: "Настройка устройства",
|
||||
connected: "Устройство подключено",
|
||||
starting: "Запуск потока",
|
||||
streaming: "Поток в реальном времени",
|
||||
replaying: "Повтор записи",
|
||||
stopping: "Остановка",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function phaseLabel(phase: RuntimePhase | null | undefined): string {
|
||||
return phase ? phaseLabels[phase] : "Нет состояния";
|
||||
}
|
||||
|
||||
export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
|
||||
if (!phase || phase === "unconfigured" || phase === "idle") return "neutral";
|
||||
if (phase === "error") return "danger";
|
||||
if (["connected", "streaming", "replaying"].includes(phase)) return "success";
|
||||
return "accent";
|
||||
}
|
||||
|
||||
export function backendLabel(status: BackendStatus): string {
|
||||
return {
|
||||
unconfigured: "Модель не выбрана",
|
||||
checking: "Проверка контура",
|
||||
online: "Контур доступен",
|
||||
degraded: "Контур ограничен",
|
||||
offline: "Контур недоступен",
|
||||
}[status];
|
||||
}
|
||||
|
||||
export function backendTone(status: BackendStatus): StatusTone {
|
||||
if (status === "online") return "success";
|
||||
if (status === "degraded" || status === "checking") return "warning";
|
||||
if (status === "unconfigured") return "neutral";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export function finiteMetric(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function pipelineLatency(metrics: StreamMetrics | undefined): number | null {
|
||||
return finiteMetric(metrics?.latencyMs);
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function sourceModeLabel(mode: SourceMode | null | undefined): string {
|
||||
if (mode === "live") return "Реальное время";
|
||||
if (mode === "replay") return "Повтор записи";
|
||||
if (mode === "idle") return "Ожидание";
|
||||
return "Неизвестно";
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
description: "От физического устройства до операторского интерфейса.",
|
||||
capabilities: [
|
||||
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
|
||||
active("Приём данных", "MQTT, декодирование облака точек и позы."),
|
||||
active("Приём данных", "Плагинский транспорт, нормализация облака точек и позы."),
|
||||
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
|
||||
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
|
||||
],
|
||||
@@ -231,7 +231,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
label: "Локальное устройство",
|
||||
title: "Локальное устройство",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Рабочий путь BLE → Wi‑Fi → поток для первого подключённого устройства.",
|
||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
||||
icon: "network",
|
||||
kind: "device",
|
||||
groups: [],
|
||||
@@ -526,8 +526,8 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
title: "Артефакты",
|
||||
description: "Сохраняется политика «сначала исходные данные».",
|
||||
capabilities: [
|
||||
active("Нативные захваты", "Сырые данные MQTT и обезличенный манифест."),
|
||||
active("Воспроизведение", "Воспроизведение исходной записи MQTT и проверенного TSV."),
|
||||
active("Нативные захваты", "Сырые конверты устройства и обезличенный манифест."),
|
||||
active("Воспроизведение", "Повтор исходной записи через адаптер выбранной модели."),
|
||||
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
|
||||
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
|
||||
],
|
||||
@@ -548,7 +548,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
title: "Транспорт",
|
||||
description: "Источник отделён от визуального представления.",
|
||||
capabilities: [
|
||||
active("MQTT вход", "Доказанный локальный поток текущего устройства."),
|
||||
active("Плагинский вход", "Локальный поток активной модели устройства."),
|
||||
active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."),
|
||||
ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."),
|
||||
ready("RRD по HTTP", "Открытие одной или нескольких записей."),
|
||||
@@ -4,3 +4,4 @@
|
||||
@import "./styles/spatial.css";
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
@import "./styles/observation.css";
|
||||
@@ -15,6 +15,7 @@ body,
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
#root[data-nodedc-theme="dark"],
|
||||
@@ -331,7 +332,6 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 1rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding: 0.7rem 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
.device-workspace--model-picker,
|
||||
.device-plugin-slot {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.device-model-catalog {
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.device-model-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.device-model-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.device-model-card > header {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.device-model-card__mark {
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
border-radius: 0.7rem;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.device-model-card header span,
|
||||
.device-model-card header h3,
|
||||
.device-model-card > p,
|
||||
.device-model-card dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.device-model-card header span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.device-model-card header h3 {
|
||||
margin-top: 0.12rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.device-model-card > p {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.67rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.device-model-card dl {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
padding: 0.7rem 0;
|
||||
}
|
||||
|
||||
.device-model-card dl > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.device-model-card dt { color: var(--nodedc-text-muted); }
|
||||
.device-model-card dd { margin: 0; color: var(--nodedc-text-secondary); text-align: right; }
|
||||
|
||||
.device-model-card__capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.device-model-card__capabilities span {
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.28rem 0.5rem;
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.device-plugin-slot__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.72rem 0.85rem;
|
||||
}
|
||||
|
||||
.device-plugin-slot__bar > div { display: grid; min-width: 0; gap: 0.12rem; }
|
||||
.device-plugin-slot__bar strong { color: var(--nodedc-text-primary); font-size: 0.74rem; }
|
||||
.device-plugin-slot__bar small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
|
||||
.device-plugin-slot__bar .device-plugin-slot__error { color: rgb(var(--nodedc-danger-rgb)); }
|
||||
@@ -0,0 +1,290 @@
|
||||
.observation-header-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.workspace-layout-feedback {
|
||||
display: inline-flex;
|
||||
max-width: 15rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.workspace-layout-feedback[data-error="true"] {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observation-session-select__trigger {
|
||||
display: inline-flex;
|
||||
min-width: 15rem;
|
||||
height: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger:hover,
|
||||
.observation-session-select__trigger[data-active="true"] {
|
||||
background: rgb(255 255 255 / 0.095);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > span {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > small {
|
||||
display: grid;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observation-session-menu {
|
||||
overflow: hidden;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observation-session-menu__content,
|
||||
.observation-session-menu__list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.observation-session-menu__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 0.9rem 0.6rem;
|
||||
}
|
||||
|
||||
.observation-session-menu__head span,
|
||||
.observation-session-menu__head strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observation-session-menu__head strong {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
.observation-session-menu__head button {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-menu__head button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-session-menu__list {
|
||||
max-height: min(26rem, 60vh);
|
||||
gap: 0.2rem;
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-session-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 2.35rem;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.observation-session-option__open {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-session-option__delete {
|
||||
display: grid;
|
||||
min-width: 2.35rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:hover:not(:disabled),
|
||||
.observation-session-option__delete:focus-visible {
|
||||
background: transparent;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:disabled,
|
||||
.observation-session-option[data-deleting="true"] {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-session-option i {
|
||||
display: block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="ready"],
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
animation: observation-session-processing 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="cold"],
|
||||
.observation-session-option i[data-session-visual-state="error"] {
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="error"] {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
@keyframes observation-session-processing {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
animation: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.observation-session-option__state {
|
||||
max-width: 9.5rem;
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-session-option__lab {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--accent, #b8ff5a) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #b8ff5a) 42%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--accent, #b8ff5a);
|
||||
display: inline-flex;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
margin-right: 7px;
|
||||
padding: 4px 6px 3px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.observation-session-menu__empty {
|
||||
display: grid;
|
||||
min-height: 9rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observation-session-menu__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observation-session-menu__empty span {
|
||||
max-width: 24rem;
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observation-session-menu__error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.9rem 0.8rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observation-session-menu__error > span {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.observation-session-menu__error button {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.4rem 0.65rem;
|
||||
font: inherit;
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-delete-error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.workspace-layout-feedback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger {
|
||||
min-width: 2.75rem;
|
||||
width: 2.75rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > span,
|
||||
.observation-session-select__trigger > small,
|
||||
.observation-session-select__trigger > svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
.scene-source-controls {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 0.85rem;
|
||||
left: 50%;
|
||||
max-width: calc(100% - 9rem);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger,
|
||||
.scene-source-control,
|
||||
.scene-focus-exit {
|
||||
display: inline-grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 50%;
|
||||
background: rgb(12 13 16 / 0.82);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.scene-navigation-hint {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
right: 0.85rem;
|
||||
bottom: 4.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger:hover,
|
||||
.scene-source-control:hover,
|
||||
.scene-focus-exit:hover,
|
||||
.scene-source-picker__trigger[data-active="true"] {
|
||||
border-color: rgb(255 255 255 / 0.18);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger > span {
|
||||
position: absolute;
|
||||
top: -0.2rem;
|
||||
right: -0.2rem;
|
||||
display: grid;
|
||||
min-width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-text-primary);
|
||||
color: #090a0c;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scene-focus-exit {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
left: 7.1rem;
|
||||
}
|
||||
|
||||
.scene-status[aria-hidden="true"],
|
||||
.scene-metrics[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observation-source-menu {
|
||||
overflow: hidden;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observation-source-menu__head,
|
||||
.observation-source-menu__foot {
|
||||
padding: 0.85rem 0.9rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong,
|
||||
.observation-source-menu__head span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong {
|
||||
margin-top: 0.34rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
.observation-source-menu__head small,
|
||||
.observation-source-menu__foot {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observation-source-menu__list {
|
||||
display: grid;
|
||||
max-height: min(28rem, 60vh);
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option .nodedc-dropdown-option__description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option i,
|
||||
.floating-observation-window i,
|
||||
.camera-slot__status i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
flex: 0 0 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="available"],
|
||||
i[data-availability="streaming"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="connecting"],
|
||||
i[data-availability="declared"] {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="unverified"] {
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="degraded"],
|
||||
i[data-availability="error"] {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observation-source-menu__empty,
|
||||
.camera-grid__empty,
|
||||
.observation-media__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty {
|
||||
min-height: 10rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty strong,
|
||||
.observation-media__empty strong,
|
||||
.camera-grid__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty span,
|
||||
.observation-media__empty span,
|
||||
.camera-grid__empty span {
|
||||
max-width: 24rem;
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observation-source-menu__foot {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.observation-media__asset,
|
||||
.observation-media__empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.observation-media__asset {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #050608;
|
||||
}
|
||||
|
||||
.recorded-media-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #070809;
|
||||
}
|
||||
|
||||
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-media-player__notice {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
color: rgba(247, 248, 244, 0.72);
|
||||
background: #070809;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mse-fmp4-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 9rem;
|
||||
overflow: hidden;
|
||||
background: #050608;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: rgb(5 6 8 / 0.88);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status span {
|
||||
max-width: 24rem;
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.48rem 0.72rem;
|
||||
font: inherit;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 760;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status button:hover {
|
||||
background: rgb(255 255 255 / 0.11);
|
||||
}
|
||||
|
||||
.observation-media__empty {
|
||||
min-height: 9rem;
|
||||
background: #07080a;
|
||||
}
|
||||
|
||||
.floating-observation-window__status,
|
||||
.floating-observation-window__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button {
|
||||
display: grid;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.camera-slot__head-actions .camera-slot__activation {
|
||||
width: auto;
|
||||
min-width: 5.6rem;
|
||||
height: 1.9rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.53rem;
|
||||
font-weight: 760;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions .camera-slot__activation:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.floating-observation-window__status {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.floating-observation-window__footer {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.floating-observation-window .nodedc-workspace-window__body {
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.floating-observation-window.nodedc-material-rim::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floating-observation-window[data-active="true"] {
|
||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
|
||||
}
|
||||
|
||||
.floating-observation-window--hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(9 10 13 / 0.78);
|
||||
padding: 0.4rem;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation span,
|
||||
.observation-timeline__accumulation code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation code {
|
||||
min-width: 2.65rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
width: 100%;
|
||||
height: 0.3rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__track::-webkit-slider-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track::-moz-range-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track:disabled {
|
||||
opacity: 0.46;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-timeline__meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.observation-timeline__meta code,
|
||||
.observation-timeline__meta small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.observation-timeline__follow:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"] {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] {
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
|
||||
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.camera-workspace__catalog {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.8rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||
grid-template-rows: none;
|
||||
grid-auto-rows: minmax(16rem, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.camera-grid[data-count="1"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.camera-grid__empty {
|
||||
min-height: 18rem;
|
||||
border-radius: 1rem;
|
||||
background: #07080a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.camera-slot {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
.camera-slot footer {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions,
|
||||
.camera-slot__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__status {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.camera-slot__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-slot footer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.camera-slot footer span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-slot footer small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.camera-timeline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] {
|
||||
height: 100%;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] > .workspace-lead,
|
||||
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] .camera-grid {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.scene-status--top-left {
|
||||
top: 4.2rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-button:first-child,
|
||||
.observation-timeline__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: minmax(14rem, 1fr);
|
||||
}
|
||||
}
|
||||
+5
-34
@@ -3,17 +3,12 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.device-workspace__grid {
|
||||
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.device-workspace__grid,
|
||||
.overview-grid,
|
||||
.mission-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -72,10 +67,6 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-lead {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
@@ -104,7 +95,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.station-label,
|
||||
.control-station .nodedc-header__profile-button {
|
||||
display: none;
|
||||
}
|
||||
@@ -178,6 +168,11 @@
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
top: 0.6rem;
|
||||
max-width: calc(100% - 8rem);
|
||||
}
|
||||
|
||||
.scene-adapter-note {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
@@ -210,36 +205,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-form--replay > :first-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.session-footer,
|
||||
.error-banner,
|
||||
.source-format-list > div {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.device-row__action {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
.station-label {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.13em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.api-dot {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
@@ -29,6 +19,36 @@
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.control-station[data-observation-fullscreen="true"] .nodedc-app-shell__content {
|
||||
z-index: calc(var(--nodedc-layer-panel) + 1);
|
||||
right: var(--nodedc-app-page-pad);
|
||||
left: var(--nodedc-app-page-pad);
|
||||
width: auto;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.control-station[data-observation-fullscreen="true"] .nodedc-app-shell__navigation {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
background: #06070a;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel__head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel__body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -79,7 +99,6 @@
|
||||
display: grid;
|
||||
width: min(24rem, 30vw);
|
||||
gap: 0.6rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.055);
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(10 10 12 / 0.58);
|
||||
padding: 1rem;
|
||||
+133
-8
@@ -13,11 +13,10 @@
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__mode,
|
||||
.spatial-toolbar__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -25,6 +24,20 @@
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch .nodedc-button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.spatial-viewport-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -32,7 +45,7 @@
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #06070a;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rerun-viewport,
|
||||
@@ -45,15 +58,57 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas[data-presented="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-session-preloaders {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: -10000px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport {
|
||||
--rerun-native-chrome-height: 72px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
.rerun-viewport__canvas {
|
||||
top: calc(-1 * var(--rerun-native-chrome-height));
|
||||
bottom: auto;
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.rerun-viewport__camera-lock {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: calc(46% - 2px);
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||
.rerun-viewport__canvas > div {
|
||||
.rerun-viewport__canvas {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice {
|
||||
@@ -63,7 +118,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
background: rgb(10 11 14 / 0.82);
|
||||
padding: 0.9rem 1rem;
|
||||
@@ -90,6 +145,24 @@
|
||||
background: rgb(10 11 14 / 0.9);
|
||||
}
|
||||
|
||||
.rerun-viewport__retry {
|
||||
margin-top: 0.65rem;
|
||||
border: 1px solid var(--station-hairline-strong);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
padding: 0.42rem 0.7rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.61rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rerun-viewport__retry:hover,
|
||||
.rerun-viewport__retry:focus-visible {
|
||||
background: rgb(255 255 255 / 0.14);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.busy-indicator {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
@@ -161,7 +234,7 @@
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
place-items: center;
|
||||
border: 1px solid var(--station-hairline-strong);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
color: var(--nodedc-text-secondary);
|
||||
@@ -186,7 +259,7 @@
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 1px solid rgb(255 255 255 / 0.055);
|
||||
border: 0;
|
||||
background: rgb(9 10 13 / 0.74);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
@@ -222,7 +295,7 @@
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
border-bottom: 0;
|
||||
padding: 0.38rem 0;
|
||||
}
|
||||
|
||||
@@ -262,6 +335,58 @@
|
||||
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
|
||||
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.scene-operation-status-stack {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
right: 0.85rem;
|
||||
bottom: 7.15rem;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.scene-operation-status {
|
||||
display: inline-flex;
|
||||
max-width: min(24rem, calc(100% - 1.7rem));
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-operation-status .busy-indicator {
|
||||
width: 0.66rem;
|
||||
height: 0.66rem;
|
||||
flex-basis: 0.66rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.scene-operation-status--action {
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scene-operation-status--action:hover,
|
||||
.scene-operation-status--action:focus-visible {
|
||||
border-color: rgb(255 255 255 / 0.14);
|
||||
background: rgb(20 21 25 / 0.82);
|
||||
color: var(--nodedc-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scene-operation-status--error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
+2
-6
@@ -16,7 +16,6 @@
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.6fr);
|
||||
gap: 1.6rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding-top: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -47,7 +46,6 @@
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
padding: 0.78rem 0;
|
||||
}
|
||||
|
||||
@@ -270,7 +268,7 @@
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #090a0c;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.camera-slot[data-primary="true"] {
|
||||
@@ -347,7 +345,7 @@
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #0a0b0d;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.map-canvas__grid,
|
||||
@@ -484,7 +482,6 @@
|
||||
}
|
||||
|
||||
.timeline-ruler span {
|
||||
border-left: 1px solid var(--station-hairline);
|
||||
padding-left: 0.2rem;
|
||||
}
|
||||
|
||||
@@ -498,7 +495,6 @@
|
||||
grid-template-columns: 7.5rem minmax(0, 1fr) 10rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding: 0.82rem 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
|
||||
import type { RegisteredDeviceModel } from "../core/device-plugins/contracts";
|
||||
|
||||
function ModelCard({
|
||||
registered,
|
||||
onSelect,
|
||||
}: {
|
||||
registered: RegisteredDeviceModel;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { model, plugin } = registered;
|
||||
return (
|
||||
<article className="device-model-card">
|
||||
<header>
|
||||
<div className="device-model-card__mark" aria-hidden="true">
|
||||
<Icon name="network" />
|
||||
</div>
|
||||
<div>
|
||||
<span>{model.vendor}</span>
|
||||
<h3>{model.displayName}</h3>
|
||||
</div>
|
||||
<StatusBadge tone={model.verified ? "success" : "warning"}>
|
||||
{model.verified ? "Проверено" : "Экспериментально"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<p>{model.description}</p>
|
||||
<dl>
|
||||
<div><dt>Категория</dt><dd>{model.category}</dd></div>
|
||||
<div><dt>Плагин</dt><dd>{plugin.manifest.metadata.displayName} · v{plugin.manifest.metadata.version}</dd></div>
|
||||
</dl>
|
||||
<div className="device-model-card__capabilities" aria-label="Возможности модели">
|
||||
{model.capabilities.map((capability) => <span key={capability.id}>{capability.label}</span>)}
|
||||
</div>
|
||||
<Button width="full" variant="primary" onClick={onSelect}>
|
||||
Выбрать модель
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace({
|
||||
onOpenSpatialScene,
|
||||
onActivateAutomaticSpatialSource,
|
||||
}: {
|
||||
onOpenSpatialScene: () => void;
|
||||
onActivateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
const {
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionPending,
|
||||
selectionTransitionError,
|
||||
selectModel,
|
||||
clearSelection,
|
||||
} = useDevicePluginHost();
|
||||
|
||||
if (!selection) {
|
||||
return (
|
||||
<div className="device-workspace device-workspace--model-picker">
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНОЕ УСТРОЙСТВО · ШАГ 01</span>
|
||||
<h2>Выберите модель устройства</h2>
|
||||
<p>
|
||||
Mission Core покажет только сценарий выбранного плагина. До выбора модели нет
|
||||
поиска оборудования, сетевых реквизитов, управления потоком или фиктивных метрик.
|
||||
</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone="neutral">Модель не выбрана</StatusBadge>
|
||||
<span>{registry.models.length} моделей доступно в локальной сборке</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<GlassSurface className="device-model-catalog" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">УСТАНОВЛЕННЫЕ ПЛАГИНЫ</span>
|
||||
<h2>Парк поддерживаемых моделей</h2>
|
||||
</div>
|
||||
<StatusBadge tone="neutral">{registry.plugins.length} плагин</StatusBadge>
|
||||
</header>
|
||||
<div className="device-model-grid">
|
||||
{registry.models.map((registered) => (
|
||||
<ModelCard
|
||||
key={registered.model.id}
|
||||
registered={registered}
|
||||
onSelect={() => void selectModel(registered.model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ConnectionView = selection.ConnectionView;
|
||||
return (
|
||||
<div className="device-plugin-slot">
|
||||
<div className="device-plugin-slot__bar">
|
||||
<div>
|
||||
<span className="section-eyebrow">АКТИВНАЯ МОДЕЛЬ</span>
|
||||
<strong>{selection.model.displayName}</strong>
|
||||
<small>{selection.plugin.manifest.metadata.displayName} · v{selection.plugin.manifest.metadata.version}</small>
|
||||
{selectionTransitionError ? (
|
||||
<small className="device-plugin-slot__error" role="alert">
|
||||
{selectionTransitionError}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
disabled={selectionTransitionPending}
|
||||
onClick={() => void clearSelection()}
|
||||
>
|
||||
{selectionTransitionPending ? "Завершаем текущую сессию…" : "Выбрать другую модель"}
|
||||
</Button>
|
||||
</div>
|
||||
<ConnectionView
|
||||
model={selection.model}
|
||||
host={{
|
||||
openSpatialScene: onOpenSpatialScene,
|
||||
activateAutomaticSpatialSource: onActivateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,690 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let parseDevicePluginManifest;
|
||||
let createDevicePluginRegistry;
|
||||
let xgridsK1Manifest;
|
||||
let xgridsK1Actions;
|
||||
let xgridsK1Api;
|
||||
let ApiError;
|
||||
let localizeRuntimeMessage;
|
||||
let lifecycle;
|
||||
let projectName;
|
||||
let automaticSourceStart;
|
||||
let presentation;
|
||||
let operatorIntentGeneration;
|
||||
let configuration;
|
||||
let compatibility;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ parseDevicePluginManifest } = await server.ssrLoadModule(
|
||||
"/src/core/device-plugins/manifestParser.ts",
|
||||
));
|
||||
({ createDevicePluginRegistry } = await server.ssrLoadModule(
|
||||
"/src/core/device-plugins/registry.ts",
|
||||
));
|
||||
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/manifest.ts",
|
||||
));
|
||||
lifecycle = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/lifecycle.ts",
|
||||
);
|
||||
projectName = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/projectName.ts",
|
||||
);
|
||||
automaticSourceStart = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/automaticSourceStart.ts",
|
||||
);
|
||||
presentation = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/presentation.ts",
|
||||
);
|
||||
operatorIntentGeneration = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/operatorIntentGeneration.ts",
|
||||
);
|
||||
configuration = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/configuration.ts",
|
||||
);
|
||||
compatibility = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/compatibility.ts",
|
||||
);
|
||||
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/api.ts",
|
||||
));
|
||||
({ localizeRuntimeMessage } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/messages.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function manifestDocument({
|
||||
apiVersion = "missioncore.nodedc/v1alpha1",
|
||||
pluginId = "test.device.plugin",
|
||||
modelId = "test.device.model",
|
||||
} = {}) {
|
||||
const v1alpha2 = apiVersion === "missioncore.nodedc/v1alpha2";
|
||||
return {
|
||||
apiVersion,
|
||||
kind: "DevicePlugin",
|
||||
metadata: {
|
||||
id: pluginId,
|
||||
version: "1.0.0",
|
||||
displayName: "Test device",
|
||||
},
|
||||
spec: {
|
||||
hostApiRange: v1alpha2 ? "v1alpha2" : "v1alpha1",
|
||||
runtime: {
|
||||
backendEntrypoint: "test.plugin:build",
|
||||
isolation: "transitional-in-process",
|
||||
},
|
||||
...(v1alpha2
|
||||
? {
|
||||
compatibilityProfiles: [
|
||||
{
|
||||
profileId: `${modelId}.fw-1.v1`,
|
||||
path: "profiles/fw-1/profile.v1.json",
|
||||
modelId,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
permissions: ["device.read"],
|
||||
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
vendor: "Test",
|
||||
displayName: "Test model",
|
||||
category: "Sensor",
|
||||
description: "Fixture model",
|
||||
verified: true,
|
||||
capabilities: [{ id: "device.read", label: "Read" }],
|
||||
ui: { slot: "device.connection", componentKey: "test.connection" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uiPlugin(manifest) {
|
||||
return {
|
||||
manifest,
|
||||
RuntimeProvider: ({ children }) => children,
|
||||
connectionViews: { "test.connection": () => null },
|
||||
};
|
||||
}
|
||||
|
||||
test("parser accepts reviewed v1alpha1 and v1alpha2 shapes", () => {
|
||||
const legacy = parseDevicePluginManifest(manifestDocument());
|
||||
const current = parseDevicePluginManifest(
|
||||
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
|
||||
);
|
||||
|
||||
assert.equal(legacy.apiVersion, "missioncore.nodedc/v1alpha1");
|
||||
assert.equal(legacy.spec.hostApiRange, "v1alpha1");
|
||||
assert.equal(current.apiVersion, "missioncore.nodedc/v1alpha2");
|
||||
assert.equal(current.spec.hostApiRange, "v1alpha2");
|
||||
assert.deepEqual(current.spec.compatibilityProfiles, [
|
||||
{
|
||||
profileId: "test.device.model.fw-1.v1",
|
||||
path: "profiles/fw-1/profile.v1.json",
|
||||
modelId: "test.device.model",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("v1alpha1 keeps the original nonblank identifier compatibility", () => {
|
||||
const legacyDocument = manifestDocument({
|
||||
pluginId: "legacy plugin id",
|
||||
modelId: "legacy model id",
|
||||
});
|
||||
legacyDocument.spec.permissions = ["legacy permission"];
|
||||
legacyDocument.spec.actions[0].secretFields = ["legacy secret field"];
|
||||
legacyDocument.spec.models[0].capabilities = [
|
||||
{ id: "legacy capability", label: "Legacy" },
|
||||
];
|
||||
|
||||
const legacy = parseDevicePluginManifest(legacyDocument);
|
||||
|
||||
assert.equal(legacy.metadata.id, "legacy plugin id");
|
||||
assert.equal(legacy.spec.models[0].id, "legacy model id");
|
||||
assert.deepEqual(legacy.spec.permissions, ["legacy permission"]);
|
||||
});
|
||||
|
||||
test("v1alpha2 applies strict identifiers without redefining v1alpha1", () => {
|
||||
const current = manifestDocument({
|
||||
apiVersion: "missioncore.nodedc/v1alpha2",
|
||||
pluginId: "current plugin id",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => parseDevicePluginManifest(current),
|
||||
/не является идентификатором/,
|
||||
);
|
||||
});
|
||||
|
||||
test("v1alpha2 parser and registry accept multiple independently profiled models", () => {
|
||||
const document = manifestDocument({
|
||||
apiVersion: "missioncore.nodedc/v1alpha2",
|
||||
pluginId: "test.family",
|
||||
modelId: "test.family.model-a",
|
||||
});
|
||||
document.spec.models.push({
|
||||
...document.spec.models[0],
|
||||
id: "test.family.model-b",
|
||||
displayName: "Test model B",
|
||||
});
|
||||
document.spec.compatibilityProfiles.push({
|
||||
profileId: "test.family.model-b.fw-1.v1",
|
||||
path: "profiles/fw-1/model-b.v1.json",
|
||||
modelId: "test.family.model-b",
|
||||
});
|
||||
|
||||
const manifest = parseDevicePluginManifest(document);
|
||||
const plugin = {
|
||||
...uiPlugin(manifest),
|
||||
connectionViews: { "test.connection": () => null },
|
||||
};
|
||||
const registry = createDevicePluginRegistry([plugin]);
|
||||
|
||||
assert.deepEqual(registry.models.map(({ model }) => model.id), [
|
||||
"test.family.model-a",
|
||||
"test.family.model-b",
|
||||
]);
|
||||
});
|
||||
|
||||
test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", () => {
|
||||
assert.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2");
|
||||
assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [
|
||||
{
|
||||
profileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
path: "profiles/fw-3.0.2/local-network.v2.json",
|
||||
modelId: "xgrids.lixelkity-k1",
|
||||
},
|
||||
]);
|
||||
assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare");
|
||||
assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start");
|
||||
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
|
||||
});
|
||||
|
||||
test("one operator action can open at most one K1 control session", () => {
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("idle", false, true), "open");
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, true), "open");
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, false), "failed");
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("failed", true, true), "failed");
|
||||
assert.equal(
|
||||
lifecycle.controlSessionEntryPlan("idle", true, true),
|
||||
"duplicate-open",
|
||||
);
|
||||
assert.equal(
|
||||
lifecycle.controlSessionEntryPlan("connection-ready", true, false),
|
||||
"continue",
|
||||
);
|
||||
});
|
||||
|
||||
test("K1 operator intent stays invalid after runtime deactivate and reactivate", () => {
|
||||
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||
generation.activateRuntime();
|
||||
const oldIntent = generation.beginOperatorIntent();
|
||||
|
||||
assert.ok(oldIntent);
|
||||
assert.equal(generation.isOperatorIntentCurrent(oldIntent), true);
|
||||
|
||||
generation.deactivateRuntime();
|
||||
generation.activateRuntime();
|
||||
const freshIntent = generation.beginOperatorIntent();
|
||||
|
||||
assert.ok(freshIntent);
|
||||
assert.notEqual(freshIntent.runtimeGeneration, oldIntent.runtimeGeneration);
|
||||
assert.equal(generation.isOperatorIntentCurrent(oldIntent), false);
|
||||
assert.equal(generation.isOperatorIntentCurrent(freshIntent), true);
|
||||
});
|
||||
|
||||
test("stale K1 intent cannot continue past an await after runtime reactivation", async () => {
|
||||
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||
generation.activateRuntime();
|
||||
const oldIntent = generation.beginOperatorIntent();
|
||||
assert.ok(oldIntent);
|
||||
|
||||
let resolveOldRead;
|
||||
const oldRead = new Promise((resolve) => {
|
||||
resolveOldRead = resolve;
|
||||
});
|
||||
const writes = [];
|
||||
const assertOldIntent = () => {
|
||||
if (!generation.isOperatorIntentCurrent(oldIntent)) {
|
||||
throw new Error("stale operator intent");
|
||||
}
|
||||
};
|
||||
const oldContinuation = operatorIntentGeneration.awaitWhileIntentCurrent(
|
||||
assertOldIntent,
|
||||
() => oldRead,
|
||||
).then(() => writes.push("old-checkpoint"));
|
||||
|
||||
generation.deactivateRuntime();
|
||||
generation.activateRuntime();
|
||||
const freshIntent = generation.beginOperatorIntent();
|
||||
assert.ok(freshIntent);
|
||||
resolveOldRead({ phase: "connection-ready" });
|
||||
|
||||
await assert.rejects(oldContinuation, /stale operator intent/);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
const assertFreshIntent = () => {
|
||||
if (!generation.isOperatorIntentCurrent(freshIntent)) {
|
||||
throw new Error("stale fresh intent");
|
||||
}
|
||||
};
|
||||
await operatorIntentGeneration.awaitWhileIntentCurrent(
|
||||
assertFreshIntent,
|
||||
async () => ({ phase: "connection-ready" }),
|
||||
);
|
||||
writes.push("fresh-checkpoint");
|
||||
assert.deepEqual(writes, ["fresh-checkpoint"]);
|
||||
});
|
||||
|
||||
test("a fresh explicit K1 intent supersedes the previous intent in one runtime", () => {
|
||||
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||
generation.activateRuntime();
|
||||
const first = generation.beginOperatorIntent();
|
||||
const second = generation.beginOperatorIntent();
|
||||
|
||||
assert.ok(first);
|
||||
assert.ok(second);
|
||||
assert.equal(first.runtimeGeneration, second.runtimeGeneration);
|
||||
assert.notEqual(first.intentGeneration, second.intentGeneration);
|
||||
assert.equal(generation.isOperatorIntentCurrent(first), false);
|
||||
assert.equal(generation.isOperatorIntentCurrent(second), true);
|
||||
});
|
||||
|
||||
test("canonical K1 launch wires the generation guard through every async stage", async () => {
|
||||
const hookSource = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const canonicalStart = hookSource.slice(
|
||||
hookSource.indexOf("const startCanonicalAcquisition"),
|
||||
hookSource.indexOf("const prepareAcquisition"),
|
||||
);
|
||||
const pollingLoop = hookSource.slice(
|
||||
hookSource.indexOf("async function waitForControlPhase"),
|
||||
hookSource.indexOf("function messageFor"),
|
||||
);
|
||||
|
||||
assert.doesNotMatch(hookSource, /mounted\.current/);
|
||||
assert.match(canonicalStart, /beginOperatorIntent\(\)/);
|
||||
assert.match(canonicalStart, /isOperatorIntentCurrent\(intentToken\)/);
|
||||
assert.doesNotMatch(canonicalStart, /await xgridsK1Api\./);
|
||||
assert.doesNotMatch(canonicalStart, /await waitForControlPhase\(/);
|
||||
assert.ok(
|
||||
canonicalStart.match(/awaitWhileIntentCurrent\(/g)?.length >= 8,
|
||||
"each canonical REST/checkpoint boundary must use the intent guard",
|
||||
);
|
||||
assert.match(pollingLoop, /for \(;;\) \{\s*assertOperatorIntentCurrent\(\)/);
|
||||
assert.equal(pollingLoop.match(/awaitWhileIntentCurrent\(/g)?.length, 2);
|
||||
});
|
||||
|
||||
test("K1 control errors stay informative and only fetch failures mark transport unavailable", async () => {
|
||||
assert.equal(
|
||||
localizeRuntimeMessage(
|
||||
"control MQTT connect call failed: [Errno 61] Connection refused",
|
||||
),
|
||||
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
|
||||
);
|
||||
|
||||
const domainError = new ApiError("Диалог остановлен до команды START.");
|
||||
assert.equal(domainError.transportUnavailable, false);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new TypeError("synthetic network failure");
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => xgridsK1Api.getState(),
|
||||
(error) => error instanceof ApiError && error.transportUnavailable === true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
|
||||
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
|
||||
document.spec.compatibilityProfiles[0].status = "verified";
|
||||
|
||||
assert.throws(
|
||||
() => parseDevicePluginManifest(document),
|
||||
/неизвестные поля status/,
|
||||
);
|
||||
});
|
||||
|
||||
test("v1alpha2 compatibility profile fails closed on unsafe paths", () => {
|
||||
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
|
||||
document.spec.compatibilityProfiles[0].path = "../private/profile.json";
|
||||
|
||||
assert.throws(
|
||||
() => parseDevicePluginManifest(document),
|
||||
/безопасным относительным JSON-путём/,
|
||||
);
|
||||
});
|
||||
|
||||
test("v1alpha2 compatibility profile cannot reference an unknown model", () => {
|
||||
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
|
||||
document.spec.compatibilityProfiles[0].modelId = "missing.model";
|
||||
|
||||
assert.throws(
|
||||
() => parseDevicePluginManifest(document),
|
||||
/ссылается на неизвестную модель missing\.model/,
|
||||
);
|
||||
});
|
||||
|
||||
test("registry accepts v1alpha1 and v1alpha2 plugins together", () => {
|
||||
const legacy = parseDevicePluginManifest(
|
||||
manifestDocument({ pluginId: "test.legacy", modelId: "test.legacy.model" }),
|
||||
);
|
||||
const current = parseDevicePluginManifest(
|
||||
manifestDocument({
|
||||
apiVersion: "missioncore.nodedc/v1alpha2",
|
||||
pluginId: "test.current",
|
||||
modelId: "test.current.model",
|
||||
}),
|
||||
);
|
||||
const registry = createDevicePluginRegistry([uiPlugin(legacy), uiPlugin(current)]);
|
||||
|
||||
assert.equal(registry.plugins.length, 2);
|
||||
assert.equal(registry.models.length, 2);
|
||||
assert.equal(registry.resolveModel("test.current.model")?.plugin.manifest.metadata.id, "test.current");
|
||||
});
|
||||
|
||||
test("registry independently rejects an uncovered v1alpha2 model", () => {
|
||||
const current = parseDevicePluginManifest(
|
||||
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
|
||||
);
|
||||
current.spec.compatibilityProfiles[0].modelId = "missing.model";
|
||||
|
||||
assert.throws(
|
||||
() => createDevicePluginRegistry([uiPlugin(current)]),
|
||||
/ссылается на неизвестную модель missing\.model/,
|
||||
);
|
||||
});
|
||||
|
||||
test("live source is confirmed only by an acquiring acquisition", () => {
|
||||
const waiting = {
|
||||
source_mode: "live",
|
||||
phase: "live",
|
||||
acquisition: { state: "awaiting_external_start" },
|
||||
};
|
||||
const acquiring = {
|
||||
...waiting,
|
||||
acquisition: { state: "acquiring" },
|
||||
};
|
||||
|
||||
assert.equal(lifecycle.isConfirmedLiveState(waiting), false);
|
||||
assert.equal(lifecycle.confirmedRuntimeSourceMode(waiting), "idle");
|
||||
assert.equal(lifecycle.sourceStatusLabel(waiting), "Ожидание реальных данных");
|
||||
assert.equal(lifecycle.isConfirmedLiveState(acquiring), true);
|
||||
assert.equal(lifecycle.confirmedRuntimeSourceMode(acquiring), "live");
|
||||
});
|
||||
|
||||
test("prepared acquisition resumes without another prepare and remains recoverable", () => {
|
||||
const prepared = {
|
||||
source_mode: "idle",
|
||||
acquisition: {
|
||||
acquisition_id: "acq-1",
|
||||
state: "prepared",
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(lifecycle.liveStartPlan(prepared), "resume-prepared");
|
||||
assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1");
|
||||
});
|
||||
|
||||
test("project name is canonicalized and rejected outside the bounded safe contract", () => {
|
||||
assert.deepEqual(projectName.validateProjectName(" Mission 01 "), {
|
||||
value: "Mission 01",
|
||||
error: null,
|
||||
});
|
||||
assert.match(projectName.validateProjectName("line\nbreak").error, /управляющие/);
|
||||
assert.match(projectName.validateProjectName("\ud800").error, /управляющие/);
|
||||
assert.match(projectName.validateProjectName("x".repeat(97)).error, /не длиннее 96/);
|
||||
assert.match(projectName.validateProjectName(" \t ").error, /Введите название/);
|
||||
});
|
||||
|
||||
test("automatic spatial source replaces the old scene only after a successful start", async () => {
|
||||
const failedEvents = [];
|
||||
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
|
||||
async () => {
|
||||
failedEvents.push("start");
|
||||
return false;
|
||||
},
|
||||
() => failedEvents.push("activate"),
|
||||
() => failedEvents.push("open"),
|
||||
), false);
|
||||
assert.deepEqual(failedEvents, ["start"]);
|
||||
|
||||
const successfulEvents = [];
|
||||
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
|
||||
async () => {
|
||||
successfulEvents.push("start");
|
||||
return true;
|
||||
},
|
||||
() => successfulEvents.push("activate"),
|
||||
() => successfulEvents.push("open"),
|
||||
), true);
|
||||
assert.deepEqual(successfulEvents, ["start", "activate", "open"]);
|
||||
});
|
||||
|
||||
test("vendor commands fail closed unless the profile and acquisition both enable them", () => {
|
||||
const capability = {
|
||||
compatibility: {
|
||||
vendor_writes_enabled: true,
|
||||
permitted_mode: "active-control",
|
||||
},
|
||||
};
|
||||
assert.equal(lifecycle.isVendorWriteCapable(capability), true);
|
||||
assert.equal(lifecycle.isVendorWriteCapable({
|
||||
compatibility: { vendor_writes_enabled: true, permitted_mode: "read-only" },
|
||||
}), false);
|
||||
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
|
||||
...capability,
|
||||
acquisition: { control_mode: "operator-manual" },
|
||||
}), false);
|
||||
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
|
||||
...capability,
|
||||
acquisition: { control_mode: "plugin-commanded" },
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("device modeling telemetry maps only finite non-negative values", () => {
|
||||
assert.deepEqual(presentation.deviceTelemetry({
|
||||
device_elapsed_seconds: 12.5,
|
||||
device_route_distance_meters: 8.25,
|
||||
device_speed_meters_per_second: 0.75,
|
||||
}), {
|
||||
elapsedSeconds: 12.5,
|
||||
routeDistanceMeters: 8.25,
|
||||
speedMetersPerSecond: 0.75,
|
||||
});
|
||||
assert.deepEqual(presentation.deviceTelemetry({
|
||||
device_elapsed_seconds: -1,
|
||||
device_route_distance_meters: Number.NaN,
|
||||
device_speed_meters_per_second: Number.POSITIVE_INFINITY,
|
||||
}), {
|
||||
elapsedSeconds: null,
|
||||
routeDistanceMeters: null,
|
||||
speedMetersPerSecond: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("spatial K1 action failures have an explicit retry-safe presentation", () => {
|
||||
assert.equal(presentation.spatialActionFailure(null), null);
|
||||
assert.equal(presentation.spatialActionFailure(" "), null);
|
||||
assert.deepEqual(presentation.spatialActionFailure(" stop failed "), {
|
||||
title: "Действие K1 не выполнено",
|
||||
detail: "stop failed",
|
||||
});
|
||||
assert.equal(lifecycle.shouldRenderSpatialControls({
|
||||
source_mode: "live",
|
||||
acquisition: { state: "failed", cleanup_pending: true },
|
||||
}), true);
|
||||
assert.equal(lifecycle.shouldRenderSpatialControls({
|
||||
source_mode: "idle",
|
||||
acquisition: { state: "failed", cleanup_pending: false },
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("replay ignores a stale failed live acquisition", () => {
|
||||
const replay = {
|
||||
source_mode: "replay",
|
||||
phase: "replay",
|
||||
rerun_grpc_url: "rerun+http://127.0.0.1:9876/proxy",
|
||||
acquisition: {
|
||||
acquisition_id: "old-live-acquisition",
|
||||
state: "failed",
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(lifecycle.normalizeRuntimePhase(replay), "replaying");
|
||||
assert.equal(lifecycle.effectiveAcquisition(replay), null);
|
||||
assert.equal(
|
||||
lifecycle.spatialSourceId(replay, replay.rerun_grpc_url),
|
||||
`replay:${replay.rerun_grpc_url}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("K1 configuration exposes all reviewed local connection directions", () => {
|
||||
assert.equal(configuration.DEFAULT_CONNECTION_MODE, "bridge");
|
||||
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
|
||||
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
|
||||
assert.deepEqual(
|
||||
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||
[
|
||||
{ value: "bridge", disabled: false },
|
||||
{ value: "quick-connect", disabled: false },
|
||||
{ value: "direct-connect", disabled: false },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
configuration.mountTypeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||
[
|
||||
{ value: "handheld", disabled: false },
|
||||
{ value: "vehicle-mounted", disabled: true },
|
||||
{ value: "uav", disabled: true },
|
||||
{ value: "backpack", disabled: true },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
configuration.gnssModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||
[
|
||||
{ value: "none", disabled: false },
|
||||
{ value: "rtk", disabled: true },
|
||||
{ value: "ppk", disabled: true },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("connection directions select distinct fail-closed topologies", () => {
|
||||
assert.deepEqual(compatibility.profileSelectionForConnectionMode("bridge"), {
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
verification: "live-device-info",
|
||||
});
|
||||
assert.equal(
|
||||
compatibility.profileSelectionForConnectionMode("quick-connect").topology,
|
||||
"device-ap",
|
||||
);
|
||||
assert.equal(
|
||||
compatibility.profileSelectionForConnectionMode("direct-connect").topology,
|
||||
"controller-hotspot",
|
||||
);
|
||||
});
|
||||
|
||||
test("a provisioned address is not presented as a verified device connection", () => {
|
||||
assert.equal(lifecycle.normalizeRuntimePhase({
|
||||
phase: "connected",
|
||||
application_control_session: { state: "idle" },
|
||||
}), "configuring");
|
||||
assert.equal(lifecycle.normalizeRuntimePhase({
|
||||
phase: "connected",
|
||||
application_control_session: { state: "connection-ready" },
|
||||
}), "connected");
|
||||
});
|
||||
|
||||
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
|
||||
let created = 0;
|
||||
const createUuid = () => {
|
||||
created += 1;
|
||||
return "11111111-1111-4111-8111-111111111111";
|
||||
};
|
||||
const first = lifecycle.provisioningIntentKey(null, createUuid);
|
||||
const repeated = lifecycle.provisioningIntentKey(first, createUuid);
|
||||
const failedOperation = {
|
||||
status: "failed",
|
||||
error: { safe_to_retry: false },
|
||||
};
|
||||
|
||||
assert.equal(first, "network-provision:11111111-1111-4111-8111-111111111111");
|
||||
assert.equal(repeated, first);
|
||||
assert.equal(created, 1);
|
||||
assert.equal(lifecycle.operationNeedsReconciliation(failedOperation), true);
|
||||
});
|
||||
|
||||
test("device mutations send explicit nested compatibility attestation", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
const syntheticCredential = "x".repeat(32);
|
||||
globalThis.fetch = async (path, init) => {
|
||||
calls.push({ path, init });
|
||||
return new Response(JSON.stringify({ state: { source_mode: "idle" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
const attestation = {
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
verification: "live-device-info",
|
||||
};
|
||||
|
||||
try {
|
||||
await xgridsK1Api.connect({
|
||||
device_id: "ble-device",
|
||||
ssid: "lab-network",
|
||||
password: syntheticCredential,
|
||||
connection_mode: "bridge",
|
||||
compatibility_attestation: attestation,
|
||||
idempotency_key: "network-provision:test",
|
||||
});
|
||||
await xgridsK1Api.prepareAcquisition({
|
||||
project_name: "Mission 01",
|
||||
mount_type: "handheld",
|
||||
gnss_mode: "none",
|
||||
compatibility_attestation: attestation,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
const provisioning = JSON.parse(calls[0].init.body);
|
||||
const prepare = JSON.parse(calls[1].init.body);
|
||||
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
|
||||
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
|
||||
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
|
||||
assert.equal(prepare.input.project_name, "Mission 01");
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
const testRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const controlStationRoot = resolve(testRoot, "..");
|
||||
const repositoryRoot = resolve(controlStationRoot, "../..");
|
||||
const coreSourceRoot = join(controlStationRoot, "src");
|
||||
const pluginFrontendRoot = join(repositoryRoot, "plugins/xgrids-k1/frontend/src");
|
||||
const legacyPluginRoot = join(coreSourceRoot, "device-plugins/xgrids-k1");
|
||||
|
||||
function sourceFiles(root) {
|
||||
return readdirSync(root).flatMap((entry) => {
|
||||
const path = join(root, entry);
|
||||
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||
return /\.(?:css|ts|tsx)$/.test(entry) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
let server;
|
||||
let createDevicePluginRegistry;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ createDevicePluginRegistry } = await server.ssrLoadModule(
|
||||
"/src/core/device-plugins/registry.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function syntheticPlugin({
|
||||
pluginId,
|
||||
modelId,
|
||||
componentKey,
|
||||
connectionView,
|
||||
spatialControlsView,
|
||||
}) {
|
||||
return {
|
||||
manifest: {
|
||||
apiVersion: "missioncore.nodedc/v1alpha2",
|
||||
kind: "DevicePlugin",
|
||||
metadata: { id: pluginId, version: "1.0.0", displayName: pluginId },
|
||||
spec: {
|
||||
hostApiRange: "v1alpha2",
|
||||
runtime: { backendEntrypoint: `${pluginId}:build`, isolation: "transitional-in-process" },
|
||||
permissions: ["device.read"],
|
||||
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
|
||||
compatibilityProfiles: [{
|
||||
profileId: `${modelId}.profile.v1`,
|
||||
path: `profiles/${modelId}.json`,
|
||||
modelId,
|
||||
}],
|
||||
models: [{
|
||||
id: modelId,
|
||||
vendor: pluginId,
|
||||
displayName: modelId,
|
||||
category: "Sensor",
|
||||
description: "Synthetic frontend contribution",
|
||||
verified: true,
|
||||
capabilities: [{ id: "device.read", label: "Read" }],
|
||||
ui: { slot: "device.connection", componentKey },
|
||||
}],
|
||||
},
|
||||
},
|
||||
RuntimeProvider: ({ children }) => children,
|
||||
connectionViews: { [componentKey]: connectionView },
|
||||
...(spatialControlsView ? { SpatialControlsView: spatialControlsView } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
test("each device plugin contributes its own connection pipeline component", () => {
|
||||
const alphaConnection = () => null;
|
||||
const betaConnection = () => null;
|
||||
const registry = createDevicePluginRegistry([
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.alpha",
|
||||
modelId: "synthetic.alpha.sensor",
|
||||
componentKey: "alpha.connection",
|
||||
connectionView: alphaConnection,
|
||||
}),
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.beta",
|
||||
modelId: "synthetic.beta.sensor",
|
||||
componentKey: "beta.connection",
|
||||
connectionView: betaConnection,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(registry.resolveModel("synthetic.alpha.sensor").ConnectionView, alphaConnection);
|
||||
assert.equal(registry.resolveModel("synthetic.beta.sensor").ConnectionView, betaConnection);
|
||||
assert.notEqual(
|
||||
registry.resolveModel("synthetic.alpha.sensor").ConnectionView,
|
||||
registry.resolveModel("synthetic.beta.sensor").ConnectionView,
|
||||
);
|
||||
});
|
||||
|
||||
test("registry exposes an optional model-scoped spatial controls contribution", () => {
|
||||
const connectionView = () => null;
|
||||
const spatialControlsView = () => null;
|
||||
const registry = createDevicePluginRegistry([
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.spatial",
|
||||
modelId: "synthetic.spatial.sensor",
|
||||
componentKey: "spatial.connection",
|
||||
connectionView,
|
||||
spatialControlsView,
|
||||
}),
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.connection-only",
|
||||
modelId: "synthetic.connection-only.sensor",
|
||||
componentKey: "connection-only.connection",
|
||||
connectionView,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
registry.resolveModel("synthetic.spatial.sensor").SpatialControlsView,
|
||||
spatialControlsView,
|
||||
);
|
||||
assert.equal(
|
||||
registry.resolveModel("synthetic.connection-only.sensor").SpatialControlsView,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
|
||||
if (existsSync(legacyPluginRoot)) {
|
||||
assert.deepEqual(readdirSync(legacyPluginRoot), []);
|
||||
}
|
||||
for (const relativePath of [
|
||||
"plugin.ts",
|
||||
"runtimeContext.tsx",
|
||||
"styles.css",
|
||||
"components/K1ProvisioningPipeline.tsx",
|
||||
"components/K1AcquisitionPipeline.tsx",
|
||||
"components/K1SpatialControls.tsx",
|
||||
"components/K1Diagnostics.tsx",
|
||||
"projectName.ts",
|
||||
]) {
|
||||
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
|
||||
}
|
||||
|
||||
const spatialControls = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(spatialControls, /shouldRenderSpatialControls\(state\)/);
|
||||
assert.match(spatialControls, /cleanup_pending/);
|
||||
assert.match(spatialControls, /spatialActionFailure/);
|
||||
assert.match(spatialControls, /role="alert"/);
|
||||
assert.match(spatialControls, /Повторить остановку/);
|
||||
});
|
||||
|
||||
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
|
||||
const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts");
|
||||
const composition = readFileSync(compositionPath, "utf8");
|
||||
assert.match(composition, /from "@xgrids-k1\/frontend\/plugin"/);
|
||||
|
||||
for (const path of sourceFiles(coreSourceRoot)) {
|
||||
if (path === compositionPath) continue;
|
||||
const source = readFileSync(path, "utf8");
|
||||
assert.doesNotMatch(source, /xgrids|lixel|\bk1\b/i, path);
|
||||
}
|
||||
|
||||
for (const path of sourceFiles(pluginFrontendRoot)) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
assert.doesNotMatch(source, /apps\/control-station|\.\.\/\.\.\/core|\.\.\/\.\.\/components/, path);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,999 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeObservationSessionCatalog;
|
||||
let decodeObservationSessionReplay;
|
||||
let decodeObservationSessionPreparation;
|
||||
let fetchObservationSessionCatalog;
|
||||
let deleteObservationSession;
|
||||
let replayObservationSession;
|
||||
let fetchObservationSessionPreparation;
|
||||
let cancelObservationSessionPreparation;
|
||||
let ObservationSessionApiError;
|
||||
let ObservationSessionContractError;
|
||||
let decodeObservationRecordedMediaManifest;
|
||||
let createObservationReplayCoordinator;
|
||||
let resolveObservationSessionReplay;
|
||||
let waitForObservationReplayPreparation;
|
||||
let storeObservationReplayPreparation;
|
||||
let loadObservationReplayPreparation;
|
||||
let observationSessionVisualState;
|
||||
let observationSessionVisualLabel;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeObservationSessionCatalog,
|
||||
decodeObservationSessionReplay,
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
deleteObservationSession,
|
||||
replayObservationSession,
|
||||
fetchObservationSessionPreparation,
|
||||
cancelObservationSessionPreparation,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
decodeObservationRecordedMediaManifest,
|
||||
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
|
||||
({
|
||||
createObservationReplayCoordinator,
|
||||
resolveObservationSessionReplay,
|
||||
waitForObservationReplayPreparation,
|
||||
storeObservationReplayPreparation,
|
||||
loadObservationReplayPreparation,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationSessions.ts",
|
||||
));
|
||||
({ observationSessionVisualState, observationSessionVisualLabel } = await server.ssrLoadModule(
|
||||
"/src/components/ObservationSessionSelect.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function session(overrides = {}) {
|
||||
return {
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
started_at_utc: "2026-07-16T20:56:32.635Z",
|
||||
completed_at_utc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
duration_seconds: 1_450.744,
|
||||
replayable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replay(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
const sourceUrl = overrides.source_url ??
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording.rrd`;
|
||||
const sha256 = overrides.sha256 ?? "a".repeat(64);
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-replay/v2",
|
||||
launch: {
|
||||
kind: "rerun-recording",
|
||||
session_id: sessionId,
|
||||
source_url: sourceUrl,
|
||||
viewer_source_url: overrides.viewer_source_url ?? `${sourceUrl}?generation=${sha256}`,
|
||||
media_type: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 1_450.744,
|
||||
seekable: true,
|
||||
byte_length: 123_456,
|
||||
sha256,
|
||||
playback: { speed: 1, loop: false },
|
||||
media_sources: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function preparation(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-preparation/v1",
|
||||
preparation: {
|
||||
preparation_id: "prepare-20260717T131400Z",
|
||||
session_id: sessionId,
|
||||
state: "queued",
|
||||
progress: null,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
status_url: `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording-preparation`,
|
||||
cancellable: true,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const preparationEtag = '"prepare-20260717T131400Z"';
|
||||
|
||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
||||
const catalog = decodeObservationSessionCatalog({ items: [session()] });
|
||||
|
||||
assert.deepEqual(catalog.items, [{
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
startedAtUtc: "2026-07-16T20:56:32.635Z",
|
||||
completedAtUtc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
durationSeconds: 1_450.744,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
lab: null,
|
||||
}]);
|
||||
assert.equal("raw_capture" in catalog.items[0], false);
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const styles = await readFile(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const spatialControls = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const acquisitionPipeline = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(appSource, /setRecordedReplayLabel\(session\.label\)/);
|
||||
assert.match(appSource, /`\$\{activeDefinition\.title\}: \$\{recordedReplayLabel\}`/);
|
||||
const deleteStyle = styles.slice(
|
||||
styles.indexOf(".observation-session-option__delete"),
|
||||
styles.indexOf(".observation-session-option__delete:disabled"),
|
||||
);
|
||||
assert.match(deleteStyle, /background:\s*transparent/);
|
||||
assert.doesNotMatch(deleteStyle, /danger-rgb\) \/ 0\.12/);
|
||||
assert.doesNotMatch(spatialControls, /Индикатор постоянно зелёный/);
|
||||
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
preparation: {
|
||||
preparation_id: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
},
|
||||
})],
|
||||
});
|
||||
assert.deepEqual(catalog.items[0].preparation, {
|
||||
preparationId: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updatedAtUtc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("saved-session rows distinguish durable, active, cold and failed states", () => {
|
||||
const makeDecoded = (state, overrides = {}) => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
status: "interrupted",
|
||||
preparation: state === null ? null : {
|
||||
preparation_id: `prepare-${state}`,
|
||||
state,
|
||||
progress: state === "ready" ? 1 : 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: ["queued", "validating", "exporting", "finalizing"].includes(state),
|
||||
retryable: state === "failed",
|
||||
...(state === "failed" ? { error: "export failed" } : {}),
|
||||
},
|
||||
...overrides,
|
||||
})],
|
||||
}).items[0];
|
||||
|
||||
assert.equal(observationSessionVisualState(makeDecoded("ready")), "ready");
|
||||
for (const state of ["queued", "validating", "exporting", "finalizing"]) {
|
||||
assert.equal(observationSessionVisualState(makeDecoded(state)), "processing");
|
||||
}
|
||||
assert.equal(observationSessionVisualState(makeDecoded("failed")), "error");
|
||||
assert.equal(observationSessionVisualState(makeDecoded("cancelled")), "error");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed", { status: "recording" })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualState(makeDecoded(null)), "cold");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded(null, { status: "recording" })),
|
||||
"processing",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready", { replayable: false })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready"), { failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed"), { pending: true, failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualLabel("ready"), "Готово");
|
||||
assert.equal(observationSessionVisualLabel("processing"), "Обработка");
|
||||
assert.equal(observationSessionVisualLabel("cold"), "Подготовить");
|
||||
assert.equal(observationSessionVisualLabel("error"), "Ошибка");
|
||||
});
|
||||
|
||||
test("saved-session lamps use green or dim gray only, never warning or danger colors", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const start = css.indexOf(".observation-session-option i {");
|
||||
const end = css.indexOf(".observation-session-option__state", start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
const lampRules = css.slice(start, end);
|
||||
assert.match(lampRules, /data-session-visual-state="ready"[\s\S]*--nodedc-success-rgb/);
|
||||
assert.match(lampRules, /data-session-visual-state="processing"[\s\S]*animation:/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*--nodedc-text-muted/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*opacity:\s*0\.48/);
|
||||
assert.doesNotMatch(lampRules, /\b(?:warning|danger|yellow|red)\b/i);
|
||||
});
|
||||
|
||||
test("session catalog sorts newest first and uses id as a deterministic tie-breaker", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [
|
||||
session({ id: "same-b", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "newest", started_at_utc: "2026-07-17T00:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "same-a", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(catalog.items.map(({ id }) => id), ["newest", "same-a", "same-b"]);
|
||||
});
|
||||
|
||||
test("session catalog rejects raw storage fields, unsafe ids and duplicate ids", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ raw_capture: "sessions/private/mqtt.raw.k1mqtt" })],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ id: "../private" })] }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session(), session()] }),
|
||||
/повторяющийся id/,
|
||||
);
|
||||
});
|
||||
|
||||
test("session catalog rejects invalid temporal, status and duration values", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ started_at_utc: "16 July 2026" })],
|
||||
}),
|
||||
/ISO-датой/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ status: "complete" })] }),
|
||||
/неизвестное состояние/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ duration_seconds: -1 })] }),
|
||||
/неотрицательным числом/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ completed_at_utc: "2026-07-16T19:00:00Z" })],
|
||||
}),
|
||||
/раньше времени начала/,
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog API preserves HTTP detail without accepting a malformed success body", async () => {
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "storage unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 503 && error.message === "storage unavailable",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ items: "not-an-array" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("delete API uses one opaque same-origin target and requires an empty 204", async () => {
|
||||
const calls = [];
|
||||
await deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "DELETE");
|
||||
await assert.rejects(
|
||||
deleteObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
await assert.rejects(
|
||||
deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "recording is open" }), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 409 && error.message === "recording is open",
|
||||
);
|
||||
});
|
||||
|
||||
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||
const calls = [];
|
||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "/api/v1/observation-sessions/session-20260716T205632Z/replay");
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(launch.kind, "ready");
|
||||
assert.equal(launch.launch.timeline, "session_time");
|
||||
assert.equal(
|
||||
launch.launch.sourceUrl,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("preparation contract is strict, session-scoped and same-origin", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const decoded = decodeObservationSessionPreparation(preparation(), sessionId);
|
||||
assert.equal(decoded.sessionId, sessionId);
|
||||
assert.equal(decoded.state, "queued");
|
||||
assert.equal(decoded.progress, null);
|
||||
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(
|
||||
preparation({ status_url: "https://invalid.test/status" }),
|
||||
sessionId,
|
||||
),
|
||||
/канонический same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ debug_path: "/tmp/raw" }), sessionId),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ progress: 1.1 }), sessionId),
|
||||
/недопустимое число/,
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
/preparation ETag/,
|
||||
);
|
||||
});
|
||||
|
||||
test("202 preparation polls through explicit phases and reveals launch only when ready", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const calls = [];
|
||||
const phases = [];
|
||||
let poll = 0;
|
||||
let clock = 0;
|
||||
let previousViewerMounted = true;
|
||||
const launch = await resolveObservationSessionReplay(sessionId, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 5_000,
|
||||
maximumWaitMs: 10_000,
|
||||
initialPollIntervalMs: 50,
|
||||
maximumPollIntervalMs: 50,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
clock += milliseconds;
|
||||
},
|
||||
onUpdate: (value) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
phases.push([value.state, value.progress]);
|
||||
},
|
||||
fetcher: async (input, init) => {
|
||||
calls.push([String(input), init.method, new Headers(init.headers).get("If-Match")]);
|
||||
if (init.method === "POST") {
|
||||
return new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
poll += 1;
|
||||
if (poll === 1) {
|
||||
return new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:01Z",
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
// This is the point where App is allowed to unmount the old viewer.
|
||||
previousViewerMounted = false;
|
||||
assert.equal(launch.sessionId, sessionId);
|
||||
assert.deepEqual(phases, [["queued", null], ["exporting", 0.5]]);
|
||||
assert.deepEqual(calls.map((entry) => entry[1]), ["POST", "GET", "GET"]);
|
||||
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
|
||||
});
|
||||
|
||||
test("initial replay restore is not mistaken for a stalled preparation poll", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
let requestSignal;
|
||||
const launch = await resolveObservationSessionReplay(sessionId, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 100,
|
||||
fetcher: async (_input, init) => {
|
||||
requestSignal = init.signal;
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 150);
|
||||
init.signal.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(requestSignal.aborted, false);
|
||||
assert.equal(launch.sessionId, sessionId);
|
||||
});
|
||||
|
||||
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "finalizing", progress: 0.95 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let receivedIfMatch;
|
||||
await assert.rejects(
|
||||
fetchObservationSessionPreparation(decoded, {
|
||||
fetcher: async (_input, init) => {
|
||||
receivedIfMatch = new Headers(init.headers).get("If-Match");
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: '"replacement-job"' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
/актуальный preparation ETag/,
|
||||
);
|
||||
assert.equal(receivedIfMatch, preparationEtag);
|
||||
});
|
||||
|
||||
test("preparation cancellation is conditional on the same preparation ETag", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.5 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let request;
|
||||
const result = await cancelObservationSessionPreparation(decoded, {
|
||||
fetcher: async (input, init) => {
|
||||
request = {
|
||||
input: String(input),
|
||||
method: init.method,
|
||||
ifMatch: new Headers(init.headers).get("If-Match"),
|
||||
};
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual(request, {
|
||||
input: decoded.statusUrl,
|
||||
method: "DELETE",
|
||||
ifMatch: preparationEtag,
|
||||
});
|
||||
});
|
||||
|
||||
test("preparation polling fails explicitly when the server heartbeat stalls", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.1 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
await assert.rejects(
|
||||
waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.1,
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
}),
|
||||
/перестала обновляться/,
|
||||
);
|
||||
});
|
||||
|
||||
test("queued preparation can wait behind the bounded backend worker without false heartbeat failure", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "queued", progress: 0 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
let polls = 0;
|
||||
const launch = await waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => {
|
||||
polls += 1;
|
||||
if (polls <= 2) {
|
||||
return new Response(JSON.stringify(preparation({ state: "queued", progress: 0 })), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(clock, 450);
|
||||
assert.equal(launch.sessionId, "session-20260716T205632Z");
|
||||
});
|
||||
|
||||
test("terminal preparation failure is explicit and retryable", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const response = await replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "failed",
|
||||
progress: 0.4,
|
||||
cancellable: false,
|
||||
retryable: true,
|
||||
error: "Повреждён индекс исходной записи.",
|
||||
})), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.kind, "preparing");
|
||||
assert.equal(response.preparation.retryable, true);
|
||||
assert.equal(response.preparation.error, "Повреждён индекс исходной записи.");
|
||||
});
|
||||
|
||||
test("pending preparation survives reload only through its strict canonical contract", () => {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.7 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
storeObservationReplayPreparation(decoded, storage);
|
||||
assert.deepEqual(loadObservationReplayPreparation(storage), decoded);
|
||||
|
||||
const key = storage.key(0);
|
||||
storage.setItem(key, JSON.stringify({ ...preparation(), raw_path: "/private/raw" }));
|
||||
assert.equal(loadObservationReplayPreparation(storage), null);
|
||||
assert.equal(storage.length, 0);
|
||||
});
|
||||
|
||||
test("replay API forwards cancellation and preserves AbortError semantics", async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal;
|
||||
const pending = replayObservationSession("session-20260716T205632Z", {
|
||||
signal: controller.signal,
|
||||
fetcher: async (_input, init) => {
|
||||
receivedSignal = init.signal;
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () => {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
});
|
||||
|
||||
test("replay coordinator makes rapid saved-session selection latest-request-wins", () => {
|
||||
const coordinator = createObservationReplayCoordinator();
|
||||
const first = coordinator.begin();
|
||||
assert.equal(first.isCurrent(), true);
|
||||
|
||||
const second = coordinator.begin();
|
||||
assert.equal(first.signal.aborted, true);
|
||||
assert.equal(first.isCurrent(), false);
|
||||
assert.equal(first.finish(), false);
|
||||
assert.equal(second.isCurrent(), true);
|
||||
assert.equal(second.finish(), true);
|
||||
assert.equal(second.isCurrent(), false);
|
||||
|
||||
const third = coordinator.begin();
|
||||
coordinator.cancel();
|
||||
assert.equal(third.signal.aborted, true);
|
||||
assert.equal(third.isCurrent(), false);
|
||||
assert.equal(third.finish(), true);
|
||||
assert.equal(third.finish(), false);
|
||||
});
|
||||
|
||||
test("an acquisition guard cancellation keeps settlement ownership", () => {
|
||||
const coordinator = createObservationReplayCoordinator();
|
||||
const archiveSwitch = coordinator.begin();
|
||||
let replacementBlanked = false;
|
||||
let replacementSettled = false;
|
||||
|
||||
// Models the point after onReplayBegin has released the previous viewer.
|
||||
replacementBlanked = true;
|
||||
coordinator.cancel();
|
||||
|
||||
assert.equal(archiveSwitch.signal.aborted, true);
|
||||
assert.equal(archiveSwitch.isCurrent(), false);
|
||||
if (archiveSwitch.finish()) replacementSettled = true;
|
||||
assert.equal(replacementBlanked, true);
|
||||
assert.equal(replacementSettled, true);
|
||||
});
|
||||
|
||||
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
|
||||
const hookSource = await readFile(
|
||||
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const selectorSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(hookSource, /cancelObservationSessionPreparation|method:\s*["']DELETE/);
|
||||
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
|
||||
});
|
||||
|
||||
test("recording preparation never presents phase heartbeats as fake percentages", async () => {
|
||||
const selectorSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(selectorSource, /exporting:\s*["']Готовим операторскую сцену["']/);
|
||||
assert.doesNotMatch(selectorSource, /Math\.round\(progress\s*\*\s*100\)/);
|
||||
});
|
||||
|
||||
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
|
||||
const workspaceSource = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(workspaceSource, /hidden=\{pointCloudFocused \|\| unifiedPerception\}/);
|
||||
assert.match(
|
||||
workspaceSource,
|
||||
/presentedMediaSourceCount = unifiedPerception \? 0 : visibleMediaSources\.length/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "/api/v1/observation-sessions/other/recording.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ seekable: false })),
|
||||
/seekable/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: "/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: `https://foreign.test/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ raw_path: "/private/raw.k1mqtt" })),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ byte_length: Number.MAX_SAFE_INTEGER + 1 })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ playback: { speed: 101, loop: false } })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const source = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [source],
|
||||
}));
|
||||
assert.equal(
|
||||
decoded.viewerSourceUrl,
|
||||
`/api/v1/observation-sessions/${sessionId}/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
);
|
||||
assert.equal(decoded.mediaSources.length, 1);
|
||||
assert.equal(decoded.mediaSources[0].id, source.id);
|
||||
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
|
||||
assert.equal(
|
||||
decoded.mediaSources[0].manifestGenerationSha256,
|
||||
source.manifest_generation_sha256,
|
||||
);
|
||||
|
||||
const manifest = decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
byte_length: 3_266,
|
||||
stream_url: source.manifest_url.replace(
|
||||
"/manifest",
|
||||
`/epochs/1/recording.mp4?generation=${"c".repeat(64)}`,
|
||||
),
|
||||
}],
|
||||
}, decoded.mediaSources[0]);
|
||||
assert.equal(manifest.epochs[0].byteLength, 3_266);
|
||||
assert.match(manifest.epochs[0].streamUrl, /recording\.mp4\?generation=/);
|
||||
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
...{
|
||||
schema_version: "missioncore.observation-recorded-media/v1",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
},
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_267,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим|launch descriptor/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay decodes a synchronized full-epoch perception video", () => {
|
||||
const sessionId = "20260720T065719Z_viewer_live";
|
||||
const resultId = `result-${"d".repeat(64)}`;
|
||||
const generation = "e".repeat(64);
|
||||
const source = {
|
||||
id: "recorded.perception.right",
|
||||
label: "Сегментация · камера right",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/perception-media/${resultId}/manifest`,
|
||||
manifest_generation_sha256: generation,
|
||||
byte_length: 12_345_678,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 35.421857292,
|
||||
timeline_end_seconds: 484.144857292,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 500,
|
||||
media_sources: [source],
|
||||
}));
|
||||
assert.equal(decoded.mediaSources[0].id, "recorded.perception.right");
|
||||
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
|
||||
|
||||
const manifest = decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: source.id,
|
||||
generation_sha256: generation,
|
||||
byte_length: source.byte_length,
|
||||
timeline_start_seconds: source.timeline_start_seconds,
|
||||
timeline_end_seconds: source.timeline_end_seconds,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: source.timeline_start_seconds,
|
||||
timeline_end_seconds: source.timeline_end_seconds,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
byte_length: source.byte_length,
|
||||
stream_url: `/api/v1/observation-sessions/${sessionId}/perception-media/${resultId}/recording.mp4?generation=${generation}`,
|
||||
}],
|
||||
}, decoded.mediaSources[0]);
|
||||
assert.equal(manifest.epochs.length, 1);
|
||||
assert.equal(manifest.epochs[0].timelineStartSeconds, source.timeline_start_seconds);
|
||||
assert.equal(manifest.epochs[0].timelineEndSeconds, source.timeline_end_seconds);
|
||||
});
|
||||
|
||||
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const base = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_url: "https://invalid.test/private" }],
|
||||
})),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, path: "/private/archive" }],
|
||||
})),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: undefined }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: "not-a-digest" }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [base],
|
||||
}));
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: base.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
byte_length: 384,
|
||||
stream_url: "file:///private/recording.mp4",
|
||||
}],
|
||||
}, decoded.mediaSources[0]),
|
||||
/небезопасный API URL/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,884 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let xgridsK1Manifest;
|
||||
let xgridsK1ObservationSources;
|
||||
let openObservationSource;
|
||||
let shouldRestartObservationSource;
|
||||
let consumeCameraLeaseRetry;
|
||||
let resetCameraLeaseRetryBudget;
|
||||
let initialObservationWindowRect;
|
||||
let ObservationTimeline;
|
||||
let shouldCaptureWorkspacePointer;
|
||||
let normalizeTimelineRange;
|
||||
let timelineOffsetSeconds;
|
||||
let formatTimelineDuration;
|
||||
let normalizeAccumulationSeconds;
|
||||
let formatAccumulationDuration;
|
||||
let resolveRerunSourceUrl;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let resolveRecordedPerceptionUrl;
|
||||
let fetchRecordedPerceptionRrd;
|
||||
let resolveRecordedPointColorsUrl;
|
||||
let fetchRecordedPointColorsRrd;
|
||||
let recordedPointColorKey;
|
||||
let isRecordedPlaybackFullyBuffered;
|
||||
let recordedObservationSources;
|
||||
let selectRecordedMediaEpoch;
|
||||
let recordedMediaLocalTime;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ xgridsK1Manifest } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/manifest.ts",
|
||||
));
|
||||
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/observationSources.ts",
|
||||
));
|
||||
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
|
||||
"/src/core/observation/layoutPolicy.ts",
|
||||
));
|
||||
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
|
||||
"/src/components/MseFmp4WebSocketPlayer.tsx",
|
||||
));
|
||||
({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
|
||||
"/src/components/FloatingObservationWindow.tsx",
|
||||
));
|
||||
({
|
||||
ObservationTimeline,
|
||||
normalizeTimelineRange,
|
||||
timelineOffsetSeconds,
|
||||
formatTimelineDuration,
|
||||
normalizeAccumulationSeconds,
|
||||
formatAccumulationDuration,
|
||||
} =
|
||||
await server.ssrLoadModule("/src/components/ObservationTimeline.tsx"));
|
||||
({
|
||||
resolveRerunSourceUrl,
|
||||
resolveRecordedBlueprintUrl,
|
||||
fetchRecordedBlueprintRrd,
|
||||
resolveRecordedPerceptionUrl,
|
||||
fetchRecordedPerceptionRrd,
|
||||
resolveRecordedPointColorsUrl,
|
||||
fetchRecordedPointColorsRrd,
|
||||
recordedPointColorKey,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedObservationSources.ts",
|
||||
));
|
||||
({ selectRecordedMediaEpoch, recordedMediaLocalTime } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
const bounds = { width: 1280, height: 720 };
|
||||
const left = initialObservationWindowRect(0, 2, bounds);
|
||||
const right = initialObservationWindowRect(1, 2, bounds);
|
||||
|
||||
assert.equal(left.y, right.y);
|
||||
assert.ok(left.x + left.width < right.x);
|
||||
assert.equal(right.x + right.width, bounds.width - 18);
|
||||
assert.ok(right.y + right.height <= bounds.height - 64);
|
||||
});
|
||||
|
||||
test("observation camera windows stack without overlap when the viewport is narrow", () => {
|
||||
const bounds = { width: 420, height: 700 };
|
||||
const lower = initialObservationWindowRect(0, 2, bounds);
|
||||
const upper = initialObservationWindowRect(1, 2, bounds);
|
||||
|
||||
assert.equal(lower.x, upper.x);
|
||||
assert.ok(upper.y + upper.height < lower.y);
|
||||
for (const rect of [lower, upper]) {
|
||||
assert.ok(rect.x >= 0 && rect.y >= 0);
|
||||
assert.ok(rect.x + rect.width <= bounds.width);
|
||||
assert.ok(rect.y + rect.height <= bounds.height - 64);
|
||||
}
|
||||
});
|
||||
|
||||
test("observation camera tiling remains in bounds on a constrained viewport", () => {
|
||||
const bounds = { width: 260, height: 280 };
|
||||
const rects = Array.from({ length: 2 }, (_, index) => (
|
||||
initialObservationWindowRect(index, 2, bounds)
|
||||
));
|
||||
|
||||
for (const rect of rects) {
|
||||
assert.ok(rect.width > 0 && rect.height > 0);
|
||||
assert.ok(rect.x >= 0 && rect.y >= 0);
|
||||
assert.ok(rect.x + rect.width <= bounds.width);
|
||||
assert.ok(rect.y + rect.height <= bounds.height - 64);
|
||||
}
|
||||
assert.ok(rects[1].y + rects[1].height <= rects[0].y);
|
||||
});
|
||||
|
||||
test("floating observation interaction captures resize and movable header pointers", () => {
|
||||
const target = (matches) => ({ closest: (selector) => matches.includes(selector) });
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__resize"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__head"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(
|
||||
0,
|
||||
target([".nodedc-workspace-window__head", "button, input, select, textarea, a"]),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(2, target([".nodedc-workspace-window__resize"])),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded observation timeline clamps relative seek time without epoch precision in the UI", () => {
|
||||
const range = normalizeTimelineRange({
|
||||
min: 0,
|
||||
max: 12_500_000_000,
|
||||
});
|
||||
assert.ok(range);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min + 2_250_000_000), 2.25);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min - 1), 0);
|
||||
assert.equal(timelineOffsetSeconds(range, range.max + 1), 12.5);
|
||||
assert.equal(formatTimelineDuration(62.125), "01:02.125");
|
||||
});
|
||||
|
||||
test("recorded observation timeline rejects empty and non-finite ranges", () => {
|
||||
assert.equal(normalizeTimelineRange(null), null);
|
||||
assert.equal(normalizeTimelineRange({ min: 10, max: 10 }), null);
|
||||
assert.equal(normalizeTimelineRange({ min: Number.NaN, max: 10 }), null);
|
||||
});
|
||||
|
||||
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 120);
|
||||
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
||||
});
|
||||
|
||||
test("spatial timeline renders synchronized accumulation and playback controls", () => {
|
||||
const markup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
accumulationSeconds: 12,
|
||||
onAccumulationChange: () => undefined,
|
||||
onAccumulationCommit: () => undefined,
|
||||
onSeek: () => undefined,
|
||||
}));
|
||||
|
||||
assert.match(markup, /data-accumulation="true"/);
|
||||
assert.match(markup, /Накопление/);
|
||||
assert.match(markup, /aria-label="Окно накопления облака точек"/);
|
||||
assert.match(markup, /aria-valuetext="12 с"/);
|
||||
assert.match(markup, /aria-label="Позиция воспроизведения"/);
|
||||
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("Rerun expands only root-relative session recordings onto the current origin", () => {
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl(
|
||||
" /api/v1/observation-sessions/session-1/recording.rrd ",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("rerun+http://127.0.0.1:9877/proxy", "http://127.0.0.1:5174"),
|
||||
"rerun+http://127.0.0.1:9877/proxy",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("//different-authority.invalid/session.rrd", "http://127.0.0.1:5174"),
|
||||
"//different-authority.invalid/session.rrd",
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint endpoint is derived only from canonical same-origin RRD sources", () => {
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/../recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
||||
assert.equal(isRecordedPlaybackFullyBuffered(null, 20), false);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_500_000_000 }, 20),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_999_500_000 }, 20),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 1 }, undefined),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 20_000_000_000 }, Number.NaN),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||
const calls = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
colorMode: "height",
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
blueprintSessionId: "a".repeat(32),
|
||||
activeView: "perception3d",
|
||||
viewResetGeneration: 1,
|
||||
followTrajectory: true,
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
detections2d: true,
|
||||
segmentation: false,
|
||||
cuboids3d: true,
|
||||
},
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual([...result], [...payload]);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(calls[0].init.credentials, "same-origin");
|
||||
assert.deepEqual(calls[0].body, {
|
||||
application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording-001",
|
||||
blueprint_session_id: "a".repeat(32),
|
||||
accumulation_seconds: 24,
|
||||
show_grid: false,
|
||||
show_points: true,
|
||||
show_trajectory: false,
|
||||
point_size: 4.5,
|
||||
color_mode: "height",
|
||||
palette: "custom",
|
||||
custom_color: "#35d7c1",
|
||||
active_view: "perception3d",
|
||||
view_reset_generation: 1,
|
||||
follow_trajectory: true,
|
||||
unified_perception: true,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
show_cuboids_3d: true,
|
||||
});
|
||||
|
||||
await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 0,
|
||||
showGrid: true,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
pointSize: 2.5,
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#ffffff",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
blueprintSessionId: "b".repeat(32),
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
detections2d: false,
|
||||
segmentation: false,
|
||||
cuboids3d: true,
|
||||
},
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(calls[1].body.unified_perception, false);
|
||||
assert.equal(calls[1].body.show_cuboids_3d, true);
|
||||
|
||||
await assert.rejects(
|
||||
fetchRecordedBlueprintRrd(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
colorMode: "height",
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
blueprintSessionId: "a".repeat(32),
|
||||
fetcher: async () => new Response(payload),
|
||||
},
|
||||
),
|
||||
/Unsafe recorded blueprint request/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded point colors use one strict same-origin component overlay", async () => {
|
||||
const endpoint = resolveRecordedPointColorsUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
);
|
||||
assert.equal(
|
||||
endpoint,
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/point-colors.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedPointColorsUrl(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
recordedPointColorKey({
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#112233",
|
||||
}),
|
||||
"intensity|turbo|-",
|
||||
);
|
||||
assert.equal(
|
||||
recordedPointColorKey({
|
||||
colorMode: "class",
|
||||
palette: "turbo",
|
||||
customColor: "#112233",
|
||||
}),
|
||||
"class|turbo|#112233",
|
||||
);
|
||||
|
||||
const calls = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedPointColorsRrd(
|
||||
endpoint,
|
||||
{
|
||||
colorMode: "distance",
|
||||
palette: "viridis",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": String(payload.byteLength),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual([...result], [...payload]);
|
||||
assert.deepEqual(calls[0].body, {
|
||||
application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording-001",
|
||||
color_mode: "distance",
|
||||
palette: "viridis",
|
||||
custom_color: "#35d7c1",
|
||||
});
|
||||
});
|
||||
|
||||
test("recorded perception fetch admits one complete same-origin RRD or no layer", async () => {
|
||||
const endpoint = resolveRecordedPerceptionUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
);
|
||||
assert.equal(
|
||||
endpoint,
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/perception.rrd",
|
||||
);
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const progress = [];
|
||||
const result = await fetchRecordedPerceptionRrd(
|
||||
endpoint,
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
onProgress: (receivedBytes, totalBytes) => {
|
||||
progress.push([receivedBytes, totalBytes]);
|
||||
},
|
||||
fetcher: async (_input, init) => {
|
||||
assert.deepEqual(JSON.parse(String(init.body)), {
|
||||
application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording-001",
|
||||
});
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": String(payload.byteLength),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual([...result], [...payload]);
|
||||
assert.deepEqual(progress[0], [0, payload.byteLength]);
|
||||
assert.deepEqual(progress.at(-1), [payload.byteLength, payload.byteLength]);
|
||||
|
||||
const absent = await fetchRecordedPerceptionRrd(
|
||||
endpoint,
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async () => new Response(null, { status: 204 }),
|
||||
},
|
||||
);
|
||||
assert.equal(absent, null);
|
||||
});
|
||||
|
||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||
const sources = recordedObservationSources({
|
||||
kind: "rerun-recording",
|
||||
sessionId: "session-1",
|
||||
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
viewerSourceUrl: `/api/v1/observation-sessions/session-1/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
mediaType: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
byteLength: 123,
|
||||
sha256: "a".repeat(64),
|
||||
playback: { speed: 1, loop: false },
|
||||
mediaSources: [{
|
||||
id: "recorded.camera.abc123",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifestUrl: "/api/v1/observation-sessions/session-1/media/recorded-video-abc123/manifest",
|
||||
manifestGenerationSha256: "c".repeat(64),
|
||||
byteLength: 1_024,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0.25,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
}],
|
||||
});
|
||||
assert.deepEqual(sources.map(({ modality }) => modality), ["point-cloud", "video"]);
|
||||
assert.equal(sources[1].delivery.kind, "recorded-fmp4-manifest");
|
||||
assert.equal(sources[1].delivery.manifestGenerationSha256, "c".repeat(64));
|
||||
assert.deepEqual(sources[1].binding, {});
|
||||
assert.equal(sources.some(({ provider }) => provider.pluginId.includes("xgrids")), false);
|
||||
assert.equal(JSON.stringify(sources).includes("192.168"), false);
|
||||
});
|
||||
|
||||
test("recorded camera epoch selection and shared-clock offset are deterministic", () => {
|
||||
const epochs = [
|
||||
{ ordinal: 1, timelineStartSeconds: 0.25, timelineEndSeconds: 9 },
|
||||
{ ordinal: 2, timelineStartSeconds: 10, timelineEndSeconds: 20 },
|
||||
];
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0.25).ordinal, 1);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9), epochs[0]);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9.9), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 10).ordinal, 2);
|
||||
assert.equal(recordedMediaLocalTime(10, 12.5), 2.5);
|
||||
assert.equal(recordedMediaLocalTime(10, 8), 0);
|
||||
assert.equal(recordedMediaLocalTime(10, 30, 4), 4);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function model() {
|
||||
const activeModel = xgridsK1Manifest.spec.models[0];
|
||||
assert.ok(activeModel, "XGRIDS plugin must declare at least one model");
|
||||
return activeModel;
|
||||
}
|
||||
|
||||
function cameraRow(sourceId, label) {
|
||||
return {
|
||||
stream_id: `camera.preview.${sourceId.split(".").at(-1)}`,
|
||||
source_id: sourceId,
|
||||
semantic_channel_id: "camera.preview.live",
|
||||
label,
|
||||
sensor_kind: "camera",
|
||||
modality: "encoded-video",
|
||||
availability: "available",
|
||||
endpoint_label: "MSE · fMP4",
|
||||
activation: {
|
||||
group_id: "camera.preview.decoder",
|
||||
max_active: 1,
|
||||
selected: false,
|
||||
controllable: true,
|
||||
},
|
||||
delivery: null,
|
||||
};
|
||||
}
|
||||
|
||||
function declaredState(cameraRows = [
|
||||
cameraRow("sensor.camera.left", "K1 · камера слева"),
|
||||
cameraRow("sensor.camera.right", "K1 · камера справа"),
|
||||
]) {
|
||||
const activeModel = model();
|
||||
return {
|
||||
phase: "connected",
|
||||
source_mode: "idle",
|
||||
k1_ip: "192.168.7.10",
|
||||
foxglove_ws_url: "ws://192.168.7.10:8765",
|
||||
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
|
||||
compatibility: {
|
||||
profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
camera_preview: "rtsp://192.168.7.10:8554/vendor-preview",
|
||||
},
|
||||
device_ref: {
|
||||
device_id: "device-k1-001",
|
||||
model_id: activeModel.id,
|
||||
identity_stability: "stable",
|
||||
identity_basis: "hardware-identifier",
|
||||
},
|
||||
device_session: {
|
||||
device_session_id: "device-session-001",
|
||||
device_id: "device-k1-001",
|
||||
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
connectivity: "connected",
|
||||
},
|
||||
sensor_catalog: {
|
||||
schema_version: "missioncore.sensor-catalog/v1alpha2",
|
||||
revision: "test-profile",
|
||||
streams: [
|
||||
{ stream_id: "spatial.point-cloud.live", modality: "point-cloud", availability: "observed" },
|
||||
...cameraRows,
|
||||
],
|
||||
},
|
||||
camera_preview: {
|
||||
phase: "idle",
|
||||
revision: 1,
|
||||
generation: 0,
|
||||
active_source_id: null,
|
||||
delivery: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pointCloudStreamingState() {
|
||||
return {
|
||||
...declaredState(),
|
||||
phase: "streaming",
|
||||
source_mode: "live",
|
||||
rerun_grpc_url: "rerun+http://127.0.0.1:9877/proxy",
|
||||
acquisition: {
|
||||
acquisition_id: "acquisition-001",
|
||||
device_id: "device-k1-001",
|
||||
device_session_id: "device-session-001",
|
||||
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
control_mode: "operator-manual",
|
||||
requested_streams: ["spatial.point-cloud.live"],
|
||||
target_host: "127.0.0.1",
|
||||
duration_seconds: 0,
|
||||
evidence_policy: "required",
|
||||
state: "acquiring",
|
||||
state_revision: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cameraStreamingState(sourceId) {
|
||||
const state = pointCloudStreamingState();
|
||||
const delivery = {
|
||||
id: `preview-generation-7:${sourceId}`,
|
||||
kind: "mse-fmp4-websocket",
|
||||
url: "/api/v1/device-plugins/xgrids-k1/camera-preview/ws?generation=7",
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
};
|
||||
state.sensor_catalog = {
|
||||
...state.sensor_catalog,
|
||||
streams: state.sensor_catalog.streams.map((stream) =>
|
||||
stream.source_id === sourceId
|
||||
? {
|
||||
...stream,
|
||||
availability: "streaming",
|
||||
activation: { ...stream.activation, selected: true },
|
||||
delivery,
|
||||
}
|
||||
: stream),
|
||||
};
|
||||
state.camera_preview = {
|
||||
phase: "streaming",
|
||||
revision: 4,
|
||||
generation: 7,
|
||||
active_source_id: sourceId,
|
||||
delivery,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
function collectUrlLikeStrings(value, found = []) {
|
||||
if (typeof value === "string") {
|
||||
if (value.includes("://")) found.push(value);
|
||||
return found;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectUrlLikeStrings(item, found);
|
||||
return found;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const item of Object.values(value)) collectUrlLikeStrings(item, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
test("K1 maps zero, one or N catalog cameras without model-specific source ids", () => {
|
||||
const zero = xgridsK1ObservationSources(declaredState([]), model());
|
||||
const one = xgridsK1ObservationSources(
|
||||
declaredState([cameraRow("rig.front", "Передняя камера")]),
|
||||
model(),
|
||||
);
|
||||
const many = xgridsK1ObservationSources(declaredState(), model());
|
||||
|
||||
assert.deepEqual(zero.map(({ modality }) => modality), ["point-cloud"]);
|
||||
assert.deepEqual(one.map(({ sourceId }) => sourceId), ["sensor.lidar.primary", "rig.front"]);
|
||||
assert.deepEqual(many.map(({ sourceId }) => sourceId), [
|
||||
"sensor.lidar.primary",
|
||||
"sensor.camera.left",
|
||||
"sensor.camera.right",
|
||||
]);
|
||||
assert.equal(new Set(many.map(({ id }) => id)).size, many.length);
|
||||
assert.ok(many.every(({ provider }) => provider.pluginId && provider.modelId));
|
||||
assert.ok(many.every(({ binding }) => binding.deviceId === "device-k1-001"));
|
||||
});
|
||||
|
||||
test("descriptor ids remain stable while point cloud and selected camera start streaming", () => {
|
||||
const declared = xgridsK1ObservationSources(declaredState(), model());
|
||||
const streaming = xgridsK1ObservationSources(
|
||||
cameraStreamingState("sensor.camera.left"),
|
||||
model(),
|
||||
);
|
||||
|
||||
assert.deepEqual(streaming.map(({ id }) => id), declared.map(({ id }) => id));
|
||||
assert.equal(declared[0].availability, "available");
|
||||
assert.equal(streaming[0].availability, "streaming");
|
||||
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
|
||||
});
|
||||
|
||||
test("only the authoritative selected camera receives browser delivery", () => {
|
||||
const sources = xgridsK1ObservationSources(
|
||||
cameraStreamingState("sensor.camera.left"),
|
||||
model(),
|
||||
);
|
||||
const cameras = sources.filter(({ modality }) => modality === "video");
|
||||
const [left, right] = cameras;
|
||||
|
||||
assert.equal(cameras.length, 2);
|
||||
assert.equal(left.activation.selected, true);
|
||||
assert.equal(left.activation.maxActive, 1);
|
||||
assert.equal(left.availability, "streaming");
|
||||
assert.equal(left.delivery.kind, "mse-fmp4-websocket");
|
||||
assert.equal(left.transport, "websocket");
|
||||
assert.equal(right.activation.selected, false);
|
||||
assert.equal(right.availability, "available");
|
||||
assert.equal(right.delivery, null);
|
||||
assert.equal(left.activation.groupId, right.activation.groupId);
|
||||
assert.match(left.activation.groupId, /device-session-001/);
|
||||
});
|
||||
|
||||
test("switching left to right keeps ids stable and never exposes both deliveries", () => {
|
||||
const left = xgridsK1ObservationSources(
|
||||
cameraStreamingState("sensor.camera.left"),
|
||||
model(),
|
||||
).filter(({ modality }) => modality === "video");
|
||||
const right = xgridsK1ObservationSources(
|
||||
cameraStreamingState("sensor.camera.right"),
|
||||
model(),
|
||||
).filter(({ modality }) => modality === "video");
|
||||
|
||||
assert.deepEqual(right.map(({ id }) => id), left.map(({ id }) => id));
|
||||
assert.deepEqual(left.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
|
||||
"sensor.camera.left",
|
||||
]);
|
||||
assert.deepEqual(right.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
|
||||
"sensor.camera.right",
|
||||
]);
|
||||
});
|
||||
|
||||
test("camera descriptors never leak vendor RTSP endpoints or device IP addresses", () => {
|
||||
const sources = xgridsK1ObservationSources(
|
||||
cameraStreamingState("sensor.camera.left"),
|
||||
model(),
|
||||
);
|
||||
const cameras = sources.filter(({ modality }) => modality === "video");
|
||||
|
||||
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
|
||||
assert.deepEqual(collectUrlLikeStrings(sources), [
|
||||
"rerun+http://127.0.0.1:9877/proxy",
|
||||
]);
|
||||
const serialized = JSON.stringify(cameras);
|
||||
assert.doesNotMatch(serialized, /rtsp:\/\//i);
|
||||
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
|
||||
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
|
||||
});
|
||||
|
||||
test("unattested, duplicate and unsafe camera entries fail closed", () => {
|
||||
const duplicate = cameraRow("sensor.camera.left", "Duplicate");
|
||||
const state = cameraStreamingState("sensor.camera.left");
|
||||
state.compatibility.profile_id = null;
|
||||
state.device_session.compatibility_profile_id = null;
|
||||
state.sensor_catalog.streams.push(duplicate);
|
||||
state.camera_preview.delivery = {
|
||||
...state.camera_preview.delivery,
|
||||
url: "ws://192.168.7.10:9000/leak",
|
||||
};
|
||||
|
||||
const cameras = xgridsK1ObservationSources(state, model()).filter(
|
||||
({ modality }) => modality === "video",
|
||||
);
|
||||
assert.deepEqual(cameras.map(({ sourceId }) => sourceId), ["sensor.camera.right"]);
|
||||
assert.equal(cameras[0].availability, "unverified");
|
||||
assert.equal(cameras[0].activation.controllable, false);
|
||||
assert.equal(cameras[0].delivery, null);
|
||||
});
|
||||
|
||||
test("camera delivery rejects literal and encoded endpoint or credential leaks", () => {
|
||||
const unsafeUrls = [
|
||||
"/api/preview?upstream=rtsp://camera.local/live",
|
||||
"/api/preview?upstream=rtsp%3A%2F%2Fcamera.local%2Flive",
|
||||
"/api/preview?upstream=rtsp%253A%252F%252Fcamera.local%252Flive",
|
||||
"/api/preview?endpoint=192.0.2.52:8554",
|
||||
"/api/preview?endpoint=192%2E168%2E68%2E52",
|
||||
"/api/preview?password=not-for-the-browser",
|
||||
"/api/preview?%70%61%73%73%77%6f%72%64=not-for-the-browser",
|
||||
"/api/preview/camera:secret@device",
|
||||
"/%2f%2fevil.example/preview",
|
||||
];
|
||||
|
||||
for (const url of unsafeUrls) {
|
||||
const state = cameraStreamingState("sensor.camera.left");
|
||||
state.camera_preview.delivery = { ...state.camera_preview.delivery, url };
|
||||
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
|
||||
stream.source_id === "sensor.camera.left"
|
||||
? { ...stream, delivery: { ...stream.delivery, url } }
|
||||
: stream,
|
||||
);
|
||||
const left = xgridsK1ObservationSources(state, model()).find(
|
||||
({ sourceId }) => sourceId === "sensor.camera.left",
|
||||
);
|
||||
assert.ok(left, `left camera descriptor missing for ${url}`);
|
||||
assert.equal(left.delivery, null, `unsafe delivery escaped for ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("manual camera reconnect restores an exhausted lease retry budget", () => {
|
||||
let budget = resetCameraLeaseRetryBudget("delivery-7");
|
||||
for (const expectedDelay of [400, 1_000, 2_000]) {
|
||||
const retry = consumeCameraLeaseRetry(budget, "delivery-7");
|
||||
assert.equal(retry.delay, expectedDelay);
|
||||
budget = retry.budget;
|
||||
}
|
||||
assert.equal(consumeCameraLeaseRetry(budget, "delivery-7").delay, null);
|
||||
|
||||
budget = resetCameraLeaseRetryBudget("delivery-7");
|
||||
const retryAfterManualReset = consumeCameraLeaseRetry(budget, "delivery-7");
|
||||
assert.equal(retryAfterManualReset.delay, 400);
|
||||
assert.equal(retryAfterManualReset.budget.count, 1);
|
||||
});
|
||||
|
||||
test("layout policy evicts only exclusive camera peers", () => {
|
||||
const sources = xgridsK1ObservationSources(
|
||||
cameraStreamingState("sensor.camera.left"),
|
||||
model(),
|
||||
);
|
||||
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
|
||||
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
|
||||
const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
|
||||
assert.ok(left && right && pointCloud);
|
||||
|
||||
const change = openObservationSource(
|
||||
[pointCloud.id, left.id],
|
||||
right.id,
|
||||
sources,
|
||||
);
|
||||
assert.deepEqual(change.visibleIds, [pointCloud.id, right.id]);
|
||||
assert.deepEqual(change.removedIds, [left.id]);
|
||||
});
|
||||
|
||||
test("selected camera without delivery is explicitly restartable", () => {
|
||||
const state = cameraStreamingState("sensor.camera.left");
|
||||
state.camera_preview = {
|
||||
...state.camera_preview,
|
||||
phase: "error",
|
||||
delivery: null,
|
||||
};
|
||||
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
|
||||
stream.source_id === "sensor.camera.left"
|
||||
? { ...stream, availability: "error", delivery: null }
|
||||
: stream,
|
||||
);
|
||||
const sources = xgridsK1ObservationSources(state, model());
|
||||
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
|
||||
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
|
||||
assert.ok(left && right);
|
||||
|
||||
assert.equal(left.activation.selected, true);
|
||||
assert.equal(left.delivery, null);
|
||||
assert.equal(left.availability, "error");
|
||||
assert.equal(shouldRestartObservationSource(left), true);
|
||||
assert.equal(shouldRestartObservationSource(right), false);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchRecordedMediaArchive;
|
||||
let recordedMediaPresentationState;
|
||||
let recordedMediaSeekableCoverage;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchRecordedMediaArchive,
|
||||
recordedMediaPresentationState,
|
||||
recordedMediaSeekableCoverage,
|
||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function fixture({ byteLength = 36_000_000_000 } = {}) {
|
||||
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
|
||||
const generation = "a".repeat(64);
|
||||
const streamUrl = manifestUrl.replace(
|
||||
"/manifest",
|
||||
`/epochs/1/recording.mp4?generation=${generation}`,
|
||||
);
|
||||
const manifest = {
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: "recorded.camera.camera-1",
|
||||
generation_sha256: generation,
|
||||
byte_length: byteLength,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 36_000,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 36_000,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
byte_length: byteLength,
|
||||
stream_url: streamUrl,
|
||||
}],
|
||||
};
|
||||
const source = {
|
||||
id: manifest.source_id,
|
||||
label: "Записанная камера",
|
||||
modality: "video",
|
||||
manifestUrl,
|
||||
manifestGenerationSha256: generation,
|
||||
byteLength,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 36_000,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
return { source, manifest, generation, streamUrl };
|
||||
}
|
||||
|
||||
function jsonResponse(payload, generation) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
ETag: `"sha256:${generation}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("multi-hour camera admission fetches only its compact generation-bound manifest", async () => {
|
||||
const { source, manifest, generation, streamUrl } = fixture();
|
||||
const requested = [];
|
||||
const archive = await fetchRecordedMediaArchive(source, {
|
||||
fetcher: async (input, request = {}) => {
|
||||
requested.push(String(input));
|
||||
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(manifest, generation);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(requested, [source.manifestUrl]);
|
||||
assert.equal(archive.byteLength, 36_000_000_000);
|
||||
assert.equal(archive.manifest.epochs[0].streamUrl, streamUrl);
|
||||
});
|
||||
|
||||
test("first camera manifest request rejects a replaced generation", async () => {
|
||||
const { source, manifest, generation } = fixture();
|
||||
const replacementGeneration = "d".repeat(64);
|
||||
await assert.rejects(
|
||||
fetchRecordedMediaArchive(source, {
|
||||
fetcher: async (_input, request = {}) => {
|
||||
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(
|
||||
{ ...manifest, generation_sha256: replacementGeneration },
|
||||
replacementGeneration,
|
||||
);
|
||||
},
|
||||
}),
|
||||
/несовместим|заменён/,
|
||||
);
|
||||
});
|
||||
|
||||
test("camera manifest fails closed when epoch bytes do not match launch bytes", async () => {
|
||||
const { source, manifest, generation } = fixture();
|
||||
const mismatched = {
|
||||
...manifest,
|
||||
epochs: [{ ...manifest.epochs[0], byte_length: manifest.byte_length - 1 }],
|
||||
};
|
||||
await assert.rejects(
|
||||
fetchRecordedMediaArchive(source, {
|
||||
fetcher: async () => jsonResponse(mismatched, generation),
|
||||
}),
|
||||
/не совпадает/,
|
||||
);
|
||||
});
|
||||
|
||||
test("camera presentation gate opens only for the seekable selected epoch", () => {
|
||||
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false), "ready");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true), "waiting");
|
||||
assert.equal(recordedMediaPresentationState("error", "g1", "g1", false), "error");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "error"), "error");
|
||||
});
|
||||
|
||||
test("decoded duration and seekable range cover the complete declared epoch", () => {
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(19, 19, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(18.99, 20, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 18.99, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
||||
});
|
||||
|
||||
test("recorded player range-streams and never builds a whole-video RAM Blob", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /video\.src\s*=\s*descriptor\.streamUrl/);
|
||||
assert.doesNotMatch(source, /new Blob\(|response\.arrayBuffer\(|SourceBuffer/);
|
||||
});
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player:not\(\[data-state="ready"\]\) \.observation-media__asset\s*\{[^}]*visibility:\s*hidden/s,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/);
|
||||
assert.match(source, /hidden=\{pointCloudFocused \|\| unifiedPerception\}/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\{!pointCloudFocused \? visibleMediaSources\.map/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\(pointCloudFocused \|\| !observationLayout\.visibleSourceIds\.has\(source\.id\)\)/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let admission;
|
||||
let rerunPresentationStatus;
|
||||
let recordedMediaPresentationState;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
admission = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedSessionAdmission.ts",
|
||||
);
|
||||
({ rerunPresentationStatus } = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedMediaPresentationState } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function camera(phase, byteLength = 1_024) {
|
||||
return { phase, byteLength, message: null };
|
||||
}
|
||||
|
||||
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const oneCamera = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("pending"),
|
||||
};
|
||||
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
||||
assert.equal(partialGate, "loading");
|
||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
||||
"loading",
|
||||
);
|
||||
|
||||
const completeGate = admission.recordedSessionAdmissionPhase("ready", ids, {
|
||||
...oneCamera,
|
||||
"camera.right": camera("ready"),
|
||||
});
|
||||
assert.equal(completeGate, "ready");
|
||||
assert.equal(rerunPresentationStatus("ready", completeGate, true), "ready");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, completeGate),
|
||||
"ready",
|
||||
);
|
||||
});
|
||||
|
||||
test("any RRD or camera failure closes the complete recorded session", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const cameras = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("error"),
|
||||
};
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("ready", ids, cameras), "error");
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("error", ids, {
|
||||
...cameras,
|
||||
"camera.right": camera("ready"),
|
||||
}), "error");
|
||||
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
test("camera admission keeps source scheduling bounded but has no duration or byte ceiling", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: 36_000_000_000,
|
||||
})),
|
||||
), "ready");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 17 }, (_, index) => ({ id: `camera.${index}`, byteLength: 1 })),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.ten-hours", byteLength: 36_000_000_000 },
|
||||
]), "ready");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.invalid", byteLength: Number.MAX_SAFE_INTEGER + 1 },
|
||||
]), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
]), "error");
|
||||
});
|
||||
|
||||
test("camera preparation remains single-flight and retains the loading permit", () => {
|
||||
const ids = ["camera.first", "camera.preferred"];
|
||||
const cameras = {
|
||||
"camera.first": camera("loading"),
|
||||
"camera.preferred": camera("pending"),
|
||||
};
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(
|
||||
ids,
|
||||
cameras,
|
||||
new Set(["camera.preferred"]),
|
||||
),
|
||||
["camera.first"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(ids, {
|
||||
...cameras,
|
||||
"camera.first": camera("ready"),
|
||||
}),
|
||||
["camera.preferred"],
|
||||
);
|
||||
});
|
||||
|
||||
test("new render workers close readiness and stale callbacks cannot reopen it", () => {
|
||||
const ready = { ...camera("ready"), workerGeneration: 1 };
|
||||
const remounted = admission.mergeRecordedCameraAdmission(ready, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(remounted.phase, "loading");
|
||||
assert.equal(remounted.workerGeneration, 2);
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 1,
|
||||
}), remounted);
|
||||
const admitted = admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(admitted.phase, "ready");
|
||||
const failed = admission.mergeRecordedCameraAdmission(admitted, {
|
||||
...camera("error"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(failed.phase, "error");
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(failed, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 3,
|
||||
}), failed);
|
||||
});
|
||||
|
||||
test("recorded player is not mounted for a camera without a scheduler permit", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/ObservationSources.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const placeholder = source.indexOf("if (!prepareRecorded)");
|
||||
const player = source.indexOf("<RecordedFmp4Player", placeholder);
|
||||
assert.ok(placeholder >= 0 && player > placeholder);
|
||||
assert.match(source.slice(placeholder, player), /return\s*\(/);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let createRecordedOpenWatchdog;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
createRecordedOpenWatchdog,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const sourceUrl = "/api/v1/observation-sessions/session-atomic/recording.rrd";
|
||||
const sha256 = "a".repeat(64);
|
||||
const viewerSourceUrl = `${sourceUrl}?generation=${sha256}`;
|
||||
|
||||
test("only the canonical digest-bound generation URL reaches the native Rerun receiver", () => {
|
||||
const descriptor = {
|
||||
sourceUrl,
|
||||
viewerSourceUrl,
|
||||
byteLength: 246_331_680,
|
||||
sha256,
|
||||
};
|
||||
const resolved = resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test");
|
||||
assert.equal(resolved, `http://mission-core.test${viewerSourceUrl}`);
|
||||
assert.equal(rerunViewerInitialSource(resolved), resolved);
|
||||
|
||||
for (const unsafeViewerSourceUrl of [
|
||||
sourceUrl,
|
||||
`${sourceUrl}?generation=${"b".repeat(64)}`,
|
||||
`${viewerSourceUrl}&extra=true`,
|
||||
`https://foreign.test${viewerSourceUrl}`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => resolveRecordedViewerSourceUrl({
|
||||
...descriptor,
|
||||
viewerSourceUrl: unsafeViewerSourceUrl,
|
||||
}, "http://mission-core.test"),
|
||||
/Unsafe recorded RRD viewer descriptor/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
|
||||
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
||||
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
|
||||
const largeDelay = recordedOpenWatchdogTimeoutMs(805_687_122);
|
||||
assert.ok(smallDelay >= 120_000);
|
||||
assert.ok(currentDelay >= smallDelay);
|
||||
assert.ok(largeDelay > currentDelay);
|
||||
assert.ok(largeDelay <= 1_800_000);
|
||||
assert.ok(largeDelay > 12_000);
|
||||
|
||||
let scheduledCallback;
|
||||
let scheduledDelay;
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 246_331_680,
|
||||
schedule(callback, timeoutMs) {
|
||||
scheduledCallback = callback;
|
||||
scheduledDelay = timeoutMs;
|
||||
return 17;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
assert.equal(watchdog.pending(), true);
|
||||
assert.equal(scheduledDelay, currentDelay);
|
||||
scheduledCallback();
|
||||
assert.equal(timeoutCount, 1);
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.deepEqual(cancelled, []);
|
||||
});
|
||||
|
||||
test("complete recorded admission clears its watchdog", () => {
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 805_687_122,
|
||||
schedule() {
|
||||
return 23;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
watchdog.clear();
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.equal(timeoutCount, 0);
|
||||
assert.deepEqual(cancelled, [23]);
|
||||
});
|
||||
|
||||
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RerunViewport.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
|
||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||
assert.doesNotMatch(source, /recordedChannel/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*if \(!isRecordedSource\) clearLiveRecordingOpenTimer\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/viewerStartResolved = true;\s*if \(isRecordedSource && !recordedSceneAdmitted\) recordedOpenWatchdog\?\.arm\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
css,
|
||||
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/data-status="loading"[^}]*\.rerun-viewport__canvas\s*>\s*div/s,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let attemptRecordedAutoplay;
|
||||
let createLatestAnimationFrameEmitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ attemptRecordedAutoplay, createLatestAnimationFrameEmitter } =
|
||||
await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("recorded autoplay reports success only after seek and play both succeed", () => {
|
||||
let seekAttempts = 0;
|
||||
let playAttempts = 0;
|
||||
const seekToStart = () => {
|
||||
seekAttempts += 1;
|
||||
};
|
||||
const startPlaying = () => {
|
||||
playAttempts += 1;
|
||||
if (playAttempts === 1) throw new Error("receiver is not ready yet");
|
||||
};
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), false);
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), true);
|
||||
assert.equal(seekAttempts, 2);
|
||||
assert.equal(playAttempts, 2);
|
||||
});
|
||||
|
||||
test("recorded autoplay does not try play when the seek itself fails", () => {
|
||||
let playAttempts = 0;
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(
|
||||
() => {
|
||||
throw new Error("timeline is not ready yet");
|
||||
},
|
||||
() => {
|
||||
playAttempts += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(playAttempts, 0);
|
||||
});
|
||||
|
||||
test("time updates coalesce to the latest value once per animation frame", () => {
|
||||
let nextHandle = 1;
|
||||
const frames = new Map();
|
||||
const cancelled = [];
|
||||
const emitted = [];
|
||||
const emitter = createLatestAnimationFrameEmitter({
|
||||
emit(value) {
|
||||
emitted.push(value);
|
||||
},
|
||||
requestFrame(callback) {
|
||||
const handle = nextHandle;
|
||||
nextHandle += 1;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
cancelFrame(handle) {
|
||||
cancelled.push(handle);
|
||||
frames.delete(handle);
|
||||
},
|
||||
});
|
||||
|
||||
emitter.push(1);
|
||||
emitter.push(2);
|
||||
emitter.push(3);
|
||||
assert.equal(frames.size, 1);
|
||||
assert.deepEqual(emitted, []);
|
||||
|
||||
const firstFrame = frames.get(1);
|
||||
frames.delete(1);
|
||||
firstFrame(0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(4);
|
||||
assert.equal(frames.size, 1);
|
||||
emitter.cancel();
|
||||
assert.deepEqual(cancelled, [2]);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(5);
|
||||
assert.equal(frames.size, 0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let canPublishRecordedPlaybackController;
|
||||
let createRecordedAutoplayGate;
|
||||
let isRecordedPlaybackReady;
|
||||
let isRecordedPlaybackPresentationReady;
|
||||
let isUsableRecordedPlaybackRange;
|
||||
let recordedPlaybackBufferState;
|
||||
let recordedPlaybackRangeWhenReady;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
canPublishRecordedPlaybackController,
|
||||
createRecordedAutoplayGate,
|
||||
isRecordedPlaybackReady,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
isUsableRecordedPlaybackRange,
|
||||
recordedPlaybackBufferState,
|
||||
recordedPlaybackRangeWhenReady,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
|
||||
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 0, max: 0 }), true);
|
||||
|
||||
const firstFrame = recordedPlaybackBufferState({ min: 0, max: 0 }, 20);
|
||||
assert.deepEqual(firstFrame, {
|
||||
bufferedEndNs: 0,
|
||||
expectedStartNs: 0,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
|
||||
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
||||
});
|
||||
|
||||
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
||||
const partial = {
|
||||
recordingId: "partial",
|
||||
timeline: "session_time",
|
||||
rangeNs: null,
|
||||
currentNs: 0,
|
||||
playing: false,
|
||||
bufferedEndNs: 5,
|
||||
expectedStartNs: 0,
|
||||
expectedEndNs: 10,
|
||||
bufferProgress: 0.5,
|
||||
fullyBuffered: false,
|
||||
};
|
||||
assert.equal(isRecordedPlaybackPresentationReady("loading", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("error", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", {
|
||||
...partial,
|
||||
rangeNs: { min: 0, max: 10 },
|
||||
bufferedEndNs: 10,
|
||||
bufferProgress: 1,
|
||||
fullyBuffered: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("buffer progress grows independently and preserves the full-buffer tolerance", () => {
|
||||
assert.deepEqual(recordedPlaybackBufferState({ min: 0, max: 5_000_000_000 }, 20), {
|
||||
bufferedEndNs: 5_000_000_000,
|
||||
expectedStartNs: 0,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0.25,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
|
||||
const complete = recordedPlaybackBufferState({ min: 0, max: 19_999_500_000 }, 20);
|
||||
assert.equal(complete.bufferProgress, 0.999975);
|
||||
assert.equal(complete.fullyBuffered, true);
|
||||
assert.equal(isRecordedPlaybackReady(false, true, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, false, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, complete), true);
|
||||
assert.equal(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, false),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, true),
|
||||
{ min: 0, max: 19_999_500_000 },
|
||||
);
|
||||
|
||||
const missingDeclaredStart = recordedPlaybackBufferState(
|
||||
{ min: 5_000_000_000, max: 20_000_000_000 },
|
||||
20,
|
||||
0,
|
||||
);
|
||||
assert.equal(missingDeclaredStart.bufferProgress, 1);
|
||||
assert.equal(missingDeclaredStart.fullyBuffered, false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), false);
|
||||
});
|
||||
|
||||
test("a verified split boundary spill covers and clamps the declared LAB window", () => {
|
||||
const actualRange = {
|
||||
min: 34_756_228_209,
|
||||
max: 95_383_857_292,
|
||||
};
|
||||
const buffer = recordedPlaybackBufferState(
|
||||
actualRange,
|
||||
95.383857292,
|
||||
35.421857292,
|
||||
);
|
||||
|
||||
assert.equal(buffer.bufferProgress, 1);
|
||||
assert.equal(buffer.fullyBuffered, true);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, buffer), true);
|
||||
assert.deepEqual(
|
||||
recordedPlaybackRangeWhenReady(actualRange, buffer, true),
|
||||
{
|
||||
min: 35_421_857_292,
|
||||
max: 95_383_857_292,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded autoplay waits for the full range and then runs exactly once", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
const seeks = [];
|
||||
let plays = 0;
|
||||
const seek = (value) => seeks.push(value);
|
||||
const play = () => {
|
||||
plays += 1;
|
||||
};
|
||||
|
||||
assert.equal(gate.attempt(false, true, true, { min: 0, max: 0 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, false, true, { min: 0, max: 500_000_000 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, true, false, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, []);
|
||||
assert.equal(plays, 0);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), true);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, [0]);
|
||||
assert.equal(plays, 1);
|
||||
assert.equal(gate.attempted(), true);
|
||||
});
|
||||
|
||||
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
const seeks = [];
|
||||
const range = { min: 0, max: 535_717_620_042 };
|
||||
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
range,
|
||||
(value) => seeks.push(value),
|
||||
() => {},
|
||||
35_421_857_292,
|
||||
), true);
|
||||
assert.deepEqual(seeks, [35_421_857_292]);
|
||||
assert.deepEqual(range, { min: 0, max: 535_717_620_042 });
|
||||
});
|
||||
|
||||
test("recorded autoplay clamps an invalid presentation start to the admitted archive", () => {
|
||||
const before = createRecordedAutoplayGate();
|
||||
const after = createRecordedAutoplayGate();
|
||||
const seeks = [];
|
||||
const range = { min: 5, max: 10 };
|
||||
|
||||
assert.equal(before.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
range,
|
||||
(value) => seeks.push(value),
|
||||
() => {},
|
||||
-50,
|
||||
), true);
|
||||
assert.equal(after.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
range,
|
||||
(value) => seeks.push(value),
|
||||
() => {},
|
||||
50,
|
||||
), true);
|
||||
assert.deepEqual(seeks, [5, 10]);
|
||||
});
|
||||
|
||||
test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
let seeks = 0;
|
||||
let plays = 0;
|
||||
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 1 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
throw new Error("receiver raced its first frame");
|
||||
},
|
||||
), false);
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 2 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(seeks, 1);
|
||||
assert.equal(plays, 1);
|
||||
});
|
||||
|
||||
test("recorded playback controller stays unpublished until the aggregate gate is ready", () => {
|
||||
assert.equal(canPublishRecordedPlaybackController(false, "ready"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "loading"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "error"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "ready"), true);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
||||
|
||||
const sha256 = (path) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
|
||||
test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
assert.equal(manifest.version, "0.34.1");
|
||||
|
||||
const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
|
||||
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
|
||||
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
|
||||
});
|
||||
|
||||
test("custom JavaScript glue references only exports present in its paired WASM", () => {
|
||||
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm");
|
||||
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js");
|
||||
const module = new WebAssembly.Module(readFileSync(wasmPath));
|
||||
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
|
||||
const imports = WebAssembly.Module.imports(module);
|
||||
const glue = readFileSync(gluePath, "utf8");
|
||||
const referencedExports = new Set(
|
||||
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
|
||||
);
|
||||
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
|
||||
|
||||
assert.equal(imports.length, 927);
|
||||
assert.equal(exports.size, 79);
|
||||
assert.deepEqual(missingExports, []);
|
||||
assert.match(glue, /export default function\(\)/);
|
||||
assert.match(glue, /if \(!wasm\) return;/);
|
||||
});
|
||||
|
||||
test("custom Rerun WASM initializes and grows its externref table", () => {
|
||||
const runtime = makeRerunRuntime();
|
||||
runtime.initSync({
|
||||
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.WebHandle, "function");
|
||||
runtime.deinit();
|
||||
});
|
||||
|
||||
test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => {
|
||||
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
|
||||
|
||||
assert.match(patch, /fn pointer_ray_direction/);
|
||||
assert.match(patch, /fn zoom_orbit_towards_pointer/);
|
||||
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
|
||||
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
|
||||
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
|
||||
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
|
||||
assert.match(patch, /fn rotate_radians_around_anchor/);
|
||||
assert.match(patch, /orbit_drag_anchor/);
|
||||
assert.match(patch, /minimum_orbital_navigation_speed/);
|
||||
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
|
||||
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
|
||||
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
|
||||
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/);
|
||||
assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/);
|
||||
assert.match(
|
||||
patch,
|
||||
/persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/,
|
||||
);
|
||||
assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/);
|
||||
assert.match(patch, /previous_picking_result/);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let guard;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
guard = await server.ssrLoadModule("/src/core/runtime/acquisitionGuard.ts");
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function runtimeState(acquisitionState, cleanupPending = false) {
|
||||
return {
|
||||
phase: "idle",
|
||||
sourceMode: "idle",
|
||||
acquisition: acquisitionState === null
|
||||
? null
|
||||
: {
|
||||
acquisitionId: "acq-1",
|
||||
deviceId: "device-1",
|
||||
deviceSessionId: "device-session-1",
|
||||
compatibilityProfileId: "profile-1",
|
||||
controlMode: "operator-manual",
|
||||
state: acquisitionState,
|
||||
stateRevision: 1,
|
||||
operatorInstructions: [],
|
||||
cleanupPending,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("every nonterminal acquisition phase blocks saved and manual source replacement", () => {
|
||||
for (const state of [
|
||||
"preparing",
|
||||
"prepared",
|
||||
"awaiting_external_start",
|
||||
"starting",
|
||||
"acquiring",
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
]) {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), true, state);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal or absent acquisitions allow an explicit source selection", () => {
|
||||
for (const state of ["completed", "failed", "aborted", "interrupted"]) {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), false, state);
|
||||
}
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(null)), false);
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(null), false);
|
||||
});
|
||||
|
||||
test("an unknown acquisition state fails closed", () => {
|
||||
assert.equal(
|
||||
guard.isSpatialSourceSwitchBlocked(runtimeState("future_vendor_phase")),
|
||||
true,
|
||||
);
|
||||
assert.match(guard.SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, /Завершите текущий приём/);
|
||||
});
|
||||
|
||||
test("terminal acquisition with retained cleanup remains source-switch blocked", () => {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", true)), true);
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", false)), false);
|
||||
});
|
||||
|
||||
test("saved-session and manual source controls share the acquisition guard", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const sessionSelectSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const observationHookSource = await readFile(
|
||||
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(appSource, /blockedReason=\{sourceSwitchBlockedReason\}/);
|
||||
assert.match(appSource, /disabled=\{sourceSwitchBlocked \|\| !sourceDraft\.trim\(\)\}/);
|
||||
assert.match(appSource, /if \(sourceSwitchBlockedRef\.current\) return;/);
|
||||
assert.match(sessionSelectSource, /replayEnabled: blockedReason === null/);
|
||||
assert.match(observationHookSource, /!replayEnabledRef\.current/);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let selectMonotonicXgridsState;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/stateOrdering.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function snapshot(revision, generation, phase = "streaming", sessionId = "device-session-a") {
|
||||
return {
|
||||
phase: "connected",
|
||||
message: `${phase}:${revision}:${generation}`,
|
||||
device_session: sessionId === null
|
||||
? null
|
||||
: {
|
||||
device_session_id: sessionId,
|
||||
device_id: `device-for-${sessionId}`,
|
||||
connectivity: "connected",
|
||||
},
|
||||
camera_preview: {
|
||||
phase,
|
||||
revision,
|
||||
generation,
|
||||
active_source_id: generation === null ? null : "sensor.camera.left",
|
||||
delivery: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("accepts the first camera preview snapshot", () => {
|
||||
const incoming = snapshot(1, 1);
|
||||
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
|
||||
});
|
||||
|
||||
test("rejects a lower revision even when its generation is higher", () => {
|
||||
const current = snapshot(8, 3);
|
||||
const stale = snapshot(7, 99);
|
||||
assert.equal(selectMonotonicXgridsState(current, stale), current);
|
||||
});
|
||||
|
||||
test("uses generation only as a tie-breaker for equal revisions", () => {
|
||||
const current = snapshot(8, 3);
|
||||
const stale = snapshot(8, 2);
|
||||
const equal = snapshot(8, 3, "selected");
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, stale), current);
|
||||
assert.equal(selectMonotonicXgridsState(current, equal), equal);
|
||||
});
|
||||
|
||||
test("accepts a newer stop revision with a null generation", () => {
|
||||
const current = snapshot(8, 3);
|
||||
const stopped = snapshot(9, null, "idle");
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, stopped), stopped);
|
||||
});
|
||||
|
||||
test("rejects an unversioned camera snapshot after a versioned one", () => {
|
||||
const current = snapshot(8, 3);
|
||||
const stale = {
|
||||
phase: "connected",
|
||||
device_session: current.device_session,
|
||||
};
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, stale), current);
|
||||
});
|
||||
|
||||
test("accepts revision reset when the authoritative device session changes", () => {
|
||||
const current = snapshot(18, 7, "streaming", "device-session-a");
|
||||
const nextDevice = snapshot(0, null, "idle", "device-session-b");
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, nextDevice), nextDevice);
|
||||
});
|
||||
|
||||
test("accepts backend reset that clears and later recreates the device session", () => {
|
||||
const current = snapshot(18, 7, "streaming", "device-session-a");
|
||||
const reset = snapshot(0, null, "idle", null);
|
||||
const reconnected = snapshot(0, null, "idle", "device-session-c");
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, reset), reset);
|
||||
assert.equal(selectMonotonicXgridsState(reset, reconnected), reconnected);
|
||||
});
|
||||
|
||||
test("rejects the entire stale atomic snapshot instead of merging unrelated fields", () => {
|
||||
const current = {
|
||||
...snapshot(8, 3),
|
||||
metrics: { point_count: 2_500 },
|
||||
};
|
||||
const stale = {
|
||||
...snapshot(7, 2),
|
||||
metrics: { point_count: 9_999 },
|
||||
};
|
||||
|
||||
const accepted = selectMonotonicXgridsState(current, stale);
|
||||
assert.equal(accepted, current);
|
||||
assert.equal(accepted.metrics.point_count, 2_500);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let createLatestAsyncCommitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ createLatestAsyncCommitter } = await server.ssrLoadModule(
|
||||
"/src/core/runtime/latestAsyncCommitter.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for async commit queue");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
test("viewer settings commits are serialized and intermediate slider values collapse", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue({ accumulationSeconds: 1 });
|
||||
committer.enqueue({ accumulationSeconds: 2 });
|
||||
committer.enqueue({ accumulationSeconds: 12 });
|
||||
|
||||
assert.deepEqual(calls, [{ accumulationSeconds: 1 }]);
|
||||
assert.equal(committer.isBusy(), true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
|
||||
assert.deepEqual(calls[1], { accumulationSeconds: 12 });
|
||||
assert.equal(settled[0].superseded, true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => !committer.isBusy());
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied, superseded }) => ({
|
||||
value,
|
||||
applied,
|
||||
superseded,
|
||||
})), [
|
||||
{ value: { accumulationSeconds: 1 }, applied: true, superseded: true },
|
||||
{ value: { accumulationSeconds: 12 }, applied: true, superseded: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("viewer settings commit failures settle as rejected without wedging the queue", async () => {
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
if (value === "broken") throw new Error("offline");
|
||||
return true;
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("broken");
|
||||
await eventually(() => settled.length === 1);
|
||||
committer.enqueue("healthy");
|
||||
await eventually(() => settled.length === 2);
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied }) => ({ value, applied })), [
|
||||
{ value: "broken", applied: false },
|
||||
{ value: "healthy", applied: true },
|
||||
]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle resolves only after the running commit and latest queued value settle", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("initial");
|
||||
committer.enqueue("latest");
|
||||
let idle = false;
|
||||
const idlePromise = committer.waitForIdle().then(() => {
|
||||
idle = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(idle, false);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
assert.deepEqual(calls, ["initial", "latest"]);
|
||||
assert.equal(idle, false);
|
||||
|
||||
resolvers.shift()(true);
|
||||
await idlePromise;
|
||||
assert.equal(idle, true);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle observes work enqueued by onSettled and resolves immediately when idle", async () => {
|
||||
const calls = [];
|
||||
let committer;
|
||||
committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
calls.push(value);
|
||||
return true;
|
||||
},
|
||||
onSettled({ value }) {
|
||||
if (value === "first") committer.enqueue("follow-up");
|
||||
},
|
||||
});
|
||||
|
||||
await committer.waitForIdle();
|
||||
committer.enqueue("first");
|
||||
await committer.waitForIdle();
|
||||
|
||||
assert.deepEqual(calls, ["first", "follow-up"]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("layout serialization can flush a staged setting and read the confirmed latest value", async () => {
|
||||
let staged = { pointSize: 2 };
|
||||
let confirmed = { pointSize: 1 };
|
||||
let resolveCommit;
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit() {
|
||||
return new Promise((resolve) => {
|
||||
resolveCommit = resolve;
|
||||
});
|
||||
},
|
||||
onSettled({ value, applied }) {
|
||||
if (applied) confirmed = value;
|
||||
},
|
||||
});
|
||||
|
||||
const saveLayout = async () => {
|
||||
committer.enqueue(staged);
|
||||
await committer.waitForIdle();
|
||||
return { sceneSettings: confirmed };
|
||||
};
|
||||
|
||||
const savePromise = saveLayout();
|
||||
staged = { pointSize: 9 };
|
||||
let saved = false;
|
||||
void savePromise.then(() => {
|
||||
saved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(saved, false);
|
||||
|
||||
resolveCommit(true);
|
||||
const layout = await savePromise;
|
||||
assert.deepEqual(layout, { sceneSettings: { pointSize: 2 } });
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeObservationWorkspaceLayoutProfile;
|
||||
let encodeObservationWorkspaceLayoutProfile;
|
||||
let fetchObservationWorkspaceLayoutProfile;
|
||||
let normalizeObservationWindowRect;
|
||||
let projectObservationLayoutSnapshot;
|
||||
let saveObservationWorkspaceLayoutProfile;
|
||||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
let visibleSourceIdsAfterRecordedCatalogActivation;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeObservationWorkspaceLayoutProfile,
|
||||
encodeObservationWorkspaceLayoutProfile,
|
||||
fetchObservationWorkspaceLayoutProfile,
|
||||
normalizeObservationWindowRect,
|
||||
projectObservationLayoutSnapshot,
|
||||
saveObservationWorkspaceLayoutProfile,
|
||||
WorkspaceLayoutApiError,
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({
|
||||
observationPresentationSourceAfterLayoutApply,
|
||||
visibleSourceIdsAfterRecordedCatalogActivation,
|
||||
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function wireProfile(overrides = {}) {
|
||||
return {
|
||||
version: 2,
|
||||
revision: 7,
|
||||
workspace_id: "observation.spatial",
|
||||
scene_settings: {
|
||||
projection: "3d",
|
||||
point_size: 2.5,
|
||||
color_mode: "intensity",
|
||||
palette: "turbo",
|
||||
custom_color: "#35d7c1",
|
||||
accumulation_seconds: 12,
|
||||
show_points: true,
|
||||
show_trajectory: true,
|
||||
show_grid: true,
|
||||
show_labels: false,
|
||||
show_camera_frustums: true,
|
||||
},
|
||||
visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
|
||||
active_floating_source_id: "camera.left",
|
||||
window_rects: {
|
||||
"camera.left": { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
"camera.right": { x: 0.55, y: 0.1, width: 0.4, height: 0.4 },
|
||||
},
|
||||
viewport_size: { width: 1_000, height: 500 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("workspace layout v2 excludes transient tool-window state", () => {
|
||||
const wire = wireProfile();
|
||||
const profile = decodeObservationWorkspaceLayoutProfile(wire);
|
||||
|
||||
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
|
||||
assert.equal(profile.sceneSettings.pointSize, 2.5);
|
||||
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
|
||||
assert.equal("tool_windows" in encodeObservationWorkspaceLayoutProfile(profile), false);
|
||||
});
|
||||
|
||||
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile({
|
||||
...wireProfile(),
|
||||
source_url: "ws://192.168.68.52:9877",
|
||||
}),
|
||||
WorkspaceLayoutContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 1 })),
|
||||
/Неподдерживаемая версия/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ revision: 1.5 })),
|
||||
/revision/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
visible_source_ids: ["../camera"],
|
||||
active_floating_source_id: null,
|
||||
})),
|
||||
/стабильный идентификатор/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
window_rects: { "camera.left": { x: 0.8, y: 0, width: 0.4, height: 0.5 } },
|
||||
})),
|
||||
/выходит за нормализованные границы/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile({
|
||||
...wireProfile(),
|
||||
tool_windows: {
|
||||
sources_open: true,
|
||||
display_open: true,
|
||||
layers_open: true,
|
||||
order: ["sources", "display", "layers"],
|
||||
},
|
||||
}),
|
||||
/неверная схема/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
scene_settings: { ...wireProfile().scene_settings, point_size: Number.POSITIVE_INFINITY },
|
||||
})),
|
||||
/point_size/,
|
||||
);
|
||||
});
|
||||
|
||||
test("restored desired layout survives an empty catalog and reveals only known stable sources later", () => {
|
||||
const profile = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
const snapshot = {
|
||||
visibleSourceIds: profile.visibleSourceIds,
|
||||
activeFloatingSourceId: profile.activeFloatingSourceId,
|
||||
windowRects: profile.windowRects,
|
||||
viewportSize: profile.viewportSize,
|
||||
};
|
||||
|
||||
const empty = projectObservationLayoutSnapshot(snapshot, new Set(), { width: 500, height: 1_000 });
|
||||
assert.deepEqual(empty, {
|
||||
visibleSourceIds: [],
|
||||
activeFloatingSourceId: null,
|
||||
windowRects: {},
|
||||
});
|
||||
assert.deepEqual(snapshot.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
|
||||
|
||||
const cameraAppeared = projectObservationLayoutSnapshot(
|
||||
snapshot,
|
||||
new Set(["camera.left"]),
|
||||
{ width: 500, height: 1_000 },
|
||||
);
|
||||
assert.deepEqual(cameraAppeared.visibleSourceIds, ["camera.left"]);
|
||||
assert.equal(cameraAppeared.activeFloatingSourceId, "camera.left");
|
||||
assert.deepEqual(cameraAppeared.windowRects["camera.left"], {
|
||||
x: 50,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test("window rectangles normalize once and project proportionally into a different viewport", () => {
|
||||
assert.deepEqual(
|
||||
normalizeObservationWindowRect(
|
||||
{ x: 100, y: 50, width: 400, height: 200 },
|
||||
{ width: 1_000, height: 500 },
|
||||
),
|
||||
{ x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
);
|
||||
});
|
||||
|
||||
test("fullscreen presentation survives the viewport resize it causes", () => {
|
||||
assert.equal(
|
||||
observationPresentationSourceAfterLayoutApply("camera.left", "preserve"),
|
||||
"camera.left",
|
||||
);
|
||||
assert.equal(
|
||||
observationPresentationSourceAfterLayoutApply("camera.left", "reset"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening a recorded catalog reveals its sealed cameras beside the point cloud", () => {
|
||||
const source = (id, modality, transport = "recording") => ({
|
||||
id,
|
||||
modality,
|
||||
transport,
|
||||
availability: "available",
|
||||
previewUrl: null,
|
||||
delivery: modality === "video" ? { kind: "recorded-fmp4-manifest" } : null,
|
||||
activation: null,
|
||||
capabilities: {
|
||||
defaultVisible: true,
|
||||
overlay: modality === "video",
|
||||
},
|
||||
});
|
||||
const sources = [
|
||||
source("recorded.spatial.primary", "point-cloud"),
|
||||
source("recorded.camera.left", "video"),
|
||||
source("recorded.camera.right", "video"),
|
||||
source("live.camera", "video", "websocket"),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
["recorded.spatial.primary"],
|
||||
sources,
|
||||
),
|
||||
["recorded.spatial.primary", "recorded.camera.left", "recorded.camera.right"],
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
const saved = await saveObservationWorkspaceLayoutProfile(current, {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(JSON.stringify(wireProfile({ revision: 8 })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "/api/v1/workspace-layouts/observation.spatial");
|
||||
assert.equal(calls[0].init.method, "PUT");
|
||||
assert.equal(calls[0].init.headers["If-Match"], '"7"');
|
||||
assert.equal(calls[0].body.revision, 7);
|
||||
assert.equal(calls[0].body.workspace_id, "observation.spatial");
|
||||
assert.equal(saved.revision, 8);
|
||||
});
|
||||
|
||||
test("workspace layout API treats 404 as no profile and exposes revision conflicts", async () => {
|
||||
assert.equal(
|
||||
await fetchObservationWorkspaceLayoutProfile({
|
||||
fetcher: async () => new Response(null, { status: 404 }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
await assert.rejects(
|
||||
saveObservationWorkspaceLayoutProfile(current, {
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "revision mismatch" }), {
|
||||
status: 412,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof WorkspaceLayoutApiError &&
|
||||
error.conflict && error.status === 412 && error.message === "revision mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
test("scene tool windows are route-scoped and toolbar actions are not duplicated", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const utilityStart = appSource.indexOf("const contentActions");
|
||||
const utilityEnd = appSource.indexOf("const header", utilityStart);
|
||||
const utilitySource = appSource.slice(utilityStart, utilityEnd);
|
||||
|
||||
assert.match(appSource, /open=\{spatialWorkspaceActive && sourceWindowOpen\}/);
|
||||
assert.match(appSource, /open=\{spatialWorkspaceActive && displayWindowOpen\}/);
|
||||
assert.match(appSource, /open=\{spatialWorkspaceActive && layerInspectorOpen\}/);
|
||||
assert.match(
|
||||
appSource,
|
||||
/if \(spatialWorkspaceActive\) return;[\s\S]*setSceneWindowOrder\(\[\]\)/,
|
||||
);
|
||||
assert.doesNotMatch(utilitySource, /Настроить визуальный движок/);
|
||||
assert.doesNotMatch(utilitySource, /Настроить отображение/);
|
||||
assert.doesNotMatch(utilitySource, /Открыть слои/);
|
||||
});
|
||||
@@ -12,6 +12,14 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
|
||||
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
|
||||
"react": ["node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
|
||||
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
@@ -20,5 +28,5 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../../plugins/xgrids-k1/frontend/src"]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# NODE.DC Rerun web viewer 0.34.1
|
||||
|
||||
This directory contains the audited Mission Core camera-controller override for
|
||||
`@rerun-io/web-viewer` 0.34.1. It changes only native spatial-camera behavior:
|
||||
|
||||
- the pointer ray selects an anchor on the current focus plane;
|
||||
- eye position and look target scale around that anchor, so the point under the
|
||||
cursor remains under the cursor;
|
||||
- orbital drag anchors to the picked world-space point under the initial cursor
|
||||
position, with the focus plane as an empty-space fallback;
|
||||
- after Rerun's `0.02 m` near-plane safety radius is reached, excess zoom becomes
|
||||
a cursor-directed dolly scaled by the scene's navigation speed instead of
|
||||
silently ignoring the wheel or moving by imperceptible millimeters;
|
||||
- orbital navigation has a scene-scale speed floor, so WASD and post-limit
|
||||
dolly remain useful at the minimum radius;
|
||||
- Mission Core's explicit `/world/sensor_pose` orbital follow keeps the current
|
||||
eye radius and orientation when enabled, remains attached through manual
|
||||
orbit and zoom, and releases only when the operator toggles follow off;
|
||||
- repeated server-side blueprint activation for 2D detections, segmentation,
|
||||
and 3D cuboids restores the last rendered operator eye instead of applying
|
||||
Rerun's scene-bounds fallback, so layer toggles cannot reset the camera;
|
||||
- first-person movement, panning and Rerun's zoom-out cap are unchanged.
|
||||
|
||||
## Source identity
|
||||
|
||||
- Upstream: `rerun-io/rerun`
|
||||
- Tag: `0.34.1`
|
||||
- Commit: `4efb18f17f6f0e41985cda99a2bdcd012febc8d5`
|
||||
- Patched files: `crates/viewer/re_view_spatial/src/eye.rs`,
|
||||
`crates/viewer/re_view_spatial/src/ui_3d.rs`
|
||||
- Patch: `NODEDC_ZOOM_TO_CURSOR.patch`
|
||||
- Rust: `1.92.0`
|
||||
- Binaryen / `wasm-opt`: `117` (the version pinned by Rerun's `pixi.lock`)
|
||||
- Build image: `rust:1.92-bookworm`
|
||||
- Build image digest:
|
||||
`sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2`
|
||||
- Build-only system packages: `clang 14.0.6`, `libudev-dev 252.39`
|
||||
- Build date: `2026-07-23` (`Europe/Moscow`)
|
||||
- Measured build stages: Rust to WASM `296.7 s`, JavaScript bindings
|
||||
`8.5 s`, Binaryen `wasm-opt -O2` `1244.2 s`
|
||||
|
||||
## Reproduction
|
||||
|
||||
Apply the patch to the exact upstream commit, then run Rerun's own builder:
|
||||
|
||||
```sh
|
||||
git apply --unidiff-zero NODEDC_ZOOM_TO_CURSOR.patch
|
||||
cargo test -p re_view_spatial --lib eye::tests:: -- --nocapture
|
||||
cargo run -p re_dev_tools -- build-web-viewer \
|
||||
--release -g \
|
||||
--target no-modules-base \
|
||||
--no-default-features \
|
||||
--features map_view \
|
||||
-o rerun_js/web-viewer
|
||||
```
|
||||
|
||||
The container also needs Binaryen `117` for Rerun's final `wasm-opt -O2` step.
|
||||
Binaryen `108` from Debian 12 must not be used: it produced a module whose
|
||||
`externref` table could not grow during initialization. The
|
||||
generated `re_viewer.js` is transformed with
|
||||
`scripts/transform-rerun-web-viewer-glue.mjs`, which mirrors Rerun 0.34.1's
|
||||
`rerun_js/web-viewer/build-wasm.mjs` no-modules wrapper and teardown guards.
|
||||
|
||||
## Verified artifacts
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `re_viewer_bg.nodedc.wasm` | `ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469` |
|
||||
| raw generated `re_viewer.js` | `cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874` |
|
||||
| `re_viewer.nodedc.js` | `0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339` |
|
||||
|
||||
The focused Rust suite completed with `10 passed, 0 failed`, including the
|
||||
orbit-to-dolly boundary, pointer-anchored rotation and scene-scale speed-floor
|
||||
cases, persistent rig-orbit tracking, camera continuity across blueprint
|
||||
reactivation, and explicit reset precedence. A Node `initSync`
|
||||
smoke test completed successfully, including the `externref` table-growth step.
|
||||
The Mission Core Node suite also checks both artifact hashes and verifies that
|
||||
every `wasm.*` reference in the JavaScript glue exists in the paired WASM
|
||||
exports.
|
||||
|
||||
`scripts/patch-rerun-web-viewer.mjs` installs the pair after npm extracts the
|
||||
official package. It accepts only the known published or NODE.DC hashes and
|
||||
fails closed on any other package contents.
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
diff --git a/crates/viewer/re_view_spatial/src/eye.rs b/crates/viewer/re_view_spatial/src/eye.rs
|
||||
index e59b311..b98653a 100644
|
||||
--- a/crates/viewer/re_view_spatial/src/eye.rs
|
||||
+++ b/crates/viewer/re_view_spatial/src/eye.rs
|
||||
@@ -207,0 +208,3 @@ pub struct EyeState {
|
||||
+ /// World-space point selected when the current orbital drag started.
|
||||
+ orbit_drag_anchor: Option<Vec3>,
|
||||
+
|
||||
@@ -245,0 +249,50 @@ pub(crate) struct EyeController {
|
||||
+/// NODE.DC's operator follow mode is explicitly toggled outside the embedded
|
||||
+/// viewer. Unlike Rerun's transient entity tracking, manual orbit/zoom must
|
||||
+/// therefore keep this rig pivot attached until the operator toggles it off.
|
||||
+const NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY: &str = "/world/sensor_pose";
|
||||
+
|
||||
+fn keeps_orbital_tracking_after_interaction(
|
||||
+ kind: Eye3DKind,
|
||||
+ tracking_entity: &EntityPath,
|
||||
+) -> bool {
|
||||
+ kind == Eye3DKind::Orbital
|
||||
+ && tracking_entity == &EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY)
|
||||
+}
|
||||
+
|
||||
+/// Keeps the last rendered operator eye when a server-side blueprint update
|
||||
+/// reactivates the same persistent follow view without explicit pose fields.
|
||||
+///
|
||||
+/// Rerun normally falls back to the scene bounding box when a blueprint has no
|
||||
+/// position or look target. Mission Core layer toggles intentionally omit those
|
||||
+/// fields so that the browser-owned eye remains authoritative.
|
||||
+fn restore_persistent_orbit_eye_after_blueprint_update(
|
||||
+ eye_controller: &mut EyeController,
|
||||
+ previous_eye: Option<&Eye>,
|
||||
+ previous_orbit_radius: Option<f32>,
|
||||
+ tracking_entity: Option<&EntityPath>,
|
||||
+ has_blueprint_position: bool,
|
||||
+ has_blueprint_look_target: bool,
|
||||
+) -> bool {
|
||||
+ let Some(tracking_entity) = tracking_entity else {
|
||||
+ return false;
|
||||
+ };
|
||||
+ let persistent =
|
||||
+ keeps_orbital_tracking_after_interaction(eye_controller.kind, tracking_entity);
|
||||
+ if persistent
|
||||
+ && !has_blueprint_position
|
||||
+ && !has_blueprint_look_target
|
||||
+ && let (Some(previous_eye), Some(previous_orbit_radius)) =
|
||||
+ (previous_eye, previous_orbit_radius)
|
||||
+ {
|
||||
+ let pos = previous_eye.pos_in_world();
|
||||
+ let radius = previous_orbit_radius.max(EyeController::MIN_ORBIT_DISTANCE);
|
||||
+ eye_controller.pos = pos;
|
||||
+ eye_controller.look_target = pos + previous_eye.forward_in_world() * radius;
|
||||
+ eye_controller.eye_up = previous_eye
|
||||
+ .world_from_rub_view
|
||||
+ .transform_vector3(Vec3::Y);
|
||||
+ eye_controller.fov_y = previous_eye.fov_y;
|
||||
+ }
|
||||
+ persistent
|
||||
+}
|
||||
+
|
||||
@@ -434 +487 @@ impl EyeController {
|
||||
- let mut rot = self.rotation();
|
||||
+ let rot = self.rotation_after_delta(delta);
|
||||
@@ -435,0 +489,2 @@ impl EyeController {
|
||||
+ self.apply_rotation_and_radius(rot, radius);
|
||||
+ }
|
||||
@@ -436,0 +492,2 @@ impl EyeController {
|
||||
+ fn rotation_after_delta(&self, delta: egui::Vec2) -> Quat {
|
||||
+ let mut rot = self.rotation();
|
||||
@@ -452 +509,2 @@ impl EyeController {
|
||||
- rot = rot.normalize();
|
||||
+ rot.normalize()
|
||||
+ }
|
||||
@@ -454 +512,13 @@ impl EyeController {
|
||||
- self.apply_rotation_and_radius(rot, radius);
|
||||
+ /// Rotate around the point selected under the pointer without moving that
|
||||
+ /// point on screen.
|
||||
+ fn rotate_radians_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) {
|
||||
+ let old_rotation = self.rotation();
|
||||
+ let new_rotation = self.rotation_after_delta(delta);
|
||||
+ let world_delta = new_rotation * old_rotation.inverse();
|
||||
+ self.pos = anchor + world_delta * (self.pos - anchor);
|
||||
+ self.look_target = anchor + world_delta * (self.look_target - anchor);
|
||||
+ }
|
||||
+
|
||||
+ fn rotate_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) {
|
||||
+ let sensitivity = 0.004;
|
||||
+ self.rotate_radians_around_anchor(sensitivity * delta, anchor);
|
||||
@@ -491 +561,10 @@ impl EyeController {
|
||||
- fn handle_drag(&mut self, response: &egui::Response, drag_threshold: f32) {
|
||||
+ fn handle_drag(
|
||||
+ &mut self,
|
||||
+ eye_state: &mut EyeState,
|
||||
+ response: &egui::Response,
|
||||
+ drag_threshold: f32,
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
+ ) {
|
||||
+ if !response.dragged_by(ROTATE3D_BUTTON) {
|
||||
+ eye_state.orbit_drag_anchor = None;
|
||||
+ }
|
||||
@@ -503 +582,15 @@ impl EyeController {
|
||||
- self.rotate(response.drag_delta());
|
||||
+ if self.kind == Eye3DKind::Orbital {
|
||||
+ let anchor = *eye_state.orbit_drag_anchor.get_or_insert_with(|| {
|
||||
+ pointer_space_position
|
||||
+ .filter(|position| position.is_finite())
|
||||
+ .or_else(|| {
|
||||
+ response.ctx.pointer_latest_pos().and_then(|pointer| {
|
||||
+ self.pointer_focus_plane_anchor(response.rect, pointer)
|
||||
+ })
|
||||
+ })
|
||||
+ .unwrap_or(self.look_target)
|
||||
+ });
|
||||
+ self.rotate_around_anchor(response.drag_delta(), anchor);
|
||||
+ } else {
|
||||
+ self.rotate(response.drag_delta());
|
||||
+ }
|
||||
@@ -514,0 +608,86 @@ impl EyeController {
|
||||
+ /// Returns the world-space ray under the pointer.
|
||||
+ ///
|
||||
+ /// Keeping this calculation local to the eye controller lets orbital zoom use the pointer
|
||||
+ /// synchronously. GPU picking arrives a frame later and would make the zoom pivot visibly lag.
|
||||
+ fn pointer_ray_direction(&self, rect: Rect, pointer: egui::Pos2) -> Option<Vec3> {
|
||||
+ if !rect.contains(pointer) || rect.width() <= 0.0 || rect.height() <= 0.0 {
|
||||
+ return None;
|
||||
+ }
|
||||
+
|
||||
+ let fov_y = self.fov_y.unwrap_or(Eye::DEFAULT_FOV_Y);
|
||||
+ let aspect_ratio = rect.width() / rect.height();
|
||||
+ let focal_scale = (fov_y * 0.5).tan();
|
||||
+ let x = (2.0 * (pointer.x - rect.left()) / rect.width() - 1.0) * focal_scale * aspect_ratio;
|
||||
+ let y = (1.0 - 2.0 * (pointer.y - rect.top()) / rect.height()) * focal_scale;
|
||||
+
|
||||
+ (self.rotation() * vec3(x, y, -1.0)).try_normalize()
|
||||
+ }
|
||||
+
|
||||
+ /// Intersect the pointer ray with the plane through the current look
|
||||
+ /// target. Empty-space drags then have a deterministic pivot even when GPU
|
||||
+ /// picking did not hit a rendered point.
|
||||
+ fn pointer_focus_plane_anchor(&self, rect: Rect, pointer: egui::Pos2) -> Option<Vec3> {
|
||||
+ let ray_direction = self.pointer_ray_direction(rect, pointer)?;
|
||||
+ let denominator = ray_direction.dot(self.fwd());
|
||||
+ if denominator <= 1.0e-4 {
|
||||
+ return None;
|
||||
+ }
|
||||
+ Some(self.pos + ray_direction * (self.radius() / denominator))
|
||||
+ }
|
||||
+
|
||||
+ /// Zoom an orbital eye around the point under the pointer on the current focus plane.
|
||||
+ ///
|
||||
+ /// The position and look target are scaled around the same anchor. This preserves the
|
||||
+ /// projected pointer position while retaining the existing orbit direction and controls.
|
||||
+ /// Once the near-plane safety radius is reached, excess zoom becomes a cursor-directed dolly
|
||||
+ /// measured in the scene's navigation speed instead of near-plane millimeters.
|
||||
+ fn zoom_orbit_towards_pointer(
|
||||
+ &mut self,
|
||||
+ zoom_factor: f32,
|
||||
+ max_radius: f32,
|
||||
+ rect: Rect,
|
||||
+ pointer: Option<egui::Pos2>,
|
||||
+ ) {
|
||||
+ let radius = self.radius();
|
||||
+ if !radius.is_finite() || radius <= f32::MIN_POSITIVE {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let requested_radius = radius / zoom_factor;
|
||||
+ let new_radius = requested_radius.clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
|
||||
+ let scale = new_radius / radius;
|
||||
+
|
||||
+ let pointer_ray = pointer.and_then(|pointer| {
|
||||
+ Some((
|
||||
+ self.pointer_ray_direction(rect, pointer)?,
|
||||
+ self.pointer_focus_plane_anchor(rect, pointer)?,
|
||||
+ ))
|
||||
+ });
|
||||
+ if let Some((ray_direction, anchor)) = pointer_ray {
|
||||
+ self.pos = anchor + (self.pos - anchor) * scale;
|
||||
+ self.look_target = anchor + (self.look_target - anchor) * scale;
|
||||
+
|
||||
+ if requested_radius < Self::MIN_ORBIT_DISTANCE {
|
||||
+ // Shrinking the remaining 2 cm orbit radius consumes only part of this input.
|
||||
+ // Hand the logarithmic remainder to the same scene-scaled speed used by WASD
|
||||
+ // and first-person scroll. Basing this on the near-plane remainder itself made
|
||||
+ // each wheel event move by millimeters and felt indistinguishable from a hard
|
||||
+ // zoom limit on building- and map-scale recordings.
|
||||
+ let orbit_zoom_factor = (radius / Self::MIN_ORBIT_DISTANCE).max(1.0);
|
||||
+ let remaining_zoom_factor = zoom_factor / orbit_zoom_factor;
|
||||
+ if remaining_zoom_factor > 1.0 && remaining_zoom_factor.is_finite() {
|
||||
+ let dolly = remaining_zoom_factor.ln() * self.speed as f32;
|
||||
+ self.pos += ray_direction * dolly;
|
||||
+ self.look_target += ray_direction * dolly;
|
||||
+ }
|
||||
+ }
|
||||
+ self.did_interact = true;
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // Pointer data can be absent for synthetic zoom events. Preserve Rerun's centered zoom
|
||||
+ // behavior in that case instead of dropping the input.
|
||||
+ self.pos = self.look_target - self.fwd() * new_radius;
|
||||
+ self.did_interact = true;
|
||||
+ }
|
||||
+
|
||||
@@ -516 +695,2 @@ impl EyeController {
|
||||
- fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
|
||||
+ fn handle_zoom(&mut self, response: &egui::Response, scene_bounding_box: &macaw::BoundingBox) {
|
||||
+ let egui_ctx = &response.ctx;
|
||||
@@ -531,2 +710,0 @@ impl EyeController {
|
||||
- let radius = self.pos.distance(self.look_target);
|
||||
-
|
||||
@@ -534,0 +713 @@ impl EyeController {
|
||||
+ let radius = self.radius();
|
||||
@@ -536,11 +715,2 @@ impl EyeController {
|
||||
- let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
|
||||
-
|
||||
- // The user may be scrolling to move the camera closer, but are not realizing
|
||||
- // the radius is now tiny.
|
||||
- // TODO(emilk): inform the users somehow that scrolling won't help, and that they should use WSAD instead.
|
||||
- // It might be tempting to start moving the camera here on scroll, but that would is bad for other reasons.
|
||||
-
|
||||
- if f32::MIN_POSITIVE < new_radius {
|
||||
- self.pos = self.look_target - self.fwd() * new_radius;
|
||||
- self.did_interact = true;
|
||||
- }
|
||||
+ let pointer = response.ctx.pointer_latest_pos();
|
||||
+ self.zoom_orbit_towards_pointer(zoom_factor, max_radius, response.rect, pointer);
|
||||
@@ -669,0 +840 @@ impl EyeController {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -671,0 +843,5 @@ impl EyeController {
|
||||
+ if self.kind == Eye3DKind::Orbital {
|
||||
+ self.speed = self
|
||||
+ .speed
|
||||
+ .max(minimum_orbital_navigation_speed(scene_bounding_box) as f64);
|
||||
+ }
|
||||
@@ -687 +863 @@ impl EyeController {
|
||||
- self.handle_drag(response, drag_threshold);
|
||||
+ self.handle_drag(eye_state, response, drag_threshold, pointer_space_position);
|
||||
@@ -690 +866 @@ impl EyeController {
|
||||
- self.handle_zoom(&response.ctx, scene_bounding_box);
|
||||
+ self.handle_zoom(response, scene_bounding_box);
|
||||
@@ -728,0 +905,18 @@ fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 {
|
||||
+/// Lower bound for free-flight and post-limit zoom speed in orbital mode.
|
||||
+///
|
||||
+/// The default orbital speed is the current orbit radius. Close to the near
|
||||
+/// limit that makes both WASD and cursor-directed dolly effectively stop.
|
||||
+fn minimum_orbital_navigation_speed(scene_bounding_box: &macaw::BoundingBox) -> f32 {
|
||||
+ const FALLBACK: f32 = 0.25;
|
||||
+ const SCENE_DIAGONAL_FACTOR: f32 = 0.01;
|
||||
+
|
||||
+ if !scene_bounding_box.is_finite() || scene_bounding_box.is_nothing() {
|
||||
+ return FALLBACK;
|
||||
+ }
|
||||
+ let scene_diagonal = scene_bounding_box.size().length();
|
||||
+ if !scene_diagonal.is_finite() || scene_diagonal <= 0.0 {
|
||||
+ return FALLBACK;
|
||||
+ }
|
||||
+ (scene_diagonal * SCENE_DIAGONAL_FACTOR).max(FALLBACK)
|
||||
+}
|
||||
+
|
||||
@@ -773,0 +968 @@ impl EyeState {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -775 +970 @@ impl EyeState {
|
||||
- ) -> Result<Eye, ViewPropertyQueryError> {
|
||||
+ ) -> Result<(Eye, bool), ViewPropertyQueryError> {
|
||||
@@ -777,0 +973,25 @@ impl EyeState {
|
||||
+ let tracking_entity = eye_property
|
||||
+ .component_or_empty::<re_sdk_types::components::EntityPath>(
|
||||
+ EyeControls3D::descriptor_tracking_entity().component,
|
||||
+ )?
|
||||
+ .filter(|tracking_entity| !tracking_entity.is_empty());
|
||||
+ let tracking_entity_path = tracking_entity
|
||||
+ .as_ref()
|
||||
+ .map(|tracking_entity| EntityPath::from(tracking_entity.as_str()));
|
||||
+ let has_blueprint_position = eye_property
|
||||
+ .component_or_empty::<Position3D>(EyeControls3D::descriptor_position().component)?
|
||||
+ .is_some();
|
||||
+ let has_blueprint_look_target = eye_property
|
||||
+ .component_or_empty::<Position3D>(
|
||||
+ EyeControls3D::descriptor_look_target().component,
|
||||
+ )?
|
||||
+ .is_some();
|
||||
+ let persistent_orbit_tracking = restore_persistent_orbit_eye_after_blueprint_update(
|
||||
+ &mut eye_controller,
|
||||
+ self.last_eye.as_ref(),
|
||||
+ self.last_orbit_radius,
|
||||
+ tracking_entity_path.as_ref(),
|
||||
+ has_blueprint_position,
|
||||
+ has_blueprint_look_target,
|
||||
+ );
|
||||
+
|
||||
@@ -794,6 +1013,0 @@ impl EyeState {
|
||||
- let tracking_entity = eye_property
|
||||
- .component_or_empty::<re_sdk_types::components::EntityPath>(
|
||||
- EyeControls3D::descriptor_tracking_entity().component,
|
||||
- )?
|
||||
- .filter(|tracking_entity| !tracking_entity.is_empty());
|
||||
-
|
||||
@@ -816,0 +1031 @@ impl EyeState {
|
||||
+ pointer_space_position,
|
||||
@@ -865 +1080 @@ impl EyeState {
|
||||
- return Ok(tracked_eye);
|
||||
+ return Ok((tracked_eye, persistent_orbit_tracking));
|
||||
@@ -872 +1087 @@ impl EyeState {
|
||||
- Ok(eye_controller.get_eye())
|
||||
+ Ok((eye_controller.get_eye(), persistent_orbit_tracking))
|
||||
@@ -891,0 +1107,2 @@ impl EyeState {
|
||||
+ let persistent_orbit_tracking =
|
||||
+ keeps_orbital_tracking_after_interaction(eye_controller.kind, &tracking_entity);
|
||||
@@ -946 +1163 @@ impl EyeState {
|
||||
- if new_tracking {
|
||||
+ if new_tracking && !persistent_orbit_tracking {
|
||||
@@ -998 +1215,4 @@ impl EyeState {
|
||||
- if eye_controller.did_interact && did_eye_orbit_center_change {
|
||||
+ if eye_controller.did_interact
|
||||
+ && did_eye_orbit_center_change
|
||||
+ && !persistent_orbit_tracking
|
||||
+ {
|
||||
@@ -1189,0 +1410 @@ impl EyeState {
|
||||
+ pointer_space_position: Option<Vec3>,
|
||||
@@ -1198 +1419 @@ impl EyeState {
|
||||
- let target_eye = self.control_and_sync_with_blueprint(
|
||||
+ let (target_eye, persistent_orbit_tracking) = self.control_and_sync_with_blueprint(
|
||||
@@ -1203,0 +1425 @@ impl EyeState {
|
||||
+ pointer_space_position,
|
||||
@@ -1210,3 +1432,6 @@ impl EyeState {
|
||||
- if eye_property
|
||||
- .component_or_empty::<Position3D>(EyeControls3D::descriptor_position().component)?
|
||||
- .is_none()
|
||||
+ if !persistent_orbit_tracking
|
||||
+ && eye_property
|
||||
+ .component_or_empty::<Position3D>(
|
||||
+ EyeControls3D::descriptor_position().component,
|
||||
+ )?
|
||||
+ .is_none()
|
||||
@@ -1251,0 +1477,196 @@ impl EyeState {
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ fn orbital_controller(pos: Vec3, look_target: Vec3) -> EyeController {
|
||||
+ EyeController {
|
||||
+ pos,
|
||||
+ look_target,
|
||||
+ kind: Eye3DKind::Orbital,
|
||||
+ speed: 1.0,
|
||||
+ eye_up: Vec3::Z,
|
||||
+ fov_y: Some(Eye::DEFAULT_FOV_Y),
|
||||
+ did_interact: false,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ fn test_rect() -> Rect {
|
||||
+ Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))
|
||||
+ }
|
||||
+
|
||||
+ fn assert_vec3_close(actual: Vec3, expected: Vec3) {
|
||||
+ assert!(
|
||||
+ actual.abs_diff_eq(expected, 1.0e-5),
|
||||
+ "actual={actual:?}, expected={expected:?}"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn centered_pointer_keeps_the_orbit_target() {
|
||||
+ let rect = test_rect();
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert_vec3_close(controller.pos, vec3(0.0, -5.0, 0.0));
|
||||
+ assert_vec3_close(controller.look_target, Vec3::ZERO);
|
||||
+ assert!(controller.did_interact);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn off_center_pointer_stays_on_the_same_view_ray() {
|
||||
+ let rect = test_rect();
|
||||
+ let pointer = egui::pos2(650.0, 240.0);
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
|
||||
+ let original_ray = controller
|
||||
+ .pointer_ray_direction(rect, pointer)
|
||||
+ .expect("test pointer must produce a ray");
|
||||
+ let forward = controller.fwd();
|
||||
+ let anchor =
|
||||
+ controller.pos + original_ray * (controller.radius() / original_ray.dot(forward));
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(pointer));
|
||||
+
|
||||
+ assert!((controller.radius() - 5.0).abs() < 1.0e-5);
|
||||
+ let ray_after = (anchor - controller.pos).normalize();
|
||||
+ assert!(ray_after.dot(original_ray) > 0.99999);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn near_limit_hands_excess_zoom_to_cursor_directed_dolly() {
|
||||
+ let rect = test_rect();
|
||||
+ let radius = EyeController::MIN_ORBIT_DISTANCE;
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -radius, 0.0), Vec3::ZERO);
|
||||
+ let old_pos = controller.pos;
|
||||
+ let old_target = controller.look_target;
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert!((controller.radius() - radius).abs() < 1.0e-6);
|
||||
+ assert_vec3_close(controller.pos, old_pos + Vec3::Y * 2.0_f32.ln());
|
||||
+ assert_vec3_close(controller.look_target, old_target + Vec3::Y * 2.0_f32.ln());
|
||||
+ assert_vec3_close(controller.look_target - controller.pos, Vec3::Y * radius);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn crossing_near_limit_preserves_unconsumed_scene_scaled_zoom() {
|
||||
+ let rect = test_rect();
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -0.03, 0.0), Vec3::ZERO);
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(3.0, 100.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert!((controller.radius() - EyeController::MIN_ORBIT_DISTANCE).abs() < 1.0e-6);
|
||||
+ assert_vec3_close(controller.pos, vec3(0.0, 2.0_f32.ln() - 0.02, 0.0));
|
||||
+ assert_vec3_close(controller.look_target, vec3(0.0, 2.0_f32.ln(), 0.0));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn zoom_out_respects_scene_radius_cap() {
|
||||
+ let rect = test_rect();
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(0.1, 25.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert!((controller.radius() - 25.0).abs() < 1.0e-5);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn orbital_rotation_keeps_selected_anchor_on_the_same_view_ray() {
|
||||
+ let anchor = vec3(3.0, 2.0, 1.5);
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 4.0), Vec3::ZERO);
|
||||
+ let original_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
|
||||
+
|
||||
+ controller.rotate_radians_around_anchor(egui::vec2(0.35, -0.2), anchor);
|
||||
+
|
||||
+ let rotated_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
|
||||
+ assert!(
|
||||
+ rotated_view_ray
|
||||
+ .normalize()
|
||||
+ .dot(original_view_ray.normalize())
|
||||
+ > 0.99999
|
||||
+ );
|
||||
+ assert!((rotated_view_ray.length() - original_view_ray.length()).abs() < 1.0e-5);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn orbital_navigation_speed_floor_tracks_scene_scale() {
|
||||
+ let building_scale = macaw::BoundingBox::from_min_max(Vec3::ZERO, vec3(300.0, 400.0, 0.0));
|
||||
+
|
||||
+ assert!((minimum_orbital_navigation_speed(&building_scale) - 5.0).abs() < 1.0e-6);
|
||||
+ assert_eq!(
|
||||
+ minimum_orbital_navigation_speed(&macaw::BoundingBox::nothing()),
|
||||
+ 0.25
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn nodedc_rig_orbit_tracking_is_persistent() {
|
||||
+ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY);
|
||||
+ let other = EntityPath::from("/world/another_entity");
|
||||
+
|
||||
+ assert!(keeps_orbital_tracking_after_interaction(
|
||||
+ Eye3DKind::Orbital,
|
||||
+ &rig
|
||||
+ ));
|
||||
+ assert!(!keeps_orbital_tracking_after_interaction(
|
||||
+ Eye3DKind::FirstPerson,
|
||||
+ &rig
|
||||
+ ));
|
||||
+ assert!(!keeps_orbital_tracking_after_interaction(
|
||||
+ Eye3DKind::Orbital,
|
||||
+ &other
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update() {
|
||||
+ let previous_controller =
|
||||
+ orbital_controller(vec3(12.0, -7.0, 4.0), vec3(10.0, 2.0, 1.0));
|
||||
+ let previous_eye = previous_controller.get_eye();
|
||||
+ let previous_radius = previous_controller.radius();
|
||||
+ let mut fallback_controller =
|
||||
+ orbital_controller(vec3(500.0, -800.0, 300.0), Vec3::ZERO);
|
||||
+ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY);
|
||||
+
|
||||
+ assert!(restore_persistent_orbit_eye_after_blueprint_update(
|
||||
+ &mut fallback_controller,
|
||||
+ Some(&previous_eye),
|
||||
+ Some(previous_radius),
|
||||
+ Some(&rig),
|
||||
+ false,
|
||||
+ false,
|
||||
+ ));
|
||||
+ assert_vec3_close(fallback_controller.pos, previous_eye.pos_in_world());
|
||||
+ assert!((fallback_controller.radius() - previous_radius).abs() < 1.0e-5);
|
||||
+ assert!(
|
||||
+ fallback_controller
|
||||
+ .fwd()
|
||||
+ .dot(previous_eye.forward_in_world())
|
||||
+ > 0.99999
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn explicit_blueprint_pose_is_not_replaced_by_the_previous_eye() {
|
||||
+ let previous_controller =
|
||||
+ orbital_controller(vec3(12.0, -7.0, 4.0), vec3(10.0, 2.0, 1.0));
|
||||
+ let previous_eye = previous_controller.get_eye();
|
||||
+ let mut explicit_controller =
|
||||
+ orbital_controller(vec3(500.0, -800.0, 300.0), Vec3::ZERO);
|
||||
+ let explicit_pos = explicit_controller.pos;
|
||||
+ let explicit_target = explicit_controller.look_target;
|
||||
+ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY);
|
||||
+
|
||||
+ assert!(restore_persistent_orbit_eye_after_blueprint_update(
|
||||
+ &mut explicit_controller,
|
||||
+ Some(&previous_eye),
|
||||
+ Some(previous_controller.radius()),
|
||||
+ Some(&rig),
|
||||
+ true,
|
||||
+ true,
|
||||
+ ));
|
||||
+ assert_vec3_close(explicit_controller.pos, explicit_pos);
|
||||
+ assert_vec3_close(explicit_controller.look_target, explicit_target);
|
||||
+ }
|
||||
+}
|
||||
diff --git a/crates/viewer/re_view_spatial/src/ui_3d.rs b/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
index 56257a8..07498ab 100644
|
||||
--- a/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
+++ b/crates/viewer/re_view_spatial/src/ui_3d.rs
|
||||
@@ -180,0 +181,4 @@ impl SpatialView3D {
|
||||
+ let pointer_space_position = state
|
||||
+ .previous_picking_result
|
||||
+ .as_ref()
|
||||
+ .and_then(crate::picking::PickingResult::space_position);
|
||||
@@ -186,0 +191 @@ impl SpatialView3D {
|
||||
+ pointer_space_position,
|
||||
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
@@ -0,0 +1,65 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
||||
|
||||
return {
|
||||
plugins: [react(), wasm()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@mission-core/plugin-sdk": fileURLToPath(
|
||||
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
|
||||
),
|
||||
"@xgrids-k1/frontend": fileURLToPath(
|
||||
new URL("../../plugins/xgrids-k1/frontend/src", import.meta.url),
|
||||
),
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@nodedc/ui-react"],
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
|
||||
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
|
||||
// pointing at the SPA fallback, which browsers reject as text/html.
|
||||
exclude: ["@rerun-io/web-viewer"],
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
fs: {
|
||||
allow: [
|
||||
fileURLToPath(new URL(".", import.meta.url)),
|
||||
fileURLToPath(new URL("../../plugins/xgrids-k1", import.meta.url)),
|
||||
fileURLToPath(new URL("../../../NODEDC_DESIGN_GUIDELINE/packages", import.meta.url)),
|
||||
],
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: "127.0.0.1",
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
# Пункт управления NODE.DC
|
||||
|
||||
Универсальный браузерный пункт управления NODE.DC на React 19, TypeScript и
|
||||
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
|
||||
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
|
||||
device adapter, но структура интерфейса от него не зависит.
|
||||
|
||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
|
||||
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
|
||||
потока или совместимой RRD-записи.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
| Контур | Состояние | Что это означает |
|
||||
| --- | --- | --- |
|
||||
| NODE.DC fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
|
||||
|
||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
|
||||
показывают `—`.
|
||||
|
||||
## Архитектура данных
|
||||
|
||||
```text
|
||||
K1 MQTT :1883, read-only
|
||||
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
||||
└── bounded latest-wins preview queue (32 сообщения)
|
||||
└── проверенный protobuf/LZ4 decoder
|
||||
└── Rerun Points3D + Transform3D + LineStrips3D
|
||||
└── gRPC/proxy TCP 9876
|
||||
└── rerun_grpc_url в REST/WebSocket state
|
||||
└── встроенный @rerun-io/web-viewer
|
||||
└── пространственная сцена NODE.DC
|
||||
|
||||
React Control Station ←→ REST /api/* + WebSocket /api/events
|
||||
FastAPI на 127.0.0.1:8000
|
||||
CoreBluetooth + live/replay runtime
|
||||
```
|
||||
|
||||
В момент готовности `RerunBridge` backend публикует адрес вида
|
||||
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
|
||||
если оператор не указал ручной source. После остановки приёма URL и встроенный
|
||||
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
|
||||
траекторию и метрики и использует тот же listener. Он закрывается вместе с
|
||||
процессом `k1link serve`.
|
||||
|
||||
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
|
||||
совместимость со старым контрактом, но текущий runtime держит их пустыми.
|
||||
|
||||
## Фиксированная оболочка
|
||||
|
||||
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
|
||||
всех функциональных модулей:
|
||||
|
||||
1. `AppHeader` — марка NODE.DC, выбор архитектурного раздела и состояние
|
||||
локального backend.
|
||||
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
|
||||
выбранного раздела.
|
||||
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
|
||||
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
|
||||
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
|
||||
раскрытия внутренних панелей визуального движка.
|
||||
|
||||
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
||||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
||||
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
|
||||
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
|
||||
layout пока остаются интерфейсным контрактом.
|
||||
|
||||
## Архитектурные разделы
|
||||
|
||||
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
|
||||
страницы.
|
||||
|
||||
| Раздел | Назначение |
|
||||
| --- | --- |
|
||||
| Центр | Оперативный обзор, состояние контура и активность оператора. |
|
||||
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
|
||||
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
||||
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
|
||||
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
|
||||
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
|
||||
|
||||
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
|
||||
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
|
||||
как утверждение, что для каждой карточки уже существует backend.
|
||||
|
||||
## Установка, проверка, сборка и запуск
|
||||
|
||||
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
|
||||
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
|
||||
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
||||
|
||||
Канонический путь от корня репозитория:
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
|
||||
uv sync --group dev
|
||||
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run build
|
||||
|
||||
cd ../..
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
|
||||
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
|
||||
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
|
||||
Wi-Fi.
|
||||
|
||||
Для разработки интерфейса после запуска backend:
|
||||
|
||||
```bash
|
||||
cd apps/k1-viewer
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
|
||||
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
|
||||
`VITE_API_TARGET`. Preview production-сборки запускается командой
|
||||
`npm run preview` на `http://127.0.0.1:4173`.
|
||||
|
||||
## Операторский путь для текущего K1 adapter
|
||||
|
||||
1. Запустить `uv run k1link serve` и открыть Control Station.
|
||||
2. Выбрать **Парк → Локальное устройство**.
|
||||
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
|
||||
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
|
||||
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
|
||||
оператор.
|
||||
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||
reviewed provisioning-запись без автоматических повторов.
|
||||
6. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
|
||||
останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
частота, число точек, задержка и пропуски preview появятся после прихода
|
||||
сообщений K1.
|
||||
|
||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
||||
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
|
||||
0 ошибок декодирования.
|
||||
|
||||
## Источники Rerun
|
||||
|
||||
Live/replay runtime автоматически назначает первый URL из примера. В
|
||||
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
|
||||
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
|
||||
зафиксированной в `package.json` версии:
|
||||
|
||||
```text
|
||||
rerun+http://127.0.0.1:9876/proxy
|
||||
http://127.0.0.1:8080/recording.rrd
|
||||
https://example.internal/recording.rrd
|
||||
```
|
||||
|
||||
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
|
||||
proxy;
|
||||
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
|
||||
- версия RRD должна быть совместима с версией Web Viewer;
|
||||
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
|
||||
автоматическим source;
|
||||
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||
|
||||
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||
оболочку. Backend принимает и применяет к Rerun размер точки `0.5–12.0`, режимы
|
||||
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
|
||||
Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака,
|
||||
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
|
||||
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
|
||||
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
|
||||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
||||
а не готовую семантическую классификацию.
|
||||
|
||||
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
|
||||
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
|
||||
в памяти текущей страницы и не записывается на диск.
|
||||
|
||||
## Контракт локального API
|
||||
|
||||
| Метод | Route | Тело / назначение |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Проверка локального сервиса. |
|
||||
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
|
||||
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
|
||||
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
|
||||
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
|
||||
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
|
||||
| `POST` | `/api/session/stop` | Остановка активного источника. |
|
||||
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
|
||||
| `WS` | `/api/events` | Периодические snapshots для live UI. |
|
||||
|
||||
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
|
||||
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
|
||||
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
|
||||
пустыми. OpenAPI доступен по `/api/docs`.
|
||||
|
||||
## Карта исходников frontend
|
||||
|
||||
| Файл | Ответственность |
|
||||
| --- | --- |
|
||||
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
||||
| `src/workspaces/DeviceWorkspace.tsx` | Реальный K1 BLE/Wi-Fi/live/replay adapter UI. |
|
||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
|
||||
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
|
||||
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
|
||||
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
||||
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. |
|
||||
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
|
||||
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
||||
|
||||
## Safety и чувствительные данные
|
||||
|
||||
- Control Station и credential endpoint доступны только на loopback.
|
||||
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
|
||||
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
|
||||
connector-level authentication или TLS. Этот порт допустим только в доверенной
|
||||
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
|
||||
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
|
||||
случайные GATT writes и автоматические повторы запрещены.
|
||||
- MQTT live/replay не публикует команды устройству. Запуск и остановка
|
||||
физического сканирования остаются за кнопкой K1.
|
||||
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
|
||||
перегрузке preview может быть отброшен, raw evidence сохраняется.
|
||||
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
|
||||
накопления viewer использует session-local `stream_time`. `capture_time` — не
|
||||
доказанный timestamp сенсора K1 и не photon-to-screen latency.
|
||||
- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует;
|
||||
текущая Rerun-сцена содержит только облако точек и позу/траекторию, а
|
||||
операционные метрики отображаются внешней оболочкой Control Station.
|
||||
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
|
||||
траекторию и карту помещения. В репозиторий попадают только redacted manifests
|
||||
и безопасная документация.
|
||||
@@ -1,740 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderAvatar,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderProfileButton,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
useApplicationWorkspace,
|
||||
type ApplicationPanelUtilityAction,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import type { ViewerSettings } from "./api";
|
||||
import {
|
||||
rootById,
|
||||
roots,
|
||||
workspaceById,
|
||||
workspacesForRoot,
|
||||
type RootId,
|
||||
} from "./productModel";
|
||||
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { useK1Console } from "./useK1Console";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import "./styles/scene-windows.css";
|
||||
|
||||
type SceneToolWindowId = "sources" | "display" | "layers" | "layout";
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
|
||||
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
|
||||
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
|
||||
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
|
||||
];
|
||||
|
||||
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
|
||||
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
|
||||
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
|
||||
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
|
||||
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
|
||||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
||||
];
|
||||
|
||||
function toViewerSettings(settings: SceneSettings): ViewerSettings {
|
||||
return {
|
||||
point_size: settings.pointSize,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
custom_color: settings.customColor,
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
show_points: settings.showPoints,
|
||||
show_trajectory: settings.showTrajectory,
|
||||
show_grid: settings.showGrid,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeViewerSettings(
|
||||
current: SceneSettings,
|
||||
remote: ViewerSettings,
|
||||
): SceneSettings {
|
||||
const next = {
|
||||
...current,
|
||||
pointSize: remote.point_size,
|
||||
colorMode: remote.color_mode,
|
||||
palette: remote.palette,
|
||||
customColor: remote.custom_color,
|
||||
accumulationSeconds: remote.accumulation_seconds,
|
||||
showPoints: remote.show_points,
|
||||
showTrajectory: remote.show_trajectory,
|
||||
showGrid: remote.show_grid,
|
||||
};
|
||||
const unchanged =
|
||||
current.pointSize === next.pointSize &&
|
||||
current.colorMode === next.colorMode &&
|
||||
current.palette === next.palette &&
|
||||
current.customColor === next.customColor &&
|
||||
current.accumulationSeconds === next.accumulationSeconds &&
|
||||
current.showPoints === next.showPoints &&
|
||||
current.showTrajectory === next.showTrajectory &&
|
||||
current.showGrid === next.showGrid;
|
||||
return unchanged ? current : next;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const console = useK1Console();
|
||||
const workspace = useApplicationWorkspace<string>({
|
||||
navigationOpen: false,
|
||||
contentExpanded: true,
|
||||
});
|
||||
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [sourceDraft, setSourceDraft] = useState("");
|
||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
|
||||
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
|
||||
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
|
||||
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
|
||||
const [layoutName, setLayoutName] = useState("Операторская сцена");
|
||||
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||
const automaticSourceUrl = console.state?.rerun_grpc_url?.trim() ?? "";
|
||||
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
||||
|
||||
useEffect(() => {
|
||||
const remote = console.state?.viewer_settings;
|
||||
if (!remote) return;
|
||||
setSceneSettings((current) => mergeViewerSettings(current, remote));
|
||||
if (!displayWindowOpen) {
|
||||
setDisplayDraft((current) => mergeViewerSettings(current, remote));
|
||||
}
|
||||
}, [console.state?.viewer_settings, displayWindowOpen]);
|
||||
|
||||
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
setSceneWindowOrder((current) => [
|
||||
...current.filter((candidate) => candidate !== windowId),
|
||||
windowId,
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
if (windowId === "layout") setLayoutWindowOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSceneWindow) return;
|
||||
|
||||
const closeActiveWindow = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
if (document.querySelector(".nodedc-dropdown-surface")) return;
|
||||
if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return;
|
||||
event.preventDefault();
|
||||
closeSceneWindow(activeSceneWindow);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", closeActiveWindow);
|
||||
return () => document.removeEventListener("keydown", closeActiveWindow);
|
||||
}, [activeSceneWindow, closeSceneWindow]);
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
workspace.closeView();
|
||||
workspace.openNavigation();
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
const definition = workspaceById(viewId);
|
||||
if (!definition) return;
|
||||
setActiveRoot(definition.root);
|
||||
workspace.openView(viewId);
|
||||
};
|
||||
|
||||
const openSource = () => {
|
||||
setSourceDraft(sourceUrl);
|
||||
setSourceWindowOpen(true);
|
||||
activateSceneWindow("sources");
|
||||
};
|
||||
|
||||
const openDisplay = () => {
|
||||
setDisplayDraft(sceneSettings);
|
||||
setDisplayWindowOpen(true);
|
||||
activateSceneWindow("display");
|
||||
};
|
||||
|
||||
const openLayers = () => {
|
||||
setLayerInspectorOpen(true);
|
||||
activateSceneWindow("layers");
|
||||
};
|
||||
|
||||
const openLayout = () => {
|
||||
setLayoutDraftSaved(false);
|
||||
setLayoutWindowOpen(true);
|
||||
activateSceneWindow("layout");
|
||||
};
|
||||
|
||||
const applyDisplaySettings = async () => {
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(displayDraft));
|
||||
if (applied) setSceneSettings(displayDraft);
|
||||
};
|
||||
|
||||
const applyScenePatch = async (patch: Partial<SceneSettings>) => {
|
||||
const previous = sceneSettings;
|
||||
const next = { ...sceneSettings, ...patch };
|
||||
setSceneSettings(next);
|
||||
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(next));
|
||||
if (!applied) {
|
||||
setSceneSettings(previous);
|
||||
if (!displayWindowOpen) setDisplayDraft(previous);
|
||||
}
|
||||
};
|
||||
|
||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
|
||||
if (activeDefinition?.kind === "device") {
|
||||
actions.push({
|
||||
label: "Обновить состояние локального контура",
|
||||
icon: "refresh",
|
||||
onClick: () => void console.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeDefinition?.kind === "spatial") {
|
||||
actions.push(
|
||||
{ label: "Настроить источник", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
onClick: openLayout,
|
||||
});
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandLabel="NODE.DC"
|
||||
left={<span className="station-label">ПУНКТ УПРАВЛЕНИЯ</span>}
|
||||
center={
|
||||
<>
|
||||
<HeaderWorkspace kind="mark" label="NODE.DC Пункт управления" imageUrl="/nodedc-mark.svg" />
|
||||
<HeaderNavigation
|
||||
label="Архитектурные блоки пункта управления"
|
||||
value={activeRoot ?? undefined}
|
||||
items={roots.map((root) => ({ value: root.id, label: root.label }))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void console.refresh()} title="Обновить локальный контур">
|
||||
<span className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(console.backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar label="DC" />
|
||||
</HeaderProfile>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApplicationShell
|
||||
data-nodedc-ui
|
||||
className="control-station"
|
||||
navigationOpen={workspace.navigationOpen && activeRoot !== null}
|
||||
contentOpen={workspace.contentOpen && activeDefinition !== null}
|
||||
contentExpanded={workspace.contentExpanded}
|
||||
header={header}
|
||||
stage={
|
||||
<LandingStage
|
||||
root={currentRoot}
|
||||
backendStatus={console.backendStatus}
|
||||
phase={console.state?.phase}
|
||||
message={localizeRuntimeMessage(console.state?.message)}
|
||||
onOpenObservation={() => openView("spatial-scene")}
|
||||
onOpenDevice={() => openView("local-device")}
|
||||
/>
|
||||
}
|
||||
navigation={currentRoot ? (
|
||||
<AdminNavigationPanel
|
||||
eyebrow="NODE.DC"
|
||||
title={currentRoot.title}
|
||||
closeLabel={`Закрыть раздел «${currentRoot.label}»`}
|
||||
navigationLabel={`Рабочие поверхности раздела «${currentRoot.label}»`}
|
||||
onClose={workspace.closeNavigation}
|
||||
contexts={[
|
||||
{
|
||||
id: "local-contour",
|
||||
label: "Локальный контур",
|
||||
description: console.state?.k1_ip || "Устройство не назначено",
|
||||
icon: <Icon name="network" />,
|
||||
active: console.backendStatus !== "offline",
|
||||
},
|
||||
]}
|
||||
items={rootWorkspaces.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
icon: <Icon name={item.icon} />,
|
||||
}))}
|
||||
activeId={workspace.activeView ?? undefined}
|
||||
onItemChange={openView}
|
||||
footer={
|
||||
<>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
<Icon name="activity" />
|
||||
</span>
|
||||
<span>{rootWorkspaces.length} рабочих поверхностей</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
content={activeDefinition ? (
|
||||
<ApplicationPanel
|
||||
key={activeDefinition.id}
|
||||
eyebrow={activeDefinition.eyebrow}
|
||||
title={activeDefinition.title}
|
||||
description={activeDefinition.description}
|
||||
expanded={workspace.contentExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
headerTools={
|
||||
activeDefinition.kind === "device" ? (
|
||||
<StatusBadge tone={phaseTone(console.state?.phase)}>
|
||||
{phaseLabel(console.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
}
|
||||
utilityActions={contentActions}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
console={console}
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
state={console.state}
|
||||
backendStatus={console.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
sceneSettings={sceneSettings}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
openDisplay,
|
||||
openLayers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ApplicationPanel>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={sourceWindowOpen}
|
||||
title="Источники"
|
||||
subtitle="Потоки и записи пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--sources"
|
||||
data-scene-window="sources"
|
||||
data-scene-active={activeSceneWindow === "sources" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("sources")}
|
||||
onClose={() => closeSceneWindow("sources")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
}}
|
||||
>
|
||||
Сбросить адрес
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!sourceDraft.trim()}
|
||||
onClick={() => {
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
}}
|
||||
>
|
||||
Применить адрес
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["connection"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "connection",
|
||||
label: "Подключение",
|
||||
description: "Адрес потока или записи",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Состояние">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
|
||||
</span>
|
||||
</ControlRow>
|
||||
{effectiveSourceUrl ? (
|
||||
<ControlRow label="Активный адрес" layout="stack">
|
||||
<code className="scene-window-code">{effectiveSourceUrl}</code>
|
||||
</ControlRow>
|
||||
) : null}
|
||||
<ControlRow label="Автоматический" layout="stack">
|
||||
<code className="scene-window-code">
|
||||
{automaticSourceUrl || "Локальный источник ещё не опубликован"}
|
||||
</code>
|
||||
</ControlRow>
|
||||
<TextField
|
||||
label="Ручной адрес"
|
||||
hint="необязательно"
|
||||
value={sourceDraft}
|
||||
onChange={(event) => setSourceDraft(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="rerun+http://127.0.0.1:9876/proxy"
|
||||
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "formats",
|
||||
label: "Поддерживаемые адреса",
|
||||
description: "Rerun gRPC и RRD",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Живой поток" layout="stack">
|
||||
<code className="scene-window-code">rerun+http://…/proxy</code>
|
||||
</ControlRow>
|
||||
<ControlRow label="Локальная запись" layout="stack">
|
||||
<code className="scene-window-code">http://…/recording.rrd</code>
|
||||
</ControlRow>
|
||||
<ControlRow label="Удалённая запись" layout="stack">
|
||||
<code className="scene-window-code">https://…/recording.rrd</code>
|
||||
</ControlRow>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "adapter",
|
||||
label: "Локальный адаптер",
|
||||
description: "Транспорт устройства",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Контур">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(console.backendStatus)}
|
||||
</span>
|
||||
</ControlRow>
|
||||
<p className="scene-window-note">
|
||||
Встроенный адаптер публикует локальный Rerun gRPC автоматически после запуска
|
||||
живого приёма или повтора записи. Ручной адрес для этого не требуется.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={displayWindowOpen}
|
||||
title="Отображение"
|
||||
subtitle="Параметры пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--display"
|
||||
data-scene-window="display"
|
||||
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={console.pendingAction === "viewer"}
|
||||
onClick={() => void applyDisplaySettings()}
|
||||
>
|
||||
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["points"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
|
||||
/>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={paletteOptions}
|
||||
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
{displayDraft.palette === "custom" ? (
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
|
||||
/>
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
description="Линия пути устройства в координатах сцены"
|
||||
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scene",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
|
||||
<Checker checked={false} disabled label="Подписи сущностей" description="Появятся после подключения семантических сущностей" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layerInspectorOpen}
|
||||
title="Слои сцены"
|
||||
subtitle="Сущности пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--layers"
|
||||
data-scene-window="layers"
|
||||
data-scene-active={activeSceneWindow === "layers" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("layers")}
|
||||
onClose={() => closeSceneWindow("layers")}
|
||||
>
|
||||
<div className="layer-inspector-intro">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
|
||||
</span>
|
||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["geometry"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "geometry",
|
||||
label: "Геометрия",
|
||||
description: "Облако точек и путь",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" description="Основной поток геометрии лидара" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" description="Положение и ориентация устройства во времени" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "frames",
|
||||
label: "Координаты и камеры",
|
||||
description: "Преобразования и области обзора",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
||||
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "perception",
|
||||
label: "Объекты и маски",
|
||||
description: "Ожидают внешние обработчики",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={false} disabled label="Рамки 2D и 3D" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Маски сегментации" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Ключевые точки и треки" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layoutWindowOpen}
|
||||
title="Компоновка рабочей области"
|
||||
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
|
||||
size="sm"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--layout"
|
||||
data-scene-window="layout"
|
||||
data-scene-active={activeSceneWindow === "layout" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("layout")}
|
||||
onClose={() => closeSceneWindow("layout")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!layoutName.trim()}
|
||||
onClick={() => setLayoutDraftSaved(true)}
|
||||
>
|
||||
Зафиксировать черновик
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<TextField
|
||||
label="Название компоновки"
|
||||
value={layoutName}
|
||||
onChange={(event) => {
|
||||
setLayoutName(event.target.value);
|
||||
setLayoutDraftSaved(false);
|
||||
}}
|
||||
placeholder="Название профиля"
|
||||
/>
|
||||
<div className="modal-contract-note" data-tone={layoutDraftSaved ? "success" : "warning"}>
|
||||
<Icon name={layoutDraftSaved ? "check" : "save"} />
|
||||
<div>
|
||||
<strong>{layoutDraftSaved ? "Черновик зафиксирован в текущем сеансе" : "Экспорт RBL ещё не подключён"}</strong>
|
||||
<p>
|
||||
{layoutDraftSaved
|
||||
? "Это состояние интерфейса без записи на диск. Будущий адаптер сохранит профиль Rerun рядом с кодом."
|
||||
: "Окно и контракт сохранения готовы; запись файла не имитируется."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
export interface BleDevice {
|
||||
device_id: string;
|
||||
name?: string | null;
|
||||
rssi?: number | null;
|
||||
address?: string | null;
|
||||
connectable?: boolean | null;
|
||||
likely_k1?: boolean | null;
|
||||
}
|
||||
|
||||
export type SourceMode = "idle" | "live" | "replay";
|
||||
|
||||
export interface K1Metrics {
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
pipeline_ms?: number | null;
|
||||
end_to_end_ms?: number | null;
|
||||
frame_rate?: number | null;
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface ConsoleState {
|
||||
phase?: string | null;
|
||||
message?: string | null;
|
||||
devices?: BleDevice[];
|
||||
selected_device_id?: string | null;
|
||||
k1_ip?: string | null;
|
||||
foxglove_ws_url?: string | null;
|
||||
foxglove_viewer_url?: string | null;
|
||||
rerun_grpc_url?: string | null;
|
||||
viewer_settings?: ViewerSettings | null;
|
||||
source_mode?: SourceMode | null;
|
||||
metrics?: K1Metrics;
|
||||
}
|
||||
|
||||
export interface ViewerSettings {
|
||||
point_size: number;
|
||||
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
service?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ScanRequest {
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
ssid: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LiveRequest {
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface ReplayRequest {
|
||||
path: string;
|
||||
speed?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function unwrapState(payload: unknown): ConsoleState {
|
||||
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
|
||||
|
||||
if (!isRecord(value)) {
|
||||
throw new ApiError("Локальный сервер вернул некорректное состояние.");
|
||||
}
|
||||
|
||||
return value as ConsoleState;
|
||||
}
|
||||
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
let body: unknown;
|
||||
|
||||
if (bodyText) {
|
||||
try {
|
||||
body = JSON.parse(bodyText) as unknown;
|
||||
} catch {
|
||||
body = bodyText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
isRecord(body) && typeof body.detail === "string"
|
||||
? body.detail
|
||||
: typeof body === "string" && body.trim()
|
||||
? body.trim()
|
||||
: `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
|
||||
throw new ApiError(
|
||||
detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postState(path: string, body?: object): Promise<ConsoleState> {
|
||||
const payload = await requestJson(path, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (payload === undefined) {
|
||||
return api.getState();
|
||||
}
|
||||
|
||||
return unwrapState(payload);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async getHealth(): Promise<HealthResponse> {
|
||||
const payload = await requestJson("/api/health");
|
||||
if (!isRecord(payload)) {
|
||||
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
|
||||
}
|
||||
return payload as HealthResponse;
|
||||
},
|
||||
|
||||
async getState(): Promise<ConsoleState> {
|
||||
return unwrapState(await requestJson("/api/state"));
|
||||
},
|
||||
|
||||
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/ble/scan", body);
|
||||
},
|
||||
|
||||
connect(body: ConnectRequest): Promise<ConsoleState> {
|
||||
return postState("/api/connect", body);
|
||||
},
|
||||
|
||||
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/session/live", body);
|
||||
},
|
||||
|
||||
startReplay(body: ReplayRequest): Promise<ConsoleState> {
|
||||
return postState("/api/session/replay", body);
|
||||
},
|
||||
|
||||
stopSession(): Promise<ConsoleState> {
|
||||
return postState("/api/session/stop");
|
||||
},
|
||||
|
||||
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
|
||||
return postState("/api/viewer/settings", body);
|
||||
},
|
||||
};
|
||||
|
||||
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
function eventSocketUrl(): string {
|
||||
const url = new URL("/api/events", window.location.href);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function openEventSocket(
|
||||
onState: (state: ConsoleState) => void,
|
||||
onStatus: (status: EventSocketStatus) => void,
|
||||
): () => void {
|
||||
onStatus("connecting");
|
||||
const socket = new WebSocket(eventSocketUrl());
|
||||
|
||||
socket.addEventListener("open", () => onStatus("open"));
|
||||
socket.addEventListener("message", (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data)) as unknown;
|
||||
onState(unwrapState(payload));
|
||||
} catch {
|
||||
// The REST poll remains authoritative if an unrelated event is received.
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => onStatus("error"));
|
||||
socket.addEventListener("close", () => onStatus("closed"));
|
||||
|
||||
return () => socket.close(1000, "Пункт управления закрыт");
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export interface RerunSelection {
|
||||
entityPath: string;
|
||||
viewName?: string;
|
||||
position?: [number, number, number];
|
||||
}
|
||||
|
||||
export interface RerunViewportProps {
|
||||
sourceUrl: string;
|
||||
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
||||
onSelectionChange?: (selection: RerunSelection | null) => void;
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
sourceUrl,
|
||||
onStatusChange,
|
||||
onSelectionChange,
|
||||
}: RerunViewportProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSource = sourceUrl.trim();
|
||||
if (!normalizedSource || !hostRef.current) {
|
||||
setStatus("idle");
|
||||
onStatusChange?.("idle");
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let stopViewer: (() => void) | undefined;
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
setStatus("loading");
|
||||
onStatusChange?.("loading");
|
||||
|
||||
void import("@rerun-io/web-viewer")
|
||||
.then(async ({ WebViewer }) => {
|
||||
const viewer = new WebViewer();
|
||||
stopViewer = () => {
|
||||
// Remove the gRPC receiver explicitly before tearing down WASM. This
|
||||
// closes the browser-side stream promptly so the local SDK server can
|
||||
// release its port before the next device session starts.
|
||||
try {
|
||||
viewer.close(normalizedSource);
|
||||
} catch {
|
||||
// The viewer can already be stopped after a startup failure.
|
||||
}
|
||||
viewer.stop();
|
||||
};
|
||||
|
||||
await viewer.start(
|
||||
normalizedSource,
|
||||
hostRef.current,
|
||||
{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
theme: "dark",
|
||||
hide_welcome_screen: true,
|
||||
enable_history: false,
|
||||
allow_fullscreen: false,
|
||||
},
|
||||
null,
|
||||
);
|
||||
if (disposed) {
|
||||
stopViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
viewer.override_panel_state("top", "hidden");
|
||||
viewer.override_panel_state("blueprint", "hidden");
|
||||
viewer.override_panel_state("selection", "hidden");
|
||||
viewer.override_panel_state("time", "hidden");
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("selection_change", (event) => {
|
||||
const entity = event.items.find((item) => item.type === "entity");
|
||||
if (!entity || entity.type !== "entity") {
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
onSelectionChange?.({
|
||||
entityPath: entity.entity_path,
|
||||
viewName: entity.view_name,
|
||||
position: entity.position,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
setStatus("ready");
|
||||
onStatusChange?.("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
const message = "Не удалось запустить встроенный визуализатор.";
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
stopViewer?.();
|
||||
onSelectionChange?.(null);
|
||||
};
|
||||
}, [onSelectionChange, onStatusChange, sourceUrl]);
|
||||
|
||||
return (
|
||||
<div className="rerun-viewport" data-status={status}>
|
||||
<div ref={hostRef} className="rerun-viewport__canvas" />
|
||||
{status === "loading" ? (
|
||||
<div className="rerun-viewport__notice" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Подключаем визуальный источник</strong>
|
||||
<span>Запуск встроенного визуализатора Rerun.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
|
||||
<div>
|
||||
<strong>Источник не открыт</strong>
|
||||
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||
[
|
||||
/Device was not rediscovered; keep the K1 powered and nearby\.?/gi,
|
||||
"Устройство не обнаружено повторно; оставьте его включённым и рядом.",
|
||||
],
|
||||
[/Foxglove/gi, "локальный мост визуализации"],
|
||||
[/MacBook/gi, "компьютер"],
|
||||
[/\bK1\b/g, "устройство"],
|
||||
];
|
||||
|
||||
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
|
||||
const localized = runtimeMessageReplacements.reduce(
|
||||
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
|
||||
message,
|
||||
);
|
||||
|
||||
const withoutTechnicalTerms = localized.replace(
|
||||
/\b(?:API|BLE|Bluetooth|gRPC|IP|JSON|K1MQTT|MQTT|Rerun|RRD|TSV|UUID|Wi-Fi)\b/gi,
|
||||
"",
|
||||
);
|
||||
if (/[A-Za-z]{3,}/.test(withoutTechnicalTerms)) {
|
||||
return "Операция не выполнена. Технические подробности сохранены в журнале сервера.";
|
||||
}
|
||||
|
||||
return localized;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
openEventSocket,
|
||||
type ConnectRequest,
|
||||
type ConsoleState,
|
||||
type EventSocketStatus,
|
||||
type LiveRequest,
|
||||
type ReplayRequest,
|
||||
type ViewerSettings,
|
||||
} from "./api";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
|
||||
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
||||
return error.status
|
||||
? `${message} (HTTP ${error.status})`
|
||||
: message;
|
||||
}
|
||||
return "Запрос к локальному сервису устройства завершился ошибкой.";
|
||||
}
|
||||
|
||||
function measuredLatency(state: ConsoleState | null): number | null {
|
||||
if (state?.source_mode !== "live") return null;
|
||||
const metrics = state?.metrics;
|
||||
if (!metrics) return null;
|
||||
|
||||
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
|
||||
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
|
||||
|
||||
const segments = [
|
||||
metrics.mqtt_to_decode_ms,
|
||||
metrics.publish_ms,
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
|
||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
export function useK1Console() {
|
||||
const [state, setState] = useState<ConsoleState | null>(null);
|
||||
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
|
||||
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const acceptState = useCallback((nextState: ConsoleState) => {
|
||||
setState(nextState);
|
||||
setBackendStatus("online");
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (reportErrors = true) => {
|
||||
const [healthResult, stateResult] = await Promise.allSettled([
|
||||
api.getHealth(),
|
||||
api.getState(),
|
||||
]);
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
if (stateResult.status === "fulfilled") {
|
||||
acceptState(stateResult.value);
|
||||
if (reportErrors) setError(null);
|
||||
}
|
||||
|
||||
if (healthResult.status === "fulfilled") {
|
||||
const health = healthResult.value;
|
||||
const healthy = health.ok !== false && health.status !== "error";
|
||||
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
|
||||
} else if (stateResult.status === "rejected") {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
|
||||
if (stateResult.status === "rejected" && reportErrors) {
|
||||
setError(messageFor(stateResult.reason));
|
||||
}
|
||||
}, [acceptState]);
|
||||
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextState = await operation();
|
||||
if (mounted.current) acceptState(nextState);
|
||||
return true;
|
||||
} catch (operationError) {
|
||||
if (mounted.current) {
|
||||
setError(messageFor(operationError));
|
||||
if (operationError instanceof ApiError && operationError.status === 0) {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted.current) setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[acceptState],
|
||||
);
|
||||
|
||||
const scan = useCallback(
|
||||
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
|
||||
[run],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
(request: ConnectRequest) => run("connect", () => api.connect(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startLive = useCallback(
|
||||
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startReplay = useCallback(
|
||||
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
() => run("stop", () => api.stopSession()),
|
||||
[run],
|
||||
);
|
||||
|
||||
const updateViewerSettings = useCallback(
|
||||
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh(true);
|
||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
let retry: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
dispose = openEventSocket(acceptState, (status) => {
|
||||
if (cancelled) return;
|
||||
setEventStatus(status);
|
||||
if ((status === "closed" || status === "error") && retry === undefined) {
|
||||
retry = window.setTimeout(() => {
|
||||
retry = undefined;
|
||||
connectEvents();
|
||||
}, 3_000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connectEvents();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retry !== undefined) window.clearTimeout(retry);
|
||||
dispose?.();
|
||||
};
|
||||
}, [acceptState]);
|
||||
|
||||
useEffect(() => {
|
||||
const latency = measuredLatency(state);
|
||||
if (latency === null) {
|
||||
setLatencyHistory((values) => (values.length ? [] : values));
|
||||
return;
|
||||
}
|
||||
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh: () => refresh(true),
|
||||
clearError: () => setError(null),
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
updateViewerSettings,
|
||||
};
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { BleDevice } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import {
|
||||
backendLabel,
|
||||
backendTone,
|
||||
eventStatusLabel,
|
||||
finiteMetric,
|
||||
formatNumber,
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
pipelineLatency,
|
||||
sourceModeLabel,
|
||||
} from "../presentation";
|
||||
import type { useK1Console } from "../useK1Console";
|
||||
|
||||
type ConsoleController = ReturnType<typeof useK1Console>;
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Реальное устройство" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardStep({
|
||||
number,
|
||||
title,
|
||||
status,
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
tone?: StatusTone;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="wizard-step">
|
||||
<div className="wizard-step__rail" aria-hidden="true">
|
||||
<span>{number}</span>
|
||||
</div>
|
||||
<div className="wizard-step__content">
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
<StatusBadge tone={tone}>{status}</StatusBadge>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({
|
||||
device,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
device: BleDevice;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="device-row"
|
||||
data-compatible={device.likely_k1 ? "true" : undefined}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
>
|
||||
<div className="device-row__identity">
|
||||
<span className="device-row__signal" aria-hidden="true" />
|
||||
<div>
|
||||
<span className="device-row__name">
|
||||
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
||||
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
|
||||
</span>
|
||||
<code>{device.device_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="device-row__action">
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "primary" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{selected ? "Выбрано" : "Выбрать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyTrace({ values }: { values: number[] }) {
|
||||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<span
|
||||
key={`${index}-${value}`}
|
||||
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
|
||||
title={`${value.toFixed(1)} мс`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p>Измерений пока нет. График появится после получения реальных данных.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace({
|
||||
console,
|
||||
onOpenSpatialScene,
|
||||
}: {
|
||||
console: ConsoleController;
|
||||
onOpenSpatialScene: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh,
|
||||
clearError,
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
} = console;
|
||||
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
const [liveHost, setLiveHost] = useState("");
|
||||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedDeviceId &&
|
||||
state?.devices &&
|
||||
!state.devices.some((device) => device.device_id === selectedDeviceId)
|
||||
) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
|
||||
const sourceLabel = sourceModeLabel(state?.source_mode);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
});
|
||||
if (succeeded) setPassword("");
|
||||
};
|
||||
|
||||
const submitLive = async () => {
|
||||
const host = liveHost.trim();
|
||||
const started = await startLive(host ? { host } : {});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
const speed = Number(replaySpeed);
|
||||
const started = await startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="device-workspace">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||
Повторить
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
|
||||
<h2>Подключение первого устройства</h2>
|
||||
<p>
|
||||
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
|
||||
операторская модель от конкретного устройства не зависят.
|
||||
</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА КАДРОВ"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение адаптера"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Реальное число декодированных точек"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Исходные данные при этом сохраняются"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<GlassSurface className="connection-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||
<h2>Подключите устройство к сети</h2>
|
||||
</div>
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="wizard-list">
|
||||
<WizardStep
|
||||
number="01"
|
||||
title="Включите устройство"
|
||||
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
|
||||
tone={powerConfirmed ? "success" : "warning"}
|
||||
>
|
||||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="Устройство включено, индикатор стабилен"
|
||||
description="Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия."
|
||||
onChange={setPowerConfirmed}
|
||||
/>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="02"
|
||||
title="Выберите Bluetooth-устройство"
|
||||
status={
|
||||
pendingAction === "scan"
|
||||
? "Поиск…"
|
||||
: selectedDeviceId
|
||||
? "Устройство выбрано"
|
||||
: `Найдено: ${devices.length}`
|
||||
}
|
||||
tone={
|
||||
pendingAction === "scan"
|
||||
? "accent"
|
||||
: selectedDeviceId
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
<p className="step-copy">
|
||||
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
|
||||
профиль — только подсказка; окончательный выбор всегда делает оператор.
|
||||
</p>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => {
|
||||
setSelectedDeviceId("");
|
||||
void scan();
|
||||
}}
|
||||
>
|
||||
{pendingAction === "scan"
|
||||
? "Сканируем Bluetooth — 6 секунд…"
|
||||
: "Показать все BLE-устройства"}
|
||||
</Button>
|
||||
<div className="device-list">
|
||||
{devices.length ? (
|
||||
devices.map((device) => (
|
||||
<DeviceRow
|
||||
key={device.device_id}
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={() => setSelectedDeviceId(device.device_id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-device-list">
|
||||
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
|
||||
повторите поиск.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Передайте настройки Wi‑Fi"
|
||||
status={state?.k1_ip ? "Подключено" : "Не подключено"}
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<TextField
|
||||
label="Название сети Wi‑Fi"
|
||||
hint="SSID"
|
||||
value={ssid}
|
||||
onChange={(event) => setSsid(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Сеть локального контура"
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль Wi‑Fi"
|
||||
hint="Только в оперативной памяти"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
<div className="connection-summary">
|
||||
<span>Устройство</span>
|
||||
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
variant="primary"
|
||||
icon={<Icon name="network" />}
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
>
|
||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||
</Button>
|
||||
<p className="safety-note">
|
||||
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
|
||||
удаляется из формы после успешного подключения.
|
||||
</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="device-workspace__side">
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||
</span>
|
||||
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
||||
{sourceLabel}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<SegmentedControl
|
||||
label="Источник данных"
|
||||
value={sessionIntent}
|
||||
items={sessionItems}
|
||||
onChange={setSessionIntent}
|
||||
/>
|
||||
|
||||
{sessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
hint="Обычно определяется автоматически"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !liveTargetReady}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||
</Button>
|
||||
<p className="live-instruction">
|
||||
{liveTargetReady
|
||||
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField
|
||||
label="Путь к записи"
|
||||
hint="Локальный файл исходных данных"
|
||||
value={replayPath}
|
||||
onChange={(event) => setReplayPath(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="sessions/.../capture.tsv"
|
||||
/>
|
||||
<TextField
|
||||
label="Скорость повтора"
|
||||
hint="Множитель"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={replaySpeed}
|
||||
onChange={(event) => setReplaySpeed(event.target.value)}
|
||||
/>
|
||||
<Checker
|
||||
checked={replayLoop}
|
||||
label="Повторять по кругу"
|
||||
description="После последнего кадра начать запись заново."
|
||||
onChange={setReplayLoop}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
onClick={() => void submitReplay()}
|
||||
>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="session-footer">
|
||||
<p>Статус изменится только после ответа локального сервиса.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="diagnostics-grid">
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
|
||||
<h2>Локальный контур</h2>
|
||||
</div>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</header>
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Канал событий">
|
||||
<span className="inline-state" data-state={eventStatus}>
|
||||
{eventStatusLabel(eventStatus)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="Адрес устройства">
|
||||
<code>{state?.k1_ip || "Не получен"}</code>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="latency-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||
<h2>Время до публикации</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>мс</span>
|
||||
</strong>
|
||||
</header>
|
||||
<LatencyTrace values={latencyHistory} />
|
||||
<div className="latency-legend">
|
||||
<span>Старые</span>
|
||||
<span>Последние</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ConsoleState } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import {
|
||||
RerunViewport,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
} from "../components/RerunViewport";
|
||||
import {
|
||||
capabilityStatusLabel,
|
||||
type CapabilityStatus,
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
if (status === "contract") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||
return (
|
||||
<div className="feature-inventory">
|
||||
{definition.groups.map((group) => (
|
||||
<section className="feature-group" key={group.title}>
|
||||
<header>
|
||||
<span className="section-eyebrow">ФУНКЦИОНАЛЬНЫЙ БЛОК</span>
|
||||
<h3>{group.title}</h3>
|
||||
<p>{group.description}</p>
|
||||
</header>
|
||||
<div className="feature-list">
|
||||
{group.capabilities.map((capability) => (
|
||||
<div className="feature-row" key={capability.label} data-status={capability.status}>
|
||||
<span className="feature-row__mark" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{capability.label}</strong>
|
||||
<p>{capability.description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone(capability.status)}>
|
||||
{capabilityStatusLabel[capability.status]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||
return (
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||
<h2>{definition.title}</h2>
|
||||
<p>{definition.description}</p>
|
||||
</div>
|
||||
{note ? <span className="workspace-lead__note">{note}</span> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: ConsoleState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
sceneSettings: SceneSettings;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
||||
function OverviewWorkspace({
|
||||
definition,
|
||||
state,
|
||||
backendStatus,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const adapterOnline = backendStatus === "online";
|
||||
const rerunReady = Boolean(state?.rerun_grpc_url);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace overview-workspace">
|
||||
<WorkspaceLead
|
||||
definition={definition}
|
||||
note="Числа появляются только из реального локального контура"
|
||||
/>
|
||||
<section className="metrics-grid" aria-label="Оперативные показатели">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение потока"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Без синтетического заполнения"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="РЕЖИМ"
|
||||
value={sourceModeLabel(state?.source_mode)}
|
||||
detail="Реальное время, повтор или ожидание"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="overview-grid">
|
||||
<GlassSurface className="contour-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
|
||||
<h2>Контур наблюдения</h2>
|
||||
</div>
|
||||
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
|
||||
{adapterOnline ? "Контур доступен" : "Нет связи"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="pipeline-strip" aria-label="Путь данных">
|
||||
<div data-state={state?.k1_ip ? "ready" : "idle"}>
|
||||
<span>01</span>
|
||||
<strong>Устройство</strong>
|
||||
<small>{state?.k1_ip || "не назначено"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={streamActive ? "ready" : "idle"}>
|
||||
<span>02</span>
|
||||
<strong>Адаптер</strong>
|
||||
<small>{sourceModeLabel(state?.source_mode)}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={rerunReady ? "ready" : "idle"}>
|
||||
<span>03</span>
|
||||
<strong>Rerun</strong>
|
||||
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state="ready">
|
||||
<span>04</span>
|
||||
<strong>Оператор</strong>
|
||||
<small>интерфейс готов</small>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="next-actions-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
|
||||
<h2>Продолжить работу</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="action-list">
|
||||
<button type="button" onClick={() => navigation.openView("local-device")}>
|
||||
<span><Icon name="network" /></span>
|
||||
<div>
|
||||
<strong>Подключить устройство</strong>
|
||||
<small>Bluetooth, Wi‑Fi и запуск потока</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigation.openView("spatial-scene")}>
|
||||
<span><Icon name="globe" /></span>
|
||||
<div>
|
||||
<strong>Открыть пространственную сцену</strong>
|
||||
<small>Облако точек, траектория и слои</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigation.openView("streams")}>
|
||||
<span><Icon name="activity" /></span>
|
||||
<div>
|
||||
<strong>Проверить потоки</strong>
|
||||
<small>Транспорт, схемы и готовность</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
<div className="empty-spatial-stage__grid" aria-hidden="true" />
|
||||
<div className="spatial-axis" aria-hidden="true">
|
||||
<span data-axis="x">X</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
|
||||
<strong>Визуальный источник не назначен</strong>
|
||||
<p>
|
||||
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
|
||||
открыть реальные данные.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
sceneSettings,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
}, []);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[viewerStatus];
|
||||
|
||||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||
|
||||
return (
|
||||
<div className="spatial-workspace">
|
||||
<div className="spatial-toolbar">
|
||||
<div className="spatial-toolbar__mode">
|
||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||
</div>
|
||||
<div className="spatial-toolbar__actions">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
Источник
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||
Слои
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
||||
Отображение
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spatial-viewport-shell">
|
||||
{sourceUrl.trim() ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
<div className="scene-status scene-status--top-left">
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
|
||||
<div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Точек</span>
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="scene-timeline">
|
||||
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
|
||||
Воспроизвести
|
||||
</Button>
|
||||
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
|
||||
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
|
||||
</div>
|
||||
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
|
||||
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
|
||||
ЭФИР
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state="contract" />Объекты / маски</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace">
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
<div className="camera-grid">
|
||||
{slots.map((slot, index) => (
|
||||
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
|
||||
<div className="camera-slot__grid" aria-hidden="true" />
|
||||
<header>
|
||||
<span>{slot}</span>
|
||||
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
|
||||
</header>
|
||||
<div className="camera-slot__empty">
|
||||
<Icon name={index === 3 ? "image" : "video"} />
|
||||
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
|
||||
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
|
||||
</footer>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const isMissionRoute = definition.id === "routes";
|
||||
return (
|
||||
<div className="standard-workspace map-workspace">
|
||||
<WorkspaceLead
|
||||
definition={definition}
|
||||
note={isMissionRoute ? "Схема интерфейса — координаты и командный канал не подключены" : "Картографический модуль не подключён"}
|
||||
/>
|
||||
<div className="map-layout">
|
||||
<div className="map-canvas" data-route={isMissionRoute ? "true" : undefined}>
|
||||
<div className="map-canvas__grid" aria-hidden="true" />
|
||||
<div className="map-canvas__contours" aria-hidden="true">
|
||||
<span /><span /><span /><span />
|
||||
</div>
|
||||
<svg viewBox="0 0 1000 560" role="img" aria-label="Схематический черновик маршрута без географических координат">
|
||||
<path d="M165 420 C 235 350, 272 375, 340 292 S 480 235, 545 290 S 680 340, 765 210 S 845 125, 892 155" />
|
||||
<circle cx="165" cy="420" r="8" />
|
||||
<circle cx="340" cy="292" r="8" />
|
||||
<circle cx="545" cy="290" r="8" />
|
||||
<circle cx="765" cy="210" r="8" />
|
||||
<circle cx="892" cy="155" r="8" />
|
||||
</svg>
|
||||
<div className="map-point-label" style={{ left: "15%", top: "72%" }}>A · старт</div>
|
||||
<div className="map-point-label" style={{ left: "52%", top: "49%" }}>C · наблюдение</div>
|
||||
<div className="map-point-label" style={{ left: "84%", top: "22%" }}>E · финиш</div>
|
||||
<div className="map-canvas__notice">
|
||||
<StatusBadge tone="warning">Схема интерфейса</StatusBadge>
|
||||
<span>Не является картой местности и не содержит реальных координат.</span>
|
||||
</div>
|
||||
</div>
|
||||
<GlassSurface className="map-inspector" padding="lg">
|
||||
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
|
||||
<h3>Рабочая композиция</h3>
|
||||
<div className="layer-summary-list">
|
||||
<div><span data-tone="cyan" /><strong>Маршрут</strong><small>5 точек · схема</small></div>
|
||||
<div><span data-tone="violet" /><strong>Геозоны</strong><small>не назначены</small></div>
|
||||
<div><span data-tone="green" /><strong>Высота</strong><small>контракт GridMap</small></div>
|
||||
<div><span data-tone="orange" /><strong>Проходимость</strong><small>обработчик не подключён</small></div>
|
||||
</div>
|
||||
<Button variant="secondary" width="full" disabled>Редактировать точки</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
|
||||
const rows = useMemo(() => [
|
||||
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) },
|
||||
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) },
|
||||
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
|
||||
{ label: "Камеры", value: "каналы не назначены", active: false },
|
||||
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
|
||||
], [state]);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace timeline-workspace">
|
||||
<WorkspaceLead definition={definition} note="Отображаются только доступные состояния" />
|
||||
<GlassSurface className="timeline-panel" padding="lg">
|
||||
<header className="timeline-panel__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">СИНХРОННЫЕ КАНАЛЫ</span>
|
||||
<h3>Текущий интервал</h3>
|
||||
</div>
|
||||
<div className="timeline-panel__controls">
|
||||
<Button size="compact" variant="secondary" disabled>Воспроизвести</Button>
|
||||
<code>—:—:—.———</code>
|
||||
</div>
|
||||
</header>
|
||||
<div className="timeline-ruler" aria-hidden="true">
|
||||
{Array.from({ length: 11 }, (_, index) => <span key={index}>{index * 10}</span>)}
|
||||
</div>
|
||||
<div className="timeline-lanes">
|
||||
{rows.map((row, index) => (
|
||||
<div className="timeline-lane" key={row.label} data-active={row.active ? "true" : undefined}>
|
||||
<strong>{row.label}</strong>
|
||||
<div><span style={{ left: row.active ? `${18 + index * 9}%` : "0" }} /></div>
|
||||
<small>{row.value}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const steps = [
|
||||
{ id: "01", label: "Аппарат", value: "Не назначен" },
|
||||
{ id: "02", label: "Зона", value: "Не задана" },
|
||||
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
|
||||
{ id: "04", label: "Наблюдение", value: "Облако точек" },
|
||||
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
|
||||
];
|
||||
return (
|
||||
<div className="standard-workspace mission-workspace">
|
||||
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
|
||||
<div className="mission-layout">
|
||||
<GlassSurface className="mission-sequence" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
|
||||
<h2>Новая миссия</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
</header>
|
||||
<div className="mission-steps">
|
||||
{steps.map((step) => (
|
||||
<button key={step.id} type="button" disabled>
|
||||
<span>{step.id}</span>
|
||||
<div><strong>{step.label}</strong><small>{step.value}</small></div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<GlassSurface className="mission-summary" padding="lg">
|
||||
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
|
||||
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
|
||||
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
|
||||
<div className="mission-summary__checks">
|
||||
<span><i />Аппарат</span>
|
||||
<span><i />Геометрия</span>
|
||||
<span><i />Связь</span>
|
||||
<span><i />Безопасность</span>
|
||||
</div>
|
||||
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
|
||||
const active = definition.groups.reduce(
|
||||
(count, group) => count + group.capabilities.filter((capability) => capability.status === "active").length,
|
||||
0,
|
||||
);
|
||||
const ready = definition.groups.reduce(
|
||||
(count, group) => count + group.capabilities.filter((capability) => capability.status === "ready").length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace catalog-workspace">
|
||||
<WorkspaceLead definition={definition} note="Архитектурный и интерфейсный контракт" />
|
||||
<section className="capability-summary" aria-label="Готовность функционала">
|
||||
<div><span>Всего функций</span><strong>{total}</strong></div>
|
||||
<div><span>Работает сейчас</span><strong>{active}</strong></div>
|
||||
<div><span>Готово к данным</span><strong>{ready}</strong></div>
|
||||
<div><span>Без серверной логики</span><strong>{Math.max(0, total - active - ready)}</strong></div>
|
||||
</section>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
switch (props.definition.kind) {
|
||||
case "overview":
|
||||
return <OverviewWorkspace {...props} />;
|
||||
case "spatial":
|
||||
return <SpatialWorkspace {...props} />;
|
||||
case "cameras":
|
||||
return <CamerasWorkspace {...props} />;
|
||||
case "map":
|
||||
return <MapWorkspace {...props} />;
|
||||
case "timeline":
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
||||
|
||||
return {
|
||||
plugins: [react(), wasm()],
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: "127.0.0.1",
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
# Technical audit
|
||||
|
||||
Status: live feasibility validated, 2026-07-15.
|
||||
Status: live feasibility and camera-preview transport validated, 2026-07-16.
|
||||
|
||||
## Executive finding
|
||||
|
||||
@@ -11,9 +11,14 @@ physical-button scan and decoded both point-cloud and pose reports.
|
||||
|
||||
All 1,140 captured `lio_pcl` frames decoded as raw-LZ4 protobuf blocks, yielding
|
||||
4,165,862 points. All 1,215 `lio_pose` messages decoded, and the resulting
|
||||
approximately 1.566 m displacement matched the controlled movement. The
|
||||
remaining feasibility question is the panoramic camera branch, not the core
|
||||
realtime LiDAR/pose path.
|
||||
approximately 1.566 m displacement matched the controlled movement. A later
|
||||
owner-operated LixelGO/iPhone capture also observed left/right RTSP/H.264 camera
|
||||
preview and the remote start/stop wire mapping. Mission Core now has a bounded
|
||||
read-only receiver, physical live left/right UI acceptance, acquisition-owned
|
||||
fMP4 archival and a generation-bound recorded player. The remaining camera gate
|
||||
is a newly recorded real K1 session played end-to-end with its point/pose
|
||||
timeline. Full-resolution/raw panorama, device-clock synchronization,
|
||||
intrinsics/extrinsics and remote delivery are still unproven.
|
||||
|
||||
The supplied Bible is useful as an OSINT dossier. It is not an executable plan
|
||||
for the actual stand because many experiments assume a phone and LixelGO. The
|
||||
@@ -52,12 +57,14 @@ the local LAS alone: current LixelStudio materials describe K1 remote control
|
||||
and realtime point-cloud streaming over USB/Wi-Fi. LixelStudio is Windows-only
|
||||
and this does not disclose or guarantee access to the protocol from macOS. It
|
||||
does, however, justify keeping realtime point cloud as the primary stream target.
|
||||
No comparable official evidence proves an exportable raw panorama/camera stream.
|
||||
The physical Lab 002 capture now provides stronger evidence than the prior
|
||||
official-material inference: a compressed left/right preview leaves the device.
|
||||
It still does not prove a full-resolution raw panorama stream.
|
||||
|
||||
### Still unsupported until measured
|
||||
|
||||
- Local `map.las` layout resembles the network stream.
|
||||
- Raw or stitched panoramic camera frames leave the device.
|
||||
- Full-resolution raw or pre-stitched panoramic camera frames leave the device.
|
||||
- The upper 24 bits of point `rgbi` are usable packed RGB.
|
||||
- MQTT application commands can be published safely without first reproducing
|
||||
device/session header state and response handling.
|
||||
|
||||
+176
-10
@@ -3,7 +3,7 @@
|
||||
This plan supersedes the app-dependent experiment order in the reference Bible.
|
||||
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||
|
||||
## Live checkpoint — 2026-07-15
|
||||
## Current checkpoint — 2026-07-24
|
||||
|
||||
| Stage | Result |
|
||||
| --- | --- |
|
||||
@@ -11,21 +11,147 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||
| Gate 1 physical operation | GO — autonomous double-click start/stop verified |
|
||||
| Stage 1 BLE discovery | GO — repeatable advertisement and GATT profile |
|
||||
| Stage 2 BLE provisioning | GO — reviewed 99-byte profile, LAN association confirmed |
|
||||
| Stage 2 local connection matrix | GO for Bridge/direct-LAN; LAB-ONLY for Quick Connect — firmware review proved the K1 3.0.2 AP credential is firmware-constant and the SSID follows the `XGR-` identity/MAC rule. One prepared Mac completed AP-enable, AP-ready, CoreWLAN association and the normal control lifecycle. The successful host had been seeded from the reviewed firmware archive; a copied build on a clean host cannot acquire that material automatically. Automatic firmware download, iPhone extraction and hard-coding were rejected. Bridge remains the product path; Direct Connect remains physically pending |
|
||||
| Stage 3 application session | GO — MQTT 3.1.1 on confirmed K1 TCP 1883 |
|
||||
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
|
||||
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
|
||||
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
|
||||
| Stage 5 camera | PAUSE — no independent frame/video stream observed |
|
||||
| Stage 6 live viewer | GO — React console, Foxglove cloud/path and Mac latency metrics |
|
||||
| Stage 5 modeling telemetry | GO (read-only) — bounded `ModelingReport` decoder and device-reported scan time/distance/speed are integrated before the preview queue |
|
||||
| Stage 5 camera | GO (live) — left/right RTSP/H.264 preview observed, read-only runtime adapter and physical UI acceptance completed |
|
||||
| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory, plugin-owned spatial controls and live device/Mac metrics |
|
||||
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
|
||||
| Stage 7 recorded cameras | GO — the accepted physical session sealed real fMP4 camera data, produced two digest-bound recorded sources and passed manifest/init/segment range admission alongside a 107 MB RRD; browser QA opened real frames from the 170 MB camera epoch at multiple shared-timeline positions |
|
||||
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
|
||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||
| Simulation Polygon | SIM S0 GO — dedicated Docker-free D-only worker, exact accepted pins, stock Ackermann PX4/ROS 2/Gazebo telemetry, authoritative clock, pause/2 ms step, 1×/2× RTF/resource baselines, repeated clean lifecycle and all nine digest-bound evidence claims passed target doctor. S1 orchestrator/control, navigation/safety acceptance and real actuator authority remain absent |
|
||||
|
||||
USB project copying remains optional ground truth rather than a blocker for the
|
||||
now-verified network path. MQTT control publishing remains deliberately deferred
|
||||
because the physical button is a known-safe start/stop mechanism.
|
||||
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
||||
start/stop mapping and RTSP camera transport. The exact start/stop protobuf
|
||||
encoder, response correlator and device-status state machine now run in the
|
||||
separately gated interactive publisher; the legacy shadow publisher remains
|
||||
disabled. Retained PCAP plus client static analysis prove that OpenAPI is one
|
||||
private application-level value in this LixelGO build, not a per-scanner
|
||||
credential. The selected BLE peripheral returns its own LAN IP; the initial
|
||||
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
|
||||
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
|
||||
10 observed pre-START requests byte-for-byte across four response windows and
|
||||
five observed publish groups: initial DeviceInfo binding; then the ordered
|
||||
ModelingStatus/RTK and time-sync/DeviceInfo/RTK groups without an invented
|
||||
response barrier between them; NTRIP read; then cloud/RTK/DeviceInfo reads. The first
|
||||
ModelingStatus request has no mandatory synchronous response barrier; readiness
|
||||
is separately attested through live DeviceStatus. A fixed macOS Keychain loader
|
||||
now reads the exact 36-byte authority without environment/file/browser fallback.
|
||||
The response orchestrator correlates required headers/results and rejects
|
||||
identity/profile drift. The legacy shadow publisher remains a separate
|
||||
write-disabled type that cannot call its injected sink. Its dormant coordinator
|
||||
is wired into the XGRIDS facade only for redacted planning/state. Separately,
|
||||
plugin v0.5.0 installs the reviewed physical-acceptance transport behind explicit
|
||||
operator actions and one continuous background session owner. Each operation
|
||||
key is consumed before publish, automatic reconnect and application retry are
|
||||
forbidden, and any unknown outcome poisons the transport. Public state contains
|
||||
only bounded lifecycle, counter and failure metadata; authority and device
|
||||
identity remain private.
|
||||
The one-time `k1link authority provision` command delegates secret entry to the
|
||||
macOS Keychain TTY prompt and validates through the production loader without
|
||||
receiving the value in argv/environment/file/browser state. During the first
|
||||
physical attempt that CLI loader did not receive macOS approval, so a temporary
|
||||
process-local Security.framework lab adapter supplied the already reviewed
|
||||
value. The transport emitted bootstrap ordinals 1–6 and received all five
|
||||
required responses through the old third window, but topic-only routing
|
||||
misassociated the unbound and bound RTK responses and the orchestrator raised
|
||||
`ApplicationBootstrapError`. No START was emitted;
|
||||
live DeviceStatus stayed READY and the local point/pose counts remained zero.
|
||||
The local capture was sealed and the temporary Keychain item was deleted.
|
||||
The second physical attempt then correlated all ten bootstrap requests and one
|
||||
START success response. It nevertheless did not reproduce the full LixelGO
|
||||
lifecycle: all eleven publishes completed in 365 ms, the process did not retain
|
||||
the control session through initialization, and it omitted the immediate and
|
||||
post-initialization status reads present in both retained LixelGO START captures.
|
||||
K1 reported `SCAN_STARTING` for about 24 seconds, emitted system error
|
||||
`0x32040133` twice (the recovered `ALGORITHM_ERROR` namespace/value), returned
|
||||
to `OTHER_STATUS`, produced zero point/pose frames and showed steady red. The
|
||||
operator restored steady green through a normal power cycle. The legacy
|
||||
`run_bootstrap()` path now rejects before emission. The offline acceptance path
|
||||
maps requests 1–6 to connection, request 7 to explicit scan-workspace entry,
|
||||
requests 8–10 to explicit project-prompt opening and request 11 to the operator's
|
||||
START confirmation. Captured human delays are not replayed or treated as minimum
|
||||
timeouts. After the immediate operation-12 read, operations 13–14 wait for the
|
||||
same bound K1 to report `SCANNING`, a bound project and `init_ready=true`.
|
||||
The same transport is then serviced until an explicit STOP request; post-STOP
|
||||
ownership waits for live unbound `READY`. System-error
|
||||
and status reports are decoded into redacted safety state. The next staged
|
||||
physical attempt, after correcting the protobuf `sint64` time field, completed
|
||||
all 14 canonical operations, reached live scanning with real point/pose data and
|
||||
then completed one explicit STOP. K1 returned to unbound `READY`; no retry or fallback device command was
|
||||
sent. The durable local session is catalogued as ready/replayable, its RRD and
|
||||
two camera sources pass digest-bound range admission, and physical double-click
|
||||
remains the fallback. Reuse of the vendor-native project is still verified
|
||||
separately through LixelGO or USB.
|
||||
|
||||
The Stage 6 alpha uses a bounded raw-first bridge: loss in the visualization
|
||||
queue cannot discard MQTT evidence. Acceptance is replay of the full captured
|
||||
scan followed by a live ideal-LAN run with measured host pipeline latency.
|
||||
Sensor-to-display latency remains a separate clock-correlation test.
|
||||
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
||||
queue cannot discard MQTT evidence. The queue holds four preview messages;
|
||||
`ModelingReport` is consumed by a plugin-injected observer before that queue and
|
||||
does not compete with point/pose frames. A project name is mandatory at
|
||||
preparation, NFKC-normalized/trimmed on both sides, rejected for control
|
||||
or surrogate characters or more than 96 characters, and retained only as
|
||||
display metadata.
|
||||
|
||||
Stage 7 adds an independent durable observation-session lifecycle. New native
|
||||
captures durably publish the clock origin before camera production, retain a
|
||||
transport-scoped provisional envelope and atomically switch the capture-summary
|
||||
pointer to a content-addressed session envelope after all producers stop.
|
||||
Completed or recovered captures are prepared once by a bounded backend worker
|
||||
into digest-bound RRD/cache-v9 and camera-manifest generations;
|
||||
a browser replay request never performs conversion. The RRD materializes real
|
||||
origin/end rows plus archived device-reported distance, speed and scan-time
|
||||
series. The saved scene, controller and timeline remain hidden until the
|
||||
complete RRD range and every declared camera pass admission. Sensor-to-display
|
||||
latency and camera/LiDAR sensor-clock alignment remain separate correlation
|
||||
tests.
|
||||
|
||||
The generic host clears a prior manual or archived spatial source only after a
|
||||
plugin-owned live/file-replay start succeeds; a failed start preserves the
|
||||
current scene. Once an acquisition is nonterminal, a fail-closed guard blocks
|
||||
saved-session switching, persisted replay reattach and manual source
|
||||
input/apply/reset until that acquisition has been finalized. The successful
|
||||
automatic-source action is an internal start-result boundary and intentionally
|
||||
does not masquerade as an operator source switch. The K1 scene-level stop
|
||||
control is plugin-owned. A plugin-commanded acquisition sends one canonical STOP
|
||||
and seals locally after protocol-reported standby; an operator-manual acquisition
|
||||
still stops and seals only local reception and reports scanner state as unknown.
|
||||
|
||||
## Parallel branch — Simulation Polygon
|
||||
|
||||
The Polygon branch follows
|
||||
[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md)
|
||||
and
|
||||
[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md).
|
||||
It does not reorder or weaken the K1 physical-evidence gates in this document.
|
||||
Its first gate, SIM S0, is accepted:
|
||||
|
||||
- exact Windows 11 / dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 worker
|
||||
profile;
|
||||
- all mutable runtime bytes physically on D;
|
||||
- ROS 2 Jazzy, Gazebo Harmonic, PX4 v1.17.0, matching `px4_msgs`,
|
||||
Micro XRCE-DDS and Nav2 compatibility;
|
||||
- authoritative Gazebo `/clock`;
|
||||
- loopback port and deterministic process ownership;
|
||||
- target-worker rover/telemetry/pause/2 ms step/1×/2× resource evidence;
|
||||
- target doctor `GO` for profile SHA-256
|
||||
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`.
|
||||
|
||||
The repository doctor is side-effect free:
|
||||
|
||||
```text
|
||||
uv run missioncore-sim s0 doctor --json
|
||||
```
|
||||
|
||||
Without the exact admitted target-worker evidence its correct result remains
|
||||
`INCOMPLETE`. S1 starts from the accepted S0 version/storage/time/process
|
||||
boundary and must add server-owned lifecycle, canonical rover commands,
|
||||
TTL/watchdog/failsafe evidence and immutable qualification runs.
|
||||
|
||||
## Stage 0 — repository and host baseline
|
||||
|
||||
@@ -204,6 +330,40 @@ confirms it.
|
||||
MVP GO: K1-to-Mac scan-correlated data can be captured reliably and at least one
|
||||
useful stream is decoded or structurally identified.
|
||||
|
||||
## Stage 8 — replaceable external perception worker
|
||||
|
||||
Stages 6–7 established the raw-first live bridge and durable combined session
|
||||
archive described above. Stage 8 introduces a replaceable GPU executor without
|
||||
giving it K1 command authority or the authoritative archive.
|
||||
|
||||
Accepted recorded gate, 2026-07-19:
|
||||
|
||||
- the Mac validates one sealed camera epoch through the same digest and
|
||||
ISO-BMFF timing inspector used by saved-session replay;
|
||||
- `k1link compute prepare-camera-job` publishes an immutable
|
||||
`missioncore.compute-job/v1` below ignored runtime storage;
|
||||
- the first TEST007 job contains only one bounded 56-frame camera epoch, not the
|
||||
MQTT archive or another K1 control path;
|
||||
- Windows revalidates all 59 job files, reconstructs the exact canonical stream
|
||||
digest and decodes all 56 frames;
|
||||
- pinned Apache-2.0 YOLOX-S executes through pinned Triton 2.70.0 on the RTX
|
||||
4090 and atomically publishes one content-addressed
|
||||
`missioncore.compute-result/v1`;
|
||||
- repeating the exact result identity returns the existing result without
|
||||
increasing Triton inference count;
|
||||
- Mission Core revalidates the returned generation, projects the exact camera
|
||||
frames and boxes onto `session_time`, and exposes the optional native Rerun
|
||||
view only after its complete RRF2 layer is accepted.
|
||||
|
||||
The accepted result is perception evidence only. The larger TEST007 epoch,
|
||||
direct/routed worker transport and bounded live fan-out remain separate gates.
|
||||
SSH/SCP is allowed only as the recorded laboratory bootstrap. The worker never
|
||||
connects to K1.
|
||||
|
||||
See [ADR 0014](adr/0014-bounded-external-perception-worker.md), the
|
||||
[worker contract](10_EXTERNAL_PERCEPTION_WORKER.md) and
|
||||
[Lab 005](lab/005_FIRST_RECORDED_PERCEPTION_20260719.redacted.md).
|
||||
|
||||
## Deferred work
|
||||
|
||||
- ROS2/MCAP bridge;
|
||||
@@ -211,4 +371,10 @@ useful stream is decoded or structurally identified.
|
||||
- automated scan-button electronics;
|
||||
- OpenWrt/monitor-mode infrastructure;
|
||||
- firmware or internal-Linux analysis;
|
||||
- camera branch if no external frame stream is evidenced.
|
||||
- browser-visible end-to-end shared-timeline playback of the accepted archive
|
||||
containing point cloud plus one selected K1 camera (backend preparation and
|
||||
digest-bound byte admission are already accepted);
|
||||
- OS-independent plugin/edge authority provider, PCAP-to-executor transcript
|
||||
regression and runtime ownership integration;
|
||||
- long-running large-session WebViewer/WASM memory telemetry;
|
||||
- production retention, replication, encryption and cross-platform packaging.
|
||||
|
||||
@@ -16,6 +16,35 @@ profile is not a generic XGRIDS protocol claim and must not be used for fuzzing.
|
||||
- The application sends operator-supplied router credentials; it does not derive
|
||||
them from the K1 and it does not require the password of the K1's own AP.
|
||||
|
||||
The 99-byte `7f01` profile is used when K1 joins an external network (Bridge or
|
||||
controller hotspot). It is not the Quick Connect bootstrap. A live Mission Core
|
||||
negative run on 2026-07-19 proved that host association alone cannot work while
|
||||
the reviewed K1 remains in station mode: `7f02` still reported the existing LAN.
|
||||
|
||||
Re-review of the owner-supplied LixelGO QuickLink branch then recovered the
|
||||
missing first step. After a real BLE connection, LixelGO sends one fixed
|
||||
100-byte frame to the same `7f01` characteristic: bytes `0..98` are zero and
|
||||
byte `99` is `0x01`. Its callback is the AP-launch result; only then does the
|
||||
application passes that device record's `WiFiAP_SSID` and `WiFiAP_Password` to
|
||||
the operating-system connector. Later analysis of the official K1 `3.0.2`
|
||||
firmware recovered the upstream source: `lixel_nman` invokes the bundled
|
||||
NetworkManager AP script, which assigns a firmware-constant WPA2 credential.
|
||||
The persisted app fields are per-device records, but the reviewed firmware
|
||||
material is not per-device. The reviewed app requests write-without-response.
|
||||
Mission Core follows the live characteristic properties on macOS, where this K1
|
||||
advertises write-with-response, and never retries either transport or command
|
||||
automatically.
|
||||
|
||||
The browser/API carries no AP password. An optional laboratory importer authenticates the
|
||||
exact official `3.0.2` archive, resolves the reviewed material without printing
|
||||
it and installs one firmware-scoped credential source in the OS secure store.
|
||||
Mission Core then binds a device-scoped host profile ID to the selected
|
||||
BLE-advertised SSID. Before any device write, the macOS helper materializes that
|
||||
profile entirely inside Keychain from the firmware-scoped source. The actual
|
||||
secret never reaches the browser, API, argv, logs, manifests or evidence. The
|
||||
bounded Python importer holds it only in a short-lived mutable buffer, sends it
|
||||
through helper stdin and zeroizes the buffer.
|
||||
|
||||
Full UUIDs:
|
||||
|
||||
- service: `00007f00-0000-1000-8000-00805f9b34fb`;
|
||||
@@ -38,6 +67,59 @@ There is no checksum, nonce, token, certificate, signature, or separate commit
|
||||
command in this production call path. The implementation must never print,
|
||||
persist, or accept the password as a command-line argument.
|
||||
|
||||
## Quick Connect AP-enable frame
|
||||
|
||||
The separately reviewed Quick Connect device action writes exactly 100 bytes:
|
||||
|
||||
| Offset | Length | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| 0 | 99 | zero |
|
||||
| 99 | 1 | enable device AP (`0x01`) |
|
||||
|
||||
It contains no SSID, password, device identifier or user input. The reviewed
|
||||
client maps response byte 51 of `7f02` to its AP-ready flag. Mission Core
|
||||
therefore requires all three observations before host association: mode
|
||||
`WIFI_AP`, address `192.168.56.1`, and non-zero byte 51. A failed or ambiguous
|
||||
attempt is not automatically repeated.
|
||||
|
||||
The first controlled AP attempt was physically accepted: one write-with-response
|
||||
completed, `7f02` changed to `WIFI_AP / 192.168.56.1`, and a later exact
|
||||
CoreWLAN scan found the SSID matching the selected BLE device. The Mac remained
|
||||
on its previous LAN only because the former host adapter looked up an invalid
|
||||
global profile. That profile inference and its APK importer were withdrawn.
|
||||
|
||||
A later physical attempt established another boundary: `7f02` can continue to
|
||||
report `WIFI_AP / 192.168.56.1` with byte 51 zero after the exact SSID is no
|
||||
longer beaconing. Mode and address alone are therefore not an idempotency or
|
||||
readiness signal. Like the reviewed LixelGO action, each new explicit Mission
|
||||
Core Quick Connect intent emits one AP-enable frame even when the baseline mode
|
||||
already says `WIFI_AP`, then polls for the byte-51 ready flag for at most 15
|
||||
seconds. It never retries the device write automatically.
|
||||
|
||||
Static review of the original client also established a lifecycle requirement:
|
||||
LixelGO keeps the same BLE manager connected after AP-ready and invokes native
|
||||
Wi-Fi association from that live session. Mission Core now retains the same
|
||||
`BleakClient` while CoreWLAN performs bounded exact-SSID discovery and one
|
||||
association. The discovery window may contain multiple read-only scans; it does
|
||||
not repeat the BLE write or Wi-Fi association.
|
||||
|
||||
The next physical attempt exposed a CoreBluetooth lifecycle boundary before any
|
||||
write: K1 appeared in the explicit six-second BLE scan, but a second lookup by
|
||||
its macOS UUID failed moments later. The UUID is a transport-local selector, not
|
||||
a durable rediscovery contract. The runtime now retains the live `BLEDevice`
|
||||
handle from the operator's scan and connects that exact selected handle in the
|
||||
following network action. A fallback lookup remains only for non-UI callers
|
||||
that did not perform discovery first.
|
||||
|
||||
The 2026-07-20 prepared-host acceptance installed the exact firmware provider,
|
||||
found one expected K1 candidate, emitted one AP-enable write, observed AP-ready
|
||||
and completed one CoreWLAN association without an iPhone or manual credential.
|
||||
Mission Core admitted `192.168.56.1`; the operator disconnected afterward only
|
||||
to restore the external chat route. Full redacted evidence and artifact hashes
|
||||
are recorded in `docs/lab/004_K1_FW302_AP_CREDENTIAL_PROVIDER_20260720.redacted.md`.
|
||||
This proves the mechanism on that Mac, not automatic credential acquisition by
|
||||
a copied application on a clean host.
|
||||
|
||||
The Android application unequivocally requests a write without response and
|
||||
negotiates MTU 120, making the 99-byte frame one ATT command. A live read-only
|
||||
CoreBluetooth check reports MTU 256 and a maximum write-without-response size of
|
||||
@@ -53,11 +135,13 @@ mode. It never fragments or retries the payload automatically.
|
||||
## Expected transition and evidence of acceptance
|
||||
|
||||
A completed GATT write only proves transport completion. It does not prove that
|
||||
the K1 joined Wi-Fi. The application polls `7f02`; the observed response frame
|
||||
contains a fixed-width mode slot, an address slot, and a status byte at offset
|
||||
50. The current AP baseline reports mode `WIFI_AP` and address `192.168.56.1`.
|
||||
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
|
||||
observed response frame contains a fixed-width mode slot, an address slot, a
|
||||
status byte at offset 50 and the AP-ready flag at offset 51. The stale AP
|
||||
baseline reports `WIFI_AP / 192.168.56.1 / byte51=0`; the physically observed
|
||||
ready transition reports the same mode/address with `byte51=1`.
|
||||
|
||||
For the controlled experiment, acceptance required at least one of:
|
||||
For Bridge/Direct Connect, acceptance requires at least one of:
|
||||
|
||||
1. `7f02` reports a non-AP IPv4 address;
|
||||
2. the same address appears as a new router/ARP client after the write;
|
||||
@@ -66,10 +150,35 @@ For the controlled experiment, acceptance required at least one of:
|
||||
|
||||
Do not infer success from a write callback alone.
|
||||
|
||||
The Bridge/Direct Connect address is a DHCP lease, not configuration and not
|
||||
device identity. Mission Core re-reads `7f02` without writing before every new
|
||||
LAN control session, implicit-host acquisition and factory-calibration read.
|
||||
If the value changes, it rotates `device_session_id`; it never retargets an
|
||||
active acquisition. A correlated MQTT `DeviceInfo` response supplies the live
|
||||
model/firmware/serial identity barrier.
|
||||
|
||||
The 2026-07-20 reboot/power-cycle check observed the startup race directly:
|
||||
one read returned the earlier `.54` lease while that exact address had no ARP or
|
||||
application endpoint; a later read returned `.52`, where exact probes found
|
||||
MQTT 1883 and RTSP 8554. No subnet scan, route change, network-profile change or
|
||||
VPN action was used. This is why no owner-LAN IPv4 value is a product constant
|
||||
and why a BLE lease observation alone is not reported as live DeviceInfo.
|
||||
|
||||
For Quick Connect, host association is not admitted until the canonical
|
||||
byte-51 ready flag is observed. CoreWLAN then searches only for the exact
|
||||
device-profile SSID for at most 15 seconds and performs at most one association.
|
||||
|
||||
## Safety, recovery and stop conditions
|
||||
|
||||
- Perform one write per explicitly named attempt, using credentials for the LAN
|
||||
already used by the Mac. Never retry automatically.
|
||||
- Perform one write per explicitly named attempt. Never retry automatically.
|
||||
- For Bridge/Direct Connect, use credentials for the operator-selected network.
|
||||
- For Quick Connect, the exact `3.0.2` provider must already exist in the OS
|
||||
secure store. Missing or mismatched firmware material fails before the AP
|
||||
write. Never extrapolate this provider to another firmware or model.
|
||||
- The macOS adapter materializes a device-scoped Keychain item from the exact
|
||||
firmware source, then performs one association. Standard Wi-Fi Keychain and
|
||||
native prompt paths remain compatibility fallbacks, not the reviewed
|
||||
zero-touch path. It never asks the browser for a password.
|
||||
- Do not alter Deco settings, scan the subnet, or guess any credential.
|
||||
- If the status does not change, do not retry automatically.
|
||||
- If the supplied credentials are wrong, reconnect over BLE and overwrite them
|
||||
|
||||
@@ -129,22 +129,192 @@ orientation is exposed as `(x,y,z,w)`. Unknown tail fields are retained.
|
||||
|
||||
`PrePathArray` is exactly 16 little-endian float64 values.
|
||||
|
||||
## Modeling telemetry
|
||||
|
||||
`lixel/application/report/modeling` contains a bounded `ModelingReport`. Field 2
|
||||
is PGO progress; field 3 is a nested scan status containing float32 move
|
||||
distance, float32 move speed and an int64 scan-time counter. Retained physical
|
||||
captures correlate that counter at exactly two ticks per second. Mission Core
|
||||
consumes this status before the disposable preview queue and exposes the current
|
||||
device-reported scan generation as elapsed seconds, route metres and metres per
|
||||
second. When scan time and distance reset together, the old route is not added
|
||||
to the new generation.
|
||||
|
||||
## Application control boundary
|
||||
|
||||
Static analysis identified the application start/stop topic as
|
||||
Static analysis first identified the application start/stop topic as
|
||||
`lixel/application/request/modeling`, QoS 2, with a protobuf
|
||||
`ModelingRequest`. Start action is 1 and stop action is 2. A valid request also
|
||||
contains a device/session/OpenAPI header and, for start, project/record/scan/mount
|
||||
settings.
|
||||
`ModelingRequest`. Start action is 1 and stop action is 2. The retained header
|
||||
contains explicit device identity and OpenAPI value; its session relation is
|
||||
literally `{device_id}:ModelingRequest`, not a caller-selected session. The
|
||||
retained start request carries an operator project name, record mode 2, scan
|
||||
mode 1 and mount mode 0; the retained stop request carries only header and
|
||||
action. QoS is 2, retain is false, and correlated success uses numeric result
|
||||
`302252033` with the same header identity and action.
|
||||
|
||||
Publishing is deliberately not implemented. The physical double-click provides
|
||||
a verified autonomous start/stop path and avoids inventing session headers or
|
||||
changing scan settings. A future publisher requires a separate reviewed profile,
|
||||
explicit confirmation, response handling and rollback.
|
||||
Lab 002 then observed both mappings in owner-operated LixelGO traffic and
|
||||
correlated them with device success responses and physical start/stop of the
|
||||
high-rate streams. This promotes the mapping from a static hint to descriptive
|
||||
wire evidence; it does not authorize replay or publishing.
|
||||
|
||||
## Decoder and capture bounds
|
||||
An offline bounded audit of the retained owner-controlled captures additionally
|
||||
proved that the repository encoder reproduces the observed START and STOP
|
||||
payloads byte-for-byte for this one K1 and exact firmware profile. It also
|
||||
separated four values which must never be substituted for one another:
|
||||
|
||||
Default defensive limits are applied before allocation or iteration:
|
||||
- Mission Core's provisional device UUID is local inventory identity and never
|
||||
belongs in a vendor request;
|
||||
- the vendor device ID comes from live K1 status and occupies the request header;
|
||||
- the K1 serial is a separate live device-binding value;
|
||||
- the OpenAPI value is private application-level material embedded by the
|
||||
observed LixelGO build; it is shared across all retained request types and is
|
||||
not derived from the current scanner while the application value is present.
|
||||
|
||||
Consequently, support for another K1 never replays captured payload bytes and
|
||||
does not require a manually maintained scanner profile. BLE selection and its
|
||||
provisioning response bind the transport to that unit; an initial
|
||||
`DeviceInfoRequest` without device ID returns the live vendor ID, serial, model,
|
||||
activation and version facts. Those values bind later requests and must agree
|
||||
with the live status stream. Any mismatch, identity drift, malformed response,
|
||||
replay-only evidence or unexpected lifecycle state fails closed.
|
||||
|
||||
The clean LixelGO cycle contains exactly ten requests before START, all QoS 2
|
||||
and retain false: `DeviceInfo`, `ModelingStatus`, `GetRtkAdvance`, a bound
|
||||
`DeviceConfig` time/timezone sync, then `DeviceInfo`, `GetRtkAdvance`,
|
||||
`GetNtripProfile`, `GetCloudServerConfig`, `GetRtkAdvance`, `DeviceInfo`. The
|
||||
special time-sync session is exactly
|
||||
`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`. The
|
||||
repository shadow bootstrap reproduces all ten retained payloads and their
|
||||
topic order byte-for-byte. Time sync is the only mutation before START; the
|
||||
other nine requests are reads. Its Unix timestamp is protobuf `sint64`, not
|
||||
`int64`/`uint64`: the retained value `3568430060` ZigZag-decodes to
|
||||
`1784215030` (`2026-07-16 15:17:10 UTC`). The runtime encoder must therefore
|
||||
ZigZag-encode the current epoch before writing field 1 of the nested time
|
||||
configuration.
|
||||
|
||||
The first ten requests contain five observed publish groups but only four
|
||||
application-response windows. The initial DeviceInfo response is the identity
|
||||
binding barrier; ordinals 2–6 then retain their exact publish order without an
|
||||
invented response barrier between ordinals 3 and 4. Two independent owner-operated START
|
||||
captures contain the same 14-operation order: the ten requests above, START,
|
||||
one bound `ModelingStatus` read, then a bound `DeviceInfo` plus
|
||||
`ModelingStatus` refresh about 24–25 seconds after START. The first capture
|
||||
reached START 202.423 seconds after its initial DeviceInfo; the faster capture
|
||||
reached it after 54.130 seconds. This variation is operator/UI time, while the
|
||||
stage order is invariant. In both captures the original control MQTT connection
|
||||
remained open throughout initialization and the later refresh; no DISCONNECT or
|
||||
UNSUBSCRIBE occurred. DeviceInfo response establishes identity before later
|
||||
bound requests. Required response barriers are correlated by operation key, topic, exact
|
||||
session, vendor identity, application authority and numeric success. The
|
||||
initial and immediate post-START ModelingStatus reads are intentionally not
|
||||
treated as mandatory synchronous responses. In both retained captures operation
|
||||
12 has no application response; the single bound ModelingStatus response in
|
||||
that part of the lifecycle arrives only after operation 14 and belongs to it.
|
||||
An unanswered optional operation therefore cannot reserve FIFO ownership over
|
||||
the required operation-14 response. Readiness belongs to live DeviceStatus plus
|
||||
point/pose observation.
|
||||
|
||||
The UI/event correlation is also fixed by the retained capture. Requests 1–6
|
||||
follow successful data connection. Entering the scan workspace causes request
|
||||
7. Opening the record/project-name prompt causes reads 8–10. The project name is
|
||||
not sent by a separate save mutation: it first appears in the START payload when
|
||||
the operator confirms the prompt. K1 then reports `SCAN_STARTING`; completion is
|
||||
the live transition to `SCANNING` with a bound project and `init_ready=true`.
|
||||
Operations 13–14 coincide with that state in both captures. The observed
|
||||
24–25-second initialization duration is therefore evidence/telemetry, not a
|
||||
hard-coded transition delay.
|
||||
|
||||
The repository now contains an inert bounded encoder/response parser and a
|
||||
fail-closed device-status state machine for this exact profile. A live-only
|
||||
shadow planner accepts START only from `READY` with no project and STOP only
|
||||
from `SCANNING` with a bound project. It emits only a non-executable digest and
|
||||
wire metadata, uses QoS 2 with retain false, and declares automatic retry
|
||||
forbidden. It has no MQTT publish dependency and cannot send a command.
|
||||
|
||||
Legacy shadow-plan publishing remains deliberately disabled. The exact 36-byte authority has a
|
||||
read-only macOS Keychain loader with no environment, file, browser or API
|
||||
fallback. A facade-owned dormant coordinator can arm it only after explicit
|
||||
operator confirmation, a connected/attested K1, idle runtime and no active
|
||||
acquisition. Its non-exportable process-memory lease is limited to 15–300
|
||||
seconds and is revoked by expiry, disarm, reprovision, acquisition preparation
|
||||
or shutdown. The current one-shot boundary is structurally write-disabled,
|
||||
exposes no emission method and cannot touch its injected sink.
|
||||
|
||||
A separate operator-driven physical-acceptance implementation is installed in
|
||||
plugin v0.5.0 through the facade/runtime. Retained-PCAP re-audit fixes its MQTT
|
||||
contract at 3.1.1,
|
||||
`clean_session=false`, keepalive 60, the three ordered 9/5/42-topic control
|
||||
subscription groups, separation from the point-cloud client, QoS 2
|
||||
request/PUBCOMP completion and
|
||||
`lixel/application/response/modeling`. It performs one connection attempt,
|
||||
consumes every ordinal/action operation key before publish, never reconnects or
|
||||
retries, rejects stale/duplicate/unexpected responses and poisons every unknown
|
||||
post-publish outcome. A 15–120 second permit requires explicit operator-present,
|
||||
owner-controlled-device, LixelGO-closed, battery/storage and expected-state
|
||||
confirmations. START additionally requires the complete four-window bootstrap;
|
||||
STOP requires its own separate permit. No plugin state read, poll, navigation or
|
||||
automatic lifecycle event may invoke either action. On 2026-07-18 its first
|
||||
operator-present physical attempt made one
|
||||
connection and emitted bootstrap ordinals 1–6. All six QoS 2 publishes completed
|
||||
and five required responses were collected, but the old topic-only collector
|
||||
misrouted one of the two `GetRtkAdvance` responses in its third window. The
|
||||
executor stopped before building or publishing START;
|
||||
K1 remained `READY`, project binding stayed absent and point/pose remained zero.
|
||||
No automatic retry was attempted. A second explicitly permitted attempt then
|
||||
correlated all ten bootstrap responses and one START success response, with 11
|
||||
QoS2 completions, but it compressed the exchange into 365 ms and ended without
|
||||
the retained post-START reads or durable control-session ownership. K1 remained
|
||||
in `SCAN_STARTING` for about 24 seconds, emitted two identical
|
||||
`report/system_error` values `0x32040133`, returned to `OTHER_STATUS`, produced
|
||||
no point/pose frames and showed steady red. The bounded system-error decoder maps
|
||||
the `0x32040000 + 307` value to the recovered `ALGORITHM_ERROR` state. A normal
|
||||
operator power cycle restored steady green.
|
||||
|
||||
This disproves the earlier assumption that byte-matched pre-START payloads plus
|
||||
a correlated START acknowledgement are sufficient acceptance. The legacy
|
||||
collapsed `run_bootstrap()` executor now rejects before emission. Its
|
||||
replacement is an operator-driven acceptance session owner. It requires the
|
||||
observed connection, workspace-entry and project-prompt checkpoints instead of
|
||||
wall-clock floors, sends the immediate post-START ModelingStatus read, services
|
||||
the same MQTT socket until live `SCANNING + project + init_ready`, then emits the
|
||||
DeviceInfo/ModelingStatus refresh. It continues servicing that socket until an
|
||||
explicit STOP request and afterwards until live unbound `READY`. That device
|
||||
status is the canonical standby evidence; no second visual acknowledgement is
|
||||
required. System-error and DeviceStatus reports are decoded into redacted safety
|
||||
state; a fault forbids further automatic action. Standalone START/STOP are
|
||||
disabled. MQTT keepalive/report processing also continues while the operator is
|
||||
between UI actions; the 15–120-second command permit is created at the actual
|
||||
START or STOP confirmation, not at connection time. Plugin v0.5.0 now exposes
|
||||
this path through separate plugin-owned UI actions. Repository tests use only
|
||||
in-memory transports; physical execution occurs only from an explicit operator
|
||||
click against an already selected direct-LAN K1.
|
||||
|
||||
The one-time Keychain administration command uses `security -w` as the final
|
||||
argument so Apple's own TTY prompt receives the authority. Mission Core never
|
||||
accepts it through CLI argv and validates the resulting fixed item through the
|
||||
production loader. The macOS CLI approval path timed out during the first lab
|
||||
attempt; a temporary Security.framework-backed item was used only inside the
|
||||
one-shot process and deleted immediately afterward. It is not the product
|
||||
authority architecture. The acceptance executor now records only ordinal,
|
||||
message type, response topic, payload size, SHA-256 and a safe correlation
|
||||
reason; it never exposes response bytes or authority in diagnostics. The
|
||||
retained clean-cycle now completes all nine bootstrap barriers offline while
|
||||
ordinals 3 and 6 are simultaneously in flight and routes them by exact
|
||||
session/device identity. After repairing the PCAP-proven protobuf `sint64` time
|
||||
field, the next operator-run physical cycle completed all 14 canonical requests
|
||||
on one socket, reached `SCANNING + project + init_ready`, delivered real
|
||||
point/pose data and completed one correlated STOP. Streams quiesced, K1 returned
|
||||
to unbound `READY`; no retry or fallback STOP was sent. The operator also
|
||||
observed stable green, but that observation is not a second protocol gate. The
|
||||
local archive sealed and passed digest-bound RRD and recorded-
|
||||
camera admission. Appearance and reuse of the vendor-native project through
|
||||
LixelGO or USB remains a separate gate. Physical double-click remains the
|
||||
operational fallback.
|
||||
|
||||
## Decoder and raw-capture bounds
|
||||
|
||||
The semantic decoder applies these default defensive limits before allocation
|
||||
or iteration:
|
||||
|
||||
- 2 MiB maximum MQTT payload;
|
||||
- 1 MiB maximum compressed block;
|
||||
@@ -153,12 +323,17 @@ Default defensive limits are applied before allocation or iteration:
|
||||
- 250,000 points per frame;
|
||||
- bounded protobuf field counts and exact LZ4 decoded length.
|
||||
|
||||
The raw-first evidence recorder has an independent 64 MiB default per-message
|
||||
limit, configurable only up to 256 MiB. That larger transport ceiling does not
|
||||
raise any semantic decoder allocation bound.
|
||||
|
||||
Per-frame failures do not justify firmware writes or speculative recovery.
|
||||
Always preserve the raw MQTT record and report the decoder error separately.
|
||||
|
||||
## Current camera result
|
||||
## Camera result outside MQTT
|
||||
|
||||
No independent camera, panorama, JPEG, H.264 or RTSP topic was observed in this
|
||||
run. `lio_pcl` may carry intensity or packed color information, but that does not
|
||||
prove access to the two raw panoramic camera streams. Camera discovery remains a
|
||||
separate evidence gate.
|
||||
No camera payload was observed in the report-topic MQTT run documented above.
|
||||
The later LixelGO/iPhone experiment found the preview on a separate transport:
|
||||
left/right RTSP sessions on TCP `8554` with interleaved RTP/TCP and H.264. This
|
||||
proves compressed preview availability, not full-resolution raw frames or
|
||||
camera calibration. See Lab 002.
|
||||
|
||||
+153
-50
@@ -1,25 +1,31 @@
|
||||
# K1 live console and embedded Rerun bridge
|
||||
|
||||
Status: the Rerun alpha milestone is implemented for the verified firmware-3
|
||||
MQTT streams. Live capture and replay use real K1 point-cloud and pose messages;
|
||||
the application does not generate a placeholder cloud, trajectory, camera frame
|
||||
or latency value.
|
||||
Status: the Rerun live and saved-session milestones are implemented for the
|
||||
verified firmware-3 point/pose streams. Live and adapter file-replay use the
|
||||
process-wide gRPC source; saved observation sessions use private digest-bound
|
||||
RRD over same-origin HTTP. Left/right RTSP preview is delivered separately from
|
||||
the spatial Rerun stream. The application does not generate placeholder cloud,
|
||||
trajectory, camera frame or latency values.
|
||||
|
||||
## Active device-to-scene path
|
||||
|
||||
```text
|
||||
K1 lio_pcl / lio_pose
|
||||
K1 lio_pcl / lio_pose / ModelingReport
|
||||
|
|
||||
v
|
||||
read-only MQTT subscription on TCP 1883
|
||||
|
|
||||
+--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
|
||||
+--> raw .k1mqtt + JSONL + durable clock origin (before camera/preview)
|
||||
+--> transport/session envelope + SHA-256 summary (shutdown/seal)
|
||||
|
|
||||
+--> injected K1 ModelingReport observer
|
||||
| `--> device scan time / distance / speed product metrics
|
||||
|
|
||||
v
|
||||
bounded latest-wins preview queue (32 messages)
|
||||
bounded latest-wins visual preview queue (4 messages)
|
||||
|
|
||||
v
|
||||
reviewed raw-LZ4/protobuf decoders
|
||||
reviewed raw-LZ4/protobuf point/pose normalizer
|
||||
|
|
||||
v
|
||||
Rerun Points3D + Transform3D + LineStrips3D
|
||||
@@ -30,16 +36,22 @@ Rerun gRPC/proxy on TCP 9876
|
||||
+--> rerun_grpc_url in REST/WebSocket state
|
||||
|
|
||||
v
|
||||
self-hosted @rerun-io/web-viewer inside NODE.DC Control Station
|
||||
self-hosted @rerun-io/web-viewer inside Mission Core Control Station
|
||||
|
||||
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
|
||||
```
|
||||
|
||||
The Paho MQTT callback does not decode or render the point cloud. It first
|
||||
writes and flushes the raw frame and metadata, then enqueues a preview message.
|
||||
If visualization cannot keep up, the oldest queued preview is discarded while
|
||||
the raw capture continues. Rerun work stays on the dedicated publisher thread
|
||||
and cannot block raw-first evidence capture.
|
||||
appends the raw frame, flushes it to the operating-system page cache and stages
|
||||
its aligned metadata, then enqueues a preview message. Raw bytes and metadata
|
||||
become a durable pair at the bounded group-commit boundary (at most 0.5 s,
|
||||
4 MiB or 32 messages), not at every callback. If visualization cannot keep up,
|
||||
the oldest queued preview is discarded while capture continues. Rerun work
|
||||
stays on the dedicated publisher thread and cannot block that raw-first path.
|
||||
The four-message queue size is independent of the 32-message evidence group
|
||||
commit. A plugin-injected observer consumes bounded K1 `ModelingReport` messages
|
||||
after raw persistence but before visual admission, so status telemetry cannot
|
||||
evict a point-cloud or pose preview.
|
||||
|
||||
The current live/replay runtime instantiates only `RerunBridge`. The former
|
||||
Foxglove implementation is not a parallel runtime and does not listen on TCP
|
||||
@@ -52,8 +64,8 @@ Prerequisites are the repository-local Python environment and Node.js 20.19+ or
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
cd apps/control-station
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm run build
|
||||
cd ../..
|
||||
@@ -103,27 +115,57 @@ directly and use their sibling metadata receive timestamps when present.
|
||||
complete visible-device list.
|
||||
4. Enter the existing router SSID/password and explicitly authorize the reviewed
|
||||
provisioning write. The backend does not retry the write automatically.
|
||||
5. When K1 reports a non-AP private address, start live reception.
|
||||
6. Wait until the UI reports that the local Rerun bridge is ready. No manual
|
||||
viewer URL is needed.
|
||||
7. Open **Наблюдение → Пространственная сцена**.
|
||||
8. Double-click the physical K1 button to start scanning. Real point frames and
|
||||
pose/trajectory updates then appear in the embedded viewport.
|
||||
9. Double-click K1 again to stop physical scanning, wait for steady green, then
|
||||
stop the local session so captures and summaries are finalized.
|
||||
5. Enter the required project name, confirm operator presence, closed LixelGO,
|
||||
storage/power and steady green, then choose **Запустить сканирование и
|
||||
локальный приём** once.
|
||||
6. Mission Core emits operations 1–6, waits for their correlated device
|
||||
responses, then performs operation 7 and operations 8–10 behind the same
|
||||
operator intent. No captured human pause is treated as a timer and no device
|
||||
command is automatically retried.
|
||||
7. Mission Core prepares evidence reception before emitting the single
|
||||
canonical START. The project name is carried only by that START; there is no
|
||||
separate K1 project-name write. Mission Core opens
|
||||
**Наблюдение → Пространственная сцена** and shows calibration while the same
|
||||
control socket waits for `SCANNING + project + init_ready`.
|
||||
8. Real point frames and pose/trajectory updates then appear in the embedded
|
||||
viewport. Do not use the physical button or open LixelGO during this cycle.
|
||||
9. When K1 emits `ModelingReport`, the same plugin block shows its current scan
|
||||
time, route distance and speed. These are device reports, not values inferred
|
||||
from a browser timer or accumulated across a device counter reset.
|
||||
10. Choose **Остановить устройство и запись** in the scene. The
|
||||
plugin-commanded path emits one canonical STOP on the original socket,
|
||||
waits for live unbound READY, then finalizes capture, camera archive and
|
||||
summaries automatically. The UI does not ask for a redundant green-indicator
|
||||
confirmation because READY plus cleared project binding is already the
|
||||
scanner's protocol evidence.
|
||||
|
||||
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
|
||||
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
|
||||
hash summary. The connector subscribes to the fixed report-topic allowlist and
|
||||
does not publish an application request or modeling command.
|
||||
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
|
||||
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
|
||||
per-message metadata and a hash summary. Repository-level
|
||||
`sessions/*_viewer_live` is legacy import-only evidence and is never selected by
|
||||
the current writer. The data connector remains subscribe-only. Application
|
||||
requests are isolated in the separate canonical control-session owner and can
|
||||
be emitted only after the explicit launch or STOP intent and the corresponding
|
||||
live response gate.
|
||||
|
||||
The recovered command substrate byte-matches the ten
|
||||
observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
|
||||
responses, and classifies live device states before any command transition. One
|
||||
private application-level OpenAPI authority is kept
|
||||
separate from the transient vendor ID/serial returned by the BLE-selected K1.
|
||||
The fixed Keychain loader and dormant shadow coordinator remain available for
|
||||
inspection. Plugin v0.5.0 additionally installs a continuous interactive MQTT
|
||||
owner: one UI launch intent advances operations 1–6, 7, 8–10 and START through
|
||||
their live response barriers; STOP remains a separate explicit action. Physical START/STOP and
|
||||
durable native-project completion still require operator acceptance.
|
||||
|
||||
## Automatic Rerun source and lifecycle
|
||||
|
||||
On the first live or replay session, `RerunBridge`:
|
||||
On the first live or adapter file-replay session, `RerunBridge`:
|
||||
|
||||
- creates an explicit `RecordingStream("nodedc_device_spatial")`;
|
||||
- creates an explicit `RecordingStream("nodedc_mission_core_spatial")`;
|
||||
- installs the default spatial blueprint;
|
||||
- starts the gRPC/proxy server on TCP 9876 with a 512 MiB late-client buffer;
|
||||
- starts the gRPC/proxy server on TCP 9876 with a 32 MiB late-client buffer;
|
||||
- reports `rerun+http://127.0.0.1:9876/proxy` only after the server is ready;
|
||||
- accepts the local development and production browser origins used by this
|
||||
repository;
|
||||
@@ -143,6 +185,16 @@ served over HTTP(S). No externally hosted viewer UI is involved:
|
||||
application. FastAPI carries control state only; the browser reads the point
|
||||
stream directly from the Rerun gRPC/proxy endpoint.
|
||||
|
||||
`activateAutomaticSpatialSource` is a generic host action, not a scanner-control
|
||||
command. A plugin invokes it only after its live or adapter-replay start returns
|
||||
success, then opens the spatial scene; a failed start preserves the old scene
|
||||
and source. The successful action releases any host-selected archive/manual URL
|
||||
so the new backend source becomes authoritative. After an acquisition becomes
|
||||
nonterminal, a fail-closed host guard blocks saved-session switching, persisted
|
||||
replay reattach and manual RRD/Rerun source input/application/reset until the
|
||||
acquisition ends. This successful-start transition intentionally does not
|
||||
consult the operator-switch guard, and the guard never stops equipment.
|
||||
|
||||
## Rerun entities
|
||||
|
||||
| Entity | Rerun archetype | Meaning |
|
||||
@@ -150,11 +202,14 @@ stream directly from the Rerun gRPC/proxy endpoint.
|
||||
| `/world` | `ViewCoordinates` | right-handed Z-up display convention |
|
||||
| `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
|
||||
| `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
|
||||
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
|
||||
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 2,000 decoded poses |
|
||||
|
||||
Point count, frame rate, queue drops and measured pipeline time remain product
|
||||
metrics in the outer Control Station; they are not logged as Rerun entities and
|
||||
therefore cannot create additional automatic viewer panes.
|
||||
metrics in the outer Control Station. Live K1 scan time, route distance and
|
||||
speed also remain product metrics in the plugin-owned scene block. Saved-session
|
||||
export separately logs those three device-reported values below
|
||||
`/metrics/device` as Rerun scalar time series; this does not add vendor parsing
|
||||
to the generic live bridge.
|
||||
|
||||
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as decoded.
|
||||
No axis swap, quaternion normalization, scanner-to-vehicle extrinsic or SLAM
|
||||
@@ -189,18 +244,61 @@ shows only the current frame. RGB mode falls back to the verified scalar
|
||||
coloring path when the active point format has no RGB fields. The current
|
||||
`class`/custom behavior is a selected solid color, not semantic segmentation.
|
||||
|
||||
Projection switching, a custom playback timeline, semantic object/mask layers,
|
||||
camera frustums and persisted RBL/layout profiles are not wired yet and must not
|
||||
be inferred from the implemented controls above.
|
||||
Projection switching, semantic object/mask layers and camera frustums are not
|
||||
wired yet. The host-owned `observation.spatial` layout profile does persist the
|
||||
implemented scene controls, tool-window state, dynamic source visibility and
|
||||
normalized floating-window geometry. It is separate from Rerun's internal
|
||||
blueprint and from observation evidence.
|
||||
|
||||
## Saved observation sessions
|
||||
|
||||
Every completed native live run is indexed by the local host session store. A
|
||||
valid crash prefix without its final summary is indexed as `interrupted`. The
|
||||
**Сохранённые сессии** control shows the three newest runs and launches an
|
||||
opaque same-origin RRD rather than starting a second live bridge.
|
||||
|
||||
Sealing, recovery or legacy import makes the session eligible for the bounded
|
||||
backend preparation worker. The worker reads every native message once and
|
||||
atomically publishes a private cache-v9 RRD using the zero-based `session_time`
|
||||
duration timeline. New sessions bind that zero/end to the durable capture-clock
|
||||
envelope; older valid evidence retains the first/last-message compatibility
|
||||
fallback. A SHA sidecar binds the generation to native evidence.
|
||||
Replay-open never performs conversion: it returns HTTP 202 and a preparation
|
||||
handle or reuses the verified artifact. The embedded viewer opens the exact
|
||||
generation through Rerun's native incremental HTTP receiver. The bottom Control
|
||||
Station timeline then controls play/pause and seek directly; it does not
|
||||
approximate time with a React timer.
|
||||
|
||||
The native `.k1mqtt`, aligned metadata and capture-clock envelope remain the
|
||||
spatial evidence master. RRD generation does not use the bounded live-preview
|
||||
queue, so it retains every point/pose frame accepted by the reviewed normalizer
|
||||
and logs every valid `ModelingReport` as distance, speed and elapsed-time scalar
|
||||
rows. See
|
||||
[`09_OBSERVATION_SESSIONS.md`](09_OBSERVATION_SESSIONS.md) for storage,
|
||||
recovery and HTTP Range details.
|
||||
|
||||
## Time and latency semantics
|
||||
|
||||
The Rerun `capture_time` timeline uses
|
||||
`StreamMessage.received_at_epoch_ns`: Unix time when the Mac received the MQTT
|
||||
message. The K1 header timestamp epoch is not proven, so `capture_time` is not
|
||||
a sensor acquisition timestamp. The viewer accumulation window uses
|
||||
`stream_time`, assigned when the current live/replay run publishes a message;
|
||||
therefore replayed historical timestamps do not mix two sequential sessions.
|
||||
The Rerun `capture_time` timeline uses Mac host-clock epoch values. Data rows use
|
||||
`StreamMessage.received_at_epoch_ns`, while new archived sessions also contain a
|
||||
real origin row at the capture-envelope start and a real end row at the sealed
|
||||
session completion. Their zero-based `session_time` is derived from the same
|
||||
monotonic envelope; it can therefore cover a camera fragment before the first
|
||||
MQTT packet or after the last spatial packet without inventing an RRD range.
|
||||
Legacy evidence without an envelope falls back to first/last message bounds.
|
||||
The K1 header timestamp epoch is not proven, so `capture_time` is not a sensor
|
||||
acquisition timestamp. The live viewer accumulation window uses `stream_time`,
|
||||
assigned when the current live/replay run publishes a message; therefore
|
||||
replayed historical timestamps do not mix two sequential sessions.
|
||||
|
||||
Retained read-only physical captures support the profile-scoped telemetry units:
|
||||
in five of six runs, `ScanTime / 2` tracks host monotonic duration within about
|
||||
0.17 seconds across 76–2,294-second spans; the remaining older run contains a
|
||||
counter/distance reset and is treated as a new scan generation. In two moving
|
||||
runs, `ModelingReport.MoveDistance` matches the independent
|
||||
`lio_pose.distance` values at 41.521 m and 26.631 m, while reported peak speeds
|
||||
of 1.382 m/s and 1.291 m/s are physically plausible. This evidence does not turn
|
||||
the counters into a cross-firmware contract.
|
||||
|
||||
For live MQTT only, `pipeline_ms` / `mqtt_to_publish_ms` uses the Mac monotonic
|
||||
clock from MQTT callback receipt through raw disk write, bounded queue wait,
|
||||
@@ -228,7 +326,7 @@ and host firewall as appropriate, and never forward it directly to the public
|
||||
Internet or cellular WAN. Remote operation requires an authenticated TLS reverse
|
||||
proxy or another reviewed secure transport before deployment.
|
||||
|
||||
The 512 MiB Rerun server buffer limits retained late-client data but is not an
|
||||
The 32 MiB Rerun server buffer limits retained late-client data but is not an
|
||||
access-control mechanism. The listener and embedded viewer intentionally remain
|
||||
ready between acquisition sessions. Stop `k1link serve` when the network
|
||||
listener and its process memory must be closed unconditionally.
|
||||
@@ -236,10 +334,14 @@ listener and its process memory must be closed unconditionally.
|
||||
## Current boundaries
|
||||
|
||||
- Raw panoramic camera frames were absent from the observed MQTT report topics.
|
||||
This milestone contains point cloud and pose/trajectory; operational metrics
|
||||
are rendered by the outer Control Station.
|
||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
||||
publisher needs a separately reviewed state-changing profile.
|
||||
The Rerun spatial recording contains point cloud and pose/trajectory;
|
||||
left/right compressed RTSP preview and acquisition-owned fMP4 archive use a
|
||||
separate generic media path. Historical sessions predating that archive have
|
||||
no recoverable video.
|
||||
- Physical double-click remains the independently verified device-local fallback.
|
||||
Mission Core also contains the installed response-gated operator-present
|
||||
acceptance transport for the exact K1/A4/FW 3.0.2 profile; its full
|
||||
START-to-STOP durable-save cycle was physically accepted on 2026-07-18/19.
|
||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
||||
mission planner or vehicle control is implemented by this viewer milestone.
|
||||
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
||||
@@ -264,12 +366,13 @@ embedded Web Viewer path without a viewer-side substitute:
|
||||
|
||||
The 38 point frames and 42 pose frames account for all 80 observed messages.
|
||||
This proves current real-device decoding, bridge publication and embedded-viewer
|
||||
delivery for point cloud plus trajectory. It does not prove a camera channel or
|
||||
sensor-to-screen latency.
|
||||
delivery for point cloud plus trajectory. It does not prove archived playback
|
||||
of one selected camera together with the point cloud or sensor-to-screen
|
||||
latency.
|
||||
|
||||
## Legacy Foxglove regression module
|
||||
|
||||
`src/k1link/viewer/foxglove_bridge.py` and its tests remain in the repository
|
||||
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its tests remain in the repository
|
||||
to compare decoder/packing behavior and preserve earlier evidence. They are not
|
||||
instantiated by `VisualizationRuntime`, are not the Control Station source, and
|
||||
do not make TCP 8765 part of the current operator path.
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
# NODEDC MISSION CORE monorepo
|
||||
|
||||
This document maps the current repository to the target Mission Core
|
||||
architecture without claiming that deferred package and process boundaries
|
||||
already exist. The device-lifecycle decision is recorded in
|
||||
[`ADR 0004`](adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md),
|
||||
and the bounded laboratory runtime seam in
|
||||
[`ADR 0011`](adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md).
|
||||
|
||||
## Current layout
|
||||
|
||||
| Path | Current responsibility | Target responsibility |
|
||||
| --- | --- | --- |
|
||||
| `apps/control-station/` | React shell, model catalog, plugin host slots, spatial scene | Vendor-neutral operator application |
|
||||
| `apps/control-station/src/core/device-plugins/frontendSdk.ts` | Public React host surface for reviewed frontend contributions | Versioned frontend Plugin SDK package surface |
|
||||
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
|
||||
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
|
||||
| `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, project/acquisition/replay, optional spatial controls, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
|
||||
| `src/k1link/web/` | Local FastAPI host, fail-closed runtime handshake/transport seam, in-memory operation/acquisition lifecycle and generic session API | Generic host APIs plus isolated plugin supervisor |
|
||||
| `src/k1link/device_plugins/xgrids_k1/` | Physically isolated K1 BLE/MQTT/protobuf/LZ4/camera/replay/CLI compatibility implementation | Independently built XGRIDS device plugin process |
|
||||
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
|
||||
| `src/k1link/viewer/` | Vendor-neutral Rerun consumer, metrics and recorded blueprint | Replaceable canonical scene sink and presentation adapters |
|
||||
| `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service |
|
||||
| `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract |
|
||||
| `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime |
|
||||
| `docs/domain-model/` | Versioned experimental Mission Core vocabulary | Promotion source for meanings proven across devices/services |
|
||||
|
||||
## Implemented boundary
|
||||
|
||||
The current K1 live/replay data path is:
|
||||
|
||||
```text
|
||||
raw K1 transport
|
||||
-> raw-first evidence capture
|
||||
-> injected K1 ModelingReport observer (device time/distance/speed)
|
||||
-> bounded four-message visual preview queue
|
||||
-> XGRIDS normalizer injected by the plugin facade
|
||||
-> DecodedPointCloudView / DecodedPoseView
|
||||
-> Rerun bridge
|
||||
-> embedded local viewer
|
||||
```
|
||||
|
||||
The host-owned recorded path is separate from the disposable live preview:
|
||||
|
||||
```text
|
||||
sealed or recovered native observation session
|
||||
-> SQLite catalog and bounded single-worker preparation
|
||||
-> capture-clock-bound atomic RRD cache v9 + immutable recorded-media manifest v2
|
||||
-> generation-bound same-origin HTTP
|
||||
-> aggregate RRD/camera admission
|
||||
-> native Rerun HTTP receiver + recorded fMP4 player
|
||||
```
|
||||
|
||||
New K1 source generations durably publish
|
||||
`captures/mqtt_live/mqtt.timeline.origin.json` before camera production. MQTT
|
||||
shutdown adds provisional transport envelope `mqtt.timeline.json`; after camera
|
||||
and MQTT/runtime shutdown, the acquisition owner publishes content-addressed
|
||||
`mqtt.timeline.session-<sha256>.json` and atomically switches the capture-summary
|
||||
pointer while still holding the session lease. Only that `session` scope is
|
||||
eligible to advertise combined media replay. Cache v9 materializes both session
|
||||
envelope endpoints as real RRD rows and logs archived device-reported scan time,
|
||||
route distance and speed as scalar time series. A crash-recovered origin-only
|
||||
point/pose prefix falls back to its last validated message and cannot advertise
|
||||
camera media. Older valid sessions without the clock contract keep their
|
||||
first/last-message fallback and cannot acquire camera evidence retroactively.
|
||||
|
||||
Rerun does not import K1 protocol code, inspect MQTT topics or receive raw
|
||||
payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its
|
||||
composition owner injects one. The local `Decoded*View` classes are in-process
|
||||
consumer projections, not the portable Plugin SDK wire envelopes.
|
||||
|
||||
The Plugin SDK v0alpha2 separately defines model/device/session identity,
|
||||
operation events, acquisition-related session state, canonical stream
|
||||
envelopes, payload handles, evidence lineage and compatibility assessments. It
|
||||
is importable through the root editable path dependency and independently
|
||||
buildable from `packages/plugin-sdk`. A runtime-owned descriptor must match the
|
||||
manifest ID, version, host API and exact action set, then complete the
|
||||
`plugin-runtime/v0alpha1` handshake before its lifecycle health becomes ready.
|
||||
Backend actions instantiate immutable SDK `RuntimeActionInvocation` and
|
||||
`RuntimeActionResult` envelopes through the replaceable transport seam, and the
|
||||
host rejects uncorrelated results. The current transport is still in-process.
|
||||
The live spatial path does not yet instantiate portable SDK stream envelopes or
|
||||
use the SDK `EvidenceStore` protocol.
|
||||
|
||||
Frontend device workflows follow the same ownership rule. The generic host
|
||||
selects a manifest model and mounts its `device.connection` component. Concrete
|
||||
XGRIDS source lives under `plugins/xgrids-k1/frontend`, imports the host only via
|
||||
`@mission-core/plugin-sdk`, and is connected by the single reviewed import in
|
||||
`apps/control-station/src/composition/devicePlugins.ts`. BLE/Wi-Fi provisioning,
|
||||
exact-profile confirmation, project metadata, acquisition/replay UI and the
|
||||
optional K1 spatial-scene control block are not Core components. The host owns
|
||||
only the optional slot contract and generic actions such as opening the scene or
|
||||
releasing a prior manual/archive selection after a plugin source successfully
|
||||
starts.
|
||||
|
||||
A generic fail-closed guard blocks manual source input/apply/reset,
|
||||
saved-session switching and persisted replay reattach whenever the runtime
|
||||
reports a nonterminal acquisition. Unknown future acquisition states block by
|
||||
default. A plugin clears the previous host-selected source only after its start
|
||||
returns success, preserving the current scene on failure. That internal
|
||||
success-result transition is distinct from an operator switch. The guard does
|
||||
not stop equipment or invent a lifecycle transition; the acquisition must
|
||||
finish through its owning plugin.
|
||||
|
||||
## Experimental vocabulary, not Platform Ontology
|
||||
|
||||
The local vocabulary distinguishes:
|
||||
|
||||
```text
|
||||
model != device != transport alias != device session
|
||||
acquisition != operation != source/channel != evidence
|
||||
observation session != preparation job != replay launch != viewer instance
|
||||
workspace layout != sensor evidence
|
||||
acknowledgement != completion
|
||||
raw evidence != decoded data != viewer state
|
||||
```
|
||||
|
||||
These meanings remain versioned under `docs/domain-model/` and do not mutate or
|
||||
create a runtime dependency on NODE.DC Platform Ontology. Promotion requires a
|
||||
second device family or another platform service using the same stable meaning,
|
||||
an owner, lifecycle, compatibility policy and migration tests.
|
||||
|
||||
## Plugin manifest and compatibility
|
||||
|
||||
The catalog accepts existing v1alpha1 manifests and additive v1alpha2 manifests.
|
||||
v1alpha1 remains a one-model contract; v1alpha2 accepts one or more models and
|
||||
requires every model to be covered by a reviewed, plugin-local, path-confined
|
||||
compatibility profile. The current XGRIDS profile matches exactly:
|
||||
|
||||
- XGRIDS LixelKity K1;
|
||||
- wire-level platform type `A4`;
|
||||
- firmware `3.0.2`;
|
||||
- direct-LAN topology;
|
||||
- one retained physical laboratory evidence scope.
|
||||
|
||||
Unknown or mismatched firmware fails closed. The UI selects the profile and the
|
||||
canonical bootstrap verifies model, platform type, activation and firmware from
|
||||
a correlated live `DeviceInfo` before START. The profile independently records observation, decode, replay,
|
||||
physical-verification and write evidence. Loading it cannot authorize a
|
||||
transport mutation. BLE Wi-Fi provisioning and application control retain
|
||||
separate explicit operator gates.
|
||||
|
||||
An exact K1 application-control substrate now lives inside the vendor
|
||||
plugin. It byte-matches the retained ten-request pre-START dialogue plus
|
||||
START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
|
||||
device-status values into a fail-closed state machine. The OpenAPI value
|
||||
is one private application-level authority; vendor ID and serial come from the
|
||||
BLE-selected live K1 and are cross-checked against status. Plugin v0.5.0 owns a
|
||||
single interactive MQTT session from connection through STOP/standby, with one
|
||||
explicit UI action per recorded lifecycle boundary and no automatic retry. A
|
||||
fixed Keychain loader and dormant shadow coordinator remain present. The staged
|
||||
physical START/live/STOP/unbound-READY cycle is accepted on the reviewed unit;
|
||||
write capability is exposed only while that interactive socket owner is active.
|
||||
|
||||
## Semantic lifecycle
|
||||
|
||||
The transitional facade now creates separate provisional device,
|
||||
device-session, acquisition and operation IDs. Acquisition can be prepared,
|
||||
wait for receiver readiness, then wait for an external physical start, and become acquiring only after real point
|
||||
data. Plugin-commanded STOP finalizes automatically after the same bound control
|
||||
session reports unbound READY. Operator-manual capture-only stop still reports
|
||||
the K1 physical state as unknown.
|
||||
|
||||
Preparation requires a project name. The frontend and backend both apply NFKC
|
||||
normalization plus surrounding-whitespace trimming, reject control/surrogate
|
||||
characters and names above 96 Unicode characters, and store the result as
|
||||
session/catalog display metadata. It is not a path component. The accepted
|
||||
interactive START carries this exact validated value to K1.
|
||||
|
||||
The K1 contribution may mount a `SpatialControlsView` beside the host-owned
|
||||
viewport. It presents plugin lifecycle wording, the stop action and live
|
||||
`ModelingReport` scan time, route distance and speed. In plugin-commanded mode
|
||||
the stop action sends the canonical device STOP; in operator-manual mode it
|
||||
seals only local reception. `ModelingReport` is consumed before the visual preview queue through
|
||||
an injected observer, preserving the generic normalizer/Rerun boundary.
|
||||
|
||||
The operation journal is bounded and in memory. It records IDs, idempotency,
|
||||
declared deadlines, progress and terminal results without action parameters or secrets.
|
||||
It is not durable, distributed or recoverable after process restart.
|
||||
|
||||
Semantic acquisition actions coexist with legacy `stream.*` actions for
|
||||
v1alpha1/UI compatibility. On the exact accepted profile, a plugin-commanded
|
||||
acquisition owns canonical START and STOP plus local reception. Physical
|
||||
double-click remains the independently verified device-local fallback.
|
||||
|
||||
Left/right camera preview transport, endpoint paths and H.264 framing are now
|
||||
observed under the exact compatibility profile. The local read-only adapter
|
||||
copy-remuxes one selected RTSP producer into bounded fMP4/WebSocket delivery for
|
||||
the generic MSE UI. New acquisitions archive selected-camera init/segments/index
|
||||
independently of browser delivery. The generic recorded player, manifest-v2
|
||||
validation and shared `session_time` controls are connected; historical sessions
|
||||
created before this archive contract contain no recoverable video. The TEST007
|
||||
recorded-camera package and browser playback are physically accepted. Portable FFmpeg packaging, disk-backed
|
||||
browser buffering, fan-out and remote delivery also remain open. Device
|
||||
calibration command and sensor-to-vehicle extrinsics are unavailable.
|
||||
|
||||
The 2026-07-17 physical acceptance gate confirmed continuously updating point
|
||||
clouds, a matching live trajectory, and both camera selections in the same
|
||||
Mission Core acquisition. The embedded Rerun blueprint owns live-edge following
|
||||
on `stream_time`; the React shell selects the timeline once and does not drive it
|
||||
with a timer. Raw captures, camera frames, device identity and network details
|
||||
remain outside Git. The bounded acquisition subset of `DeviceStatusReport` and
|
||||
`ModelingReport` route telemetry are decoded for the exact profile; heartbeat
|
||||
and nested system/RTK status payloads remain raw observed evidence. See
|
||||
[`ADR 0007`](adr/0007-k1-camera-preview-copy-remux-gateway.md)
|
||||
for the measured gate and remaining limits.
|
||||
|
||||
## Remaining extraction order
|
||||
|
||||
Native replay parity, exact-profile physical point/pose regression, immutable
|
||||
SDK runtime-action envelopes, fail-closed runtime handshake and physical
|
||||
extraction of K1 transports/codecs are complete. Portable SDK stream/evidence
|
||||
envelopes remain separate from the allocation-conscious in-process preview
|
||||
views.
|
||||
|
||||
1. When a real deployment or second execution target requires it, implement a
|
||||
subprocess transport behind the current seam, then add heartbeat timeout,
|
||||
restart policy and resource limits as separately accepted behavior.
|
||||
2. Replace compatibility routes and singleton state with multi-device session
|
||||
routing.
|
||||
3. Split Edge execution from the Control Station behind authenticated transport.
|
||||
4. Physically accept a newly archived left/right K1 session, then package the
|
||||
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||
plane. Keep modeling commands limited to the installed operator-present,
|
||||
exact-profile acceptance transport until durable-save confirmation is
|
||||
physically accepted.
|
||||
|
||||
Complex equipment will likely be assembled from separately useful component
|
||||
plugins into configured hardware packs, while standalone equipment remains
|
||||
available for engineering work. That direction is intentionally not a current
|
||||
domain schema: pack ownership, roles, compatibility and lifecycle must be
|
||||
derived from real additional component families rather than inferred from the
|
||||
single K1 vertical.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Raw bytes are appended and OS-flushed before preview work; aligned raw and
|
||||
metadata become durable together at the bounded group-commit boundary and
|
||||
are never overwritten by a decode result.
|
||||
- Mission Core starts when the XGRIDS manifest is absent. Formal installed
|
||||
plugin quarantine remains a future supervisor feature.
|
||||
- Core code does not branch on XGRIDS IDs, fields, topics or firmware.
|
||||
- A plugin cannot add global navigation or arbitrary CSS.
|
||||
- A transport alias never becomes a stable device identity merely because it
|
||||
appears in a vendor header.
|
||||
- Acknowledgement, first data, operator confirmation and physical completion
|
||||
remain distinct evidence levels.
|
||||
- Vendor writes require a declared capability, exact compatibility evidence,
|
||||
host authorization and an operator gate. Manifest metadata and profiles alone
|
||||
grant none of these.
|
||||
- The visualization engine can be replaced without changing vendor transports
|
||||
or codecs.
|
||||
- Real captures, credentials, device identities, maps and router metadata remain
|
||||
outside Git.
|
||||
|
||||
## Deferred, not implemented
|
||||
|
||||
- isolated/signed plugin processes and crash containment;
|
||||
- durable operation journal, restart recovery, vault and RBAC;
|
||||
- persistent device identity and concurrent multi-device routing;
|
||||
- complete SDK-envelope/EvidenceStore hot-path integration;
|
||||
- remote Edge split, authenticated WAN relay and fleet orchestration;
|
||||
- automatic K1 start/stop/calibration commands;
|
||||
- device-reported camera capability discovery beyond the reviewed K1 profile;
|
||||
- physical acceptance of shared-timeline point-cloud plus one selected-camera
|
||||
playback on a newly archived K1 session;
|
||||
- frame-accurate camera/LiDAR calibration, panoramic stitching, disk-backed
|
||||
browser media cache and remote multi-consumer delivery;
|
||||
- production retention, replication, encryption and long-run WebViewer/WASM
|
||||
memory acceptance for large observation sessions.
|
||||
|
||||
## Workspace-repeatable locked architecture gate
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
uv sync --frozen --group dev
|
||||
uv run pytest \
|
||||
tests/test_plugin_sdk_v0alpha2_contracts.py \
|
||||
tests/test_plugin_runtime.py \
|
||||
tests/test_plugin_catalog.py \
|
||||
tests/test_xgrids_compatibility_profile.py \
|
||||
tests/test_device_lifecycle.py \
|
||||
tests/test_xgrids_acquisition_lifecycle.py \
|
||||
tests/test_canonical_pipeline.py \
|
||||
tests/test_rerun_bridge.py \
|
||||
tests/test_web_validation_security.py
|
||||
uv run ruff check .
|
||||
uv run mypy
|
||||
uv run mypy --strict packages/plugin-sdk/python/missioncore_plugin_sdk
|
||||
uv run python plugins/xgrids-k1/profile_loader.py
|
||||
uv run python -m missioncore_plugin_sdk.v0alpha2 \
|
||||
> /tmp/missioncore-plugin-sdk-v0alpha2.schemas.json
|
||||
|
||||
cd apps/control-station
|
||||
npm ci
|
||||
npm run test:unit
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
These checks perform no BLE scan, network capture, router mutation or K1
|
||||
application write. The next physical and communication gates are specified in
|
||||
ADR 0004.
|
||||
|
||||
This gate is repeatable in the current workspace, not standalone reproducible.
|
||||
The frontend consumes mutable sibling `file:` dependencies from
|
||||
`NODEDC_DESIGN_GUIDELINE`; its lockfile does not pin the donor checkout's Git
|
||||
revision or content hash. CI and portable packaging remain blocked until those
|
||||
packages are published or vendored, or an immutable donor revision and content
|
||||
verification are enforced. The current root Python wheel also omits the plugin
|
||||
manifests/profile loader and built frontend, so it is not a deployment bundle.
|
||||
@@ -0,0 +1,76 @@
|
||||
# LixelGO / iPhone observation runbook
|
||||
|
||||
This runbook prepares the Mac without full Xcode and defines the first bounded,
|
||||
read-only capture. The accepted decision is ADR 0005. Do not start the iPhone
|
||||
experiment until the exact-profile physical regression in ADR 0004 passes.
|
||||
|
||||
## Current Mac baseline
|
||||
|
||||
- macOS 26.5.1, Apple Silicon;
|
||||
- Xcode Command Line Tools 16.4 are present;
|
||||
- full Xcode and `rvictl` are absent;
|
||||
- built-in `tcpdump` and `uv` are present;
|
||||
- Wireshark/tshark are not required for capture;
|
||||
- the isolated lab uses Python 3.12 and `pymobiledevice3==9.36.0`.
|
||||
|
||||
## Preparation
|
||||
|
||||
```bash
|
||||
uv sync --project plugins/xgrids-k1/lab/iphone-capture --frozen
|
||||
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
|
||||
pymobiledevice3 version
|
||||
```
|
||||
|
||||
Expected version: `9.36.0`.
|
||||
|
||||
Connect one unlocked iPhone using a data cable, approve the Mac accessory prompt,
|
||||
and tap **Trust This Computer**. Do not paste a UDID into Terminal or notes.
|
||||
|
||||
```bash
|
||||
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
|
||||
python plugins/xgrids-k1/lab/iphone-capture/preflight.py
|
||||
```
|
||||
|
||||
## First smoke without K1
|
||||
|
||||
After the iPhone preflight succeeds, run a 10-second idle capture only after the
|
||||
current source milestone is committed or with an explicitly recorded dirty-tree
|
||||
fingerprint:
|
||||
|
||||
```bash
|
||||
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
|
||||
python plugins/xgrids-k1/lab/iphone-capture/capture.py \
|
||||
--duration 10
|
||||
```
|
||||
|
||||
The tool retains the original metadata-bearing `PCAPNG` and creates a compatible
|
||||
classic `PCAP`. Validate the latter with built-in `tcpdump`:
|
||||
|
||||
```bash
|
||||
tcpdump -nn -r sessions/iphone-k1-observation/<session>/captures/iphone-network.pcap -c 1
|
||||
```
|
||||
|
||||
After the capture and operator timeline are both closed, seal the complete
|
||||
session without rewriting the capture manifest:
|
||||
|
||||
```bash
|
||||
uv run python plugins/xgrids-k1/lab/iphone-capture/session_integrity.py \
|
||||
sessions/iphone-k1-observation/<session>
|
||||
```
|
||||
|
||||
The derived private inventory verifies all manifest hashes and also hashes the
|
||||
late operator-event artifact when it exists.
|
||||
|
||||
## Full baseline sequence
|
||||
|
||||
1. Complete the ADR 0004 physical K1 regression without LixelGO.
|
||||
2. Close unrelated iPhone apps and pause unrelated network activity.
|
||||
3. Keep the iPhone on the K1/shared Wi-Fi while USB remains connected to Mac.
|
||||
4. Start a bounded 60–180 second network capture.
|
||||
5. Perform one predefined LixelGO action at a recorded monotonic offset.
|
||||
6. Stop and retain the raw capture; analyze only offline.
|
||||
7. Record endpoints, ports, sizes, timing and correlation before interpreting a
|
||||
payload as a command, image, or model frame.
|
||||
|
||||
The first baseline does not install the Bluetooth logging profile. BLE/HCI is a
|
||||
separate operator-approved experiment.
|
||||
@@ -0,0 +1,586 @@
|
||||
# Observation sessions, playback and workspace layout
|
||||
|
||||
Status: implemented for native K1 point/pose evidence and host-side camera
|
||||
archival. Valid K1 `ModelingReport` scan time, distance and speed are also
|
||||
materialized as recorded Rerun time series. Recorded spatial playback is exposed
|
||||
in the Mission Core observation workspace. The camera archive/player contract
|
||||
is implemented and covered by tests. TEST007 physically accepted shared-timeline
|
||||
playback of the saved cameras and point cloud. It also carries the first optional
|
||||
external-perception result projected into the same Rerun recording; older
|
||||
sessions without canonical camera evidence or an admitted compute result remain
|
||||
fully usable with only their available modalities.
|
||||
|
||||
## Operator path
|
||||
|
||||
1. Enter the required project name and start a normal acquisition from **Парк →
|
||||
Локальное устройство**. The NFKC-normalized/trimmed value is local display
|
||||
metadata, not a filesystem path, and becomes the saved-session catalog name.
|
||||
A normal acquisition has no duration deadline and runs until the operator
|
||||
explicitly stops it. Compatibility clients may still request a positive
|
||||
finite duration, without an application-level maximum.
|
||||
2. Open **Наблюдение → Пространственная сцена**. Live point cloud, trajectory
|
||||
and the selected camera remain live-only while acquisition is running.
|
||||
3. Stop acquisition normally, or allow the local service to recover an
|
||||
unexpected interruption on its next start. Session recording is automatic;
|
||||
the disk action is not required.
|
||||
4. Open **Сохранённые сессии** in the observation header. The menu shows up to
|
||||
100 indexed runs, their date, duration, state, modalities and
|
||||
background preparation state.
|
||||
5. Choose a replayable run. Opening a replay never performs conversion in the
|
||||
request. A ready recording opens immediately; otherwise the client receives
|
||||
HTTP 202, keeps the current scene mounted and polls the preparation status.
|
||||
Only after the server returns a verified launch descriptor does Mission Core
|
||||
pause and unload the previous viewer.
|
||||
6. The client then receives and decodes the complete RRD. The new viewport and
|
||||
timeline stay hidden until the declared recording range is fully buffered
|
||||
(`fullyBuffered`). Playback starts once at that point. Partial frames are
|
||||
never shown. If the run contains canonical camera archives, its recorded
|
||||
camera sources replace all live device overlays.
|
||||
7. Use **Воспроизвести / Пауза**, the scrubber, **К началу** and **К концу** on
|
||||
the right half of the bottom timeline. The left half changes point-cloud
|
||||
accumulation. The recorded timeline is zero-based `session_time`.
|
||||
8. If a validated derived camera result exists, **Распознавание** appears in the
|
||||
scene toolbar. It switches the active native Rerun view to camera frames and
|
||||
boxes; **Облако точек** returns to the 3D view. No button is shown for an
|
||||
absent or rejected result, and the base recording remains usable.
|
||||
9. Use the first disk button to save the current workspace layout. The next
|
||||
opening restores display settings and dynamic sensor-source window
|
||||
positions. The transient **Движок / Слои / Отображение** tool windows are
|
||||
scoped to the mounted spatial workspace and are never persisted. This action
|
||||
saves no sensor evidence.
|
||||
|
||||
Mission Core does not switch an active acquisition to another spatial source.
|
||||
Every nonterminal or unknown acquisition state fail-closes saved-session replay,
|
||||
persisted replay reattach and manual RRD/Rerun input/apply/reset with
|
||||
**Завершите текущий приём перед сменой источника.** A plugin-owned automatic
|
||||
source action clears the older source and opens the scene only after its start
|
||||
returns success; failure leaves the current scene mounted. This internal
|
||||
success-result transition is not an operator switch. The guard does not stop the
|
||||
scanner or local receiver automatically.
|
||||
|
||||
Display controls have no separate Apply/Reset transaction. Boolean controls
|
||||
commit immediately; sliders, colors and selects commit on release/blur or after
|
||||
a short quiet period, and concurrent commits collapse to the newest value. A
|
||||
layout save flushes and awaits this queue before serializing the confirmed
|
||||
settings. Recorded display updates reuse stable Rerun scene identifiers and do
|
||||
not mutate playback state, so changing accumulation, size, color or visibility
|
||||
does not pause the active replay or replace its camera view.
|
||||
|
||||
Completed and recovered sessions are discovered on startup and by the catalog
|
||||
reconciler. The first scan is only a historical baseline: it never enqueues the
|
||||
existing archive. A session that becomes finalized after that baseline is
|
||||
deduplicated into the bounded, single-worker preparation queue. The worker
|
||||
validates the native source, exports point/pose data, verifies source stability
|
||||
and both SHA-256 digests, finalizes once, and atomically publishes the
|
||||
digest-bound derived RRD plus its cache sidecar. Before the same job becomes
|
||||
`ready`, it also parses and validates every archived camera index and publishes
|
||||
the immutable media-manifest sidecar. The catalog exposes preparation state and
|
||||
progress while this runs; a recording URL and camera descriptors are returned
|
||||
only after the complete launch generation has passed validation.
|
||||
|
||||
Preparation is a backend lifecycle, not a viewer computation. Reopening,
|
||||
seeking, refreshing the catalog or changing the workspace never reruns a
|
||||
published conversion. After a process restart, a catalog/replay read restores a
|
||||
complete compatible RRD and camera package from its durable sidecars using
|
||||
bounded schema and stat-identity checks; it does not hash native evidence, parse
|
||||
camera indexes or invoke the exporter. A historical session whose package is
|
||||
missing, stale or from an incompatible cache schema remains cold. Only an
|
||||
explicit replay/RRD request schedules that one session for preparation; listing
|
||||
the catalog and finalizing another session do not schedule it.
|
||||
|
||||
The session menu uses three operator indicators:
|
||||
|
||||
| Indicator | Catalog/preparation state | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Solid green + `Готово` | `ready` | Verified RRD is available. |
|
||||
| Pulsing green + `Обработка` | `queued`, `validating`, `exporting`, `finalizing` | Background preparation is active. |
|
||||
| Solid gray + `Подготовить` | replayable session with no compatible published package | No work is running; choosing the row schedules this session only. |
|
||||
| Dim gray + `Ошибка` | failed, cancelled, non-replayable or invalid session | No launchable recording; inspect the message or retry. |
|
||||
|
||||
Saved-session status never uses yellow or red. Switching sessions aborts only
|
||||
the obsolete browser poll; it does not cancel the process-owned conversion.
|
||||
A queued job may legitimately wait behind another export and therefore uses the
|
||||
overall preparation deadline rather than the active-export heartbeat timeout.
|
||||
|
||||
## Immutable LAB instances
|
||||
|
||||
An accepted experiment is published as a new saved-session identity instead of
|
||||
changing the AI result selected for its source recording. A LAB instance binds:
|
||||
|
||||
- a stable `LAB E…` marker and opaque session id;
|
||||
- the immutable source observation session;
|
||||
- the exact result, source-result and configuration SHA-256 identities;
|
||||
- run and publication timestamps plus JSON provenance;
|
||||
- a content-addressed compute job, LiDAR pack and integrated visual result.
|
||||
|
||||
The source evidence remains the only raw sensor master. Full-session LAB replay
|
||||
caches and unchanged camera inputs use hard links on the same filesystem; no
|
||||
second copy of the source RRD, video or native K1 capture is created. A bounded
|
||||
experiment instead stores a small derived RRD slice with exact start/end markers
|
||||
and omits the source's unbounded recorded-video descriptor. Its integrated
|
||||
perception overlay owns the matching bounded camera. This prevents Rerun from
|
||||
holding the final AI frame while a longer source cloud continues to play.
|
||||
Derived arrays are stored only when the run did not already persist a
|
||||
viewer-ready form. Deleting a LAB catalog entry cannot delete the referenced
|
||||
source session, and a source session with published LAB instances fails closed
|
||||
on deletion.
|
||||
|
||||
The visual result is resolved by the LAB session id, so `LAB E19`, `LAB E21`
|
||||
and the derived temporal comparison `LAB E22` can coexist over `RAVNOVES00`
|
||||
without “latest accepted result” replacing an earlier experiment. The operator
|
||||
can open any row in **Сохранённые сессии** and use the same
|
||||
**Объекты 2D / Сегментация / Кубы 3D** controls. First publication must also
|
||||
warm the result-specific Rerun overlay; later opens reuse that verified cache
|
||||
and do not rerun AI inference.
|
||||
|
||||
E21 is a bounded special case: its worker persisted 121 semantic mask hashes,
|
||||
not duplicate mask pixels. Its visual publication materializes a mask only
|
||||
after an exact SHA-256 match against the immutable accepted E19 semantic
|
||||
reference. The seven detector frames replaced by the bounded latest-wins queue
|
||||
remain explicit empty frames. This preserves the measured near-real-time
|
||||
behavior rather than presenting a fabricated gap-free run. The saved-session
|
||||
duration and base RRD range must equal the E21 visual result range; attaching
|
||||
the 60-second result to the complete 8:55 source replay is an invalid
|
||||
presentation because latest-at image data would appear static after the E21
|
||||
window ends.
|
||||
|
||||
E22 is also bounded to the exact E21 60-second range, but it resolves its
|
||||
catalog provenance directly to the physical source session rather than chaining
|
||||
one LAB entry to another. Its temporal result remains a separate immutable
|
||||
integrated overlay. Raw point-cloud, camera, calibration and E21 inference
|
||||
payloads are not replaced. Short held boxes are provenance-marked diagnostic
|
||||
presentation/world-state values, not fresh sensor measurements.
|
||||
|
||||
The first preparation of a long capture can take time because every decodable
|
||||
point/pose message and every valid K1 `ModelingReport` is projected into RRD.
|
||||
Later openings reuse a verified, digest-bound cache. Derived RRD cache v9 is
|
||||
intentionally incompatible with v8 and older generations. In addition to plugin
|
||||
and ordered-artifact identity, it binds the durable clock origin and active
|
||||
envelope and materializes real `session_time = 0` origin and sealed completion
|
||||
rows. Older generations remain cold after startup and are rebuilt only when an
|
||||
operator explicitly opens that session.
|
||||
The browser may use the strict `source_url` with `If-Match`, while the embedded
|
||||
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
|
||||
`generation` query is bound to the same launch descriptor. A missing or stale
|
||||
generation fails with `412`; a matching URL is served with exact length, strong
|
||||
ETag and private immutable/no-transform caching. Every cache pin is released when the
|
||||
HTTP response completes, disconnects or fails, so an interrupted switch cannot
|
||||
make a derived recording permanently non-evictable. A bounded 120-second launch
|
||||
reservation also protects the artifact between the verified launch response and
|
||||
the WebViewer's subsequent RRD request, including a cold WASM startup; it
|
||||
expires automatically if that request never arrives.
|
||||
|
||||
## Storage roots
|
||||
|
||||
By default, both catalog state and new source evidence are private to the
|
||||
checkout:
|
||||
|
||||
```text
|
||||
.runtime/mission-core/
|
||||
├── mission-core.sqlite3
|
||||
├── mission-core.sqlite3-shm
|
||||
├── mission-core.sqlite3-wal
|
||||
├── evidence/
|
||||
│ └── sessions/
|
||||
│ ├── .current_session # present only while a writer owns the root
|
||||
│ └── <UTC>_viewer_live/
|
||||
│ ├── manifest.redacted.json # normalized local project display metadata
|
||||
│ ├── captures/
|
||||
│ │ └── mqtt_live/
|
||||
│ │ ├── mqtt.raw.k1mqtt
|
||||
│ │ ├── mqtt.metadata.jsonl
|
||||
│ │ ├── mqtt.timeline.origin.json
|
||||
│ │ ├── mqtt.timeline.json # provisional transport envelope
|
||||
│ │ ├── mqtt.timeline.session-<sha256>.json # sealed generation
|
||||
│ │ └── mqtt.summary.json # atomic active-envelope pointer
|
||||
│ └── media/ # canonical camera archives
|
||||
├── recordings/
|
||||
├── .export.lock # cross-process conversion/eviction lock
|
||||
└── <opaque-session-id>/
|
||||
├── scene.rrd
|
||||
└── scene.rrd.cache.json
|
||||
└── perception-overlays/
|
||||
└── <opaque-session-id>/<result-id>/<recording-id>.rrd
|
||||
```
|
||||
|
||||
`MISSIONCORE_DATA_DIR` relocates the catalog and derived cache. Unless it is
|
||||
overridden separately, new evidence is written to
|
||||
`$MISSIONCORE_DATA_DIR/evidence/sessions`. `MISSIONCORE_EVIDENCE_DIR` can select
|
||||
another absolute MQTT evidence root. In the current K1 integration the camera
|
||||
gateway additionally confines its recording directory to the repository
|
||||
checkout, so a full point-plus-camera acquisition must keep the evidence root
|
||||
inside that checkout. An external evidence volume currently supports MQTT
|
||||
capture/cataloging but camera archival will fail closed until the gateway gets
|
||||
a separately attested storage root:
|
||||
|
||||
```bash
|
||||
export MISSIONCORE_DATA_DIR=/absolute/private/path/mission-core
|
||||
# Full K1 point-plus-camera evidence must currently remain below the checkout.
|
||||
export MISSIONCORE_EVIDENCE_DIR=/absolute/path/to/NODEDC_MISSION_CORE/.runtime/mission-core/evidence/sessions
|
||||
# Optional operator retention quota; unset means no application byte quota.
|
||||
# export MISSIONCORE_RRD_CACHE_MAX_BYTES=8589934592
|
||||
export MISSIONCORE_RRD_FREE_SPACE_RESERVE_BYTES=2147483648
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
The directories and derived recording cache use owner-only permissions where
|
||||
the host filesystem permits them. A new acquisition writer is assigned only a
|
||||
direct child of the private evidence root; it no longer writes a new run to the
|
||||
repository-level `sessions/` directory.
|
||||
|
||||
Repository-level `sessions/*_viewer_live` runs are a legacy, import-only source
|
||||
for the catalog. They are discovered and confined in place: refresh does not
|
||||
copy, move or migrate their large payloads, and no current writer is assigned
|
||||
that root. Startup may recovery-seal an already existing, incomplete canonical
|
||||
camera epoch there by preserving its valid segment prefix and writing an
|
||||
`interrupted` summary; this is evidence recovery, not a new acquisition write.
|
||||
|
||||
Never add `.runtime/`, `sessions/`, raw captures, RRD files or camera media to
|
||||
Git. They can contain mapped interiors, trajectories and identifiable images.
|
||||
The recorded perception importer reads repo-local ignored
|
||||
`.runtime/compute-jobs` and `.runtime/compute-results`; those derived handoff
|
||||
artifacts are likewise private and never become browser paths or Git inputs.
|
||||
|
||||
## Session source of record
|
||||
|
||||
For the current K1 profile:
|
||||
|
||||
```text
|
||||
MQTT callback
|
||||
├─ durable native .k1mqtt + aligned metadata (source of record)
|
||||
├─ durable clock origin (before camera production)
|
||||
├─ provisional + content-addressed envelopes (transport/session bounds)
|
||||
├─ ModelingReport -> live product metrics (before visual preview queue)
|
||||
└─ bounded latest-wins Rerun live preview (disposable)
|
||||
|
||||
selected RTSP producer
|
||||
├─ durable init + fMP4 segments + JSONL index (camera evidence)
|
||||
└─ bounded WebSocket/MSE preview (disposable)
|
||||
```
|
||||
|
||||
The live preview is intentionally allowed to drop frames under load. Native
|
||||
point/pose evidence and camera archive writes do not traverse that queue.
|
||||
|
||||
Native `.k1mqtt` bytes with aligned metadata, the capture-clock artifacts and
|
||||
canonical camera fMP4 archives are the evidence source of truth. The derived RRD
|
||||
contains every decodable point and pose frame plus every valid `ModelingReport`
|
||||
distance/speed/scan-time sample from that source, but remains a rebuildable view
|
||||
rather than an evidence master. A cache entry is eligible for rebuild only when
|
||||
native source identity/digests change or an incompatible derived-data export
|
||||
revision is introduced. Historical entries are not rebuilt during startup or
|
||||
because another session is finalized; an explicit open schedules the affected
|
||||
entry. A UI blueprint or workspace-layout revision never invalidates or rewrites
|
||||
the data RRD. Cache v8 and older payloads are not reusable as v9 because they do
|
||||
not bind both real capture-envelope endpoints.
|
||||
|
||||
`ModelingReport` time is the device's `ScanTime` counter at two ticks per second;
|
||||
distance and speed are the reported `MoveDistance`/`MoveSpeed` values. Live state
|
||||
keeps the current device scan generation and does not accumulate an earlier
|
||||
distance after the device resets scan time and route distance. These values are
|
||||
not reconstructed from pose integration or a browser timer.
|
||||
|
||||
Expensive cache misses run through the single-worker preparation queue and one
|
||||
global cross-process export gate to cap concurrent RAM, CPU and temporary-disk
|
||||
use. Crash leftovers from candidates, exporter temporary files and staged replay
|
||||
prefixes are scavenged under that lock before capacity accounting. Ready cache
|
||||
hits and active response leases do not wait behind that gate. The derived cache
|
||||
has no application byte quota by default, so a single multi-hour RRD is not
|
||||
rejected at 8 GiB. It still preserves a 2 GiB default filesystem reserve. An
|
||||
operator may set `MISSIONCORE_RRD_CACHE_MAX_BYTES` to enable LRU eviction of
|
||||
derived RRDs only; native evidence is never deleted.
|
||||
|
||||
## Capture-clock envelope
|
||||
|
||||
New K1 captures first publish bounded schema-1
|
||||
`captures/mqtt_live/mqtt.timeline.origin.json` with start epoch and monotonic
|
||||
nanoseconds. The writer creates and synchronizes this immutable artifact after
|
||||
its raw/metadata files are open and before camera production is armed. A crash
|
||||
therefore cannot leave retained camera evidence whose intended session zero
|
||||
existed only in memory.
|
||||
|
||||
MQTT shutdown then publishes schema-1 `mqtt.timeline.json` with the same start
|
||||
plus transport completion. Capture-summary schema 2 initially points to it with
|
||||
`capture_clock_scope: transport`, and binds both origin/envelope names and
|
||||
SHA-256 values. That provisional file is not rewritten.
|
||||
|
||||
After the camera archive and MQTT/runtime stop, the acquisition owner creates
|
||||
`mqtt.timeline.session-<sha256>.json` with the extended session completion and
|
||||
atomically switches `mqtt.summary.json` to that content-addressed name, digest
|
||||
and `capture_clock_scope: session` while still holding the active-session lease.
|
||||
Publication order is sealed file first, summary pointer second, so a crash leaves
|
||||
the previous pointer valid and the idempotent seal can converge on retry. The
|
||||
same summary update records `session_elapsed_seconds`. Files and containing
|
||||
directories are synchronized at their publication boundaries.
|
||||
|
||||
Discovery validates the summary-selected filename, both digests, the shared
|
||||
origin and the envelope bounds, then catalogs that exact artifact. The
|
||||
materializer passes its exact staged path to the exporter; it never chooses a
|
||||
sealed generation by glob or “latest file” ordering. Direct/legacy export keeps
|
||||
the fixed provisional-name fallback only for evidence outside the cataloged
|
||||
session contract, and orphan sealed candidates not selected by the validated
|
||||
summary are ignored.
|
||||
|
||||
A provisional `transport` scope is never advertised as combined camera replay.
|
||||
An interrupted origin-only point/pose prefix may use the durable origin and its
|
||||
last validated message as a compatibility end, but it cannot advertise camera
|
||||
media. If the origin, active envelope, summary pointer or digest relationship is
|
||||
missing/corrupt, new-schema discovery fails closed instead of inventing a
|
||||
session boundary.
|
||||
|
||||
For a sealed generation, `session_time = 0` is the envelope start and the RRD
|
||||
end is the envelope completion. Cache v9 logs real rows at
|
||||
`/__mission_core/session_origin` and `/__mission_core/session_end`, so a camera
|
||||
fragment accepted before the first MQTT message or after the last point/pose
|
||||
message can remain inside the declared RRD interval. Valid legacy sessions
|
||||
without this artifact keep their first/last-message compatibility fallback and
|
||||
cannot gain historical camera coverage.
|
||||
|
||||
## Camera archive contract
|
||||
|
||||
New acquisitions archive each selected source and codec epoch below the same
|
||||
private evidence session:
|
||||
|
||||
```text
|
||||
<evidence-root>/sessions/<session-id>/media/<stable-source-id>/epoch-1/
|
||||
├── init.mp4
|
||||
├── segments/
|
||||
│ ├── 1.m4s
|
||||
│ └── ...
|
||||
├── index.jsonl
|
||||
└── summary.json
|
||||
```
|
||||
|
||||
Each index row binds a segment sequence, byte length, SHA-256 and host arrival
|
||||
epoch/monotonic timestamps. Camera durability uses a
|
||||
`per-segment-fsync` policy: `init.mp4` and every complete media segment are
|
||||
individually fsynced and their directory entries synchronized before the
|
||||
matching JSONL index row is committed. The index and an atomic `interrupted`
|
||||
checkpoint summary are fsynced before the append returns. The old interval and
|
||||
byte constructor options are compatibility-only and cannot weaken this
|
||||
segment-bound RPO. A power failure may still lose or leave uncommitted the
|
||||
fragment currently being produced; it cannot make a committed index row point
|
||||
past durable media. Camera windows may close or reconnect without terminating
|
||||
archival while the owning acquisition remains active.
|
||||
|
||||
Synchronization is `host-arrival-best-effort`: LiDAR/MQTT and camera segments
|
||||
share the Mac host clock boundary, but K1 sensor exposure time and LiDAR firing
|
||||
time are not proven to use a shared device clock. Do not infer frame-accurate
|
||||
calibration from the playback timeline.
|
||||
|
||||
Finalized media is prepared once by the same process-owned background job that
|
||||
materializes the RRD. JSONL indexes are read incrementally and no total index,
|
||||
segment-count or archive-byte ceiling is used. The gateway records host time
|
||||
only after a complete
|
||||
`moof+mdat` fragment has arrived, so that timestamp is an availability/end
|
||||
anchor, never a fragment-start timestamp. Preparation reads and SHA-verifies
|
||||
every fragment, parses bounded ISO-BMFF timing tables (`mdhd`, `trex`, `tfhd`,
|
||||
`trun`), anchors the epoch at
|
||||
`max(0, first_arrival - first_fragment_duration)`, and sets its end to that start
|
||||
plus the checked sum of every decoded fragment duration. The declared interval
|
||||
therefore has exactly the duration MSE is expected to expose and is not stretched
|
||||
by host scheduling jitter.
|
||||
|
||||
Epoch arrivals must be strictly monotonic; epoch intervals must be finite,
|
||||
monotonic and non-overlapping. Replay v2 also requires every media interval to
|
||||
fit inside the spatial RRD interval with a 50 ms numeric tolerance; it fails
|
||||
preparation instead of clamping unreachable evidence. A future replay v3 must
|
||||
separate `spatial_range` from a session-wide union range before out-of-RRD camera
|
||||
coverage can be navigated. A missing, ambiguous, oversized or otherwise
|
||||
unparseable timing table fails preparation; the archive remains evidence but is
|
||||
never advertised as seekable media.
|
||||
|
||||
The durable path-free v2 descriptor and full source stat identity (native raw
|
||||
and timing metadata plus every camera summary, index, init and segment file) are
|
||||
written under the private derived cache with a schema, generation and checksum.
|
||||
Publication uses a private temporary file, file and directory `fsync`, and atomic
|
||||
rename; startup scavenges crash-left temporary files. A restart reuses this
|
||||
sidecar after confined O(n) stat validation, without rereading, hashing or parsing
|
||||
media. A missing, corrupt or stale sidecar remains cold until an explicit open
|
||||
schedules the complete session package in the background worker. No catalog,
|
||||
status, manifest or payload request itself performs conversion or recalculates
|
||||
these intervals.
|
||||
|
||||
Every derived RRD contains a real `session_time = 0` row at the internal
|
||||
`/__mission_core/session_origin` entity. The anchor is deliberately outside
|
||||
`/world`, so it establishes the actual recording time range without creating a
|
||||
3D layer or drawable scene object. Preparation verifies the derived recording
|
||||
against the declared spatial range rather than relying on summary metadata
|
||||
alone.
|
||||
|
||||
Sessions made before this archive contract have no recoverable video even if a
|
||||
camera preview was visible at the time. In particular, the 2026-07-16 browser
|
||||
camera acceptance run retained point/pose evidence only.
|
||||
|
||||
## Crash and interruption behavior
|
||||
|
||||
- SQLite uses WAL, foreign keys and full synchronous durability.
|
||||
- Before creating a session directory, the acquisition takes an exclusive,
|
||||
cross-process lease by atomically creating and locking
|
||||
`<evidence-root>/.current_session`. The marker contains only the direct child
|
||||
session name. While the lock is held, a second writer fails closed and
|
||||
discovery excludes that in-progress session from replay.
|
||||
- A process exit releases the operating-system lock. On the next service start,
|
||||
stale-marker recovery removes the marker only after it can take the lock and
|
||||
revalidate the marker inode without following symlinks. It never deletes the
|
||||
interrupted session directory; normal catalog recovery then evaluates the
|
||||
durable prefix. A locked/live or suspicious marker is left untouched.
|
||||
- Native MQTT writes use a bounded group commit: at most 0.5 seconds, 4 MiB or
|
||||
32 messages per group. Raw bytes are fsynced before their metadata rows are
|
||||
written and fsynced. This is a bounded RPO, not a zero-loss guarantee: a hard
|
||||
process or power failure may discard the final uncommitted group. Raw bytes
|
||||
from an interrupted commit window may survive beyond the durable metadata;
|
||||
replay uses only the last validated metadata-aligned raw boundary.
|
||||
- New capture-summary schema 2 binds the durable origin and its active envelope
|
||||
by name and SHA-256. A normal combined replay requires the owner-sealed,
|
||||
content-addressed `session` scope. A crash can leave an origin-only or
|
||||
provisional `transport` generation, but discovery will not use either to
|
||||
advertise camera coverage. A mismatched origin/envelope/summary fails closed.
|
||||
- A native capture with a missing final summary is accepted only when the raw
|
||||
and metadata prefix is aligned and structurally valid. Recovery streams the
|
||||
metadata JSONL one row at a time with no total-byte or message-count ceiling;
|
||||
it is cataloged as `interrupted`, never silently promoted to `ready`.
|
||||
- A non-newline metadata crash tail can be ignored. Newline-terminated or
|
||||
mid-file corruption fails closed.
|
||||
- Camera recovery retains a contiguous valid segment prefix, quarantines
|
||||
non-contiguous/orphan fragments instead of deleting them, rebuilds the index
|
||||
when necessary and writes an `interrupted` summary. Unindexed bytes are not
|
||||
presented as valid media.
|
||||
- If a valid normal summary appears later, repeat discovery updates the same
|
||||
session to `ready`.
|
||||
- Materialization detects a native capture changing during export and refuses
|
||||
to publish the derived RRD. It reopens the prepared raw source with no symlink
|
||||
following inside the cataloged session roots, and interrupted recordings are
|
||||
materialized from the last validated raw/metadata boundary only.
|
||||
- Internal preparation cancellation is cooperative. A queued job cancels
|
||||
immediately; an active job stops at a safe checkpoint, removes its candidate
|
||||
and never publishes a partial RRD. The operator UI does not cancel automatic
|
||||
preparation when switching sessions or closing a tab: preparation belongs to
|
||||
the application worker, not to an HTTP request.
|
||||
- `failed` and `cancelled` are terminal, retryable states. The API exposes a
|
||||
sanitized error, and an explicit retry creates a fresh job for the current
|
||||
source identity. A stalled poll or failed switch leaves the current scene
|
||||
mounted; the latest operator selection wins over obsolete responses.
|
||||
- A lifecycle shutdown marks only its own interrupted work for automatic
|
||||
reconciliation after restart. An operator cancellation and a genuine failed
|
||||
export remain terminal and are never retried forever by the reconciler.
|
||||
|
||||
These rules provide crash recovery, not replication. A single host disk failure
|
||||
can still destroy local data. Vehicle deployment must add independent onboard
|
||||
and control-station copies, capacity monitoring and a documented retention
|
||||
policy.
|
||||
|
||||
## HTTP API
|
||||
|
||||
All paths are same-origin and expose opaque identifiers only:
|
||||
|
||||
```text
|
||||
GET /api/v1/observation-sessions?limit=3
|
||||
GET /api/v1/observation-sessions/{id}
|
||||
POST /api/v1/observation-sessions/{id}/replay
|
||||
GET /api/v1/observation-sessions/{id}/recording-preparation
|
||||
DELETE /api/v1/observation-sessions/{id}/recording-preparation
|
||||
GET /api/v1/observation-sessions/{id}/recording.rrd
|
||||
POST /api/v1/observation-sessions/{id}/blueprint.rrd
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/manifest
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/init.mp4
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/segments/{m}.m4s
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/recording.mp4?generation=<sha256>
|
||||
|
||||
GET /api/v1/workspace-layouts/observation.spatial
|
||||
PUT /api/v1/workspace-layouts/observation.spatial
|
||||
```
|
||||
|
||||
The catalog embeds each replayable session's preparation state and progress.
|
||||
`POST .../replay` returns either a verified replay v2 launch document or HTTP
|
||||
202 with the preparation v1 document, `Location`, `Retry-After`, an exact quoted
|
||||
preparation `ETag` and a same-origin status URL. Polling
|
||||
`GET .../recording-preparation` sends that value as `If-Match` and returns HTTP
|
||||
202 while the job is active, HTTP 409 for retryable `failed`/`cancelled` states,
|
||||
and the launch document only when the same job and artifact are ready; every
|
||||
response echoes the same `ETag`. `DELETE` also requires `If-Match`, so a stale
|
||||
tab cannot cancel a replacement job. Conversion is never executed synchronously
|
||||
by a replay-open request. Playback speed and loop are request-local launch
|
||||
policy; they are not stored on or shared through a preparation job.
|
||||
|
||||
Production `recording.rrd` access is bound to the launch generation. The client
|
||||
can use the strict query-free URL with exact strong
|
||||
`If-Match: "sha256:<launch.sha256>"`; a request with neither an `If-Match` nor a
|
||||
generation returns 428. The embedded Rerun receiver instead gets only the
|
||||
canonical `viewer_source_url = source_url + "?generation=<launch.sha256>"`.
|
||||
The server returns 412 for a malformed or replaced generation before opening
|
||||
the body. A successful response echoes the strong ETag, exact `Content-Length`,
|
||||
`application/vnd.rerun.rrd` and immutable private/no-transform cache policy.
|
||||
Rerun's native HTTP receiver owns incremental decoding; `LogChannel.send_rrd`
|
||||
is reserved for independently complete RRD payloads such as the small generated
|
||||
blueprint and must never receive arbitrary HTTP byte slices. The canvas,
|
||||
timeline, controller and autoplay remain closed until the decoded spatial range
|
||||
exactly matches the launch descriptor and every declared camera is ready. API
|
||||
documents never contain local paths. Layout updates require the quoted current
|
||||
revision in `If-Match`; stale writers receive HTTP 412 rather than overwriting
|
||||
another saved profile.
|
||||
|
||||
Recorded media routes expose only opaque catalog identifiers and ordinal codec
|
||||
epochs. The public compact
|
||||
`missioncore.observation-recorded-media/v3` manifest carries a strong
|
||||
`generation_sha256`, exact JS-safe aggregate `byte_length`, and finite
|
||||
`timeline_start_seconds` / `timeline_end_seconds` for every epoch. Epoch ends
|
||||
participate in the generation digest. Each epoch declares one generation-bound
|
||||
`stream_url`, media type and aggregate byte length; thousands of internal
|
||||
segment rows never enter browser memory. The launch source repeats the same
|
||||
aggregate `byte_length` and uses exactly `max(epoch.timeline_end_seconds)` as
|
||||
its end; the spatial RRD end must never pad camera coverage. The browser
|
||||
cross-checks launch and manifest identity, but applies no duration, per-source
|
||||
byte or aggregate-session byte admission ceiling.
|
||||
|
||||
The `<video>` element reads the immutable virtual fMP4 through native HTTP Range
|
||||
requests. The server maps each requested interval onto init/segment files,
|
||||
opens them through confined descriptors with no symlink following and verifies
|
||||
the digest of each touched component. It never assembles the full video in
|
||||
backend or JavaScript memory. Responses carry exact `Content-Length` /
|
||||
`Content-Range`, a generation-and-epoch ETag, `Accept-Ranges: bytes` and private
|
||||
immutable `no-transform` caching. The manifest ETag is the exact generation and
|
||||
its GET requires the matching `If-Match`; the stream URL binds that same
|
||||
generation in its query. The older init/segment routes remain internal
|
||||
compatibility surfaces. Physical source ids, RTSP addresses and storage paths
|
||||
never cross the API boundary.
|
||||
|
||||
## Current synchronization boundary
|
||||
|
||||
The spatial RRD, trajectory, archived device-metric series and archived fMP4
|
||||
cameras use the same operator scrubber now. Camera epochs are aligned to
|
||||
zero-based `session_time` from the shared host-arrival monotonic clock and
|
||||
rendered through the browser's native fMP4/Range pipeline. The metric tab carries
|
||||
device-reported route distance,
|
||||
speed and scan time at their `ModelingReport` receive times. The client selects
|
||||
a camera epoch only inside its declared inclusive interval and verifies that the
|
||||
decoded native-media seekable duration covers that interval. This remains best-effort
|
||||
correlation: codec PTS, K1 sensor exposure time and LiDAR firing time are not
|
||||
proven to share a device clock. A codec epoch whose init segment does not expose
|
||||
a browser-supported codec or whose timing cannot be proven remains retained
|
||||
evidence and fails closed in the UI/background preparation.
|
||||
Historical sessions with no canonical camera archive honestly show no recorded
|
||||
video. Recorded blueprints can change accumulation, grid visibility, point and
|
||||
trajectory visibility, point radius and a uniform custom point color without
|
||||
rewriting the recording. The client deliberately has no progressive recorded
|
||||
mode: the viewport, timeline and autoplay gate remain closed until the complete
|
||||
declared RRD has been received and decoded. Height, intensity, distance and RGB
|
||||
palettes remain baked into current RRD rows; fully dynamic recoloring requires
|
||||
exporting the corresponding scalar components in a future recording schema.
|
||||
|
||||
## Verification boundary — 2026-07-17
|
||||
|
||||
The current automated gate covers project-name validation, source-switch
|
||||
fail-closure, optional plugin scene controls, bounded `ModelingReport` decode,
|
||||
inert start/stop encoding and correlation, capture-clock publication/validation,
|
||||
cache-v9 origin/end materialization and archived metric series. The standard
|
||||
repository gate remains Python tests/Ruff/mypy plus frontend unit tests,
|
||||
TypeScript checking and the Vite production build; exact pass counts belong to
|
||||
the commit's CI/pre-push result rather than this durable architecture contract.
|
||||
|
||||
Vite still reports its expected large-chunk warning for the embedded Rerun
|
||||
viewer/WASM payload. That is a packaging optimization item, not a failed gate.
|
||||
No retained physical K1 session contains the new canonical camera archive, so a
|
||||
real point-cloud plus one-camera recorded playback remains an explicit hardware
|
||||
acceptance test. Automated protocol tests also do not authorize K1 modeling
|
||||
publishing: operator-owned Keychain item provisioning, operator-present
|
||||
physical acceptance and durable save remain separate physical/security gates.
|
||||
@@ -0,0 +1,488 @@
|
||||
# External perception worker contract
|
||||
|
||||
## Boundary
|
||||
|
||||
```text
|
||||
K1
|
||||
-> XGRIDS device plugin / Mission Core Edge
|
||||
-> authoritative raw session on Mac
|
||||
-> bounded missioncore.compute-job/v1
|
||||
-> replaceable GPU worker / Triton
|
||||
-> content-addressed missioncore.compute-result/v1
|
||||
-> optional validated Rerun derived layer
|
||||
```
|
||||
|
||||
The GPU worker cannot discover, provision, start or stop a K1. It receives an
|
||||
observation package and returns derived observations. The Mac archive is never
|
||||
rewritten when a job is prepared or a result is received.
|
||||
|
||||
## Recorded camera job v1
|
||||
|
||||
`k1link compute prepare-camera-job` accepts a sealed cataloged session, one
|
||||
camera source and one physical codec epoch. It performs the canonical camera
|
||||
checks before and after copying into a private staging directory:
|
||||
|
||||
- summary schema/source/epoch and per-segment durability contract;
|
||||
- exact ordered JSONL index and its summary SHA-256;
|
||||
- init SHA-256, every segment length/SHA-256 and complete stream SHA-256;
|
||||
- continuous ISO-BMFF decode timeline and finite media duration;
|
||||
- source session-time mapping through the captured host clock origin.
|
||||
|
||||
Publication atomically renames this layout below ignored storage:
|
||||
|
||||
```text
|
||||
<jobs>/<job-id>/
|
||||
├── job.json
|
||||
└── input/camera/<source-id>/epoch-N/
|
||||
├── summary.json
|
||||
├── index.jsonl
|
||||
├── init.mp4
|
||||
└── segments/*.m4s
|
||||
```
|
||||
|
||||
`input_sha256` is SHA-256 over canonical JSON for the complete path-free input
|
||||
descriptor. `job_id` includes its first 96 bits and validators require the full
|
||||
digest, so a prefix collision fails closed.
|
||||
|
||||
## Recorded result v1
|
||||
|
||||
The accepted worker profile verifies every transferred file, reconstructs the
|
||||
stream outside the job, decodes frames and preserves strictly increasing
|
||||
best-effort timestamps in session seconds. Its result identity is canonical JSON
|
||||
covering:
|
||||
|
||||
- job and full input generation;
|
||||
- pipeline ID/version;
|
||||
- model ID/version/weight SHA-256;
|
||||
- score/NMS thresholds, tensor shape, color order and letterbox policy.
|
||||
|
||||
The identity's full SHA-256 names an immutable `result-<sha256>` directory.
|
||||
The result manifest binds the detection artifact's length and SHA-256. A repeat
|
||||
with the same identity validates the existing directory and returns it without
|
||||
calling Triton.
|
||||
|
||||
## Full-epoch panoptic result v2
|
||||
|
||||
`missioncore.recorded-perception-result/v2` is the complete recorded-camera
|
||||
profile. It is separate from the YOLOX detector proof and processes every
|
||||
admitted frame without sampling. Its immutable identity binds the complete
|
||||
compute job, factory-calibration generation and camera slot, exact model
|
||||
revisions/weight files, thresholds, alpha values, runner SHA-256 and publication
|
||||
encoder.
|
||||
|
||||
The current research configuration produces:
|
||||
|
||||
- TorchVision Mask R-CNN ResNet50-FPN v2 instance masks and COCO labels;
|
||||
- Microsoft BEiT ADE20K-150 semantic masks at the original 800x600 frame size;
|
||||
- a seekable H.264 MP4 with the two overlays combined for operator playback;
|
||||
- lossless instance and semantic mask PNGs for later calibrated fusion;
|
||||
- ordered per-frame JSONL and one-second GPU telemetry;
|
||||
- a machine-readable run report with input/config/model identities, decode,
|
||||
inference, encode and end-to-end timing, latency percentiles, throughput,
|
||||
CUDA peak allocation/reservation, process peak RSS, system load and GPU
|
||||
utilization/VRAM/temperature/power samples.
|
||||
|
||||
The worker runs with `--network none` and cached model weights. A preflight
|
||||
revalidates the complete transferred payload, CUDA execution and both cached
|
||||
model generations before decoding a long epoch. The run then repeats payload
|
||||
validation inside the inference container, requires exactly the declared frame
|
||||
count and a strictly increasing session-time row for every frame, and publishes
|
||||
only by atomic rename after every output digest is sealed.
|
||||
|
||||
There is no recorded-duration, frame-count or aggregate-video-byte admission
|
||||
ceiling in this profile. Resource use therefore scales with the real input and
|
||||
is reported, not hidden behind an arbitrary eight-minute laboratory limit.
|
||||
|
||||
## Native panoptic playback
|
||||
|
||||
Full raster masks are not copied into the RRD. That would turn a long video into
|
||||
a multi-gigabyte browser-memory object. Instead Mission Core validates the v2
|
||||
result and exposes its MP4 through the same generation-bound, seekable HTTP
|
||||
Range contract as a recorded camera. Replay advertises an additional opaque
|
||||
source such as `recorded.perception.right`; the Control Station opens it in a
|
||||
native video window on the shared `session_time` timeline. A one-, three- or
|
||||
ten-hour video remains disk/range streamed and does not have to fit in RAM.
|
||||
|
||||
The lossless masks remain private derived evidence. A host-side calibrated
|
||||
fusion step samples them at K1 KB4 LiDAR projections and publishes compact
|
||||
semantic `Points3D`, support-gated `Boxes3D` and diagnostic distances as a
|
||||
separate replaceable generation. Missing or rejected v2/fusion results never
|
||||
replace or invalidate the base raw point-cloud recording.
|
||||
|
||||
## RAVNOVES00 qualification · 2026-07-20
|
||||
|
||||
The first full recorded run admitted all 4,489 frames from
|
||||
`sensor.camera.right` without sampling, failures or skips. The sealed input was
|
||||
363,235,615 bytes over `35.421857292–484.144857292` session seconds. Its
|
||||
immutable result is
|
||||
`result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02`.
|
||||
|
||||
Measured RTX 4090 worker results:
|
||||
|
||||
- inference: 2,674.722 s and 1.678 frames/s;
|
||||
- end to end: 2,816.349 s and 1.594 frames/s;
|
||||
- instance latency: 89.826 ms p50, 121.344 ms p95, 915.325 ms max;
|
||||
- semantic latency: 249.678 ms p50, 284.826 ms p95, 406.594 ms max;
|
||||
- GPU utilization: 67% p50, 82% p95, 90% max over 2,675 one-second samples;
|
||||
- process CUDA peak: 2,230.8 MiB allocated and 2,872 MiB reserved;
|
||||
- total GPU memory observed, including the worker's shared resident services:
|
||||
13,373 MiB p50 and 13,388 MiB max;
|
||||
- GPU power/temperature: 182.46 W p50, 190.10 W p95 and 49 C p50, 54 C max;
|
||||
- process peak RSS: 2,375.9 MiB;
|
||||
- publication: 31.229 s decode, 3.397 s NVENC, 81,109,627-byte H.264 MP4,
|
||||
60,326,719-byte lossless mask archive.
|
||||
|
||||
The factory-calibrated full fusion generation
|
||||
`fusion-0b1be23128ebd3d230562cffd96491169e99f0e839e56b812661c974e4fdc00b`
|
||||
matched LiDAR and pose within the admitted 250 ms host-arrival window for 4,323
|
||||
frames and declared 166 frames `depth-unavailable`. It produced 6,124,145
|
||||
semantic points and 13,496 support-gated diagnostic boxes in 77.708 s. The
|
||||
compact fusion payload is 43 MiB.
|
||||
|
||||
Native browser QA opened `RAVNOVES00`, played the raw and panoptic 800x600
|
||||
videos together at ready-state 4, and measured approximately 12 ms between
|
||||
their media clocks. The Rerun scene showed the synchronized semantic points and
|
||||
distance-labeled diagnostic boxes. The current baseline is deliberately not an
|
||||
accuracy or safety acceptance: generic perspective-trained models produce
|
||||
large fisheye false positives in 916 frames, and timing/distance have not been
|
||||
ground-truthed. The next A/B should compare an admitted undistort/ROI transform
|
||||
before inference rather than silently hiding these observations.
|
||||
|
||||
## E1 valid-FOV preprocessing qualification · 2026-07-20
|
||||
|
||||
The first post-baseline A/B uses two immutable inputs derived from the same
|
||||
RAVNOVES00 job and factory calibration:
|
||||
|
||||
- valid-FOV generation
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`;
|
||||
- qualification-slice generation
|
||||
`qualification-slice-2394070b4f3e38f1b8c483e878fcc11fd3c29f751f3d4cd7e3a2553304c5c142`.
|
||||
|
||||
The mask is not estimated from each image. It is bound to the exact calibration
|
||||
SHA, `sensor.camera.right`, `camera_1`, the admitted 800x600 linear-resize
|
||||
profile and the KB4 principal point `(396.319, 301.496)`. A four-pixel inner
|
||||
margin produces a 293.504-pixel radius, 270,606 valid pixels (56.37625%) and an
|
||||
exclusive crop rectangle `[103, 8, 690, 595]`. Repeated preparation reuses the
|
||||
same content-addressed PNG and manifest.
|
||||
|
||||
The slice selects 256 exact frame indices uniformly across all 4,489 frames,
|
||||
including both endpoints. The same loaded FP32 Mask R-CNN and BEiT generations
|
||||
were interleaved per frame across three variants: unmodified baseline, fixed
|
||||
valid-FOV fill, and valid-FOV crop remapped into the original pixel coordinates.
|
||||
The sealed result is
|
||||
`qualification-result-98a2fee3d22979f3e18847719667cc76bdeacf25904c0fd1da4c5202253b3940`.
|
||||
|
||||
The run completed in 318.969 s and emitted 12 preview frames per variant. GPU
|
||||
telemetry recorded 319 one-second samples: utilization was 74% p50 and 82% p95,
|
||||
power was 199.34 W p50 and 206.96 W p95, and temperature was 50 C p50 and 55 C
|
||||
max. The qualification process peaked at 1,849.9 MiB CUDA allocated, 2,256 MiB
|
||||
reserved and 2,298.2 MiB RSS.
|
||||
|
||||
Measured mean model paths, excluding the frame decode shared by all variants:
|
||||
|
||||
| Variant | Mask R-CNN path | BEiT path | Combined |
|
||||
|---|---:|---:|---:|
|
||||
| baseline | 106.292 ms | 278.064 ms | 384.356 ms |
|
||||
| valid-FOV fill | 107.027 ms | 280.762 ms | 387.789 ms |
|
||||
| valid-FOV crop | 104.055 ms | 282.840 ms | 386.895 ms |
|
||||
|
||||
The crop reduced Mask R-CNN forward time by 6.38%, but BEiT still receives its
|
||||
fixed 640x640 tensor and became 1.18% slower. With mask/crop preprocessing
|
||||
included, neither variant improved the combined path; fill was 0.89% slower and
|
||||
crop was 0.66% slower than baseline. A binary mask improves admission quality,
|
||||
but multiplying an unchanged tensor by it does not remove dense neural FLOPs.
|
||||
|
||||
The quality proxies are useful but are not ground truth. Baseline produced 58
|
||||
instance masks and 58 boxes larger than half the admitted comparison area; both
|
||||
masked variants produced zero. The fraction of raw predicted instance-mask
|
||||
pixels outside the canonical FOV fell from 41.263% to 0.077% for fill and 0.911%
|
||||
for crop before the final output clamp. Inside the valid circle, mean BEiT
|
||||
disagreement with baseline was 8.713% for fill and 11.547% for crop. This is a
|
||||
measure of change, not accuracy.
|
||||
|
||||
E1 therefore accepts the immutable valid-FOV artifact and the 256-frame gate.
|
||||
Fixed fill is the conservative next accuracy baseline because it preserves the
|
||||
800x600 geometry, removes the exterior lens region and changes the semantic
|
||||
result less than crop. Crop remains an experimental model-specific option, not
|
||||
a general speed optimization. The next run needs human labels/ground truth and
|
||||
must compare native KB4 input, calibrated virtual views and fisheye-trained
|
||||
models before promoting any preprocessing profile to a full-epoch result.
|
||||
|
||||
## E2 evaluation pack and annotation gate · 2026-07-20
|
||||
|
||||
LAB E2 starts from the same immutable RAVNOVES00 compute job, E1 qualification
|
||||
slice and factory-calibrated valid-FOV generation. All eight contact sheets,
|
||||
covering the 256 uniformly distributed E1 candidates, were reviewed before
|
||||
selection. The sealed evaluation generation is
|
||||
`evaluation-pack-7a983bba75d46c7c260252cb2d461e1384dcb92cda9e164397e841e6ebb37789`.
|
||||
|
||||
The pack contains 64 exact 800x600 images: 48 reviewed full-epoch anchors from
|
||||
the E1 slice and four four-frame consecutive clips for temporal measurements.
|
||||
The clips cover a person with a stroller, a close moving car, vehicle
|
||||
occlusion/relative motion and a near building/terrace scene with a partially
|
||||
visible carried laptop. The anchors retain the recording's road, sidewalk,
|
||||
ground, grass, woody vegetation, buildings, sky, people, cars, trucks, lens
|
||||
boundary and hard-negative diversity.
|
||||
|
||||
Every image is stored both as the raw decoded RGB frame and as the accepted E1
|
||||
fixed-valid-FOV-fill input. The identity binds the job/input SHA, source, codec
|
||||
epoch, selected segment SHA, decoded session timestamp, E1 qualification,
|
||||
valid-FOV generation, calibration SHA, camera slot, FFmpeg 7.1.1 generation and
|
||||
the raw/fill RGB pixel hashes. It also binds the reviewed selection-document
|
||||
SHA and both producer-code hashes. The pack has 130 hashed payload artifacts
|
||||
plus its manifest and occupies approximately 67 MiB locally.
|
||||
|
||||
One earlier local preparation generation, `evaluation-pack-b6d9215a…`, was not
|
||||
promoted because its identity omitted the producer-code and selection-document
|
||||
hashes. It remains a superseded diagnostic artifact and is not an accepted E2
|
||||
input.
|
||||
|
||||
The immutable pack is deliberately `unannotated`. Its annotation contract
|
||||
defines 15 robotics-oriented thing/stuff classes, label 0 for the excluded lens
|
||||
exterior, label 255 for genuinely unresolved pixels, two-pass human review and
|
||||
required semantic, instance, safety-proxy and temporal metrics. The empty
|
||||
annotation template must be copied to a review workspace; it must never be
|
||||
edited inside the sealed pack. Model-generated prelabels may accelerate review
|
||||
but are not accepted as ground truth without a human pass.
|
||||
|
||||
No AP, mIoU or model-ranking claim is attached to E2 yet. The next gate is to
|
||||
complete and seal the reviewed annotations. Only then may candidate models be
|
||||
ranked on this pack; a full 4,489-frame run remains prohibited until one
|
||||
configuration passes both the accuracy and throughput gates.
|
||||
|
||||
The first model-assisted draft is sealed separately as
|
||||
`evaluation-prelabels-4ba26bbf6eb8a49631f5caf984267e0445958540aeda2b5b0d82ca6440835cf1`.
|
||||
It reuses the exact E0 Mask R-CNN and BEiT weights and maps their COCO/ADE
|
||||
classes into the E2 taxonomy. It is explicitly marked
|
||||
`unreviewed-model-draft`; it never mutates the evaluation pack or annotation
|
||||
template.
|
||||
|
||||
The isolated RTX 4090 run processed 64/64 frames in 31.551 s. Mean forward time
|
||||
was 57.539 ms for Mask R-CNN, 215.164 ms for BEiT and 272.703 ms combined. The
|
||||
process peaked at 1,842.8 MiB CUDA allocated, 2,768 MiB reserved and 2,239.9 MiB
|
||||
RSS. Across 32 one-second samples, GPU utilization was 51% p50 / 71% p95, power
|
||||
167.73 W p50 / 186.89 W p95 and temperature 42 C p50 / 47 C max. Shared Triton,
|
||||
Frigate and Ollama services remained running and healthy.
|
||||
|
||||
The draft emitted 775 mapped instances: 640 car, 58 static obstacle, 45 person,
|
||||
25 heavy vehicle, five bicycle and one each motorcycle/animal. Sixteen previews
|
||||
were reviewed. They confirm that the fixed-FOV exterior stays clean and that
|
||||
the draft is useful for annotation assistance, but also expose the expected E0
|
||||
domain errors: duplicated/distant car boxes, unstable small instances, coarse
|
||||
fisheye boundaries and excessive static-obstacle proposals on planters. These
|
||||
counts are workload indicators for review, not precision or recall.
|
||||
|
||||
The first prelabel attempt stopped before model loading because the container
|
||||
mountpoint `/evaluation-pack` was incorrectly required to equal the
|
||||
content-addressed generation basename. The path-name check was removed while
|
||||
all manifest, artifact and identity hashes remained mandatory. No failed result
|
||||
was published; the second attempt completed and 147 payload artifacts were
|
||||
reverified locally with zero digest/length mismatches.
|
||||
|
||||
The review handoff is sealed separately as
|
||||
`annotation-workspace-9a950d1c37d56dc12cc285b13c5addd7795285879cbcb1fbb2d5811c3c69821a`.
|
||||
It contains a deterministic 64-image upload, a 775-instance COCO RLE draft, a
|
||||
dense CVAT Segmentation Mask archive, an exact frame/timestamp map, the fixed
|
||||
valid-FOV mask, the 15-class label specification and an unreviewed two-pass
|
||||
checklist. The three ZIP archives passed both the workspace validator and
|
||||
independent ZIP integrity checks. The 11 payload artifacts occupy 31,861,798
|
||||
bytes. This workspace remains `ground_truth=false`; the two synchronized CVAT
|
||||
tasks must be reviewed and their accepted exports sealed as a separate
|
||||
generation before any AP or mIoU claim is allowed.
|
||||
|
||||
The real prelabel generation also exposed one identity-serialization defect in
|
||||
its producer: `target_categories` used integer dictionary keys while hashing,
|
||||
but JSON reloads them as strings and changes their sorted order. The stored
|
||||
artifact files and all 147 recorded payload hashes are unchanged. The workspace
|
||||
validator admits only this exact reversible legacy representation and binds the
|
||||
serialized `result.json` SHA separately. The worker producer now emits string
|
||||
keys before hashing, so subsequent prelabel identities are stable across a JSON
|
||||
round trip. The existing result was neither rewritten nor renamed.
|
||||
|
||||
Prepare or reproduce the review inputs with:
|
||||
|
||||
```console
|
||||
.venv/bin/python experiments/perception/prepare_e2_evaluation_pack.py candidates \
|
||||
--job-root .runtime/compute-jobs/<job-id> \
|
||||
--qualification-root .runtime/compute-experiments/e1/qualification-slices/<generation> \
|
||||
--output-root .runtime/compute-experiments/e2/<candidate-review>
|
||||
|
||||
.venv/bin/python experiments/perception/prepare_e2_evaluation_pack.py seal \
|
||||
--job-root .runtime/compute-jobs/<job-id> \
|
||||
--qualification-root .runtime/compute-experiments/e1/qualification-slices/<generation> \
|
||||
--valid-fov-root .runtime/compute-experiments/e1/valid-fov/<generation> \
|
||||
--selection .runtime/compute-experiments/e2/selection-e2.json \
|
||||
--output-root .runtime/compute-experiments/e2/evaluation-packs
|
||||
|
||||
.venv/bin/python experiments/perception/prepare_e2_annotation_workspace.py prepare \
|
||||
--evaluation-pack .runtime/compute-experiments/e2/evaluation-packs/<generation> \
|
||||
--prelabels .runtime/compute-experiments/e2/prelabels/<generation> \
|
||||
--valid-fov-root .runtime/compute-experiments/e1/valid-fov/<generation> \
|
||||
--output-root .runtime/compute-experiments/e2/annotation-workspaces
|
||||
```
|
||||
|
||||
## E4 full-session semantic playback · 2026-07-21
|
||||
|
||||
LAB E4 promotes the plain EoMT valid-FOV control from LAB E3 into the first
|
||||
complete saved-session semantic playback. It consumed all 4,489 frames of
|
||||
RAVNOVES00 (`20260720T065719Z_viewer_live`) from
|
||||
`sensor.camera.right`, using factory calibration slot `camera_1`, FP16
|
||||
autocast, batch size one and no sampling. CLAHE, five-view rectification and the
|
||||
instance branch were deliberately disabled.
|
||||
|
||||
The immutable published result is
|
||||
`result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30`.
|
||||
It contains an 800x600 H.264 semantic-overlay video, 4,489 semantic masks,
|
||||
4,489 timestamp rows, one-second GPU telemetry and a run report. FFprobe and the
|
||||
recorded-perception validator independently confirmed the exact frame count,
|
||||
448.723-second timeline, artifact hashes and input/job/calibration binding.
|
||||
|
||||
The inference loop ran for 1,447.565 seconds at 3.101 FPS. Full extraction,
|
||||
inference, publication, hashing and validation took 1,668.259 seconds at 2.691
|
||||
FPS. GPU utilization was 73.05% mean / 89% p95, E4 process CUDA allocation
|
||||
peaked at 2,099.8 MiB, process RSS at 2,009.3 MiB, power at 253.23 W and
|
||||
temperature at 58 C. The exact configuration is about 3.72 times slower than
|
||||
the source recording rate and is therefore an offline baseline, not a live
|
||||
configuration.
|
||||
|
||||
All task-controlled worker paths remained under `D:\NDC_MISSIONCORE`. The
|
||||
orchestrator enforced a 360 GiB free-space floor and a 17.195 GiB conservative
|
||||
working-set reserve. Final free space after exact task-temporary cleanup was
|
||||
379.395 GiB; C: was not used or mounted by the task.
|
||||
|
||||
Mission Core exposes the result in **Сохранённые сессии → RAVNOVES00 →
|
||||
Источники данных сцены → Сегментация · камера right**. Browser acceptance
|
||||
confirmed the exact result source, 800x600 dimensions, full duration, no media
|
||||
error and advancing playback time. The detailed configuration, timing tables,
|
||||
artifact hashes, disk checkpoints, limitations and next gates are recorded in
|
||||
`experiments/perception/LAB_E4_REPORT_2026-07-21.md` and Ops card MISSIONCOR-18.
|
||||
|
||||
E4 remains `ground_truth=false` and semantic-only. It makes no claim about live
|
||||
latency, instances, tracking, 3D cuboids, LiDAR association, distance accuracy,
|
||||
point-cloud labels or safety fitness.
|
||||
|
||||
## Recorded Rerun projection
|
||||
|
||||
Mission Core discovers only results whose validated job names the opened
|
||||
session. Before projection it revalidates the complete job/result binding,
|
||||
artifact digests, ordered timestamps and result metrics. It then reconstructs
|
||||
the exact fMP4 epoch, verifies decoded dimensions/frame count with `ffprobe`,
|
||||
decodes exactly the admitted number of RGB frames and rejects boxes outside the
|
||||
image.
|
||||
|
||||
The optional `perception.rrd` endpoint returns either HTTP 204 or one complete,
|
||||
bounded RRF2 stream for the opened Rerun recording ID. The browser never splits
|
||||
that stream and never replaces the base recording when the optional layer is
|
||||
absent or rejected. Accepted frames are logged under `/perception/camera` on
|
||||
the canonical zero-based `session_time` timeline; `Image` and `Boxes2D` rows
|
||||
therefore seek with the point-cloud archive. Generated overlays are a private,
|
||||
content-addressed cache and remain rebuildable from the immutable job/result.
|
||||
|
||||
In **Наблюдение → Пространственная сцена**, a **Распознавание** button appears
|
||||
only after the layer has been admitted. It switches the active native Rerun view
|
||||
between the camera/detections and the point cloud. TEST007's accepted fragment
|
||||
occupies approximately `01:07.351–01:12.848`; Rerun's latest-at query retains
|
||||
the last admitted frame after that short fragment.
|
||||
|
||||
## Accepted model profile
|
||||
|
||||
- YOLOX-S official ONNX from upstream release `0.1.1rc0`;
|
||||
- Apache-2.0;
|
||||
- SHA-256
|
||||
`c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
|
||||
- FP32 input `[1,3,640,640]`, BGR, bilinear top-left letterbox, pad 114;
|
||||
- FP32 output `[1,8400,85]`, official grid/stride decode, COCO-80;
|
||||
- Triton 2.70.0 / ONNX Runtime GPU backend in the pinned 26.06 image.
|
||||
|
||||
This generic model is useful for proving the contract and timing path. It is not
|
||||
accepted for obstacle avoidance, free-space estimation or safety decisions.
|
||||
|
||||
## Open gates
|
||||
|
||||
1. Qualify TEST007's 206-second epoch and record throughput/resource telemetry.
|
||||
2. Replace SSH/SCP with a reviewed authenticated worker transport.
|
||||
3. Add bounded live queues, sampling/drop policy and acquisition-isolation
|
||||
tests.
|
||||
4. Introduce tracking, segmentation/free-space, calibration and point-cloud
|
||||
models as separate versioned pipelines.
|
||||
|
||||
## E5 recorded instance tracking qualification · 2026-07-21
|
||||
|
||||
LAB E5 establishes the first measured temporal object-identity baseline on a
|
||||
preselected 601-frame, 60.069-second RAVNOVES00 interval. It uses the official
|
||||
Apache-2.0 YOLOX-S ONNX release through the existing Triton service, the
|
||||
immutable `camera_1`-bound valid-FOV mask, and a ByteTrack-style two-stage IoU
|
||||
tracker. The accepted profile, runner, model and configuration are pinned by
|
||||
SHA-256; output remains `ground_truth=false` and qualification-only.
|
||||
|
||||
The immutable result is
|
||||
`e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6`.
|
||||
It contains an 800x600 H.264 ID-overlay video, exactly 601 timestamped detection
|
||||
and track rows, one-second GPU telemetry, a contact sheet and the complete run
|
||||
report. The result validator rehashed all artifacts and verified the exact job,
|
||||
input, session, source, clip and timeline binding. FFprobe independently
|
||||
confirmed 601 declared/read frames and 60.068948 seconds.
|
||||
|
||||
The frame loop ran for 88.093 seconds at 6.822 FPS; the complete worker run took
|
||||
166.336 seconds at 3.613 FPS. Mean per-frame time was 12.359 ms in Triton,
|
||||
0.247 ms in tracking, 30.132 ms decoding and 87.363 ms writing overlays. The
|
||||
detector and tracker are therefore not the main live-rate bottleneck; artifact
|
||||
I/O must be decoupled before a live path is admitted. GPU utilization was
|
||||
51.65% mean / 56% max, power 144.78 W mean / 148.40 W max, temperature 43 C max
|
||||
and process RSS 126.199 MiB.
|
||||
|
||||
The run admitted 4,049 detections and emitted 3,320 observations across 167
|
||||
confirmed IDs. Useful clear-view persistence is proven, including vehicle
|
||||
tracks lasting 100–212 frames. The result also exposes real limitations: an ID
|
||||
fragments during the close woman/stroller occlusion, the stroller can be called
|
||||
`motorcycle`, and parked vehicles can flicker between `car` and `truck`. There
|
||||
is no identity ground truth, so IDF1, HOTA, MOTA and true ID-switch counts are
|
||||
not claimed.
|
||||
|
||||
All task-controlled worker paths remained under `D:\NDC_MISSIONCORE`; the
|
||||
orchestrator enforced the 360 GiB floor and a 4.402 GiB working-set reserve.
|
||||
Free space after exact temporary cleanup was 380.595 GiB. The YOLOX model was
|
||||
loaded only for the run and restored to its prior unloaded state. Detailed
|
||||
provenance, pilot history, thresholds, timing tables, artifacts, hashes,
|
||||
quality findings and the LAB E6 gate are in
|
||||
`experiments/perception/LAB_E5_REPORT_2026-07-21.md` and Ops card
|
||||
MISSIONCOR-19.
|
||||
|
||||
The next bounded gate is calibrated 2D-track/LiDAR association on the same
|
||||
601-frame interval: robust point support, metric range, coarse 3D cuboids and a
|
||||
qualification-only Rerun recording. Full-session and live promotion remain
|
||||
blocked on measured identity quality and a bounded queue/drop design.
|
||||
|
||||
## E6 factory-calibrated tracked LiDAR fusion · 2026-07-21
|
||||
|
||||
LAB E6 closes the recorded 2D-track/LiDAR association gate on the exact E5
|
||||
601-frame interval. It reuses the immutable E4 semantic masks, E5 track IDs,
|
||||
raw K1 point/pose capture and the XGRIDS `camera_1` factory KB4 calibration.
|
||||
Fused observations must satisfy 100 ms camera/point and pose/point gates.
|
||||
Nearest-depth buffering, semantic support, depth splitting, 3D connected
|
||||
components, robust range history and plausible-size gates prevent unsupported
|
||||
image rectangles from becoming fabricated 3D boxes.
|
||||
|
||||
The immutable result is
|
||||
`e6-fusion-b4e4226674a66f6196c033785eb307c255a7bdd8493ef9809dfb1d6e5bd68eaa`.
|
||||
It processed 601/601 frames, fused 526, emitted 918 point-supported oriented
|
||||
cuboids across 56 track IDs and failed closed for depth on 75 frames outside
|
||||
the strict timing gate. LiDAR/camera absolute delta was 29.389 ms mean,
|
||||
70.794 ms p95 and 96.107 ms maximum. Accepted range spans 1.239–37.749 m with a
|
||||
10.189 m median.
|
||||
|
||||
The single-process local geometry/artifact path took 14.774 seconds, peaked at
|
||||
240.469 MiB RSS and is faster than the source rate. This is not an end-to-end
|
||||
live result because E4 and E5 were precomputed. The external worker and both
|
||||
worker disks were untouched. The output includes an 800x600 H.264 overlay,
|
||||
timestamped JSONL/NPZ data, a contact sheet and a standalone 60 MiB Rerun
|
||||
recording with camera, map-frame cloud, support points and translucent
|
||||
`Boxes3D`. Visual QA confirmed that these are real oriented Rerun primitives;
|
||||
they remain visible-surface envelopes, not ground-truthed complete object
|
||||
volumes.
|
||||
|
||||
Detailed provenance, profile freeze, pilot failures, thresholds, timing,
|
||||
rejection counts, artifact hashes, limitations and the LAB E7 gate are in
|
||||
`experiments/perception/LAB_E6_REPORT_2026-07-21.md`. Ops synchronization is
|
||||
pending restoration of the direct `nodedc-ops-agent` tasker tools; the legacy
|
||||
Ops API was not used.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user