Author SHA1 Message Date
DCCONSTRUCTIONS 2e5d52521f feat: finalize corrected-route planning and Rerun recording review 2026-09-22 10:10:03 +03:00
DCCONSTRUCTIONS c804d89b18 fix(lab): separate direct device launches from planning profiles 2026-09-21 09:19:14 +03:00
DCCONSTRUCTIONS 76dc9f19c9 chore: consolidate superseded experiment history into main
History-only merge: retain the original Node worktree snapshot 1c7dd29 without reverting its canonicalized implementation (63ea2be and subsequent main fixes). RAV004 commits 45019f8/cd16ea2/88e47fc and Gaussian commits 2ccf172/2e44e27 are patch-equivalent to commits already in main (verified with git cherry). The current source tree is intentionally unchanged; old snapshots remain reachable for audit.
2026-09-21 08:48:14 +03:00
DCCONSTRUCTIONS e515ab1b8c feat(planning): consolidate recorded-route localization and spatial scene
Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
2026-09-21 08:47:19 +03:00
DCCONSTRUCTIONS 1c7dd29d8a chore(node): preserve pre-canonicalization experiment snapshot
Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
2026-09-21 08:45:34 +03:00
DCCONSTRUCTIONS be58d589e2 feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
2026-09-10 22:12:05 +03:00
DCCONSTRUCTIONS 5f75afb720 docs(node): record current handoff status and X4 acceptance boundaries 2026-09-10 16:39:29 +03:00
DCCONSTRUCTIONS 99f29218d7 feat(x4): add per-camera manual wake and hide power action when connected 2026-09-10 16:27:48 +03:00
DCCONSTRUCTIONS db14ee5435 Record repeatable X4 reconnect and prepare recovery cancellation test 2026-09-10 14:36:07 +03:00
DCCONSTRUCTIONS 81dd44906d Record first X4 USB reconnect and automatic preview acceptance 2026-09-10 14:28:34 +03:00
DCCONSTRUCTIONS d45b329de8 Add packaged per-camera X4 wake and preview recovery 2026-09-10 14:20:52 +03:00
DCCONSTRUCTIONS f164606828 Add bounded X4 Bluetooth wake and record Android recovery proof 2026-09-10 12:48:09 +03:00
DCCONSTRUCTIONS 556e376a62 Record X4 wake by short Power with USB retained and BLE discovery 2026-09-10 12:08:01 +03:00
DCCONSTRUCTIONS 1a2de8df65 Package bounded X4 BLE diagnostics and USB mode reader 2026-09-10 11:59:00 +03:00
DCCONSTRUCTIONS b536f0049a Document X4 Android reconnect protocol research and recovery options 2026-09-10 11:25:07 +03:00
DCCONSTRUCTIONS 3b85299fd9 Document X4 USB power limits from root hub diagnostics 2026-09-10 11:04:04 +03:00
DCCONSTRUCTIONS 505844adc3 Record failed X4 USB reconnect with preview stopped 2026-09-10 10:44:27 +03:00
DCCONSTRUCTIONS 89b0eeca3f Record automatic X4 USB mode and two-camera viewing confirmation 2026-09-10 10:27:40 +03:00
DCCONSTRUCTIONS 1488a46794 Document X4 USB mode recovery and verified video after restart 2026-09-10 10:09:29 +03:00
DCCONSTRUCTIONS e4339f4e88 Record B11 hardware acceptance and X4 USB reconnect failure 2026-09-10 09:47:34 +03:00
DCCONSTRUCTIONS 7e257275ab Record X4 checkpoint and Ops implementation report
Link the committed Core/Design Guideline pair to MISSIONCOR-77 and preserve
MISSIONCOR-76 baseline through an appended report. Record successful MCP
readback, qualified source comparison and remaining hardware acceptance.
2026-09-10 09:25:12 +03:00
DCCONSTRUCTIONS a3c15e11e9 Add packaged Insta360 X4 integration and recover paired Node channels
Discover independent camera instances and prepare their versioned runtime
from Node or remote Core. Add isolated SDK workers, camera controls, raw
dual-fisheye WebRTC preview, and shared action/region loading states.

Recover existing Node bindings over known Tailscale addresses after a Core
LAN address change. Preserve identities and trust, pin both peers, migrate
endpoints with revision checks, and require real heartbeats for online status.
Fix the Python client certificate profile for Go X509 verification.

Pin Design Guideline 8c53f73 and retain installer/build/acceptance history.
Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending.

Validation: qualified DG/Node builds and Go race tests; 31 fleet tests;
Python-to-Go certificate interoperability and live tailnet recovery with five
fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance.
Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
2026-09-10 09:21:24 +03:00
DCCONSTRUCTIONS 54a85fdf50 Record installed R19 recovery and onboard K1 acceptance 2026-09-08 11:01:59 +03:00
DCCONSTRUCTIONS 14f8375713 Repair monitor cluster upgrade without blocking K1 package configuration 2026-09-08 10:44:27 +03:00
DCCONSTRUCTIONS 6492ebcdee Record R18 evidence and retain signed telemetry values in plots 2026-09-08 01:53:30 +03:00
DCCONSTRUCTIONS e57511f975 Keep K1 runtime payload manifest in canonical order 2026-09-08 01:41:27 +03:00
DCCONSTRUCTIONS f0802d2713 Fix onboard WebKit preview and archive board telemetry locally 2026-09-08 01:37:52 +03:00
DCCONSTRUCTIONS d8afb61229 Record successful Node R17 installation on owner Ubuntu board 2026-09-07 23:28:46 +03:00
DCCONSTRUCTIONS 421a8c515a Record R17 package validation and pending onboard transfer 2026-09-07 23:03:11 +03:00
DCCONSTRUCTIONS a23c5b2005 Reuse canonical home and settings on Node and admit local K1 viewer 2026-09-07 22:55:13 +03:00
DCCONSTRUCTIONS 1832943558 Record verified Ops publication of onboard K1 laboratory checkpoint 2026-09-07 21:50:38 +03:00
DCCONSTRUCTIONS cc90709919 Record owner-confirmed onboard K1 Bridge laboratory checkpoint 2026-09-07 21:43:19 +03:00
DCCONSTRUCTIONS ee5d760c30 Verify R16 onboard installation and service health 2026-09-07 21:27:40 +03:00
DCCONSTRUCTIONS 0e9faa6824 Record inspected R16 artifacts and pending onboard acceptance 2026-09-07 21:22:41 +03:00
DCCONSTRUCTIONS c6693d6f44 Retain K1 connection between scans and admit the next named acquisition 2026-09-07 21:13:17 +03:00
DCCONSTRUCTIONS ed9a77ba73 Record verified R15 installation after owner restart 2026-09-07 20:16:42 +03:00
DCCONSTRUCTIONS 7dd2c1c836 Record verified R15 packages and pending onboard acceptance 2026-09-07 20:13:37 +03:00
DCCONSTRUCTIONS 762d77ef95 Unify K1 discovery ownership and verification across enrollment paths 2026-09-07 20:04:46 +03:00
DCCONSTRUCTIONS 9c4d70b0e9 Record R14 public artifacts and staged installation scope 2026-09-07 19:04:52 +03:00
DCCONSTRUCTIONS eed53dbc0b Fix onboard K1 enrollment continuity and share named acquisition preparation 2026-09-07 18:53:38 +03:00
DCCONSTRUCTIONS 4f333fc11c Record verified R13 installation on onboard computer 2026-09-07 17:59:36 +03:00
DCCONSTRUCTIONS 35a517f921 Record R13 artifact provenance and pending installation acceptance 2026-09-07 17:54:13 +03:00
DCCONSTRUCTIONS 9bba44f7c4 Preserve onboard preview recordings across backpressure and share the complete spatial scene 2026-09-07 17:47:10 +03:00
DCCONSTRUCTIONS 9b6534287a Record R12 update artifacts and pending clean-cache acceptance 2026-09-07 16:53:32 +03:00
DCCONSTRUCTIONS 9e03404f23 Admit onboard camera preview only after its acquisition producer is ready 2026-09-07 16:44:38 +03:00
DCCONSTRUCTIONS fa8ac760a1 Share the K1 live scene template and preserve idle media channels 2026-09-07 16:38:39 +03:00
DCCONSTRUCTIONS 309bdf759e Confirm installed K1 R11 runtime and authority availability 2026-09-07 15:57:53 +03:00
DCCONSTRUCTIONS ff00851216 Record staged K1 R11 artifact and acceptance gate 2026-09-07 15:54:38 +03:00
DCCONSTRUCTIONS ad24518b71 Refresh selected BlueZ path before K1 GATT operations 2026-09-07 15:50:06 +03:00
DCCONSTRUCTIONS 7ce09eabcd Record R10 release hashes and remaining acceptance gates 2026-09-07 15:32:56 +03:00
DCCONSTRUCTIONS 92b60de945 Preserve RRD preview frames and clarify onboard K1 status 2026-09-07 15:27:13 +03:00
DCCONSTRUCTIONS 67398fef10 Record staged R9 packages and pending installed acceptance 2026-09-07 14:55:28 +03:00
DCCONSTRUCTIONS a93f1f1b8b Accept systemd credential delivery and clarify manual K1 control 2026-09-07 14:44:46 +03:00
DCCONSTRUCTIONS 102e93796b Record staged K1 causal diagnostics release R8 2026-09-07 14:16:18 +03:00
DCCONSTRUCTIONS 4f715e41d2 Preserve native K1 error causes and distinguish Wi-Fi actions 2026-09-07 14:11:34 +03:00
DCCONSTRUCTIONS 8f17a7e282 Confirm installed Linux route contract fix R7 2026-09-07 13:55:50 +03:00
DCCONSTRUCTIONS 7187fd86bd Record prepared Linux route contract fix release R7 2026-09-07 13:53:03 +03:00
DCCONSTRUCTIONS e6521e855a Fix Linux route probe contract and expose K1 verification after Wi-Fi apply 2026-09-07 13:46:01 +03:00
DCCONSTRUCTIONS 06d216ac4a Record installed wireless steps and diagnostic release R6 2026-09-07 13:35:25 +03:00
DCCONSTRUCTIONS d7986cb62f Group onboard wireless steps and trace journalled Bluetooth failures 2026-09-07 13:27:21 +03:00
DCCONSTRUCTIONS a0732386f1 docs(node): record R5 package and pending Ubuntu authentication 2026-09-07 13:08:01 +03:00
DCCONSTRUCTIONS 5c4546a2a6 fix(node): clarify wireless enrollment and restore active K1 after upgrade 2026-09-07 13:02:28 +03:00
DCCONSTRUCTIONS ef1c1f50de docs(k1): record successful onboard Bluetooth discovery 2026-09-07 12:46:14 +03:00
DCCONSTRUCTIONS 2f80507bc0 docs(k1): record R4 onboard installation and pending hardware acceptance 2026-09-07 12:44:22 +03:00
DCCONSTRUCTIONS d2dbfc9c85 fix(k1): translate onboard operation identities at plugin boundary 2026-09-07 12:34:48 +03:00
DCCONSTRUCTIONS 3dffe39c89 Record installation of the onboard discovery fix 2026-09-07 12:20:27 +03:00
DCCONSTRUCTIONS 77acb377e2 Fix onboard BLE admission and stage wireless device enrollment 2026-09-07 12:11:48 +03:00
DCCONSTRUCTIONS 2fdf1d3cc4 Record private K1 installer release and Mini installation acceptance 2026-09-07 11:56:32 +03:00
DCCONSTRUCTIONS d8cc5367c4 Package onboard K1 separately with a private portable installer 2026-09-07 11:43:24 +03:00
DCCONSTRUCTIONS 111fe3dcfb docs(k1): record Node 0.7.1 installation and acceptance boundaries 2026-09-07 10:57:56 +03:00
DCCONSTRUCTIONS 63ea2bed67 feat(k1): stabilize LAB bridge and isolate onboard device integration 2026-09-07 10:31:33 +03:00
DCCONSTRUCTIONS 020a878915 docs(node): reconcile release status and build provenance for 0.6.11 2026-09-06 01:39:02 +03:00
DCCONSTRUCTIONS 404285419f fix(sensors): align device lamp with identity and refresh to end 2026-09-06 01:18:47 +03:00
DCCONSTRUCTIONS ba0c948ca1 fix(node): show camera connectivity and remove company header marks 2026-09-06 01:05:19 +03:00
DCCONSTRUCTIONS 429b4681fe fix(node): use compact guideline copy and plain setup checkmarks 2026-09-06 00:44:23 +03:00
DCCONSTRUCTIONS 22d69107ba feat(node): push USB changes and preserve sensor setup across sessions 2026-09-06 00:24:48 +03:00
DCCONSTRUCTIONS a8647c4d87 fix(node): verify D455 access and share sensor progress and recording UI 2026-09-05 23:25:11 +03:00
DCCONSTRUCTIONS b1aaa40508 Add D455 sensor host and shared Node/Core preparation surface 2026-09-05 22:27:07 +03:00
DCCONSTRUCTIONS 3616acc648 fix(node): clear consumed invitations after confirmed pairing 2026-09-05 21:37:56 +03:00
DCCONSTRUCTIONS e82d012907 feat(node): pair onboard computers with the Core fleet through UI 2026-09-05 21:16:13 +03:00
DCCONSTRUCTIONS fc545f8440 docs(node): proceed to pairing with clean-install acceptance deferred 2026-09-05 20:31:43 +03:00
DCCONSTRUCTIONS a98ef339de fix(node): show completed setup checks with a timestamp 2026-09-05 20:11:17 +03:00
DCCONSTRUCTIONS 9062126913 fix(node): make environment progress readable from the desktop 2026-09-05 19:54:08 +03:00
DCCONSTRUCTIONS 59e14c5bc0 feat(node): configure system environment through the desktop workflow 2026-09-05 19:46:52 +03:00
DCCONSTRUCTIONS 15bb793e5e fix(node): restore Linux network inventory and expose read failures 2026-09-05 18:47:12 +03:00
DCCONSTRUCTIONS 79911eb316 feat(node): consolidate board system views and plan fleet pairing 2026-09-05 18:35:57 +03:00
DCCONSTRUCTIONS d696842f5d feat(node): package Ubuntu desktop setup and trusted access 2026-09-05 17:27:59 +03:00
DCCONSTRUCTIONS 57f2537af3 Document AI Inference architecture and freeze current baseline 2026-09-05 10:05:01 +03:00
DCCONSTRUCTIONS e56eb42507 docs: inventory Rerun customizations and upstream dependencies 2026-09-05 09:21:28 +03:00
DCCONSTRUCTIONS b2a1b23131 fix(observatory): preserve camera across follow transitions 2026-09-05 08:46:35 +03:00
DCCONSTRUCTIONS cada687173 feat(observatory): ship modular AI inference labs 2026-09-04 17:59:05 +03:00
DCCONSTRUCTIONS eff60e490a docs(observatory): record saved replay evidence and open visual gates 2026-09-03 13:24:32 +03:00
DCCONSTRUCTIONS 9db29bcdc6 feat(observatory): share native result replay and expose saved TGS layers 2026-09-03 13:23:56 +03:00
DCCONSTRUCTIONS 27979854a7 feat(observatory): cache sealed camera and TGS replay on Core 2026-09-03 13:15:07 +03:00
DCCONSTRUCTIONS 3ea8d987a7 docs(observatory): record real M49 publication and restart acceptance 2026-09-03 11:50:32 +03:00
DCCONSTRUCTIONS bd947b49f4 fix(observatory): preserve source identity on unchanged reindex 2026-09-03 11:43:03 +03:00
DCCONSTRUCTIONS 54c8d82884 ops(observatory): install exact recorded-progress agent layers 2026-09-03 11:22:03 +03:00
DCCONSTRUCTIONS bee8552003 feat(observatory): expose attempt-bound recorded progress 2026-09-03 11:17:56 +03:00
DCCONSTRUCTIONS 4e0565eb4b docs(observatory): refresh four-stage recorded-first roadmap 2026-09-03 10:35:48 +03:00
DCCONSTRUCTIONS 44afadb021 docs(observatory): record selector rules and local UI acceptance 2026-09-03 10:16:12 +03:00
DCCONSTRUCTIONS e436fb56d4 fix(observatory): offer only uncalculated profiles without completion badges 2026-09-03 10:16:01 +03:00
DCCONSTRUCTIONS c2710b55a7 docs(observatory): record cache activation and Docker memory recovery 2026-09-03 09:44:02 +03:00
DCCONSTRUCTIONS d777f3198b docs(observatory): record cache proof and pending activation 2026-09-03 09:15:12 +03:00
DCCONSTRUCTIONS 1e4ddc2cff feat(observatory): verify exact published result reuse 2026-09-03 09:14:54 +03:00
DCCONSTRUCTIONS 80fc0058cb docs(observatory): record claim-v3 repair and preserved queue history 2026-09-03 00:00:08 +03:00
DCCONSTRUCTIONS 43f1cdc862 fix(worker): preserve transport patch in Docker snapshot layers 2026-09-02 23:50:22 +03:00
DCCONSTRUCTIONS 0041e9fadb fix(worker): bind claim migration to the imported transport module 2026-09-02 23:45:06 +03:00
DCCONSTRUCTIONS cabd3081c7 fix(observatory): version non-binding idle claims and preserve grant history 2026-09-02 23:40:11 +03:00
DCCONSTRUCTIONS 049ed3eed4 docs(observatory): prioritize recorded laboratories and cached review before onboard profiles 2026-09-02 23:16:01 +03:00
DCCONSTRUCTIONS 35c1249631 fix(observatory): reject duplicate active portable computations atomically 2026-09-02 23:15:39 +03:00
DCCONSTRUCTIONS 941183d624 docs(perception): record TLS and direct TCP latency attribution 2026-09-02 22:40:08 +03:00
DCCONSTRUCTIONS 6af4c208ce test(perception): localize transport tails with bounded TLS record witnesses 2026-09-02 22:34:12 +03:00
DCCONSTRUCTIONS 75a5320756 docs(perception): record clock route A/B/A and continuous LAN canary 2026-09-02 22:14:58 +03:00
DCCONSTRUCTIONS 59a74a3677 test(perception): add bounded clock-only route and scheduler diagnostics 2026-09-02 21:57:02 +03:00
DCCONSTRUCTIONS 35d28418f3 docs(perception): record joint startup proof and remaining clock wait 2026-09-02 21:16:03 +03:00
DCCONSTRUCTIONS 48835a0499 fix(perception): require joint clock readiness before source activation 2026-09-02 21:03:33 +03:00
DCCONSTRUCTIONS 93ae1edbb4 docs(perception): record cross-host canary failure and joint startup gate 2026-09-02 20:44:08 +03:00
DCCONSTRUCTIONS 093df8b38f style(perception): format streaming lifecycle guards 2026-09-02 20:43:31 +03:00
DCCONSTRUCTIONS 35b6cd9e9e feat(perception): gate cross-host graph with acknowledged source clocks 2026-09-02 20:37:21 +03:00
DCCONSTRUCTIONS d80df61a7e docs(perception): record cross-host clock and control proof 2026-09-02 19:46:27 +03:00
DCCONSTRUCTIONS d7b8989e43 feat(perception): add scoped stream control and bounded clock observations 2026-09-02 19:41:43 +03:00
DCCONSTRUCTIONS 0cc6b96306 docs(perception): record full-graph network and recovery evidence 2026-09-02 19:05:13 +03:00
DCCONSTRUCTIONS 6469505c41 feat(perception): connect full graph to external streaming source and scene receiver 2026-09-02 19:04:41 +03:00
DCCONSTRUCTIONS 191612282b test(perception): record cross-host gRPC recovery and backpressure proof 2026-09-02 18:14:51 +03:00
DCCONSTRUCTIONS 0310592ee3 feat(perception): add bounded authenticated gRPC stream transport 2026-09-02 18:11:42 +03:00
DCCONSTRUCTIONS df0158e1ba docs(perception): record Worker observability repair and remaining stream gates 2026-09-02 17:40:45 +03:00
DCCONSTRUCTIONS 2ef8c08bf2 feat(perception): export bounded runtime observations outside heartbeat lane 2026-09-02 17:35:46 +03:00
DCCONSTRUCTIONS c570f7d938 fix(telemetry): restore worker agent delivery and truthful system status 2026-09-02 17:35:20 +03:00
DCCONSTRUCTIONS 03e8e71bea docs(perception): record host telemetry recovery and scoped inventory evidence 2026-09-02 16:56:22 +03:00
DCCONSTRUCTIONS 61cbdb30a0 feat(perception): connect scoped host telemetry to recoverable profile lifecycle 2026-09-02 16:50:57 +03:00
DCCONSTRUCTIONS e91721fe6a docs(perception): record full graph recovery and separate availability latency 2026-09-02 16:01:36 +03:00
DCCONSTRUCTIONS 70927ea585 feat(perception): resume full graph on fresh input without restarting models 2026-09-02 15:54:13 +03:00
DCCONSTRUCTIONS 03a7e730ae docs(perception): record recoverable input policy and decoder continuity proof 2026-09-02 15:33:53 +03:00
DCCONSTRUCTIONS c31c46caa5 feat(perception): preserve resident runtime across recoverable input gaps 2026-09-02 15:27:06 +03:00
DCCONSTRUCTIONS 06a66a3da8 docs(perception): record controller readiness boundary and fault evidence 2026-09-02 14:54:43 +03:00
DCCONSTRUCTIONS 92625bf48b feat(perception): gate streaming lifecycle on controller readiness 2026-09-02 14:49:36 +03:00
DCCONSTRUCTIONS b8ba0cae1e docs(perception): record exact parity and fixed-envelope IPC comparison 2026-09-02 14:28:58 +03:00
DCCONSTRUCTIONS f39f1ff0b6 perf(perception): borrow contiguous image buffers for local IPC 2026-09-02 14:20:39 +03:00
DCCONSTRUCTIONS 380b6eaba7 fix(perception): preserve quaternion alignment across binary ingress 2026-09-02 14:20:38 +03:00
DCCONSTRUCTIONS f6d73d8bfb docs(perception): record binary full-graph evidence and remaining gates 2026-09-02 14:02:28 +03:00
DCCONSTRUCTIONS 221e429c0a feat(perception): connect binary ingress to the supervised full graph 2026-09-02 13:53:25 +03:00
DCCONSTRUCTIONS a85aed2e03 docs(perception): record decoder parity and remaining full-graph bridge 2026-09-02 13:14:11 +03:00
DCCONSTRUCTIONS f102712f0b test(perception): verify incremental decoder parity and binary ingress on Worker 2026-09-02 13:07:33 +03:00
DCCONSTRUCTIONS 350366bae0 refactor(perception): decode bounded fMP4 without source file dependencies 2026-09-02 13:07:33 +03:00
DCCONSTRUCTIONS c23e86fd39 docs(perception): record bounded binary ingress evidence and remaining bridge 2026-09-02 12:37:25 +03:00
DCCONSTRUCTIONS 79ce55d197 test(perception): verify reuse of existing raw live ingress 2026-09-02 12:30:16 +03:00
DCCONSTRUCTIONS a937400942 test(perception): seal source end marker before IPC shutdown 2026-09-02 12:28:23 +03:00
DCCONSTRUCTIONS da60feff90 feat(perception): add bounded binary stream ingress 2026-09-02 12:21:25 +03:00
DCCONSTRUCTIONS 85349f42f9 docs(perception): record lifecycle ownership and expiry evidence 2026-09-02 12:05:00 +03:00
DCCONSTRUCTIONS ac69e3b444 test(perception): exercise lifecycle and lease loss in full profile 2026-09-02 11:55:57 +03:00
DCCONSTRUCTIONS 6acf468b25 feat(perception): fence streaming lifecycle with worker-local ownership 2026-09-02 11:55:56 +03:00
DCCONSTRUCTIONS d264cbce04 docs(perception): record stage-two scheduler and cell freshness proof 2026-09-02 11:03:26 +03:00
DCCONSTRUCTIONS 1f8101e4a5 feat(perception): expire costmap permissions per cell 2026-09-02 10:57:42 +03:00
DCCONSTRUCTIONS bcacb02a22 refactor(perception): share bounded streaming scheduler 2026-09-02 10:57:41 +03:00
DCCONSTRUCTIONS adcca7aa81 docs(perception): close stage-one baseline and record freshness proof 2026-09-02 10:21:44 +03:00
DCCONSTRUCTIONS 097e450a87 feat(perception): enforce six-layer freshness through receipt 2026-09-02 10:14:28 +03:00
DCCONSTRUCTIONS 4e633a662c docs(perception): record gpu clock latency proof 2026-09-02 09:42:40 +03:00
DCCONSTRUCTIONS c6c42c29e8 fix(perception): reject material ties and trace gpu pacing 2026-09-02 09:34:20 +03:00
DCCONSTRUCTIONS ce09d30c1a docs(perception): record latency and triton parity limits 2026-09-02 02:52:38 +03:00
DCCONSTRUCTIONS 34f13ba59e feat(perception): add bounded triton runtime diagnostics 2026-09-02 02:52:11 +03:00
DCCONSTRUCTIONS d9ea7c1129 perf(perception): reuse local surface cell observations 2026-09-02 02:03:09 +03:00
DCCONSTRUCTIONS a3c67e249c docs(perception): record rendezvous worker proof 2026-09-02 01:50:35 +03:00
DCCONSTRUCTIONS 294585936f fix(perception): rendezvous completed gpu handoff 2026-09-02 01:48:25 +03:00
DCCONSTRUCTIONS 380a13688c docs(perception): record preroll worker pilot 2026-09-02 01:40:06 +03:00
DCCONSTRUCTIONS fd8313899b fix(perception): separate preroll history from current sensor binding
Preserve rolling points and original clocks; expose per-modality ages, held pose and rejection reasons. Saved-lineage audit changes only the first admission (75 to 76 of 128). No new model/performance claim. 384 focused Python and 62 frontend tests passed.
2026-09-02 01:09:03 +03:00
DCCONSTRUCTIONS 05c99efb68 test(observatory): isolate legacy contracts from installed package pins
Freeze configuration-only legacy fixtures from 7025e17, update current installed-image assertions, and explicitly reject the prior agent image. Production admission and digest validation are unchanged.
2026-09-02 01:02:38 +03:00
DCCONSTRUCTIONS ffd6be5606 docs(observatory): record realtime plan and measured latency evidence 2026-09-02 00:59:48 +03:00
DCCONSTRUCTIONS bfe6ef4c77 feat(perception): add bounded full-graph streaming and latency pilots 2026-09-02 00:59:48 +03:00
DCCONSTRUCTIONS 4e04d06191 feat(perception): define stream-first profile and qualification contracts 2026-09-02 00:59:47 +03:00
DCCONSTRUCTIONS 5a78c997ac feat(observatory-ui): review portable results and retry publication
Checkpoint the existing generic result-viewing and publication lifecycle UI. Focused architecture and Observatory tests: 62 passed; no new visual changes or rebuild in this checkpoint.
2026-09-02 00:59:21 +03:00
DCCONSTRUCTIONS 62d5520c7a feat(worker): package independent installed LAB container steps
Preserve local installer, offline validation and pinned component adapters; 62 focused packaging tests pass. No deployment performed by this commit.
2026-09-02 00:58:51 +03:00
DCCONSTRUCTIONS a945d665dd feat(observatory): add installed package dispatch and durable publication
Checkpoint existing backend lifecycle changes. Focused verification found nine legacy fixture failures in portable LAB V1 executor/runtime tests; repair follows separately without rewriting this snapshot. ADR date retains its intentional Markdown hard break.
2026-09-02 00:58:37 +03:00
DCCONSTRUCTIONS d655d6998d feat(observatory): attest recording equipment and capture profiles 2026-09-02 00:57:27 +03:00
DCCONSTRUCTIONS 7025e173a3 fix(worker): accept omitted empty image command 2026-08-31 21:47:47 +03:00
DCCONSTRUCTIONS 10fe727561 fix(worker): preserve exec-form entrypoint 2026-08-31 21:45:51 +03:00
DCCONSTRUCTIONS 00ee5f163a fix(worker): keep commit labels shell-safe 2026-08-31 21:42:31 +03:00
DCCONSTRUCTIONS cbcc09afd6 fix(worker): mount generated shell scripts 2026-08-31 21:39:07 +03:00
DCCONSTRUCTIONS e958729e9d fix(worker): seal coordinator Python dependencies 2026-08-31 21:31:45 +03:00
DCCONSTRUCTIONS 0fcd037e4f fix(worker): compose staged source paths safely 2026-08-31 21:01:30 +03:00
DCCONSTRUCTIONS 11a146011f feat(worker): install combined observatory profiles 2026-08-31 20:56:29 +03:00
DCCONSTRUCTIONS 44ebbe7ca2 test(lab): import adapters in legacy runtimes 2026-08-31 20:04:31 +03:00
DCCONSTRUCTIONS a3299dcfc3 fix(lab): avoid runtime typing on Python 3.9 2026-08-31 20:01:49 +03:00
DCCONSTRUCTIONS 2989fba99c fix(lab): support legacy Python 3.9 adapters 2026-08-31 19:58:20 +03:00
DCCONSTRUCTIONS 142481f0d8 fix(lab): probe optional adapter image safely 2026-08-31 19:52:34 +03:00
DCCONSTRUCTIONS b2ae29b8d8 feat(lab): install thin adapter layers offline 2026-08-31 19:51:51 +03:00
DCCONSTRUCTIONS 8eced6c34d feat(worker): compose installed LAB V1 profile 2026-08-31 19:47:24 +03:00
DCCONSTRUCTIONS 732cc139fd refactor(worker): externalize runtime registries 2026-08-31 19:47:07 +03:00
DCCONSTRUCTIONS 104e54f4dd refactor(lab): separate release contract identity 2026-08-31 19:46:58 +03:00
DCCONSTRUCTIONS 7b021782d3 feat(lab): install sealed component adapters 2026-08-31 19:46:47 +03:00
DCCONSTRUCTIONS 759830b054 feat(lab): seal reusable EoMT runtime trees 2026-08-31 19:24:42 +03:00
DCCONSTRUCTIONS 19c0bd70d0 feat(lab): bind sealed component adapter images 2026-08-31 19:19:47 +03:00
DCCONSTRUCTIONS 35d99e40d5 perf(m49): verify source payloads during assembly 2026-08-31 19:06:59 +03:00
DCCONSTRUCTIONS bfc1f1bbed feat(worker): define unified agent image artifact 2026-08-31 19:00:06 +03:00
DCCONSTRUCTIONS f41428f907 feat(lab): add sealed local component runners 2026-08-31 18:59:47 +03:00
DCCONSTRUCTIONS 3744d79ad3 feat(lab): seal reusable ffmpeg runtime asset 2026-08-31 18:59:37 +03:00
DCCONSTRUCTIONS b57c3e94da perf(m49): defer fresh stage revalidation 2026-08-31 18:42:31 +03:00
DCCONSTRUCTIONS 73a7d28065 perf(lidar): avoid duplicate replay decode during build 2026-08-31 18:35:12 +03:00
DCCONSTRUCTIONS f64fbb1fa6 feat(worker): compose all ready observatory profiles 2026-08-31 18:31:54 +03:00
DCCONSTRUCTIONS 1ff527b264 perf(observatory): avoid duplicate camera verification 2026-08-31 18:25:09 +03:00
DCCONSTRUCTIONS def8c71410 perf(observatory): bound source destination planning 2026-08-31 18:19:04 +03:00
DCCONSTRUCTIONS 7e59176581 fix(observatory): disable source artifact compression 2026-08-31 18:05:34 +03:00
DCCONSTRUCTIONS 4cd94b5805 perf(observatory): pack camera epoch source transfer 2026-08-31 17:59:18 +03:00
DCCONSTRUCTIONS 1766c0bdb2 feat(observatory): reconcile quarantined recorded jobs 2026-08-31 17:59:03 +03:00
DCCONSTRUCTIONS 56bfeaee7b fix(observatory): admit camera archive metadata overhead 2026-08-31 17:19:45 +03:00
DCCONSTRUCTIONS 892d0085e3 refactor(worker): defer optional visualization imports 2026-08-31 17:15:13 +03:00
DCCONSTRUCTIONS e2de8188ab feat(observatory): bridge local M49 worker container 2026-08-31 16:58:03 +03:00
DCCONSTRUCTIONS 9c69d81296 feat(observatory): install local M49 worker path 2026-08-31 16:51:10 +03:00
DCCONSTRUCTIONS 2f6e45bc96 fix(observatory): validate M49 fixture filtering 2026-08-31 16:12:45 +03:00
DCCONSTRUCTIONS 8a1c578e0e fix(observatory): admit committed M49 executor candidates 2026-08-31 16:06:23 +03:00
DCCONSTRUCTIONS 9beb534108 feat(observatory): add portable calculation profiles 2026-08-31 15:42:56 +03:00
DCCONSTRUCTIONS d1b75efcea fix(observatory): fence portable catalog refresh 2026-08-31 11:11:36 +03:00
DCCONSTRUCTIONS c9302c78c2 feat(observatory): add portable LAB V1 foundation 2026-08-31 11:02:34 +03:00
DCCONSTRUCTIONS d67e86176e feat(observatory): add durable recorded compute queue 2026-08-31 01:04:25 +03:00
DCCONSTRUCTIONS e72609120f fix(observatory): align setup typography 2026-08-30 23:31:09 +03:00
DCCONSTRUCTIONS 177be8869f feat(local-service): allow exact worktree migration 2026-08-30 23:23:36 +03:00
DCCONSTRUCTIONS 2a5763d3fb feat(observatory): seal blocked run preparations 2026-08-30 23:14:05 +03:00
DCCONSTRUCTIONS 8d5aeb0533 feat(observatory): add laboratory setup preflight 2026-08-30 22:21:58 +03:00
DCCONSTRUCTIONS be83283ee0 fix(observatory): refine catalog and replay UX 2026-08-30 20:36:12 +03:00
DCCONSTRUCTIONS 023151c186 feat(observatory): admit canonical recorded replay 2026-08-30 19:21:27 +03:00
DCCONSTRUCTIONS e6a9846167 feat(observatory): add bounded M5.1 session review 2026-08-30 17:29:39 +03:00
DCCONSTRUCTIONS 81fdf6904a refactor(lab): объединить RAV004 в единый Rerun replay 2026-08-30 16:40:12 +03:00
DCCONSTRUCTIONS 9c5259dbc9 fix(lab): запускать RAV004 с первого кадра 2026-08-30 14:33:17 +03:00
DCCONSTRUCTIONS 35daf73d5c fix(lab): сжать и адресовать RAV004 overlay 2026-08-30 14:05:32 +03:00
DCCONSTRUCTIONS 07453142c2 fix(lab): стабилизировать нативный RAV004 replay 2026-08-30 13:28:33 +03:00
DCCONSTRUCTIONS f5ee42751d refactor(lab): перевести RAV004 на канонический Rerun pipeline 2026-08-30 12:59:16 +03:00
DCCONSTRUCTIONS e9ffb829c9 docs(sim): зафиксировать контур AI-улучшения гауссов 2026-08-30 11:57:49 +03:00
DCCONSTRUCTIONS 757d368c86 fix(lab): seal RAV004 spatial replay transport 2026-08-30 10:42:41 +03:00
DCCONSTRUCTIONS f1cbe0061a refactor(lab): restore canonical RAV004 replay 2026-08-30 01:22:44 +03:00
DCCONSTRUCTIONS 74da6437e9 refactor(lab): canonicalize recorded spatial replay 2026-08-29 23:38:17 +03:00
DCCONSTRUCTIONS bd2892140f fix(lab): enforce canonical replay runtime 2026-08-29 22:42:30 +03:00
DCCONSTRUCTIONS 525ab74168 fix(lab): restore canonical RAV004 replay 2026-08-29 21:08:49 +03:00
DCCONSTRUCTIONS c30d77572e fix(lab): restore canonical vegetation evidence layers 2026-08-29 20:33:52 +03:00
DCCONSTRUCTIONS 5179e93f4a fix(lab): restore canonical spatial replay 2026-08-29 19:52:06 +03:00
DCCONSTRUCTIONS c06b709fd7 refactor(viewer): profile rerun loading by playback stage 2026-08-29 17:46:07 +03:00
DCCONSTRUCTIONS 856b61be99 fix(simulation): standardize streamed rendering and UGV proxies 2026-08-29 17:39:40 +03:00
DCCONSTRUCTIONS 76694cc869 fix(simulation): reserve disk space before artifact import 2026-08-29 16:18:56 +03:00
DCCONSTRUCTIONS e6df8cb264 fix(simulation): retry temporary provider proxy failures 2026-08-29 16:01:28 +03:00
DCCONSTRUCTIONS 4b487e367f fix(simulation): retry builds across worker tunnel outages 2026-08-29 14:37:27 +03:00
DCCONSTRUCTIONS d7d9485724 fix(lab): overlay full route semantic masks 2026-08-29 14:19:32 +03:00
DCCONSTRUCTIONS 0a0cde7b0e fix(lab): keep full route result indexable 2026-08-29 13:54:40 +03:00
DCCONSTRUCTIONS 9dec37a8a9 feat(lab): seal full RAVNOVES004TREE semantic review 2026-08-29 13:37:57 +03:00
DCCONSTRUCTIONS 24b65926fe fix(simulation): hold ugv on sloped collision meshes 2026-08-29 12:41:28 +03:00
DCCONSTRUCTIONS 57dacc5a04 fix(simulation): model parked UGV tyre grip 2026-08-29 12:03:33 +03:00
DCCONSTRUCTIONS d9ec8c9cef fix(simulation): tune physical UGV braking 2026-08-29 11:26:01 +03:00
DCCONSTRUCTIONS 2e44e27967 feat(simulation): auto-build source mesh collision 2026-08-28 13:48:44 +03:00
DCCONSTRUCTIONS 2ccf172319 feat(simulation): add Gaussian UGV runtime pipeline 2026-08-28 11:51:48 +03:00
1204 changed files with 230158 additions and 8179 deletions
+1
View File
@@ -10,3 +10,4 @@
*.las binary *.las binary
*.lcc 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 apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
+34
View File
@@ -62,6 +62,40 @@ and the boundary between Mission Core and vendor-specific integration code.
- Synthetic or explicitly redacted fixtures may be committed under - Synthetic or explicitly redacted fixtures may be committed under
`tests/fixtures/`. `tests/fixtures/`.
## Insta360 and clean-host installation — owner requirement, 2026-09-08
- The current X4 starting point is USB enumeration only. SDK installation,
camera initialization and live video have not been accepted.
- Every Ubuntu change needed for Insta360 must execute through the shipped
installer or the application's versioned device-preparation workflow from
the first board experiment. Do not repair the board with an ad-hoc apt/pip
install, copied library, chmod, environment override, service edit or root
invocation and promise to package it later. Fix the product artifact first,
then rerun that artifact. Read-only engineering inspection remains allowed.
- Document every relevant action and its before/after evidence in
`docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version,
owning installer step, dependencies, idempotency, failure behavior and rollback.
Keep private logs/identifiers/media out of Git and redact their public summary.
- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency
closure on the declared clean OS image and actual USB operation separately;
installer, preparation and GUI acceptance must use the same shipped code.
- X4 is operator live video and camera control (settings, photo, recording
start/stop and file access), per the owners subsequent scope expansion.
Preview and recording are separate operations. No stitching, AI, navigation
or vehicle-control integration is admitted. Model support and physical device instances are separate: several
cameras of the same model must retain independent identity and lifecycle.
- Follow the scoped implementation plan in
`docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan,
not evidence that SDK compatibility, hardware capacity or installation passed.
- Subsequent owner direction: the existing Ubuntu Mini is the available build
and qualification machine; do not depend on Worker 006 or another host for
this X4 work. Its existing GCC may compile through a versioned build artifact
in bounded temporary staging. This is a build step, not runtime installation:
no system changes, SDK execution or camera access during compilation. The
final installer must carry the compiled payload so a clean operator Ubuntu
does not inherit an undeclared compiler/build-cache prerequisite. All board
staging/build/install/test actions remain documented and artifact-owned.
## Implementation order ## Implementation order
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
+4 -4
View File
@@ -15,7 +15,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1", "@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1", "meshoptimizer": "1.1.1",
"playcanvas": "2.21.4", "playcanvas": "2.21.4",
"react": "^19.1.0", "react": "^19.1.0",
@@ -900,9 +900,9 @@
"link": true "link": true
}, },
"node_modules/@rerun-io/web-viewer": { "node_modules/@rerun-io/web-viewer": {
"version": "0.34.1", "version": "0.36.3",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz", "resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==", "integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
+3 -2
View File
@@ -4,8 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
"dev": "vite", "dev": "vite",
"postinstall": "node scripts/install-rerun-navigation.mjs",
"prebuild": "node scripts/install-rerun-navigation.mjs",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "node --test test/*.test.mjs", "test:unit": "node --test test/*.test.mjs",
@@ -18,7 +19,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1", "@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1", "meshoptimizer": "1.1.1",
"playcanvas": "2.21.4", "playcanvas": "2.21.4",
"react": "^19.1.0", "react": "^19.1.0",
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Синхронизированная запись</title>
<style>
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<div id="viewer"></div>
<script type="module" src="/src/components/rerun/isolatedRerunEntry.ts"></script>
</body>
</html>
@@ -0,0 +1,59 @@
import { createHash } from "node:crypto";
import { 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 vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
const manifest = JSON.parse(readFileSync(resolve(vendorRoot, "navigation-build.json"), "utf8"));
const installed = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
if (installed.version !== "0.36.3" || manifest.upstreamVersion !== installed.version) {
throw new Error("Rerun navigation requires the audited 0.36.3 package; rebase before upgrading");
}
if (sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))) !== manifest.patchSha256) {
throw new Error("Rerun navigation source patch identity mismatch");
}
const eyeType = "{ position: [number, number, number]; lookTarget: [number, number, number]; eyeUp: [number, number, number] } | null";
const additions = {
"index.js": {
marker: " get_active_recording_id() {",
code: ` get_camera_eye() {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
},
"index.ts": {
marker: " get_active_recording_id(): string | null {",
code: ` get_camera_eye(): ${eyeType} {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
},
"index.d.ts": {
marker: " get_active_recording_id(): string | null;",
code: ` get_camera_eye(): ${eyeType};\n`,
},
};
// Validate the complete set before changing any installed file. A package
// upgrade, corrupt artifact or independently modified wrapper fails closed.
const writes = [];
for (const [name, identity] of Object.entries(manifest.files)) {
const current = readFileSync(resolve(packageRoot, name));
let output;
if (additions[name]) {
const { marker, code } = additions[name];
const source = current.toString("utf8");
const original = source.includes(code) ? source.replace(code, "") : source;
if (sha(original) !== identity.upstreamSha256 || original.split(marker).length !== 2) {
throw new Error(`Unexpected upstream wrapper: ${name}`);
}
output = Buffer.from(original.replace(marker, code + marker));
} else {
output = readFileSync(resolve(vendorRoot, identity.artifact));
if (sha(output) !== identity.sha256) throw new Error(`Corrupt navigation artifact: ${name}`);
if (![identity.upstreamSha256, identity.sha256].includes(sha(current))) {
throw new Error(`Unexpected installed runtime: ${name}`);
}
}
writes.push([resolve(packageRoot, name), output]);
}
for (const [path, output] of writes) writeFileSync(path, output);
console.log("Installed NODE.DC Rerun 0.36.3 navigation/v1 (native camera, one renderer).");
+77 -218
View File
@@ -1,3 +1,5 @@
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
AdminNavigationPanel, AdminNavigationPanel,
@@ -5,18 +7,14 @@ import {
ApplicationPanel, ApplicationPanel,
ApplicationShell, ApplicationShell,
Button, Button,
Checker,
ColorField,
ControlRow, ControlRow,
HeaderNavigation, HeaderNavigation,
HeaderProfile, HeaderProfile,
HeaderWorkspace,
Icon, Icon,
Inspector, Inspector,
RangeControl,
Select,
StatusBadge, StatusBadge,
TextField, TextField,
ToastStack,
UserProfileMenu, UserProfileMenu,
Window, Window,
WindowFooterActions, WindowFooterActions,
@@ -30,6 +28,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector"; import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
import { useComputeContourSettings } from "./components/system/useComputeContourSettings"; import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
import { useApplicationPanelActions } from "./components/useApplicationPanelActions"; import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
import { useSessionOverviewMode } from "./core/observation/useSessionOverview";
import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace";
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings"; import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
@@ -50,6 +50,7 @@ import type {
} from "./core/observation/sessionArchive"; } from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission"; import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile"; import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget"; import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
import { resolvePolygonRunRoute } from "./core/polygon/runArchive"; import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
import { import {
@@ -67,33 +68,18 @@ import {
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation"; import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
import { import {
defaultSceneSettings, defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings, type SceneSettings,
} from "./sceneSettings"; } from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace"; import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
import { WorkspaceRenderer } from "./workspaces/Workspaces"; import { WorkspaceRenderer } from "./workspaces/Workspaces";
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader"; import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
import "./styles/scene-windows.css"; import "./styles/scene-windows.css";
type SceneToolWindowId = "sources" | "display" | "layers"; type SceneToolWindowId = "sources" | "display" | "layers";
const viewerSettingsQuietPeriodMs = 750; const viewerSettingsQuietPeriodMs = 750;
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: "Один назначенный цвет" },
];
interface LivePerceptionLayers { interface LivePerceptionLayers {
detections2d: boolean; detections2d: boolean;
segmentation: boolean; segmentation: boolean;
@@ -170,7 +156,9 @@ export default function App() {
polygonDatasetRoute.active ? "data" : null, polygonDatasetRoute.active ? "data" : null,
); );
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false); const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
const [sourceUrl, setSourceUrl] = useState(""); const [sourceUrl, setSourceUrl] = useState("");
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null); const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false); const [replayTransitioning, setReplayTransitioning] = useState(false);
@@ -195,6 +183,7 @@ export default function App() {
const appliedProfileKeyRef = useRef<string | null>(null); const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings); const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings); const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
const closeDisplayRef = useRef<() => void>(() => {});
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings); const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const viewerSettingsCommitTimerRef = useRef<number | null>(null); const viewerSettingsCommitTimerRef = useRef<number | null>(null);
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings); const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
@@ -242,6 +231,13 @@ export default function App() {
); );
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings; runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayPresented; replayActiveRef.current = replayPresented;
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
sceneSettingsRef.current = settings;
displayDraftRef.current = settings;
confirmedSceneSettingsRef.current = settings;
setSceneSettings(settings);
setDisplayDraft(settings);
});
useEffect(() => { useEffect(() => {
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return; if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
@@ -324,6 +320,7 @@ export default function App() {
const remote = runtime.state?.viewerSettings; const remote = runtime.state?.viewerSettings;
if ( if (
!remote || !remote ||
replayActiveRef.current ||
workspaceLayoutProfile.profile || workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy() sceneSettingsCommitterRef.current?.isBusy()
) return; ) return;
@@ -346,7 +343,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
const profile = workspaceLayoutProfile.profile; const profile = workspaceLayoutProfile.profile;
if (!profile) return; if (!profile || selectedRecordedSessionIdRef.current) return;
if (viewerSettingsCommitTimerRef.current !== null) { if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current); window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null; viewerSettingsCommitTimerRef.current = null;
@@ -417,7 +414,10 @@ export default function App() {
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => { const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
if (windowId === "sources") setSourceWindowOpen(false); if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false); if (windowId === "display") {
setDisplayWindowOpen(false);
closeDisplayRef.current();
}
if (windowId === "layers") setLayerInspectorOpen(false); if (windowId === "layers") setLayerInspectorOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId)); setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []); }, []);
@@ -449,9 +449,10 @@ export default function App() {
} }
}; };
const openView = (viewId: string) => { const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
const definition = workspaceById(viewId); const definition = workspaceById(viewId);
if (!definition) return; if (!definition) return;
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
setActiveRoot(definition.root); setActiveRoot(definition.root);
workspace.openView(viewId); workspace.openView(viewId);
}; };
@@ -477,14 +478,22 @@ export default function App() {
const next = { ...displayDraftRef.current, ...patch }; const next = { ...displayDraftRef.current, ...patch };
displayDraftRef.current = next; displayDraftRef.current = next;
setDisplayDraft(next); setDisplayDraft(next);
sessionDisplayProfile.edited();
if (viewerSettingsCommitTimerRef.current !== null) { if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current); window.clearTimeout(viewerSettingsCommitTimerRef.current);
} }
// Preview and persistence are separate. An open inspector must not freeze
// either its controls or the shared accumulation slider below the scene.
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(next);
return;
}
viewerSettingsCommitTimerRef.current = window.setTimeout(() => { viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
viewerSettingsCommitTimerRef.current = null; viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current); sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, viewerSettingsQuietPeriodMs); }, viewerSettingsQuietPeriodMs);
}, []); }, [sessionDisplayProfile.edited]);
const flushDisplaySettings = useCallback(() => { const flushDisplaySettings = useCallback(() => {
if (viewerSettingsCommitTimerRef.current === null) return; if (viewerSettingsCommitTimerRef.current === null) return;
@@ -494,8 +503,15 @@ export default function App() {
}, []); }, []);
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => { const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
sessionDisplayProfile.edited();
commitDisplaySettings({ ...displayDraftRef.current, ...patch }); commitDisplaySettings({ ...displayDraftRef.current, ...patch });
}, [commitDisplaySettings]); }, [commitDisplaySettings, sessionDisplayProfile.edited]);
closeDisplayRef.current = () => {
const settings = {...displayDraftRef.current};
commitDisplaySettings(settings);
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
};
const openDisplay = () => { const openDisplay = () => {
if (!sceneWorkspaceActive) return; if (!sceneWorkspaceActive) return;
@@ -629,7 +645,11 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [layoutSaveNotice]); }, [layoutSaveNotice]);
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
const contentActions = useApplicationPanelActions({ const contentActions = useApplicationPanelActions({
onAddVehicle,
definition: activeDefinition, definition: activeDefinition,
refreshRuntime: runtime.refresh, refreshRuntime: runtime.refresh,
resetConnectionScenario: runtime.resetConnectionScenario, resetConnectionScenario: runtime.resetConnectionScenario,
@@ -637,6 +657,7 @@ export default function App() {
saveWorkspaceLayout, saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving", workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityActions: computeContourSettings.utilityActions, systemUtilityActions: computeContourSettings.utilityActions,
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
}); });
const header = ( const header = (
@@ -645,8 +666,6 @@ export default function App() {
brandHref="/" brandHref="/"
brandLabel="NODEDC MISSION CORE" brandLabel="NODEDC MISSION CORE"
center={ center={
<>
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
<HeaderNavigation <HeaderNavigation
label="Архитектурные блоки пункта управления" label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined} value={activeRoot ?? undefined}
@@ -656,7 +675,6 @@ export default function App() {
}))} }))}
onChange={selectRoot} onChange={selectRoot}
/> />
</>
} }
right={ right={
<HeaderProfile> <HeaderProfile>
@@ -802,10 +820,14 @@ export default function App() {
settleRecordedReplaySwitch(outcome)} settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete} onDeleteBegin={releaseRecordedReplayForDelete}
/> />
) : activeDefinition.kind === "datasets" ? ( ) : activeDefinition.kind === "missions" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge> <StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? ( ) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.root === "system" ? ( ) : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector <SystemWorkspaceSelector
value={activeDefinition.id} value={activeDefinition.id}
@@ -818,14 +840,28 @@ export default function App() {
utilityActions={contentActions} utilityActions={contentActions}
onClose={workspace.closeView} onClose={workspace.closeView}
> >
<PlanningCaptureGuard
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
onResume={() => openView('local-device', 'planning')}
>
{activeDefinition.kind === "device" ? ( {activeDefinition.kind === "device" ? (
<DeviceWorkspace launchProfile === 'planning' ? <PlanningConnectionWindow>
onOpenSpatialScene={() => openView("spatial-scene")} <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
</PlanningConnectionWindow> : <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource} onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/> />
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : ( ) : (
<WorkspaceRenderer <WorkspaceRenderer
launchProfile={launchProfile}
definition={activeDefinition} definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
state={activeRuntimeState} state={activeRuntimeState}
backendStatus={runtime.backendStatus} backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl} sourceUrl={effectiveSourceUrl}
@@ -866,6 +902,7 @@ export default function App() {
}} }}
/> />
)} )}
</PlanningCaptureGuard>
</ApplicationPanel> </ApplicationPanel>
) : null} ) : null}
/> />
@@ -1027,133 +1064,12 @@ export default function App() {
onPointerDown={() => activateSceneWindow("display")} onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("display")} onClose={() => closeSceneWindow("display")}
> >
<Inspector <SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
defaultOpen={["points"]} commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
singleOpen
sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
min={0.5}
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
/>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Атрибут цвета">
<Select
variant="split"
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
/>
</ControlRow>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Палитра">
<Select
variant="split"
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => stageDisplayPatch({ palette })}
/>
</ControlRow>
</div>
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => stageDisplayPatch({ customColor })}
/>
</ControlRow>
</div>
) : null}
{replayPresented ? (
<p className="scene-window-note">
Первый новый режим читает индекс архивных точек. Следующие палитры
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
</p>
) : null}
</div>
),
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
min={0}
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
/>
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
<Checker
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
/>
</div>
</div>
),
},
{
id: "scene",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: (
<div className="inspector-control-stack">
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
</Window> </Window>
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
onDismiss={sessionDisplayProfile.dismissError} />
<Window <Window
open={sceneWorkspaceActive && layerInspectorOpen} open={sceneWorkspaceActive && layerInspectorOpen}
@@ -1178,64 +1094,7 @@ export default function App() {
</span> </span>
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p> <p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
</div> </div>
<Inspector <SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
defaultOpen={["geometry"]}
singleOpen
sections={[
{
id: "geometry",
label: "Геометрия",
description: "Облако точек и путь",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div>
</div>
),
},
{
id: "frames",
label: "Координаты и камеры",
description: "Преобразования и области обзора",
content: (
<div className="inspector-control-stack">
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
{
id: "perception",
label: "Объекты и маски",
description: "Ожидают внешние обработчики",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
</Window> </Window>
</> </>
); );
@@ -1,92 +1 @@
import { useEffect, useMemo, useState } from "react"; export { EnvironmentBackgroundMedia } from "@nodedc/ui-react";
import type {
EnvironmentBackground,
EnvironmentMediaItem,
} from "../core/environment/environmentSettings";
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
url: string;
mediaKind: "image" | "video";
};
function isReadyMediaItem(
item: EnvironmentMediaItem,
): item is ReadyEnvironmentMediaItem {
return Boolean(item.url && item.mediaKind);
}
export function EnvironmentBackgroundMedia({
background,
}: {
background: EnvironmentBackground;
}) {
const items = useMemo(
() => background.items.filter(isReadyMediaItem),
[background.items],
);
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
const [activeIndex, setActiveIndex] = useState(0);
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
const playableItems = items.filter((item) => !failedIds.has(item.id));
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
useEffect(() => {
setActiveIndex(0);
setFailedIds(new Set());
}, [playlistIdentity]);
useEffect(() => {
if (
!background.enabled
|| !activeItem
|| activeItem.mediaKind !== "image"
|| playableItems.length < 2
) {
return;
}
const timer = window.setTimeout(() => {
setActiveIndex((current) => (current + 1) % playableItems.length);
}, background.imageDurationSeconds * 1_000);
return () => window.clearTimeout(timer);
}, [
activeItem,
background.enabled,
background.imageDurationSeconds,
playableItems.length,
]);
if (!background.enabled || !activeItem) return null;
return (
<div className="landing-stage__media" aria-hidden="true">
{activeItem.mediaKind === "video" ? (
<video
key={activeItem.url}
src={activeItem.url}
autoPlay
muted
loop={playableItems.length === 1}
playsInline
onEnded={() => setActiveIndex((current) => (
(current + 1) % playableItems.length
))}
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
) : (
<img
key={activeItem.url}
src={activeItem.url}
alt=""
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
)}
</div>
);
}
@@ -1,248 +1 @@
import { useEffect, useMemo, useRef, useState } from "react"; export { EnvironmentMediaPlaylistEditor } from "@nodedc/ui-react";
import {
Icon,
IconButton,
MediaSourceField,
RangeControl,
SortableList,
} from "@nodedc/ui-react";
import {
appendEnvironmentMediaItem,
inferEnvironmentMediaKind,
maxEnvironmentMediaItems,
removeEnvironmentMediaItem,
type EnvironmentBackground,
type EnvironmentMediaItem,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
interface EnvironmentMediaPlaylistEditorProps {
surfaceId: EnvironmentSurfaceId;
background: EnvironmentBackground;
disabled: boolean;
error: string | null;
onChange: (background: EnvironmentBackground) => void;
onBusyChange: (busy: boolean) => void;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
const acceptedEnvironmentMedia = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"video/quicktime",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".avif",
".mp4",
".webm",
".mov",
].join(",");
function patchItem(
background: EnvironmentBackground,
itemId: string,
patch: Partial<EnvironmentMediaItem>,
): EnvironmentBackground {
return {
...background,
items: background.items.map((item) => (
item.id === itemId ? { ...item, ...patch } : item
)),
};
}
export function EnvironmentMediaPlaylistEditor({
surfaceId,
background,
disabled,
error,
onChange,
onBusyChange,
onUpload,
}: EnvironmentMediaPlaylistEditorProps) {
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const backgroundRef = useRef(background);
backgroundRef.current = background;
const displayedItems = useMemo(
() => [...background.items].reverse(),
[background.items],
);
useEffect(() => {
onBusyChange(uploadingIds.size > 0);
}, [onBusyChange, uploadingIds.size]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
const setItemError = (itemId: string, message?: string) => {
setItemErrors((current) => {
const next = { ...current };
if (message) next[itemId] = message;
else delete next[itemId];
return next;
});
};
const uploadFile = async (itemId: string, file?: File) => {
if (!file) return;
setUploadingIds((current) => new Set(current).add(itemId));
setItemError(itemId);
try {
const uploaded = await onUpload(surfaceId, itemId, file);
onChange(patchItem(backgroundRef.current, itemId, {
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
setItemError(
itemId,
reason instanceof Error
? reason.message
: "Не удалось загрузить медиаконтент.",
);
} finally {
setUploadingIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
};
return (
<div className="environment-media-playlist">
<div className="environment-media-playlist__head">
<div>
<span>Видео / картинка</span>
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
</div>
<IconButton
label="Добавить медиаконтент"
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
onClick={() => onChange(appendEnvironmentMediaItem(background))}
>
<Icon name="plus" />
</IconButton>
</div>
{displayedItems.length ? (
<SortableList
items={displayedItems}
getId={(item) => item.id}
className="environment-media-playlist__items"
onReorder={(items) => onChange({
...background,
items: [...items].reverse(),
})}
>
{(item, { handle }) => {
const playbackIndex = background.items.findIndex(
(candidate) => candidate.id === item.id,
);
return (
<div className="environment-media-playlist__item">
<MediaSourceField
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
kindLabel={item.mediaKind ?? "media"}
source={item.source}
url={item.url ?? ""}
fileName={item.fileName}
uploading={uploadingIds.has(item.id)}
previewSrc={item.url}
previewKind={item.mediaKind}
accept={acceptedEnvironmentMedia}
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
error={itemErrors[item.id] ?? (
playbackIndex === background.items.length - 1 ? error : null
)}
onSourceChange={(source) => {
if (source === item.source) return;
setItemError(item.id);
onChange(patchItem(background, item.id, {
source,
url: null,
mediaKind: null,
fileName: null,
}));
}}
onUrlChange={(url) => {
setItemError(item.id);
onChange(patchItem(background, item.id, {
source: "url",
url: url || null,
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
fileName: null,
}));
}}
onFileChange={(file) => void uploadFile(item.id, file)}
/>
<div className="environment-media-playlist__item-actions">
<IconButton
label={`Удалить медиаконтент ${playbackIndex + 1}`}
disabled={disabled || uploadingIds.has(item.id)}
onClick={() => {
setItemError(item.id);
onChange(removeEnvironmentMediaItem(background, item.id));
}}
>
<Icon name="trash" />
</IconButton>
{handle}
</div>
</div>
);
}}
</SortableList>
) : (
<>
<p className="environment-media-playlist__empty">
Добавьте первый файл или прямую ссылку на медиаконтент.
</p>
{error ? (
<p className="environment-media-playlist__error" role="alert">
{error}
</p>
) : null}
</>
)}
<div className="environment-media-playlist__timing">
<RangeControl
label="Показывать изображение"
value={background.imageDurationSeconds}
min={1}
max={60}
step={1}
disabled={disabled}
formatValue={(value) => `${value} с`}
onChange={(imageDurationSeconds) => onChange({
...background,
imageDurationSeconds,
})}
/>
<span>
Новые элементы появляются сверху. Воспроизведение начинается снизу;
перетаскивание меняет порядок.
</span>
</div>
</div>
);
}
@@ -1,358 +1,25 @@
import { useEffect, useMemo, useState } from "react"; import { EnvironmentSettingsWindow as SharedEnvironmentSettingsWindow } from "@nodedc/ui-react";
import { import { environmentSurfaceIds, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia } from "../core/environment/environmentSettings";
Button, import { roots, workspaces, workspacesForRoot } from "../productModel";
FeatureSettingsWindow,
Select,
SettingsCard,
Switch,
TextAreaField,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
import { interface Props {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentPage,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
import {
roots,
workspaces,
workspacesForRoot,
} from "../productModel";
interface EnvironmentSettingsWindowProps {
open: boolean; open: boolean;
settings: EnvironmentSettings; settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error"; state: "loading" | "ready" | "saving" | "error";
error: string | null; error: string | null;
onClose: () => void; onClose: () => void;
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>; onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: ( onUpload: (surfaceId: EnvironmentSurfaceId, itemId: string, file: File) => Promise<UploadedEnvironmentMedia>;
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
} }
const surfaces = environmentSurfaceIds.map(id => ({
function patchPage( id, home: id === "home", description: id === "home" ? "Главная страница продукта" : `Стартовая страница раздела «${roots.find(root => root.id === id)?.label}»`,
draft: EnvironmentSettings, actions: id === "home" ? workspaces.filter(item => !item.internalOnly) : workspacesForRoot(id),
surfaceId: EnvironmentSurfaceId, }));
patch: Partial<EnvironmentPage>, export function EnvironmentSettingsWindow(props: Props) {
): EnvironmentSettings { return <SharedEnvironmentSettingsWindow {...props} productName="Mission Core" surfaces={surfaces} initialSurfaceId="fleet"
return { onSave={draft => props.onSave({ ...draft, pages: Object.fromEntries(environmentSurfaceIds.map(id => [id, draft.pages[id]])) as EnvironmentSettings["pages"] })}
...draft, onUpload={(id, itemId, file) => {
pages: { if (!environmentSurfaceIds.includes(id as EnvironmentSurfaceId)) throw new Error("Страница окружения недоступна.");
...draft.pages, return props.onUpload(id as EnvironmentSurfaceId, itemId, file);
[surfaceId]: { }} />;
...draft.pages[surfaceId],
...patch,
},
},
};
}
function patchBackground(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentBackground>,
): EnvironmentSettings {
const page = draft.pages[surfaceId];
return patchPage(draft, surfaceId, {
background: {
...page.background,
...patch,
},
});
}
export function EnvironmentSettingsWindow({
open,
settings,
state,
error,
onClose,
onSave,
onUpload,
}: EnvironmentSettingsWindowProps) {
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}, [open, settings]);
const selectedPage = draft.pages[surfaceId];
const selectedBackground = selectedPage.background;
const pageOptions = useMemo(() => [
{
value: "home" as const,
label: draft.pages.home.headerLabel,
description: "Главная страница продукта",
},
...roots.map((root) => ({
value: root.id,
label: draft.pages[root.id].headerLabel,
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
})),
], [draft.pages]);
const quickActionWorkspaces = useMemo(
() => surfaceId === "home"
? workspaces.filter((workspace) => !workspace.internalOnly)
: workspacesForRoot(surfaceId),
[surfaceId],
);
const quickActionOptions = useMemo(() => [
{
value: "none",
label: "Не показывать",
description: "Кнопка скрыта на стартовом экране",
},
...quickActionWorkspaces.map((workspace) => ({
value: workspace.id,
label: workspace.label,
description: workspace.description,
})),
], [quickActionWorkspaces]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(settings),
[draft, settings],
);
const busy = state === "saving" || uploading;
const updatePage = (patch: Partial<EnvironmentPage>) => {
setDraft((current) => patchPage(current, surfaceId, patch));
};
const save = async () => {
const invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim()
|| !page.eyebrow.trim()
|| !page.title.trim()
|| !page.description.trim()
));
if (invalid) {
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
return;
}
if (
selectedPage.primaryWorkspaceId
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
) {
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return;
}
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
(page.background.enabled && !page.background.items.length)
|| page.background.items.some((item) => {
if (!item.url || !item.mediaKind) return true;
if (item.source !== "url") return false;
try {
const parsed = new URL(item.url);
return !["http:", "https:"].includes(parsed.protocol);
} catch {
return true;
}
})
));
if (invalidMedia) {
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
setLocalError(
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
);
return;
}
setLocalError(null);
try {
await onSave({
...draft,
pages: Object.fromEntries(
Object.entries(draft.pages).map(([id, page]) => [id, {
...page,
headerLabel: page.headerLabel.trim(),
eyebrow: page.eyebrow.trim(),
title: page.title.trim(),
description: page.description.trim(),
}]),
) as EnvironmentSettings["pages"],
});
onClose();
} catch (reason) {
setLocalError(reason instanceof Error
? reason.message
: "Не удалось сохранить настройки окружения.");
}
};
return (
<FeatureSettingsWindow
open={open}
title="Настройки Mission Core"
subtitle="Локальное операторское окружение"
identity={{
title: "DC",
subtitle: "Mission Core",
avatarLabel: "DC",
}}
sections={[
{
id: "environment",
label: "Окружение",
group: "MISSION CORE",
icon: "settings",
},
]}
activeSection="environment"
onSectionChange={() => undefined}
onClose={onClose}
footer={(
<WindowFooterActions>
<Button
variant="secondary"
disabled={!dirty || busy}
onClick={() => {
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}}
>
Сбросить изменения
</Button>
<Button
variant="primary"
disabled={!dirty || busy}
onClick={() => void save()}
>
{state === "saving" ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<div className="environment-settings">
<SettingsCard
eyebrow="ОКРУЖЕНИЕ"
title="Основные элементы управления"
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
actions={(
<Switch
checked={selectedBackground.enabled}
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !selectedBackground.items.length) {
setLocalError("Сначала добавьте медиаконтент.");
return;
}
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, { enabled }));
}}
/>
)}
>
<div className="environment-settings__editor">
<div className="environment-settings__surface">
<span>Страница</span>
<Select
label="Выбрать страницу окружения"
value={surfaceId}
options={pageOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => {
setSurfaceId(value);
setLocalError(null);
}}
/>
</div>
<div className="environment-settings__copy">
<TextField
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
value={selectedPage.headerLabel}
maxLength={40}
onChange={(event) => updatePage({
headerLabel: event.currentTarget.value,
})}
/>
<TextField
label="Надзаголовок"
value={selectedPage.eyebrow}
maxLength={80}
onChange={(event) => updatePage({
eyebrow: event.currentTarget.value,
})}
/>
<TextField
label="Основной заголовок"
value={selectedPage.title}
maxLength={120}
onChange={(event) => updatePage({
title: event.currentTarget.value,
})}
/>
<TextAreaField
label="Описание"
value={selectedPage.description}
maxLength={500}
rows={3}
onChange={(event) => updatePage({
description: event.currentTarget.value,
})}
/>
</div>
<div className="environment-settings__quick-actions">
<div>
<span>Кнопка 1</span>
<Select
label="Выбрать первую быструю кнопку"
value={selectedPage.primaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
primaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
<div>
<span>Кнопка 2</span>
<Select
label="Выбрать вторую быструю кнопку"
value={selectedPage.secondaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
secondaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
</div>
<EnvironmentMediaPlaylistEditor
surfaceId={surfaceId}
background={selectedBackground}
disabled={busy}
error={localError ?? error}
onBusyChange={setUploading}
onChange={(background) => {
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, background));
}}
onUpload={onUpload}
/>
</div>
</SettingsCard>
</div>
</FeatureSettingsWindow>
);
} }
@@ -1,5 +1,6 @@
import { useLayoutEffect, useState, type RefObject } from "react"; import type {RefObject} from "react";
import { WorkspaceWindow } from "@nodedc/ui-react"; import {FloatingMediaWindow} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
export {initialObservationWindowRect,shouldCaptureWorkspacePointer} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout"; import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts"; import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
@@ -10,60 +11,6 @@ import type {
RecordedCameraAdmissionState, RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission"; } 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({ export function FloatingObservationWindow({
source, source,
index, index,
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
state: RecordedCameraAdmissionState, state: RecordedCameraAdmissionState,
) => void; ) => void;
}) { }) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); return <FloatingMediaWindow title={source.label} subtitle={source.description}
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
useLayoutEffect(() => { active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
const element = boundsRef.current; onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
if (!element) return; 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>}>
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]);
// Camera media admission must not wait for the viewport observer. A source
// can arrive in the same commit as a workspace transition; mounting it with
// the canonical fallback geometry keeps the browser transport alive until
// the real bounds are measured immediately afterward.
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
const windowRect = rect ?? initialRect;
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 <ObservationMedia
source={source} source={source}
playback={playback} playback={playback}
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
recordedAdmissionKey={recordedAdmissionKey} recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange} onRecordedAdmissionChange={onRecordedAdmissionChange}
/> />
</WorkspaceWindow> </FloatingMediaWindow>;
);
} }
@@ -1,10 +1,9 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react"; import { LandingStage as SharedLandingStage, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts"; import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentPage } from "../core/environment/environmentSettings"; import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel"; import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation"; import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
export interface LandingStageProps { export interface LandingStageProps {
root: RootDefinition | null; root: RootDefinition | null;
@@ -25,40 +24,10 @@ export function LandingStage({
quickActions, quickActions,
onOpenWorkspace, onOpenWorkspace,
}: LandingStageProps) { }: LandingStageProps) {
const { background } = page; return <SharedLandingStage page={page} pageId={root?.id ?? "home"}
const hasMedia = background.enabled && background.items.some( actions={quickActions.map(workspace => ({ id: workspace.id, label: workspace.label, icon: workspace.icon, onSelect: () => onOpenWorkspace(workspace.id) }))}
(item) => item.url && item.mediaKind, status={<>
); <div>
return (
<section
className="landing-stage"
data-root={root?.id ?? "home"}
data-has-media={hasMedia ? "true" : undefined}
>
<EnvironmentBackgroundMedia background={background} />
<div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy">
<span className="section-eyebrow">{page.eyebrow}</span>
<h1>{page.title}</h1>
<p>{page.description}</p>
{quickActions.length ? (
<div className="landing-stage__actions">
{quickActions.map((workspace, index) => (
<Button
key={workspace.id}
variant={index === 0 ? "primary" : "secondary"}
icon={<Icon name={workspace.icon} />}
onClick={() => onOpenWorkspace(workspace.id)}
>
{workspace.label}
</Button>
))}
</div>
) : null}
</div>
<div className="landing-stage__status">
<div>
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span> <span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge> <StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</div> </div>
@@ -67,12 +36,9 @@ export function LandingStage({
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge> <StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
</div> </div>
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p> <p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
</div> </>}
footer={<>
<footer className="landing-stage__footer"> <span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span> <span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
</footer> </>} />;
</section>
);
} }
@@ -28,6 +28,7 @@ export interface CameraLeaseRetryBudget {
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const; const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000; export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000; export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
export const CAMERA_BUFFERING_NOTICE_DELAY_MS = 1_000;
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a // The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
// main-thread stall. Keep one bounded append margin above that complete batch; // main-thread stall. Keep one bounded append margin above that complete batch;
// crossing either limit replaces the MSE epoch instead of dropping fragments. // crossing either limit replaces the MSE epoch instead of dropping fragments.
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
let onBufferError: (() => void) | null = null; let onBufferError: (() => void) | null = null;
let objectUrl = ""; let objectUrl = "";
let retryTimer: number | undefined; let retryTimer: number | undefined;
let bufferingNoticeTimer: number | undefined;
let receivedMedia = false; let receivedMedia = false;
let failed = false; let failed = false;
let playingReported = false; let playingReported = false;
@@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({
); );
const queue: ArrayBuffer[] = []; const queue: ArrayBuffer[] = [];
let queuedBytes = 0; let queuedBytes = 0;
const clearBufferingNotice = () => {
if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer);
bufferingNoticeTimer = undefined;
};
const fail = (copy: string) => { const fail = (copy: string) => {
if (!transportIsCurrent() || failed) return; if (!transportIsCurrent() || failed) return;
startupWatchdog?.clear(); startupWatchdog?.clear();
clearBufferingNotice();
failed = true; failed = true;
transportHealthyRef.current = false; transportHealthyRef.current = false;
recoveryPendingAuthorityRef.current = null; recoveryPendingAuthorityRef.current = null;
@@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({
return; return;
} }
startupWatchdog?.clear(); startupWatchdog?.clear();
clearBufferingNotice();
failed = true; failed = true;
transportHealthyRef.current = false; transportHealthyRef.current = false;
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id); const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
@@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({
const onPlaying = () => { const onPlaying = () => {
if (!transportIsCurrent() || failed) return; if (!transportIsCurrent() || failed) return;
clearBufferingNotice();
startupWatchdog?.markPlaying(); startupWatchdog?.markPlaying();
transportHealthyRef.current = true; transportHealthyRef.current = true;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id); leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
@@ -410,7 +419,21 @@ export function MseFmp4WebSocketPlayer({
} }
}; };
const onWaiting = () => {
if (!transportIsCurrent() || failed || !playingReported) return;
// A short receipt gap must not flicker or restart the acquisition-owned
// stream. A sustained decoder wait must not retain a playing presentation.
if (bufferingNoticeTimer !== undefined) return;
bufferingNoticeTimer = window.setTimeout(() => {
bufferingNoticeTimer = undefined;
if (!transportIsCurrent() || failed || video.readyState >= 3) return;
setStatus("buffering");
setMessage("Ожидание новых кадров.");
}, CAMERA_BUFFERING_NOTICE_DELAY_MS);
};
video.addEventListener("playing", onPlaying); video.addEventListener("playing", onPlaying);
video.addEventListener("waiting", onWaiting);
video.addEventListener("stalled", onWaiting);
const appendNext = () => { const appendNext = () => {
if ( if (
@@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({
disposed = true; disposed = true;
transportHealthyRef.current = false; transportHealthyRef.current = false;
startupWatchdog?.clear(); startupWatchdog?.clear();
clearBufferingNotice();
if (retryTimer !== undefined) window.clearTimeout(retryTimer); if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0; queue.length = 0;
socket?.close(1000, "Browser preview transport replaced or hidden"); socket?.close(1000, "Browser preview transport replaced or hidden");
video.removeEventListener("playing", onPlaying); video.removeEventListener("playing", onPlaying);
video.removeEventListener("waiting", onWaiting);
video.removeEventListener("stalled", onWaiting);
mediaSource.removeEventListener("sourceopen", onSourceOpen); mediaSource.removeEventListener("sourceopen", onSourceOpen);
if (sourceBuffer && onBufferUpdateEnd) { if (sourceBuffer && onBufferUpdateEnd) {
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd); sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
@@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({
{status !== "playing" ? ( {status !== "playing" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite"> <div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} /> <Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong> <strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
<span>{message}</span> <span>{message}</span>
{status === "error" ? ( {status === "error" ? (
<button <button
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null); const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({ const sessions = useObservationSessions({
limit, limit,
scope: "source", scope: "standalone",
replayEnabled: blockedReason === null, replayEnabled: blockedReason === null,
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
@@ -1,9 +1,9 @@
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react"; import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
import { Icon } from "@nodedc/ui-react";
import type { import type {
ObservationSourceAvailability,
ObservationSourceDescriptor, ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts"; } from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer"; import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import { import {
@@ -16,28 +16,6 @@ import type {
} from "../core/observation/recordedSessionAdmission"; } from "../core/observation/recordedSessionAdmission";
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery"; import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
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({ export function ObservationMedia({
source, source,
playback, playback,
@@ -159,105 +137,3 @@ export function ObservationMedia({
</div> </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>
);
}
@@ -1,197 +1 @@
import { Button, Icon, Select } from "@nodedc/ui-react"; export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
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,
showJumpToEnd = true,
playbackRate,
onPlaybackRateChange,
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;
showJumpToEnd?: boolean;
playbackRate?: number;
onPlaybackRateChange?: (rate: number) => 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
className="observation-timeline__transport"
size="compact"
variant="ghost"
disabled={!buffered || !onPlayingChange}
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
icon={<Icon name={playing ? "stop" : "play"} />}
onClick={() => onPlayingChange?.(!playing)}
/>
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
<Select
label="Скорость воспроизведения"
value={String(playbackRate)}
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
variant="inline"
menuWidth="anchor"
onChange={(value) => onPlaybackRateChange(Number(value))}
/>
) : null}
<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>
{showJumpToEnd ? (
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
) : null}
</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")}`;
}
@@ -29,7 +29,9 @@ export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1; export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000; const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000; // A valid local fragment becomes decoder-ready well below one second. Keeping
// a damaged GOP on screen for ten seconds only delays the keyframe recovery.
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 2_500;
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000; const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12; const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36; const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
@@ -72,6 +74,39 @@ export function recordedMediaDecodeStartSequence(
return selected; return selected;
} }
export function nextRecordedMediaRandomAccessSequence(
randomAccessSequences: readonly number[],
failedSequence: number,
): number | null {
if (!Number.isInteger(failedSequence) || failedSequence < 1) return null;
for (const sequence of randomAccessSequences) {
if (!Number.isInteger(sequence) || sequence < 1) return null;
if (sequence > failedSequence) return sequence;
}
return null;
}
export function recordedMediaRecoveryTargetSequence(
requestedSequence: number | null,
failedSequence: number | null,
recoverySequence: number | null,
): number | null {
if (requestedSequence === null) return null;
if (
!Number.isInteger(requestedSequence)
|| requestedSequence < 1
|| failedSequence === null
|| recoverySequence === null
|| !Number.isInteger(failedSequence)
|| !Number.isInteger(recoverySequence)
|| failedSequence < 1
|| recoverySequence <= failedSequence
) return requestedSequence;
return requestedSequence >= failedSequence && requestedSequence < recoverySequence
? recoverySequence
: requestedSequence;
}
export function recordedMediaSegmentAppendOrder( export function recordedMediaSegmentAppendOrder(
appended: ReadonlySet<number>, appended: ReadonlySet<number>,
decodeStartSequence: number, decodeStartSequence: number,
@@ -90,6 +125,32 @@ export function recordedMediaSegmentAppendOrder(
return missing; return missing;
} }
/** Resolve a source-clock timestamp to the first fMP4 fragment covering it. */
export function recordedMediaSegmentSequenceAtTime(
segmentEndTimesSeconds: readonly number[],
epochStartSeconds: number,
currentSeconds: number,
): number | null {
if (
!segmentEndTimesSeconds.length ||
!Number.isFinite(epochStartSeconds) ||
!Number.isFinite(currentSeconds)
) return null;
const localSeconds = Math.max(0, currentSeconds - epochStartSeconds);
let left = 0;
let right = segmentEndTimesSeconds.length - 1;
while (left < right) {
const middle = Math.floor((left + right) / 2);
const endSeconds = segmentEndTimesSeconds[middle];
if (!Number.isFinite(endSeconds) || endSeconds <= 0) return null;
if (endSeconds + 0.001 >= localSeconds) right = middle;
else left = middle + 1;
}
const finalEndSeconds = segmentEndTimesSeconds[left];
if (!Number.isFinite(finalEndSeconds) || finalEndSeconds + 0.001 < localSeconds) return null;
return left + 1;
}
export function recordedMediaCanRollTarget( export function recordedMediaCanRollTarget(
previousSequence: number, previousSequence: number,
nextSequence: number, nextSequence: number,
@@ -121,6 +182,27 @@ function recordedMediaTimeRangesContain(
return false; return false;
} }
export function recordedMediaTimestampStallRecoveryTarget(
currentSeconds: number,
bufferedRanges: readonly (readonly [number, number])[],
skipSeconds = 0.18,
): number | null {
if (!Number.isFinite(currentSeconds) || !Number.isFinite(skipSeconds) || skipSeconds <= 0) {
return null;
}
for (const [startSeconds, endSeconds] of bufferedRanges) {
if (
!Number.isFinite(startSeconds)
|| !Number.isFinite(endSeconds)
|| currentSeconds < startSeconds - 0.05
|| currentSeconds > endSeconds
) continue;
const targetSeconds = Math.min(currentSeconds + skipSeconds, endSeconds - 0.05);
return targetSeconds >= currentSeconds + 0.04 ? targetSeconds : null;
}
return null;
}
export function recordedMediaPresentationState( export function recordedMediaPresentationState(
state: "loading" | "ready" | "error", state: "loading" | "ready" | "error",
readyGeneration: string | null, readyGeneration: string | null,
@@ -151,6 +233,24 @@ export function selectRecordedMediaEpoch(
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null; return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
} }
/**
* Keep admission bounded even when the shared Rerun clock is currently before,
* between, or after camera epochs. Presentation still reports `waiting`; this
* selector only chooses the nearest epoch whose first/last fragment can prove
* that the camera transport is usable without downloading the whole MP4.
*/
export function selectRecordedMediaPreparationEpoch(
epochs: readonly ObservationRecordedMediaEpoch[],
currentSeconds: number,
): ObservationRecordedMediaEpoch | null {
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
const active = selectRecordedMediaEpoch(epochs, currentSeconds);
if (active) return active;
return epochs.find((epoch) => epoch.timelineStartSeconds > currentSeconds)
?? epochs.at(-1)
?? null;
}
export function recordedMediaSeekableCoverage( export function recordedMediaSeekableCoverage(
durationSeconds: number, durationSeconds: number,
seekableEndSeconds: number, seekableEndSeconds: number,
@@ -231,29 +331,14 @@ export async function fetchRecordedMediaArchive(
return { manifest, byteLength: totalBytes }; return { manifest, byteLength: totalBytes };
} }
function videoHasSeekableArchive( function waitForRecordedVideoInitialFrame(
video: HTMLVideoElement, 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, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve(); if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const; const events = ["loadeddata", "canplay", "progress"] as const;
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined; let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
const armStallTimer = () => { const armStallTimer = () => {
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer); if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
@@ -270,7 +355,7 @@ function waitForSeekableArchive(
}; };
const onProgress = () => { const onProgress = () => {
armStallTimer(); armStallTimer();
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return; if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
cleanup(); cleanup();
resolve(); resolve();
}; };
@@ -313,11 +398,7 @@ async function mountRecordedEpochStream(
}; };
video.load(); video.load();
try { try {
await waitForSeekableArchive( await waitForRecordedVideoInitialFrame(video, signal);
video,
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
signal,
);
return cleanup; return cleanup;
} catch (error) { } catch (error) {
cleanup(); cleanup();
@@ -336,6 +417,11 @@ interface RecordedSegmentTarget {
resetAttempts: number; resetAttempts: number;
} }
interface RecordedSegmentRecovery {
readonly failedSequence: number;
readonly recoverySequence: number;
}
interface RecordedSegmentStreamRuntime { interface RecordedSegmentStreamRuntime {
readonly generation: string; readonly generation: string;
readonly mediaSource: MediaSource; readonly mediaSource: MediaSource;
@@ -716,6 +802,9 @@ export function RecordedFmp4Player({
onAdmissionChange, onAdmissionChange,
onPlaybackChange, onPlaybackChange,
onPlayingRejected, onPlayingRejected,
playbackAuthority = "media",
playbackTransport = "segmented",
recoverTimestampStalls = false,
}: { }: {
source: ObservationSourceDescriptor; source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null; playback?: RecordedObservationPlayback | null;
@@ -728,6 +817,9 @@ export function RecordedFmp4Player({
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void; onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void; onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
onPlayingRejected?: () => void; onPlayingRejected?: () => void;
playbackAuthority?: "media" | "host";
playbackTransport?: "segmented" | "epoch-stream";
recoverTimestampStalls?: boolean;
}) { }) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const onAdmissionChangeRef = useRef(onAdmissionChange); const onAdmissionChangeRef = useRef(onAdmissionChange);
@@ -768,12 +860,16 @@ export function RecordedFmp4Player({
); );
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null); const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [readyGeneration, setReadyGeneration] = useState<string | null>(null); const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [bufferRevision, setBufferRevision] = useState(0); const [bufferRevision, setBufferRevision] = useState(0);
const [segmentRecoveryGeneration, setSegmentRecoveryGeneration] = useState(0);
const [segmentRecovery, setSegmentRecovery] = useState<RecordedSegmentRecovery | null>(null);
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null); const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null); const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
const targetRevisionRef = useRef(0); const targetRevisionRef = useRef(0);
const targetReadyAbortRef = useRef<AbortController | null>(null); const targetReadyAbortRef = useRef<AbortController | null>(null);
const lastSegmentRecoveryRef = useRef<string | null>(null);
const playAttemptRevisionRef = useRef(0); const playAttemptRevisionRef = useRef(0);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0; const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const playbackPlayingRef = useRef(Boolean(playback?.playing)); const playbackPlayingRef = useRef(Boolean(playback?.playing));
@@ -781,25 +877,63 @@ export function RecordedFmp4Player({
const playbackRate = playback?.rate && Number.isFinite(playback.rate) const playbackRate = playback?.rate && Number.isFinite(playback.rate)
? Math.min(4, Math.max(0.25, playback.rate)) ? Math.min(4, Math.max(0.25, playback.rate))
: 1; : 1;
const epoch = useMemo( const playbackRateRef = useRef(playbackRate);
playbackRateRef.current = playbackRate;
const presentationEpoch = useMemo(
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds), () => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds], [archive?.manifest.epochs, currentSeconds],
); );
const epoch = useMemo(
() => selectRecordedMediaPreparationEpoch(
archive?.manifest.epochs ?? [],
currentSeconds,
),
[archive?.manifest.epochs, currentSeconds],
);
const segmentClockSeconds = epoch
? Math.min(
Math.max(currentSeconds, epoch.timelineStartSeconds),
epoch.timelineEndSeconds,
)
: currentSeconds;
const effectiveSegmentCount = segmentCount ?? epoch?.segmentCount ?? null;
const requestedSegmentSequence = segmentSequence ?? (epoch
? recordedMediaSegmentSequenceAtTime(
epoch.segmentEndTimesSeconds,
epoch.timelineStartSeconds,
segmentClockSeconds,
)
: null);
const effectiveSegmentSequence = recordedMediaRecoveryTargetSequence(
requestedSegmentSequence,
segmentRecovery?.failedSequence ?? null,
segmentRecovery?.recoverySequence ?? null,
);
const holdingForSegmentRecovery = Boolean(
segmentRecovery
&& requestedSegmentSequence !== null
&& effectiveSegmentSequence !== requestedSegmentSequence,
);
const segmented = Boolean( const segmented = Boolean(
segmentCount !== null playbackTransport === "segmented"
&& Number.isInteger(segmentCount) && requestedSegmentSequence !== null
&& segmentCount >= 1 && Number.isInteger(requestedSegmentSequence)
&& requestedSegmentSequence >= 1
&&
effectiveSegmentCount !== null
&& Number.isInteger(effectiveSegmentCount)
&& effectiveSegmentCount >= 1
&& typeof MediaSource !== "undefined" && typeof MediaSource !== "undefined"
&& epoch && epoch
&& epoch.segmentCount === segmentCount && epoch.segmentCount === effectiveSegmentCount
&& epoch.randomAccessSequences.length > 0 && epoch.randomAccessSequences.length > 0
&& epoch.segmentEndTimesSeconds.length === segmentCount && epoch.segmentEndTimesSeconds.length === effectiveSegmentCount
&& MediaSource.isTypeSupported(epoch.mediaType), && MediaSource.isTypeSupported(epoch.mediaType),
); );
const directPlaybackSeconds = segmented ? null : currentSeconds; const directPlaybackSeconds = segmented ? null : currentSeconds;
const waitingForEpoch = Boolean(archive && !epoch); const waitingForEpoch = Boolean(archive && !presentationEpoch);
const selectedGeneration = contract && epoch const selectedGeneration = contract && presentationEpoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}` ? `${contract.manifestGenerationSha256}:${presentationEpoch.ordinal}:${presentationEpoch.timelineStartSeconds}:${presentationEpoch.timelineEndSeconds}`
: null; : null;
const visualState = recordedMediaPresentationState( const visualState = recordedMediaPresentationState(
state, state,
@@ -813,6 +947,7 @@ export function RecordedFmp4Player({
if (!contract) { if (!contract) {
setArchive(null); setArchive(null);
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Некорректный descriptor записанной камеры.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -825,6 +960,9 @@ export function RecordedFmp4Player({
const abort = new AbortController(); const abort = new AbortController();
setArchive(null); setArchive(null);
setReadyGeneration(null); setReadyGeneration(null);
setSegmentRecovery(null);
lastSegmentRecoveryRef.current = null;
setErrorMessage(null);
setState("loading"); setState("loading");
reportAdmission({ reportAdmission({
phase: "loading", phase: "loading",
@@ -842,6 +980,7 @@ export function RecordedFmp4Player({
} }
setArchive(null); setArchive(null);
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Архив записанной камеры не прошёл проверку.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -853,43 +992,14 @@ export function RecordedFmp4Player({
}, [admissionKey, contract, prepare]); }, [admissionKey, contract, prepare]);
useEffect(() => { useEffect(() => {
if (!archive || !contract || !prepare || segmented) return; if (!segmentRecovery || requestedSegmentSequence === null) return;
const abort = new AbortController(); if (
let disposed = false; requestedSegmentSequence >= segmentRecovery.failedSequence
void (async () => { && requestedSegmentSequence < segmentRecovery.recoverySequence
for (const candidate of archive.manifest.epochs) { ) return;
const probe = document.createElement("video"); lastSegmentRecoveryRef.current = null;
probe.muted = true; setSegmentRecovery(null);
probe.playsInline = true; }, [requestedSegmentSequence, segmentRecovery]);
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, segmented]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -910,6 +1020,7 @@ export function RecordedFmp4Player({
setReadyGeneration(null); setReadyGeneration(null);
setSegmentedRuntimeGeneration(null); setSegmentedRuntimeGeneration(null);
setErrorMessage(null);
setState("loading"); setState("loading");
video.pause(); video.pause();
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal); const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
@@ -957,6 +1068,7 @@ export function RecordedFmp4Player({
return; return;
} }
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Покадровый буфер записанной камеры не открылся.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -982,7 +1094,14 @@ export function RecordedFmp4Player({
} }
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
}; };
}, [archive, contract, epoch, segmentCount, segmented]); }, [
archive,
contract,
effectiveSegmentCount,
epoch,
segmented,
segmentRecoveryGeneration,
]);
useEffect(() => { useEffect(() => {
const runtime = segmentedRuntimeRef.current; const runtime = segmentedRuntimeRef.current;
@@ -993,18 +1112,19 @@ export function RecordedFmp4Player({
|| !archive || !archive
|| !segmented || !segmented
|| segmentedRuntimeGeneration !== runtime.generation || segmentedRuntimeGeneration !== runtime.generation
|| segmentSequence === null || effectiveSegmentSequence === null
|| !Number.isInteger(segmentSequence) || !Number.isInteger(effectiveSegmentSequence)
|| segmentSequence < 1 || effectiveSegmentSequence < 1
|| segmentSequence > runtime.segmentCount || effectiveSegmentSequence > runtime.segmentCount
) return; ) return;
const archiveByteLength = archive.byteLength; const archiveByteLength = archive.byteLength;
const decodeStart = recordedMediaDecodeStartSequence( const decodeStart = recordedMediaDecodeStartSequence(
runtime.randomAccessSequences, runtime.randomAccessSequences,
segmentSequence, effectiveSegmentSequence,
); );
if (decodeStart === null) { if (decodeStart === null) {
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Для кадра записанной камеры нет random-access фрагмента.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1015,10 +1135,11 @@ export function RecordedFmp4Player({
} }
const targetSeconds = recordedSegmentStartSeconds( const targetSeconds = recordedSegmentStartSeconds(
runtime.segmentEndTimesSeconds, runtime.segmentEndTimesSeconds,
segmentSequence, effectiveSegmentSequence,
); );
if (targetSeconds === null) { if (targetSeconds === null) {
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Для кадра записанной камеры нет точной media timestamp.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1030,15 +1151,15 @@ export function RecordedFmp4Player({
const previousTarget = runtime.target; const previousTarget = runtime.target;
const readyEnd = Math.min( const readyEnd = Math.min(
runtime.segmentCount, runtime.segmentCount,
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS, effectiveSegmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
); );
const desiredEnd = Math.min( const desiredEnd = Math.min(
runtime.segmentCount, runtime.segmentCount,
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD, effectiveSegmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
); );
const candidateTarget: RecordedSegmentTarget = { const candidateTarget: RecordedSegmentTarget = {
revision: previousTarget?.revision ?? 0, revision: previousTarget?.revision ?? 0,
sequence: segmentSequence, sequence: effectiveSegmentSequence,
decodeStart, decodeStart,
readyEnd, readyEnd,
desiredEnd, desiredEnd,
@@ -1046,6 +1167,24 @@ export function RecordedFmp4Player({
forceReset: false, forceReset: false,
resetAttempts: 0, resetAttempts: 0,
}; };
const recoverFromSegmentFailure = (failedSequence: number): boolean => {
const recoverySequence = nextRecordedMediaRandomAccessSequence(
runtime.randomAccessSequences,
failedSequence,
);
if (recoverySequence === null) return false;
const recoveryToken = `${runtime.generation}:${failedSequence}:${recoverySequence}`;
if (lastSegmentRecoveryRef.current === recoveryToken) return true;
lastSegmentRecoveryRef.current = recoveryToken;
setReadyGeneration(null);
setSegmentRecovery({ failedSequence, recoverySequence });
setErrorMessage(
`Восстанавливаем камеру с ключевого кадра ${recoverySequence}.`,
);
setState("loading");
setSegmentRecoveryGeneration((generation) => generation + 1);
return true;
};
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget( const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
previousTarget.sequence, previousTarget.sequence,
candidateTarget.sequence, candidateTarget.sequence,
@@ -1059,21 +1198,62 @@ export function RecordedFmp4Player({
|| runtime.abort.signal.aborted || runtime.abort.signal.aborted
|| (error instanceof DOMException && error.name === "AbortError") || (error instanceof DOMException && error.name === "AbortError")
) return; ) return;
const failedSequence = runtime.target?.sequence ?? effectiveSegmentSequence;
if (failedSequence !== null && recoverFromSegmentFailure(failedSequence)) return;
const detail = error instanceof Error && error.message
? `: ${error.message}`
: ".";
const message = `Покадровый фрагмент записанной камеры недоступен${detail}`;
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage(message);
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
byteLength: archiveByteLength, byteLength: archiveByteLength,
message: "Покадровый фрагмент записанной камеры недоступен.", message,
}); });
}; };
const resumePlaybackIfRequested = async (revision: number) => {
if (
runtime.disposed
|| segmentedRuntimeRef.current !== runtime
|| runtime.target?.revision !== revision
|| !playbackPlayingRef.current
) return;
video.playbackRate = playbackRateRef.current;
try {
await video.play();
} catch {
if (
runtime.disposed
|| segmentedRuntimeRef.current !== runtime
|| runtime.target?.revision !== revision
) return;
// Canonical recorded LABs run one host-owned clock for camera and
// spatial evidence. A transient MSE play() rejection (commonly a
// pause/reset race while the next fragment is admitted) must stay a
// decoder concern: the rolling target will retry and catch up.
if (playbackAuthority === "host") return;
onPlayingRejectedRef.current?.();
setReadyGeneration(null);
setErrorMessage("Запуск записанной камеры отклонён браузером.");
setState("error");
reportAdmission({
phase: "error",
byteLength: archiveByteLength,
message: "Запуск записанной камеры отклонён браузером.",
});
}
};
if (rollingTarget && previousTarget) { if (rollingTarget && previousTarget) {
runtime.target = { runtime.target = {
...candidateTarget, ...candidateTarget,
revision: previousTarget.revision, revision: previousTarget.revision,
}; };
runtime.onTargetBuffered = null; runtime.onTargetBuffered = null;
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError); void pumpRecordedSegmentWindow(runtime)
.then(() => resumePlaybackIfRequested(previousTarget.revision))
.catch(reportPumpError);
return; return;
} }
@@ -1128,18 +1308,22 @@ export function RecordedFmp4Player({
setBufferRevision((revision) => revision + 1); setBufferRevision((revision) => revision + 1);
runtime.hasPresentedFrame = true; runtime.hasPresentedFrame = true;
setReadyGeneration(runtime.generation); setReadyGeneration(runtime.generation);
setErrorMessage(null);
setState("ready"); setState("ready");
reportAdmission({ reportAdmission({
phase: "ready", phase: "ready",
byteLength: archiveByteLength, byteLength: archiveByteLength,
message: null, message: null,
}); });
await resumePlaybackIfRequested(bufferedTarget.revision);
} catch (error) { } catch (error) {
if ( if (
targetReadyAbort.signal.aborted targetReadyAbort.signal.aborted
|| (error instanceof DOMException && error.name === "AbortError") || (error instanceof DOMException && error.name === "AbortError")
) return; ) return;
if (recoverFromSegmentFailure(bufferedTarget.sequence)) return;
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Кадр записанной камеры не стал decoder-ready.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1160,7 +1344,13 @@ export function RecordedFmp4Player({
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null; if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null; if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
}; };
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]); }, [
archive?.byteLength,
effectiveSegmentSequence,
playbackAuthority,
segmented,
segmentedRuntimeGeneration,
]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -1170,6 +1360,7 @@ export function RecordedFmp4Player({
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}` ? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
: null; : null;
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage(null);
setState("loading"); setState("loading");
const abort = new AbortController(); const abort = new AbortController();
let disposed = false; let disposed = false;
@@ -1185,7 +1376,13 @@ export function RecordedFmp4Player({
} }
setBufferRevision((revision) => revision + 1); setBufferRevision((revision) => revision + 1);
setReadyGeneration(generation); setReadyGeneration(generation);
setErrorMessage(null);
setState("ready"); setState("ready");
reportAdmission({
phase: "ready",
byteLength: archive?.byteLength ?? null,
message: null,
});
} catch (error) { } catch (error) {
if ( if (
disposed || disposed ||
@@ -1195,11 +1392,12 @@ export function RecordedFmp4Player({
return; return;
} }
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Записанная камера не открыла первый декодируемый кадр.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
byteLength: archive?.byteLength ?? null, byteLength: archive?.byteLength ?? null,
message: "Записанная камера не стала seekable.", message: "Записанная камера не открыла первый декодируемый кадр.",
}); });
} }
}; };
@@ -1234,6 +1432,7 @@ export function RecordedFmp4Player({
video.currentTime = target; video.currentTime = target;
} catch { } catch {
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Seek записанной камеры завершился ошибкой.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1244,11 +1443,13 @@ export function RecordedFmp4Player({
} }
} }
video.playbackRate = playbackRate; video.playbackRate = playbackRate;
if (playback?.playing) { if (playback?.playing && !holdingForSegmentRecovery) {
void video.play().catch(() => { void video.play().catch(() => {
if (playAttemptRevisionRef.current !== playAttemptRevision) return; if (playAttemptRevisionRef.current !== playAttemptRevision) return;
if (playbackAuthority === "host") return;
onPlayingRejectedRef.current?.(); onPlayingRejectedRef.current?.();
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Запуск записанной камеры отклонён браузером.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1264,12 +1465,95 @@ export function RecordedFmp4Player({
bufferRevision, bufferRevision,
directPlaybackSeconds, directPlaybackSeconds,
epoch, epoch,
holdingForSegmentRecovery,
playback?.playing, playback?.playing,
playbackAuthority,
playbackRate, playbackRate,
segmented, segmented,
visualState, visualState,
]); ]);
useEffect(() => {
const video = videoRef.current;
if (!video || !recoverTimestampStalls || !playback?.playing || visualState !== "ready") {
return;
}
let lastSeconds = video.currentTime;
let lastProgressAtMs = performance.now();
const interval = window.setInterval(() => {
if (
!playbackPlayingRef.current
|| video.paused
|| video.ended
|| video.seeking
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
) {
lastSeconds = video.currentTime;
lastProgressAtMs = performance.now();
return;
}
const nowMs = performance.now();
if (video.currentTime >= lastSeconds + 0.02) {
lastSeconds = video.currentTime;
lastProgressAtMs = nowMs;
return;
}
if (nowMs - lastProgressAtMs < 1_250) return;
const bufferedRanges = Array.from(
{ length: video.buffered.length },
(_, index) => [video.buffered.start(index), video.buffered.end(index)] as const,
);
const targetSeconds = recordedMediaTimestampStallRecoveryTarget(
video.currentTime,
bufferedRanges,
);
lastProgressAtMs = nowMs;
if (targetSeconds === null) return;
// Field recordings can contain non-monotonic or corrupt H.264 timestamps.
// If decoded time is frozen despite proven buffered media ahead, skip only
// the broken timestamp interval and return authority to the media clock.
video.currentTime = targetSeconds;
lastSeconds = targetSeconds;
}, 250);
return () => window.clearInterval(interval);
}, [playback?.playing, recoverTimestampStalls, visualState]);
useEffect(() => {
const video = videoRef.current;
if (!video || !segmented || !playback?.playing || visualState !== "ready") return;
let cancelled = false;
const resumeIfDecoderReady = () => {
if (cancelled || !playbackPlayingRef.current || !video.paused) return;
const runtime = segmentedRuntimeRef.current;
const target = runtime?.target;
if (
!runtime
|| !target
|| runtime.disposed
|| video.seeking
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|| Math.abs(video.currentTime - target.targetSeconds) > 0.25
|| !recordedMediaTimeRangesContain(video.buffered, target.targetSeconds)
) return;
playAttemptRevisionRef.current += 1;
const playAttemptRevision = playAttemptRevisionRef.current;
video.playbackRate = playbackRateRef.current;
void video.play().catch(() => {
if (cancelled || playAttemptRevisionRef.current !== playAttemptRevision) return;
if (playbackAuthority === "host") return;
onPlayingRejectedRef.current?.();
});
};
const queueResume = () => window.queueMicrotask(resumeIfDecoderReady);
const events = ["pause", "canplay", "seeked"] as const;
for (const event of events) video.addEventListener(event, queueResume);
resumeIfDecoderReady();
return () => {
cancelled = true;
for (const event of events) video.removeEventListener(event, queueResume);
};
}, [bufferRevision, playback?.playing, playbackAuthority, segmented, visualState]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return; if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
@@ -1327,7 +1611,9 @@ export function RecordedFmp4Player({
{visualState === "waiting" {visualState === "waiting"
? "Камера на этой позиции ещё не записывалась" ? "Камера на этой позиции ещё не записывалась"
: visualState === "error" : visualState === "error"
? "Записанное видео недоступно" ? errorMessage ?? "Записанное видео недоступно"
: errorMessage
? errorMessage
: archive : archive
? "Проверяем seek и codec записанного видео…" ? "Проверяем seek и codec записанного видео…"
: "Читаем manifest записанного видео…"} : "Читаем manifest записанного видео…"}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveAICompositionReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import type { AICompositionRun } from "../../core/observatory/aiComposition";
function resolveComposition(
runId: string,
launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal },
) {
return resolveAICompositionReplay(runId, launch, options);
}
export function AICompositionReplay({ run }: { run: AICompositionRun }) {
const semantics = run.moduleIds.includes("ddrnet") || run.moduleIds.includes("eomt");
const detections = run.moduleIds.includes("rf-detr")
|| run.moduleIds.includes("object-distance");
const costmap = run.moduleIds.includes("tgs");
return (
<CanonicalResultRerunReplay
key={run.runId}
resultId={run.runId}
sessionId={run.sourceSessionId}
resolveReplay={resolveComposition}
semantics={semantics}
detections={detections}
costmap={costmap}
moduleIds={run.moduleIds}
viewerLayers={run.presentation.viewerLayers}
label={run.configurationLabel}
/>
);
}
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { SegmentedControl, SplitPane, type SplitPaneOrientation } from "@nodedc/ui-react";
import { LaboratoryEvidenceViewer } from "./LaboratoryEvidenceViewer";
export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
"missioncore.canonical-recorded-lab-replay/v1";
export interface CanonicalRecordedLabMode<T extends string> {
value: T;
label: string;
disabled?: boolean;
}
/**
* The interaction contract from the accepted recorded-LAB instrument.
*
* Keeping pane selection, collapse semantics, responsive split orientation,
* expansion and splitter state here prevents individual experiments from
* quietly growing their own replay behaviour. Experiments provide evidence
* layers; they do not reimplement the laboratory shell.
*/
export function useCanonicalRecordedLabReplayState<
TMedia extends string,
TSpatial extends string,
>({
initialMediaMode,
initialSpatialMode,
}: {
initialMediaMode: TMedia;
initialSpatialMode: TSpatial | null;
}) {
const [mediaMode, setMediaMode] = useState<TMedia | null>(initialMediaMode);
const [spatialMode, setSpatialMode] = useState<TSpatial | null>(initialSpatialMode);
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
? "horizontal"
: "vertical"
));
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const query = window.matchMedia("(max-width: 900px)");
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
const onMediaModeChange = useCallback((next: TMedia | "none") => {
if (next === "none") return;
setMediaMode((current) => current === next ? null : next);
}, []);
const onSpatialModeChange = useCallback((next: TSpatial | "none") => {
if (next === "none") return;
setSpatialMode((current) => current === next ? null : next);
}, []);
return {
mediaMode,
spatialMode,
splitView: mediaMode !== null && spatialMode !== null,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange: setSplitPrimarySize,
onExpandedChange: setExpanded,
};
}
export function CanonicalRecordedLabReplay<
TMedia extends string,
TSpatial extends string,
>({
label,
mediaMode,
mediaModes,
spatialMode,
spatialModes,
expanded,
splitPrimarySize,
splitOrientation,
mediaAriaLabel,
spatialAriaLabel,
mediaLayerControls,
spatialLayerControls,
spatialLeadingControl,
spatialTrailingControl,
mediaModeControlsVisible = true,
paneToolbarsAlwaysVisible = false,
mediaMultiLayer = false,
mediaContent,
spatialContent,
unifiedContent,
emptyMessage,
deckOverlays,
actions,
overlay,
transport,
trailingActions,
onMediaModeChange,
onSpatialModeChange,
onExpandedChange,
onSplitPrimarySizeChange,
}: {
label: string;
mediaMode: TMedia;
mediaModes: readonly CanonicalRecordedLabMode<TMedia>[];
spatialMode: TSpatial;
spatialModes: readonly CanonicalRecordedLabMode<TSpatial>[];
expanded: boolean;
splitPrimarySize: number;
splitOrientation: SplitPaneOrientation;
mediaAriaLabel: string;
spatialAriaLabel: string;
mediaLayerControls?: ReactNode;
spatialLayerControls?: ReactNode;
spatialLeadingControl?: ReactNode;
spatialTrailingControl?: ReactNode;
mediaModeControlsVisible?: boolean;
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
paneToolbarsAlwaysVisible?: boolean;
mediaMultiLayer?: boolean;
mediaContent?: ReactNode;
spatialContent?: ReactNode;
/** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
unifiedContent?: ReactNode;
emptyMessage: string;
deckOverlays?: ReactNode;
actions?: ReactNode;
overlay?: ReactNode;
transport?: ReactNode;
trailingActions?: ReactNode;
onMediaModeChange: (mode: TMedia) => void;
onSpatialModeChange: (mode: TSpatial) => void;
onExpandedChange: (expanded: boolean) => void;
onSplitPrimarySizeChange: (size: number) => void;
}) {
const splitView = mediaMode !== "none" && spatialMode !== "none";
const mediaModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
<SegmentedControl
value={mediaMode}
items={[...mediaModes]}
label="Видео и камера"
size="dense"
onChange={onMediaModeChange}
/>
</div>
);
const spatialModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
<SegmentedControl
value={spatialMode}
items={[...spatialModes]}
label="3D и план"
size="dense"
onChange={onSpatialModeChange}
/>
</div>
);
const mediaPane = (
<section
className="m4-replay-threat-visual__pane"
data-pane="media"
aria-label={mediaAriaLabel}
hidden={mediaMode === "none"}
>
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControlsVisible ? mediaModeControls : null}
</div>
) : null}
{mediaContent}
</section>
);
const spatialPane = spatialMode !== "none" ? (
<section
className="m4-replay-threat-visual__pane"
data-pane="spatial"
aria-label={spatialAriaLabel}
>
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
>
{spatialLeadingControl}
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
{spatialTrailingControl}
</div>
</div>
) : null}
{spatialContent}
</section>
) : null;
return (
<div
className="l3-visual-audit m4-replay-threat-visual"
data-contract={CANONICAL_RECORDED_LAB_REPLAY_CONTRACT}
>
<LaboratoryEvidenceViewer
label={label}
className="m4-replay-threat-evidence-viewer"
mode={mediaMode}
modes={[...mediaModes]}
secondaryMode={{
value: spatialMode,
modes: [...spatialModes],
label: "3D и план",
onChange: onSpatialModeChange,
}}
expanded={expanded}
onModeChange={onMediaModeChange}
onExpandedChange={onExpandedChange}
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
actions={actions}
overlay={overlay}
transport={transport}
trailingActions={trailingActions}
>
<div
className="m4-replay-threat-visual__deck"
data-split={splitView ? "true" : undefined}
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
data-native-rerun={unifiedContent ? "true" : undefined}
>
{unifiedContent ? (
<div className="m4-replay-threat-visual__unified-content">
{unifiedContent}
</div>
) : null}
<SplitPane
primary={mediaPane}
secondary={spatialPane ?? <div />}
primarySize={splitView ? splitPrimarySize : mediaMode !== "none" ? 100 : 0}
onPrimarySizeChange={onSplitPrimarySizeChange}
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView}
separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
<div className="l3-visual-audit__state" role="status">
{emptyMessage}
</div>
) : null}
{deckOverlays}
</div>
</LaboratoryEvidenceViewer>
</div>
);
}
@@ -0,0 +1,525 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
Button,
Checker,
ControlRow,
Icon,
IconButton,
Inspector,
InspectorSelectField,
RangeControl,
ToastStack,
Window,
type ToastItem,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunViewportStatus,
} from "../RerunViewport";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../laboratory/CanonicalRecordedLabReplay";
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
import {
fetchLabViewProfile,
saveLabViewProfile,
} from "../../core/observatory/labViewProfile";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "../../sceneSettings";
type MediaMode = "camera";
type SpatialMode = "3d" | "plan";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const LAB_SCENE_SETTINGS_STORAGE_PREFIX = "missioncore.observatory.lab-scene-settings.v1:";
const LAB_VIEW_PROFILE_ERROR_TOAST = "observatory-lab-view-profile-error";
const COLOR_OPTIONS: readonly { value: PointColorMode; label: string }[] = [
{ value: "intensity", label: "Интенсивность" },
{ value: "height", label: "Высота Z" },
{ value: "distance", label: "Дистанция" },
{ value: "rgb", label: "RGB" },
{ value: "class", label: "Класс" },
];
const PALETTE_OPTIONS: readonly { value: PointPalette; label: string }[] = [
{ value: "turbo", label: "Turbo" },
{ value: "viridis", label: "Viridis" },
{ value: "plasma", label: "Plasma" },
{ value: "grayscale", label: "Серый" },
];
const LAB_SCENE_DEFAULTS: SceneSettings = {
...defaultSceneSettings,
pointSize: 3.8,
accumulationSeconds: 5,
};
export function normalizeLabSceneSettings(settings: SceneSettings): SceneSettings {
const pointSize = Number.isFinite(settings.pointSize) ? settings.pointSize : LAB_SCENE_DEFAULTS.pointSize;
const accumulationSeconds = Number.isFinite(settings.accumulationSeconds)
? settings.accumulationSeconds : LAB_SCENE_DEFAULTS.accumulationSeconds;
return {
...settings,
pointSize: Math.round(Math.max(0.1, pointSize) * 10) / 10,
accumulationSeconds: Math.round(Math.max(0, accumulationSeconds)),
};
}
function loadLabSceneSettings(resultId: string): SceneSettings {
if (typeof window === "undefined") return LAB_SCENE_DEFAULTS;
try {
const raw = window.localStorage.getItem(`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`);
if (raw === null) return LAB_SCENE_DEFAULTS;
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || Array.isArray(value)) return LAB_SCENE_DEFAULTS;
const row = value as Record<string, unknown>;
const pointSize = row.pointSize;
const accumulationSeconds = row.accumulationSeconds;
const colorMode = row.colorMode;
const palette = row.palette;
const showGrid = row.showGrid;
const showLabels = row.showLabels;
const showCameraFrustums = row.showCameraFrustums;
if (
typeof pointSize !== "number" || !Number.isFinite(pointSize) || pointSize < 0.1
|| typeof accumulationSeconds !== "number" || !Number.isFinite(accumulationSeconds)
|| accumulationSeconds < 0
|| !COLOR_OPTIONS.some((option) => option.value === colorMode)
|| !PALETTE_OPTIONS.some((option) => option.value === palette)
|| typeof showGrid !== "boolean"
|| typeof showLabels !== "boolean"
|| typeof showCameraFrustums !== "boolean"
) return LAB_SCENE_DEFAULTS;
return {
...LAB_SCENE_DEFAULTS,
pointSize,
accumulationSeconds,
colorMode: colorMode as PointColorMode,
palette: palette as PointPalette,
showGrid,
showLabels,
showCameraFrustums,
};
} catch {
return LAB_SCENE_DEFAULTS;
}
}
export function CanonicalResultRerunReplay({
resultId,
sessionId,
initialPlaybackStartSeconds,
resolveReplay,
semantics = false,
detections = false,
costmap = false,
moduleIds = [],
viewerLayers = [],
label = "Сохранённый результат",
}: {
resultId: string;
sessionId: string;
initialPlaybackStartSeconds?: number;
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
semantics?: boolean;
detections?: boolean;
costmap?: boolean;
portable?: boolean;
moduleIds?: readonly string[];
viewerLayers?: readonly AIViewerLayer[];
label?: string;
}) {
const {
mediaMode,
spatialMode,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "camera",
initialSpatialMode: "3d",
});
const layerIds = useMemo(
() => new Set(viewerLayers.map((layer) => layer.layerId)),
[viewerLayers],
);
const hasProjectedLayers = viewerLayers.length > 0;
const hasDDRNet = layerIds.has("camera.ddrnet")
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
const hasEoMT = layerIds.has("camera.eomt")
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
const legacyUnclassified = !hasProjectedLayers
&& moduleIds.length === 0
&& !semantics
&& !detections
&& !costmap;
const hasCameraPane = layerIds.has("camera.source")
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
const hasSourcePoints = layerIds.has("spatial.source-points")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasLocalSlam = layerIds.has("spatial.local-slam")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
const splitView = hasCameraPane && hasSpatialPane;
const [showCamera, setShowCamera] = useState(true);
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
const [showEoMT, setShowEoMT] = useState(hasEoMT);
const [showDetections, setShowDetections] = useState(hasDetections);
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
const [showTgs, setShowTgs] = useState(hasTgs);
const [followTarget, setFollowTarget] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0);
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const sceneDraftRef = useRef(sceneDraft);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
useEffect(() => {
const launchSha256 = launch?.replay.sha256 ?? null;
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
useEffect(() => {
if (!splitView) return;
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
return () => window.clearTimeout(timer);
}, [splitPrimarySize, splitView]);
useEffect(() => {
try {
window.localStorage.setItem(
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
JSON.stringify({
pointSize: sceneDraft.pointSize,
accumulationSeconds: sceneDraft.accumulationSeconds,
colorMode: sceneDraft.colorMode,
palette: sceneDraft.palette,
showGrid: sceneDraft.showGrid,
showLabels: sceneDraft.showLabels,
showCameraFrustums: sceneDraft.showCameraFrustums,
}),
);
} catch {
// The visual remains usable when the browser refuses local persistence.
}
}, [resultId, sceneDraft]);
useEffect(() => {
const controller = new AbortController();
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
if (!controller.signal.aborted && settings) {
sceneDraftRef.current = settings;
setSceneDraft(settings);
}
}).catch(() => {
// The per-browser copy remains a usable fallback while the local service recovers.
});
return () => controller.abort();
}, [resultId]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
})).then((value) => {
if (!controller.signal.aborted) setLaunch(value);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) {
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
}
});
return () => controller.abort();
}, [resultId, sessionId, resolveReplay]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady ? "ready" : "loading";
const sceneSettings = useMemo<SceneSettings>(() => ({
...sceneDraft,
projection: spatialMode === "plan" ? "2d" : "3d",
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
showTrajectory: spatialMode !== null && showLocalSlam,
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
const semanticVisible = showDDRNet || showEoMT;
const profile = launch ? laboratoryResultRerunProfile({
resultId,
sourceUrl: launch.replay.sourceUrl,
artifact: {
sourceUrl: launch.replay.sourceUrl,
viewerSourceUrl: launch.replay.viewerSourceUrl,
byteLength: launch.replay.byteLength,
sha256: launch.replay.sha256,
},
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
autoplayWhenReady: false,
presentationGate: "ready",
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
initialPlaybackStartSeconds: initialPlaybackStartSeconds
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
+ (hasTgs ? 0.000001 : 0)),
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: followTarget,
semanticLayer: showEoMT ? "city" : "vegetation",
unifiedPerception: splitView,
unifiedCameraShare: blueprintSplitPrimarySize / 100,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: true,
cameraImage: showCamera,
detections2d: hasDetections && showDetections,
segmentation: semantics && semanticVisible,
cuboids3d: false,
costmap: hasTgs && showTgs,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
}) : null;
const toggle = (caption: string, active: boolean, onClick: () => void) => (
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
aria-pressed={active} onClick={onClick}>
{caption}
</Button>
);
const openSlamSettings = useCallback(() => {
if (expanded) onExpandedChange(false);
setSettingsOpen(true);
}, [expanded, onExpandedChange]);
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
const next = { ...sceneDraftRef.current, ...patch };
sceneDraftRef.current = next;
setSceneDraft(next);
}, []);
const closeSlamSettings = useCallback(() => {
const next = normalizeLabSceneSettings(sceneDraftRef.current);
sceneDraftRef.current = next;
setSceneDraft(next);
setSettingsOpen(false);
void saveLabViewProfile(resultId, next).then((settings) => {
sceneDraftRef.current = settings;
setSceneDraft(settings);
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
}).catch(() => {
setToasts((current) => [
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
{
id: LAB_VIEW_PROFILE_ERROR_TOAST,
tone: "error",
title: "Настройки LAB не сохранились",
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
},
]);
});
}, [resultId]);
const mediaLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
{hasDDRNet || hasFallbackSemantics
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
{hasDetections
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
</div>
);
const spatialLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
{hasSourcePoints
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
{hasLocalSlam ? (
<div className="m4-replay-threat-visual__layer-with-settings">
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
<Icon name="settings" size={14} />
</IconButton>
</div>
) : null}
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
</div>
);
const followTargetControl = toggle(
"СЛЕДОВАТЬ",
followTarget,
() => setFollowTarget((value) => !value),
);
const resetSpatialView = (
<IconButton label="Сбросить положение видов"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
<Icon name="refresh" size={16} />
</IconButton>
);
const transport = presentationReady && playback && playbackController ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
active
sourceCount={3}
mode="recorded"
seekable
synchronization="shared-clock"
rangeNs={playback.rangeNs}
currentNs={playback.currentNs}
playing={playback.playing}
onSeek={playbackController.seek}
onPlayingChange={playbackController.setPlaying}
showJumpToEnd={false}
/>
) : undefined;
return (
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}>
<CanonicalRecordedLabReplay
label={label}
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitView ? "vertical" : splitOrientation}
mediaAriaLabel="Камера и рассчитанные слои"
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialTrailingControl={followTargetControl}
mediaModeControlsVisible={false}
paneToolbarsAlwaysVisible
mediaMultiLayer
actions={resetSpatialView}
unifiedContent={profile ? (
<div className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}>
<RerunViewport profile={profile} sceneSettings={sceneSettings}
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController} />
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
) : <div aria-hidden="true" />}
emptyMessage="Слои результата недоступны."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
placement="end" size="sm" onClose={closeSlamSettings}>
<Inspector defaultOpen={["points"]} sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: <>
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
exactValueBounds={{ min: 0.1 }}
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
options={[...COLOR_OPTIONS]}
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
options={[...PALETTE_OPTIONS]}
onChange={(palette) => patchSceneDraft({ palette })} />
</>,
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: <RangeControl label="Накопление" min={0} max={500} step={1}
exactValueBounds={{ min: 0 }}
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
},
{
id: "environment",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: <>
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
</>,
},
]} />
</Window>
<ToastStack items={toasts}
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
</div>
);
}
@@ -0,0 +1,12 @@
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
export function CanonicalVegetationRerunReplay({ resultId, review }: {
resultId: string;
review: VegetationFullRouteReview;
}) {
return <CanonicalResultRerunReplay resultId={resultId} sessionId={review.sessionId}
initialPlaybackStartSeconds={review.timelineStartSeconds} semantics
resolveReplay={resolveCanonicalLabReplay} label="RAVNOVES004TREE · канонический повтор Rerun" />;
}
@@ -53,7 +53,7 @@ export const LABORATORY_REPORT_TEMPLATE_VERSION =
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = { const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
deterministic: "Детерминированный", deterministic: "Детерминированный",
"ai-inference": "AI inference", "ai-inference": "AI Inference",
hybrid: "Гибридный", hybrid: "Гибридный",
}; };
@@ -59,6 +59,16 @@ export function laboratoryRecordedClipEndExclusiveNs(
return last.sourceTimeNs + typicalDelta; return last.sourceTimeNs + typicalDelta;
} }
export function laboratoryRecordedClipClockGate(
pendingSequence: number | null,
observedSequence: number,
): { accept: boolean; pendingSequence: number | null } {
if (pendingSequence !== null && pendingSequence !== observedSequence) {
return { accept: false, pendingSequence };
}
return { accept: true, pendingSequence: null };
}
export function LaboratoryRecordedClipPlayer({ export function LaboratoryRecordedClipPlayer({
source, source,
segmentCount, segmentCount,
@@ -94,7 +104,8 @@ export function LaboratoryRecordedClipPlayer({
}) { }) {
const [companionSpatialSize, setCompanionSpatialSize] = useState(69); const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
const lastEmittedSequenceRef = useRef(sequence); const lastEmittedSequenceRef = useRef(sequence);
lastEmittedSequenceRef.current = sequence; const lastObservedSequenceRef = useRef<number | null>(sequence);
const pendingSequenceRef = useRef<number | null>(null);
const frame = useMemo( const frame = useMemo(
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null, () => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
[frames, sequence], [frames, sequence],
@@ -113,23 +124,42 @@ export function LaboratoryRecordedClipPlayer({
if (!continuousPlayback && playing) onPlayingChange(false); if (!continuousPlayback && playing) onPlayingChange(false);
}, [continuousPlayback, onPlayingChange, playing]); }, [continuousPlayback, onPlayingChange, playing]);
useEffect(() => {
if (lastObservedSequenceRef.current !== sequence) {
pendingSequenceRef.current = sequence;
}
lastEmittedSequenceRef.current = sequence;
}, [sequence]);
const emitSequence = useCallback((nextSequence: number) => { const emitSequence = useCallback((nextSequence: number) => {
if (lastEmittedSequenceRef.current === nextSequence) return; if (lastEmittedSequenceRef.current === nextSequence) return;
lastEmittedSequenceRef.current = nextSequence; lastEmittedSequenceRef.current = nextSequence;
onSequenceChange(nextSequence); onSequenceChange(nextSequence);
}, [onSequenceChange]); }, [onSequenceChange]);
const requestSequence = useCallback((nextSequence: number) => {
pendingSequenceRef.current = nextSequence;
emitSequence(nextSequence);
}, [emitSequence]);
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => { const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000); const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
const first = frames[0]; const first = frames[0];
if (!first || endExclusiveNs === null) return; if (!first || endExclusiveNs === null) return;
if (sourceTimeNs >= endExclusiveNs) { if (sourceTimeNs >= endExclusiveNs) {
emitSequence(first.sequence); requestSequence(first.sequence);
return; return;
} }
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs); const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
if (nearest) emitSequence(nearest.sequence); if (!nearest) return;
}, [emitSequence, endExclusiveNs, frames]); lastObservedSequenceRef.current = nearest.sequence;
const gate = laboratoryRecordedClipClockGate(
pendingSequenceRef.current,
nearest.sequence,
);
pendingSequenceRef.current = gate.pendingSequence;
if (gate.accept) emitSequence(nearest.sequence);
}, [emitSequence, endExclusiveNs, frames, requestSequence]);
const timelineStart = frames[0]?.sourceTimeNs ?? 0; const timelineStart = frames[0]?.sourceTimeNs ?? 0;
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1; const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
@@ -142,7 +172,7 @@ export function LaboratoryRecordedClipPlayer({
className="laboratory-recorded-clip-player__spatial" className="laboratory-recorded-clip-player__spatial"
aria-hidden={cameraPresentation === "primary"} aria-hidden={cameraPresentation === "primary"}
> >
{cameraPresentation !== "primary" ? alternativeScene : null} {alternativeScene}
</div> </div>
); );
const cameraPane = ( const cameraPane = (
@@ -202,7 +232,7 @@ export function LaboratoryRecordedClipPlayer({
onPlayingChange={continuousPlayback ? onPlayingChange : undefined} onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
onSeek={(timeNs) => { onSeek={(timeNs) => {
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs); const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
if (nearest) emitSequence(nearest.sequence); if (nearest) requestSequence(nearest.sequence);
}} }}
showJumpToEnd={false} showJumpToEnd={false}
/> />
@@ -0,0 +1,51 @@
import { GlassSurface } from "@nodedc/ui-react";
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
}
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
}
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
}
export function PortableResultReplay({
review,
label,
}: {
review: ObservatoryPortableResultReview;
label?: string;
}) {
const schema = review.resultDocument.schema_version;
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
const module = review.resultDocument.module;
const moduleId = module && typeof module === "object" && "module_id" in module
? module.module_id : undefined;
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|| (schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "ddrnet" || moduleId === "eomt"));
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "rf-detr" || moduleId === "object-distance");
if (!tgs && !semantics && !objects) return (
<GlassSurface className="observatory-replay-state" padding="lg">
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
</GlassSurface>
);
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
detections={objects}
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
label={label}
resolveReplay={tgs ? resolvePortableTgsReplay
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
}
@@ -1,8 +1,11 @@
import { useEffect, useState } from "react";
import { import {
RecordedFmp4Player, RecordedFmp4Player,
type RecordedObservationPlayback, type RecordedObservationPlayback,
} from "../RecordedFmp4Player"; } from "../RecordedFmp4Player";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts"; import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
import type { RecordedCameraAdmissionState } from "../../core/observation/recordedSessionAdmission";
import { import {
RecordedEvidenceBoxOverlay, RecordedEvidenceBoxOverlay,
type RecordedEvidenceBox, type RecordedEvidenceBox,
@@ -25,6 +28,7 @@ export function RecordedEvidenceVideoScene({
imageWidth, imageWidth,
imageHeight, imageHeight,
boxes, boxes,
overlaySeconds,
semanticOverlay, semanticOverlay,
pointCloudOverlay, pointCloudOverlay,
ariaLabel, ariaLabel,
@@ -33,12 +37,17 @@ export function RecordedEvidenceVideoScene({
segmentCount, segmentCount,
onPlaybackChange, onPlaybackChange,
onPlayingRejected, onPlayingRejected,
onAdmissionChange,
playbackAuthority = "media",
playbackTransport = "segmented",
recoverTimestampStalls = false,
}: { }: {
source: ObservationSourceDescriptor; source: ObservationSourceDescriptor;
playback: RecordedObservationPlayback; playback: RecordedObservationPlayback;
imageWidth: number; imageWidth: number;
imageHeight: number; imageHeight: number;
boxes: readonly RecordedEvidenceBox[]; boxes: readonly RecordedEvidenceBox[];
overlaySeconds?: number;
semanticOverlay?: RecordedEvidenceSemanticOverlay; semanticOverlay?: RecordedEvidenceSemanticOverlay;
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData; pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
ariaLabel: string; ariaLabel: string;
@@ -47,7 +56,41 @@ export function RecordedEvidenceVideoScene({
segmentCount?: number; segmentCount?: number;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void; onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
onPlayingRejected?: () => void; onPlayingRejected?: () => void;
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
playbackAuthority?: "media" | "host";
playbackTransport?: "segmented" | "epoch-stream";
recoverTimestampStalls?: boolean;
}) { }) {
const generation = source.delivery?.kind === "recorded-fmp4-manifest"
? source.delivery.manifestGenerationSha256
: "invalid";
const [admissionPhase, setAdmissionPhase] = useState<RecordedCameraAdmissionState["phase"]>(
"loading",
);
const [presentedSeconds, setPresentedSeconds] = useState<number | null>(null);
useEffect(() => {
setAdmissionPhase("loading");
setPresentedSeconds(null);
}, [generation, source.id]);
const handleAdmissionChange = (next: RecordedCameraAdmissionState) => {
setAdmissionPhase(next.phase);
onAdmissionChange?.(next);
};
const sourceReady = admissionPhase === "ready";
const overlaysPresented = sourceReady
&& presentedSeconds !== null
&& Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25
&& (overlaySeconds === undefined || Math.abs(presentedSeconds - overlaySeconds) <= 0.25);
const handlePlaybackChange = (next: RecordedObservationPlayback) => {
setPresentedSeconds(next.currentSeconds);
// During a paused operator seek the existing media element can emit its old
// timestamp while the requested MSE window is being rebuilt. That stale
// callback must not undo the host target before the decoder reaches it.
if (!playback.playing && Math.abs(next.currentSeconds - playback.currentSeconds) > 0.35) {
return;
}
onPlaybackChange?.(next);
};
return ( return (
<div className="recorded-evidence-video-scene"> <div className="recorded-evidence-video-scene">
<RecordedFmp4Player <RecordedFmp4Player
@@ -57,29 +100,35 @@ export function RecordedEvidenceVideoScene({
prepare prepare
segmentSequence={segmentSequence} segmentSequence={segmentSequence}
segmentCount={segmentCount} segmentCount={segmentCount}
onPlaybackChange={onPlaybackChange} onPlaybackChange={handlePlaybackChange}
onPlayingRejected={onPlayingRejected} onPlayingRejected={onPlayingRejected}
onAdmissionChange={handleAdmissionChange}
playbackAuthority={playbackAuthority}
playbackTransport={playbackTransport}
recoverTimestampStalls={recoverTimestampStalls}
/> />
{semanticOverlay ? ( {overlaysPresented && semanticOverlay ? (
<RecordedEvidenceSemanticMaskOverlay <RecordedEvidenceSemanticMaskOverlay
{...semanticOverlay} {...semanticOverlay}
imageWidth={imageWidth} imageWidth={imageWidth}
imageHeight={imageHeight} imageHeight={imageHeight}
/> />
) : null} ) : null}
{pointCloudOverlay ? ( {overlaysPresented && pointCloudOverlay ? (
<RecordedEvidencePointCloudOverlay <RecordedEvidencePointCloudOverlay
imageWidth={imageWidth} imageWidth={imageWidth}
imageHeight={imageHeight} imageHeight={imageHeight}
overlay={pointCloudOverlay} overlay={pointCloudOverlay}
/> />
) : null} ) : null}
<RecordedEvidenceBoxOverlay {overlaysPresented ? (
imageWidth={imageWidth} <RecordedEvidenceBoxOverlay
imageHeight={imageHeight} imageWidth={imageWidth}
boxes={boxes} imageHeight={imageHeight}
ariaLabel={ariaLabel} boxes={boxes}
/> ariaLabel={ariaLabel}
/>
) : null}
</div> </div>
); );
} }
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from "react";
import {
fetchCanonicalRecordedLabSpatialFrame,
type CanonicalRecordedLabSpatialFrame,
} from "../../core/laboratory/canonicalRecordedLabSpatial";
const FRAME_CACHE_LIMIT = 12;
/**
* Shared latest-request-wins scheduler for recorded LAB spatial evidence.
*
* A feature supplies only the sealed session identity and host-clock time.
* Cache ownership, identity fencing and stale-response suppression remain in
* the canonical instrument instead of being reimplemented per experiment.
*/
export function useCanonicalRecordedLabSpatialFrame({
sessionId,
generationSha256,
targetTimeNs,
}: {
sessionId: string;
generationSha256: string | null;
targetTimeNs: number;
}) {
const [frame, setFrame] = useState<CanonicalRecordedLabSpatialFrame | null>(null);
const [error, setError] = useState<string | null>(null);
const desiredRef = useRef<number | null>(null);
const runningRef = useRef(false);
const mountedRef = useRef(true);
const identityRef = useRef("");
const cacheRef = useRef(new Map<number, CanonicalRecordedLabSpatialFrame>());
const pumpRef = useRef<() => void>(() => undefined);
const identity = `${sessionId}:${generationSha256 ?? "unavailable"}`;
identityRef.current = identity;
pumpRef.current = () => {
if (runningRef.current || desiredRef.current === null || !generationSha256) return;
runningRef.current = true;
const requestIdentity = identity;
let settledTimeNs: number | null = null;
void (async () => {
while (
mountedRef.current
&& identityRef.current === requestIdentity
&& desiredRef.current !== null
) {
const requestedTimeNs = desiredRef.current;
const cached = cacheRef.current.get(requestedTimeNs);
try {
const next = cached ?? await fetchCanonicalRecordedLabSpatialFrame(
sessionId,
generationSha256,
requestedTimeNs,
);
if (!mountedRef.current || identityRef.current !== requestIdentity) break;
if (!cached) {
cacheRef.current.set(requestedTimeNs, next);
while (cacheRef.current.size > FRAME_CACHE_LIMIT) {
const oldest = cacheRef.current.keys().next().value as number | undefined;
if (oldest === undefined) break;
cacheRef.current.delete(oldest);
}
}
setFrame(next);
setError(null);
} catch (caught: unknown) {
if (!mountedRef.current || identityRef.current !== requestIdentity) break;
setError(caught instanceof Error
? caught.message
: "Spatial-слои записанной LAB недоступны.");
}
settledTimeNs = requestedTimeNs;
if (desiredRef.current === requestedTimeNs) break;
}
})().finally(() => {
runningRef.current = false;
if (
mountedRef.current
&& desiredRef.current !== null
&& (identityRef.current !== requestIdentity || desiredRef.current !== settledTimeNs)
) {
pumpRef.current();
}
});
};
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
desiredRef.current = null;
};
}, []);
useEffect(() => {
cacheRef.current.clear();
desiredRef.current = null;
setFrame(null);
setError(null);
}, [identity]);
useEffect(() => {
if (!generationSha256) return;
desiredRef.current = targetTimeNs;
const cached = cacheRef.current.get(targetTimeNs);
if (cached) {
setFrame(cached);
setError(null);
return;
}
pumpRef.current();
}, [generationSha256, identity, targetTimeNs]);
return { frame, error, loading: Boolean(generationSha256) && !frame && !error };
}
@@ -130,8 +130,12 @@ export function useRecordedEvidencePlayback(
const synchronize = useCallback((next: RecordedObservationPlayback) => { const synchronize = useCallback((next: RecordedObservationPlayback) => {
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return; if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
// Animation-clock mode is retained only for non-media diagnostics. A
// recorded LAB with video uses the external media clock so spatial and
// overlays never advance past the frame the decoder actually presented.
if (clock === "animation") return;
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range)); setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
}, [range]); }, [clock, range]);
return useMemo(() => ({ return useMemo(() => ({
playback, playback,
@@ -0,0 +1,59 @@
import type {MapVisualSettings} from '../../core/map/mapView';
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
return {
atmosphere_enabled: settings.atmosphereEnabled,
atmosphere_hue: settings.atmosphereHue,
atmosphere_saturation:
settings.atmosphereSaturation,
atmosphere_brightness:
settings.atmosphereBrightness,
fog_enabled: settings.fogEnabled,
fog_density: settings.fogDensity,
sun_enabled: settings.sunEnabled,
sun_hour: settings.sunHour,
sun_intensity: settings.sunIntensity,
shadows_enabled: settings.shadowsEnabled,
monochrome_enabled: settings.monochromeEnabled,
monochrome_color: settings.monochromeColor,
imagery_brightness: settings.imageryBrightness,
imagery_contrast: settings.imageryContrast,
imagery_saturation: settings.imagerySaturation,
imagery_gamma: settings.imageryGamma,
imagery_hue: settings.imageryHue,
imagery_alpha: settings.imageryAlpha,
globe_color: settings.globeColor,
background_color: settings.backgroundColor,
terrain_exaggeration:
settings.terrainExaggeration,
buildings_color: settings.buildingsColor,
buildings_opacity: settings.buildingsOpacity,
buildings_maximum_screen_space_error:
settings.buildingsMaximumScreenSpaceError,
grid_lod_enabled: settings.gridLodEnabled,
grid_height_meters: settings.gridHeightMeters,
grid_lod_1_max_height_km:
settings.gridLod1MaxHeightKm,
grid_lod_1_step_km:
settings.gridLod1StepKm,
grid_lod_2_max_height_km:
settings.gridLod2MaxHeightKm,
grid_lod_2_step_km:
settings.gridLod2StepKm,
grid_lod_3_step_km:
settings.gridLod3StepKm,
grid_radius_km: settings.gridRadiusKm,
grid_line_width: settings.gridLineWidth,
grid_color: settings.gridColor,
grid_opacity: settings.gridOpacity,
grid_dots_enabled:
settings.gridDotsEnabled,
grid_dots_size: settings.gridDotsSize,
grid_dots_color: settings.gridDotsColor,
grid_dots_opacity:
settings.gridDotsOpacity,
camera_animation_enabled:
settings.cameraAnimationEnabled,
};
}
@@ -0,0 +1,21 @@
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
import type { RouteCheck } from "../../core/missions/planner";
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
}) {
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
<LoadingRegion loading={busy} label="Проверка исходных данных">
{error && <p role="alert">{error}</p>}
{report && <><dl>
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
{report.warnings.map(w => <p key={w}>{w}</p>)}
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
</LoadingRegion>
</div></Window>;
}
@@ -0,0 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { RangeControl } from "@nodedc/ui-react";
import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner";
/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */
export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) {
const [cursor, setCursor] = useState(0);
useEffect(() => setCursor(0), [poses]);
const projection = useMemo(() => {
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); }
const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin));
const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale];
const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" ");
return { project, reference: line(source.poses), selected: line(poses), scale };
}, [source, poses]);
const point = poses[Math.min(cursor, poses.length - 1)];
if (!point) return <div className="session-overview__empty">Выберите участок траектории.</div>;
const [x, y] = projection.project(point);
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
</div>;
}
@@ -0,0 +1,12 @@
import type {ReactNode} from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { useSessionOverview } from "../../core/observation/useSessionOverview";
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
const { data, error, retry } = useSessionOverview(sessionId);
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
const failure = error || (data?.state === "error" ? data.message : null);
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
}
@@ -0,0 +1,23 @@
import type {ReactNode} from 'react';
import {Button, StatusBadge} from '@nodedc/ui-react';
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Composition-level handoff; never sends a device or acquisition command. */
export function PlanningCaptureGuard({enabled, onResume, children}: {
enabled: boolean;
onResume: () => void;
children: ReactNode;
}) {
const planning = usePlanningTest();
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
return <div className="mission-planner__check">
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
<p>{planning.test!.draft.name}</p>
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
{planning.error && <p role="alert">{planning.error}</p>}
<div className="mission-planner__actions">
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
</div>
</div>;
}
@@ -0,0 +1,32 @@
import {useEffect,useState,type ReactNode} from 'react';
import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react';
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Wrap the existing plugin connection flow, retaining its acquisition authority. */
export function PlanningConnectionWindow({children}:{children:ReactNode}){
const p=usePlanningTest(); const [open,setOpen]=useState(true);
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
useEffect(()=>{
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
if(models.length===1)void selectModel(models[0].model.id);
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
if(!p.test)return <>{children}</>;
const t=p.test, terminal=planningTestTerminal(t);
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
return <div className="mission-planner__check">
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
<div className="mission-planner__check">{details}
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
: terminal?<p>{t.message}</p>:children}
</div>
</Window>
</div>;
}
@@ -0,0 +1,105 @@
import {useEffect,useRef,useState} from 'react';
import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react';
import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost';
import {plannerBase} from '../../core/missions/planner';
import {startPlanningSceneStream} from '../../core/missions/planningSceneStream';
import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry';
function nextAnimationFrame(limitMs=500):Promise<number|null>{
return new Promise(resolve=>{
let frame:number|undefined,settled=false;
const finish=(value:number|null)=>{
if(settled)return;
settled=true;
if(frame!==undefined&&value===null)cancelAnimationFrame(frame);
clearTimeout(timeout);resolve(value);
};
const timeout=setTimeout(()=>finish(null),limitMs);
frame=requestAnimationFrame(()=>finish(performance.now()));
});
}
/** One persistent viewer/channel. Updates replace entities without resetting the camera. */
type HeightBounds={min:number;max:number};
const CLIP_CEILING_M=80;
export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record<string,string|number|boolean>;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){
const optionsRef=useRef(options);optionsRef.current=options;
const stateRef=useRef({active,revision});stateRef.current={active,revision};
const ceilingRef=useRef<number|null>(null);
const boundsRef=useRef<{min:number;max:number}|null>(null);
const host=useRef<HTMLDivElement>(null);
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
const [ceiling,setCeiling]=useState<number|null>(null);
useEffect(()=>{
ceilingRef.current=null;
const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null;
boundsRef.current=next;
setBounds(next);setCeiling(null);
},[runId,heightBounds?.min,heightBounds?.max]);
useEffect(()=>{
if(!host.current)return;
let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined;
const reporter=createPlanningPresentationReporter({
url:`${plannerBase}/live-tests/${runId}/presentation-observations`,
});
const runtime=createIsolatedRerunHost(host.current);setState('loading');
const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000);
void runtime.ready.then(async({viewer,mount})=>{
if(disposed)return;
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false});
if(disposed)return;
const channel=viewer.open_channel('planning-'+runId);
viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}});
stopStream=startPlanningSceneStream({
url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`,
snapshot:()=>({...stateRef.current,options:{...optionsRef.current,
...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}),
apply:async(bytes,delivery)=>{
if(disposed)return;
if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){
const next={min:delivery.heightMinM,max:delivery.heightMaxM};
const previous=boundsRef.current;
if(!previous||previous.min!==next.min||previous.max!==next.max){
boundsRef.current=next;setBounds(next);
if(ceilingRef.current!==null&&(ceilingRef.current<next.min||ceilingRef.current>next.max)){
ceilingRef.current=null;setCeiling(null);
}
}
}
sceneAvailable=true;
const admissionStarted=performance.now();
channel.send_rrd(bytes);
const admissionEnded=performance.now();
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');}
// Rerun exposes no canvas-paint receipt. Two bounded browser frame
// opportunities are retained as an explicit proxy, never as a GPU claim.
const first=await nextAnimationFrame();
const second=first===null?null:await nextAnimationFrame();
if(!disposed)reporter.record(delivery,{
rerunAdmissionMs:admissionEnded-admissionStarted,
firstAnimationFrameMs:first===null?null:first-admissionEnded,
secondAnimationFrameMs:second===null?null:second-admissionEnded,
});
},
error:()=>{sceneAvailable=false;if(!disposed)setState('error');},
});
}).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}});
return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();};
},[runId,retry]);
const sourceBounds=bounds??heightBounds;
const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null;
return <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
formatValue={value=>`${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')}
onChange={value=>{
const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value;
ceilingRef.current=next;setCeiling(next);
}}/>
</div>}
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
</LoadingRegion>;
}
@@ -0,0 +1,25 @@
import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react';
import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects';
export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) {
const r=p.result;
return <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
</div>},
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
</div>},
]}/>;
}
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react';
import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects';
export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: {
items: PlanningProject[];
value: string;
disabled?: boolean;
onChange: (key: string) => void;
onRemove: (project: PlanningProject) => Promise<void>;
}) {
const [target, setTarget] = useState<PlanningProject | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
return <>
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
value: item.key, label: item.name,
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
action: {
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
icon: <Icon name="trash" />,
tone: 'danger' as const,
disabled: !planningProjectDeletable(item),
onAction: () => { setError(null); setTarget(item); },
},
}))]}
onChange={key => { if (key) onChange(key); }} />
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
description={target ? <>
<strong>{target.name}</strong>
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
</> : null}
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
onClose={() => { if (!busy) setTarget(null); }}
onConfirm={async () => {
if (!target || busy) return;
setBusy(true); setError(null);
try { await onRemove(target); setTarget(null); }
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
finally { setBusy(false); }
}} />
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
onDismiss={() => setError(null)} />
</>;
}
@@ -0,0 +1,30 @@
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
export function PlanningProjectSettings({p,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
}) {
const disabled=p.busy||starting;
const length=routeLength(p.poses);
const routeReady=canStartPlanningRoute(length,'scanner');
return <><Inspector variant="panel" defaultOpen={['project','zone']} sections={[
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
</div>},
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
{p.source&&<>
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
</>}
</div>},
]}/>
<div className="inspector-control-stack">
{p.source&&!routeReady&&<small>В записи недостаточно перемещения для привязки. Выберите другой эталон.</small>}
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!routeReady} onClick={onStart}><Icon name="play"/>Начать новый проход</Button>
{p.error&&<p role="alert">{p.error}</p>}
</div>
</>;
}
@@ -0,0 +1,19 @@
import {useState,type ReactNode,type RefObject} from 'react';
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
}){
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
const [maximized,setMaximized]=useState(false);
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
title={title} maximized={maximized} onMaximizedChange={setMaximized}
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
}}>
<div className="mission-planner__check">{children}</div>
</WorkspaceWindow>;
}
@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
useEffect(() => {
if (!host.current) return;
let disposed = false; setState("loading");
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
</LoadingRegion>;
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from "react";
import { overviewChartPoints } from "../../core/observation/sessionOverview";
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
const svg = useRef<SVGSVGElement>(null);
const [size, setSize] = useState({ width: 900, height: 160 });
useEffect(() => {
if (!svg.current) return;
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
}
});
observer.observe(svg.current);
return () => observer.disconnect();
}, [chart.length > 0]);
const width = Math.max(1, size.width - 106);
const height = Math.max(1, size.height - 38);
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
return <div className="session-overview__chart">
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
<g transform="translate(64,10)">
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
</g>)}
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
</g>
</svg>
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
</div>;
}
@@ -0,0 +1,96 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [retry, setRetry] = useState(0);
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
const [ceiling, setCeiling] = useState<number | null>(null);
const [mode, setMode] = useState<OverviewViewMode>("3d");
const [viewError, setViewError] = useState<string | null>(null);
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
const [updating, setUpdating] = useState(false);
const comparison = metadata?.comparison;
const appliedMode = useRef<OverviewViewMode | null>(null);
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
useEffect(() => {
if (!host.current) return;
let disposed = false;
const abort = new AbortController();
appliedMode.current = null;
setState("loading");
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
setRepresentation("original"); setAppliedRepresentation("original"); setUpdating(false);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(data => {
if (disposed) return;
setRepresentation(data.default_representation ?? "original"); setMetadata(data);
}).catch(() => { if (!disposed) { setState("error"); setViewError("Версия облака недоступна."); } });
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true,
enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
useEffect(() => {
if (state !== "ready" || !metadata || !controller.current) return;
const abort = new AbortController();
setUpdating(true);
const timer = setTimeout(() => {
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal,
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
if (abort.signal.aborted || !controller.current) return;
controller.current.channel.send_rrd(result.bytes);
appliedMode.current = mode;
setVisiblePoints(result.visiblePoints); setViewError(null);
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
}, 180);
return () => { abort.abort(); clearTimeout(timer); };
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
const low = comparison?.height_min_m ?? metadata?.height_min_m;
const high = comparison?.height_max_m ?? metadata?.height_max_m;
return <>
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
{toolbar}
</div>
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
</div>}
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</LoadingRegion>
<span className="session-overview__note" role="status" aria-busy={updating}>
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
</span>
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</>;
}
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
FieldFrame,
Select,
StatusBadge,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
aiModuleIdForSetup,
fetchAIModuleCatalog,
fetchAICompositionRuns,
fetchAICompositionJobs,
saveAIComposition,
type AICompositionReceipt,
type AICompositionRun,
type AIGroupId,
type AIModuleCatalog,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
];
export function AIConfigurationWindow({
open, sourceSessionId, sourceLabel, onClose, onCalculated,
}: {
readonly open: boolean;
readonly sourceSessionId: string;
readonly sourceLabel: string;
readonly onClose: () => void;
readonly onCalculated: (receipt: AICompositionReceipt) => void;
}) {
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const request = new AbortController();
setState("loading");
setMessage(null);
void Promise.all([
fetchAIModuleCatalog(request.signal),
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
]).then(([value, jobs, runs]) => {
if (request.signal.aborted) return;
setCatalog(value);
setExistingJobs(jobs);
setExistingRuns(runs);
setSelected({});
setState("ready");
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
setState("error");
});
return () => request.abort();
}, [open, sourceSessionId]);
const existingByModule = useMemo(() => {
const result = new Map<string, ObservatoryRecordedJob>();
for (const job of existingJobs) {
if (job.state === "failed" || job.publication.state === "failed") continue;
const moduleId = aiModuleIdForSetup(job.setupId);
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
}
return result;
}, [existingJobs]);
const selections = useMemo(() => groups.flatMap(({ id }) => {
const moduleId = selected[id];
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
(item) => item.moduleId === moduleId,
);
return module ? [{ group: id, module }] : [];
}), [catalog, selected]);
const selectedModuleIds = useMemo(
() => selections.map(({ module }) => module.moduleId).sort(),
[selections],
);
const matchingRun = useMemo(() => existingRuns.find((run) => (
run.state !== "failed"
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
)), [existingRuns, selectedModuleIds]);
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
const configurationIsRunning = matchingRun?.state === "running";
function selectModule(group: AIGroupId, moduleId: string): void {
if (!catalog) return;
setSelected((current) => {
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
const modules = catalog.groups.flatMap((item) => item.modules);
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
if (moduleId !== "none") {
const module = modules.find((item) => item.moduleId === moduleId);
for (const requirement of module?.requires ?? []) {
if (requirement.startsWith("source.")) continue;
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
continue;
}
const providers = modules.filter((item) => item.provides.includes(requirement));
if (providers.length === 1) {
const providerGroup = catalog.groups.find((item) => item.modules.some(
(candidate) => candidate.moduleId === providers[0]?.moduleId,
))?.group;
if (providerGroup) {
next[providerGroup] = providers[0]?.moduleId;
selectedIds.add(providers[0]?.moduleId ?? "");
}
}
}
} else {
let changed = true;
while (changed) {
changed = false;
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
.flatMap((item) => item.provides));
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
(module) => module.moduleId === item.moduleId,
))?.group;
if (dependentGroup) {
next[dependentGroup] = undefined;
changed = true;
}
}
}
}
}
return next;
});
setMessage(null);
if (state === "error") setState("ready");
}
async function calculate(): Promise<void> {
if (selections.length === 0 || state === "saving") return;
if (configurationAlreadyExists) {
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
return;
}
setState("saving");
setMessage(null);
try {
const receipt = await saveAIComposition(sourceSessionId, selections);
onCalculated(receipt);
onClose();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
setState("error");
}
}
return <Window
open={open}
className="observatory-ai-config-window"
title="Сконфигурировать AI-слой"
subtitle={sourceLabel}
size="lg"
closeOnBackdrop={state !== "saving"}
closeOnEscape={state !== "saving"}
onClose={onClose}
footer={<WindowFooterActions>
{configurationAlreadyExists ? (
<span className="observatory-ai-config__duplicate" role="status">
{configurationIsRunning
? "Текущая конфигурация уже рассчитывается."
: "Текущая конфигурация уже рассчитана."}
</span>
) : null}
<Button
variant="primary"
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
aria-busy={state === "saving"}
onClick={() => void calculate()}
>
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
</Button>
</WindowFooterActions>}
>
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
<p className="observatory-ai-config__lead">
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
</p>
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
{catalog ? groups.map(({ id, label, description }) => {
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
const selectedExisting = selectedModule
? existingByModule.get(selectedModule.moduleId)
: undefined;
const allCalculated = modules.length > 0 && modules.every(
(module) => existingByModule.has(module.moduleId),
);
return <section className="observatory-ai-module-group" key={id}>
<header>
<div>
<strong>{label}</strong>
<span>{description}</span>
</div>
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
{selectedExisting
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
: selectedModule?.label ?? (allCalculated
? "Результат доступен"
: modules.length ? "Не используется" : "Не установлен")}
</StatusBadge>
</header>
<div className="observatory-ai-module-group__body">
<FieldFrame
label={`Модуль: ${label}`}
description={modules.length
? allCalculated
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
: id === "segmentation"
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
: id === "range"
? "Дистанция использует рамки детектора и синхронное облако точек."
: id === "geometry"
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
: "Связи с другими блоками определяются входами выбранного модуля."
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
>
<Select
label={`Выбрать модуль: ${label}`}
value={selected[id] ?? "none"}
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
value: module.moduleId,
label: module.label,
description: existingByModule.has(module.moduleId)
? "Готовый результат будет использован без повторного расчёта"
: module.dockerName,
}))]}
disabled={modules.length === 0 || state === "saving"}
onChange={(value) => selectModule(id, value)}
/>
</FieldFrame>
</div>
</section>;
}) : null}
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
</div>
</Window>;
}
@@ -0,0 +1,10 @@
import { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { createRecordedRerunOwner } from "./recordedRerunOwner";
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
const mount = document.getElementById("viewer");
if (!mount) throw new Error("Recorded viewer mount is missing");
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
@@ -0,0 +1,77 @@
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
export function createIsolatedRerunHost(host: HTMLElement) {
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
frame.title = "Синхронизированная запись";
frame.className = "rerun-viewport__runtime";
frame.src = "/rerun-runtime.html";
let disposed = false;
let releaseFacade: (() => void) | null = null;
let releaseInput: (() => void) | null = null;
let frameWindow: RerunFrameWindow | null = null;
let rejectReady: ((reason: Error) => void) | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
frame!.onload = null;
frame!.onerror = null;
releaseInput?.();
releaseInput = null;
try {
releaseFacade?.();
} catch {
// Realm teardown is required even if a partial native start failed.
} finally {
releaseFacade = null;
frameWindow = null;
frame!.remove();
frame = null;
rejectReady?.(new Error("Recorded viewer was disposed"));
rejectReady = null;
}
};
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
rejectReady = reject;
frame!.onload = () => {
if (disposed) return;
try {
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
const api = frameWindow?.missionCoreRerun;
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
const owner = createRecordedRerunFacade((request, bytes) => {
const current = frameWindow?.missionCoreRerun;
if (!current) throw new Error("Recorded viewer was disposed");
return current.invoke(request, bytes);
});
releaseFacade = owner.dispose;
api.connect(owner.notify);
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
clearTimeout(timer);
rejectReady = null;
frame!.onload = null;
frame!.onerror = null;
resolve({ viewer: owner.facade, mount: host });
} catch (error) {
reject(new Error(String(error)));
dispose();
}
};
frame!.onerror = () => {
reject(new Error("Recorded viewer host failed to load"));
dispose();
};
timer = setTimeout(() => {
reject(new Error("Recorded viewer host timed out"));
dispose();
}, 30_000);
});
host.append(frame);
return { ready, dispose };
}
@@ -0,0 +1,17 @@
/** Relay Escape from the isolated viewer realm to the owning application. */
export function relayIsolatedRerunInput(
host: HTMLElement,
frameWindow: Window | null,
_frame: HTMLIFrameElement | null,
) {
const parent = host.ownerDocument.defaultView;
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
};
frameWindow?.addEventListener("keydown", escape);
return () => {
frameWindow?.removeEventListener("keydown", escape);
frameWindow = null;
};
}
@@ -0,0 +1,28 @@
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
export type RecordedRerunCameraEye = {
readonly position: readonly [number, number, number];
readonly lookTarget: readonly [number, number, number];
readonly eyeUp: readonly [number, number, number];
};
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
};
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
};
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
if (value == null) return null; // No 3D frame rendered yet.
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
const eye = value as Record<string, unknown>;
const vector = (key: string): [number, number, number] => {
const item = eye[key];
if (!Array.isArray(item) || item.length !== 3 ||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
throw new Error("Invalid native Rerun camera vector");
}
return [item[0], item[1], item[2]];
};
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
}
@@ -0,0 +1,93 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunInvoke } from "./recordedRerunProtocol";
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
export type RecordedRerunViewer = Pick<WebViewer,
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
"get_playing" | "get_time_range" | "set_active_timeline" |
"set_current_time" | "set_playing"
> & {
open_channel: (name?: string) => Channel;
get_camera_eye: () => RecordedRerunCameraEye | null;
};
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
let nextId = 0;
const callbacks = new Map<number, (...args: unknown[]) => void>();
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
if (!invoke) throw new Error("Recorded viewer was disposed");
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
if (response.error) throw new Error(response.error);
return response.result;
};
const dispose = () => {
callbacks.clear();
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
starts.clear();
try { if (invoke) command("stop"); } finally { invoke = null; }
};
const notify = (message: string) => {
if (!invoke) return;
const event = JSON.parse(message);
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
else {
const pending = starts.get(event.id);
starts.delete(event.id);
if (event.type === "started") pending?.resolve();
else pending?.reject(new Error(event.message));
}
};
const facade: RecordedRerunViewer = {
get ready() { return invoke ? command("ready") : false; },
start(source, _parent, options) {
return new Promise<void>((resolve, reject) => {
const id = ++nextId;
starts.set(id, { resolve, reject });
try { command("start", [source, options], id); }
catch (error) { starts.delete(id); reject(error); }
});
},
stop: dispose,
on: ((event: string, callback: (...args: unknown[]) => void) => {
const id = ++nextId;
callbacks.set(id, callback);
try { command("on", [event], id); }
catch (error) { callbacks.delete(id); throw error; }
return () => {
if (callbacks.delete(id) && invoke) command("off", [], id);
};
}) as WebViewer["on"],
open_channel(name) {
const id = ++nextId;
command("channel-open", name === undefined ? [] : [name], id);
let closed = false;
return {
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
close() {
if (closed) return;
closed = true;
if (invoke) command("channel-close", [], id);
},
};
},
open: (...args) => command("open", args),
close: (...args) => { if (invoke) command("close", args); },
override_panel_state: (...args) => command("override_panel_state", args),
get_active_recording_id: () => command("get_active_recording_id"),
get_active_timeline: (...args) => command("get_active_timeline", args),
get_current_time: (...args) => command("get_current_time", args),
get_playing: (...args) => command("get_playing", args),
get_time_range: (...args) => command("get_time_range", args),
set_active_timeline: (...args) => command("set_active_timeline", args),
set_current_time: (...args) => command("set_current_time", args),
set_playing: (...args) => command("set_playing", args),
get_camera_eye: () => command("get-camera-eye"),
};
return { facade, dispose, notify };
}
@@ -0,0 +1,104 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
let native: WebViewer | null = null;
let notify: RerunNotify | null = null;
const subscriptions = new Map<number, () => void>();
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
const send = (message: object) => notify?.(JSON.stringify(message));
const stop = () => {
notify = null;
for (const unsubscribe of subscriptions.values()) {
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
}
subscriptions.clear();
for (const channel of channels.values()) {
try { channel.close(); } catch { /* Other channels must still close. */ }
}
channels.clear();
const current = native;
native = null;
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
};
const required = () => {
if (!native) throw new Error("Recorded viewer was disposed");
return native;
};
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
const starting = required();
try {
await starting.start(args[0], mount, args[1]);
if (native === starting) send({ type: "started", id });
} catch (error) {
if (native === starting) send({ type: "start-failed", id, message: String(error) });
} finally {
if (native !== starting) {
try { starting.stop(); } catch { /* Late completion after disposal. */ }
}
}
};
return {
version: 2,
connect(callback) {
if (native) throw new Error("Recorded viewer already has an owner");
native = create();
notify = callback;
},
invoke(request, bytes) {
try {
const { method, args = [], id } = JSON.parse(request);
let result: unknown;
switch (method) {
case "stop": stop(); break;
case "start": required(); void start(id, args); break;
case "ready": result = native?.ready ?? false; break;
case "on": {
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
break;
}
case "off":
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
break;
case "channel-open": {
const name = args[0] as string | undefined;
channels.set(id, required().open_channel(name));
break;
}
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
case "channel-send": if (bytes) {
channels.get(id)?.send_rrd(bytes);
} break;
case "channel-close":
try { channels.get(id)?.close(); } finally {
channels.delete(id);
}
break;
case "get-camera-eye": result = readNativeRerunCameraEye(
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
); break;
case "open": required().open(args[0]); break;
case "close": required().close(args[0]); break;
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
case "get_active_recording_id": result = required().get_active_recording_id(); break;
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
case "get_playing": result = required().get_playing(args[0]); break;
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
case "set_playing": required().set_playing(args[0], args[1]); break;
default: throw new Error("Unsupported recorded viewer command");
}
return JSON.stringify({ result });
} catch (error) {
// A native Error also has a foreign prototype. Return text, never throw
// that object into a parent Promise/callback retained after disposal.
return JSON.stringify({ error: String(error) });
}
},
};
}
@@ -0,0 +1,12 @@
/** Only parent-owned inputs and primitive strings cross the recorded realm.
* An SDK instance, Promise, DOM node or native callback never becomes a value
* retained by React. This is not a second playback clock.
*/
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
export type RerunNotify = (message: string) => void;
export interface RerunFrameApi {
version: 2;
connect: (notify: RerunNotify) => void;
invoke: RerunInvoke;
}
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
@@ -0,0 +1,66 @@
import {useEffect, useRef, useState} from 'react';
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
import type {RecordedPointColorLoadState} from '../RerunViewport';
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
sourceGeneration?: string;
}) {
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
const loadRef = useRef(onLoad); loadRef.current = onLoad;
const failed = useRef(false);
const [retry, setRetry] = useState(0);
const key = settings ? pointDisplayKey(settings) : '';
const percent = settings?.pointDecimationPercent ?? 0;
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
const matched = sameSource && applied?.key === key;
useEffect(() => {
// A new settings interaction retries a failed request, but accumulation or
// size edits must not cancel an already-running preparation of the same key.
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
}, [settings]);
useEffect(() => {
if (!endpoint || !settings || !ready) return;
if (percent === 0 || percent === 100) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
return;
}
if (matched) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: 'Прореженное облако готово.'});
return;
}
const owner = ownerRef.current();
if (!owner) return;
const abort = new AbortController();
failed.current = false;
// Never append another generation at the same temporal entity path: an
// accumulated range would otherwise draw older samples as well.
const bank = crypto.randomUUID().replaceAll('-', '');
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
if (!abort.signal.aborted) owner.send(chunk);
}, undefined, sourceGeneration).then(bytes => {
if (abort.signal.aborted) return;
setApplied({endpoint, revision, key, bank, sourceGeneration});
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
message: 'Прореженное облако готово.'});
}).catch(() => {
if (!abort.signal.aborted) {
failed.current = true;
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
}
});
return () => abort.abort();
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
hidePoints: percent === 100};
}
@@ -118,6 +118,9 @@ export class PlayCanvasRuntime implements SimulationRuntime {
private renderIntervalMs = 1000 / 60; private renderIntervalMs = 1000 / 60;
private renderAccumulatorMs = 0; private renderAccumulatorMs = 0;
private disposed = false; private disposed = false;
private requestGsplatFrame = (): void => {
this.requestRender();
};
private updateFrame = (deltaSeconds: number): void => { private updateFrame = (deltaSeconds: number): void => {
if (!this.app || !this.camera || this.disposed) return; if (!this.app || !this.camera || this.disposed) return;
this.ugvController?.update(deltaSeconds); this.ugvController?.update(deltaSeconds);
@@ -158,6 +161,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.app = app; this.app = app;
app.autoRender = false; app.autoRender = false;
app.on("update", this.updateFrame); app.on("update", this.updateFrame);
app.systems.gsplat?.on("frame:request", this.requestGsplatFrame);
app.setCanvasFillMode(FILLMODE_NONE, 1, 1); app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
app.setCanvasResolution(RESOLUTION_AUTO); app.setCanvasResolution(RESOLUTION_AUTO);
app.scene.ambientLight = new Color(0.35, 0.37, 0.42); app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
@@ -379,6 +383,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
if (this.app) { if (this.app) {
this.app.autoRender = false; this.app.autoRender = false;
this.app.renderNextFrame = false; this.app.renderNextFrame = false;
this.app.systems.gsplat?.off("frame:request", this.requestGsplatFrame);
this.app.off("update", this.updateFrame); this.app.off("update", this.updateFrame);
this.app.destroy(); this.app.destroy();
} }
@@ -24,7 +24,14 @@ const PHYSICS_PROXY_ERROR = 0.0003;
const PHYSICS_PROXY_MAX_TRIANGLES = 240_000; const PHYSICS_PROXY_MAX_TRIANGLES = 240_000;
const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8; const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8;
const COAST_DECELERATION_MPS2 = 0.18; const COAST_DECELERATION_MPS2 = 0.18;
const BRAKE_ATTITUDE_DAMPING = 6; const PARKING_BRAKE_HOLD_DECELERATION_MPS2 = 6;
const PARKING_BRAKE_ENGAGE_SPEED_MPS = 0.08;
const TYRE_FRICTION_SLIP = 8.5;
const TYRE_STATIC_FRICTION_COEFFICIENT = 0.95;
const TYRE_KINETIC_FRICTION_COEFFICIENT = 0.78;
const TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND = 10;
const GRAVITY_METERS_PER_SECOND_SQUARED = 9.81;
const MIN_TYRE_NORMAL_FORCE_NEWTONS = 1;
const DEFAULT_ORBIT_PITCH = 0.48; const DEFAULT_ORBIT_PITCH = 0.48;
const CAMERA_RETURN_DELAY_SECONDS = 1.2; const CAMERA_RETURN_DELAY_SECONDS = 1.2;
const CAMERA_RETURN_DURATION_SECONDS = 2; const CAMERA_RETURN_DURATION_SECONDS = 2;
@@ -79,12 +86,20 @@ interface NativeTransform extends NativeObject {
getRotation(): NativeQuaternion; getRotation(): NativeQuaternion;
} }
interface NativeRaycastInfo extends NativeObject {
get_m_contactNormalWS(): NativeVector3;
get_m_contactPointWS(): NativeVector3;
get_m_wheelAxleWS(): NativeVector3;
}
interface NativeWheelInfo extends NativeObject { interface NativeWheelInfo extends NativeObject {
set_m_suspensionStiffness(value: number): void; set_m_suspensionStiffness(value: number): void;
set_m_wheelsDampingRelaxation(value: number): void; set_m_wheelsDampingRelaxation(value: number): void;
set_m_wheelsDampingCompression(value: number): void; set_m_wheelsDampingCompression(value: number): void;
set_m_frictionSlip(value: number): void; set_m_frictionSlip(value: number): void;
set_m_rollInfluence(value: number): void; set_m_rollInfluence(value: number): void;
get_m_wheelsSuspensionForce(): number;
get_m_raycastInfo(): NativeRaycastInfo;
} }
interface NativeRaycastVehicle extends NativeObject { interface NativeRaycastVehicle extends NativeObject {
@@ -102,6 +117,7 @@ interface NativeRaycastVehicle extends NativeObject {
setBrake(force: number, wheel: number): void; setBrake(force: number, wheel: number): void;
setSteeringValue(value: number, wheel: number): void; setSteeringValue(value: number, wheel: number): void;
getNumWheels(): number; getNumWheels(): number;
getWheelInfo(wheel: number): NativeWheelInfo;
updateWheelTransform(wheel: number, interpolated: boolean): void; updateWheelTransform(wheel: number, interpolated: boolean): void;
getWheelTransformWS(wheel: number): NativeTransform; getWheelTransformWS(wheel: number): NativeTransform;
getForwardVector(): NativeVector3; getForwardVector(): NativeVector3;
@@ -126,6 +142,11 @@ interface NativeDynamicsWorld extends NativeObject {
removeAction(action: NativeObject): void; removeAction(action: NativeObject): void;
} }
interface NativeRigidBody extends NativeObject {
applyImpulse(impulse: NativeVector3, relativePosition: NativeVector3): void;
setActivationState(state: number): void;
}
interface PhysicsSystemAccess { interface PhysicsSystemAccess {
systems: { systems: {
rigidbody: { rigidbody: {
@@ -137,7 +158,7 @@ interface PhysicsSystemAccess {
interface NativeRigidBodyAccess { interface NativeRigidBodyAccess {
rigidbody?: { rigidbody?: {
body: NativeObject | null; body: NativeRigidBody | null;
linearVelocity: Vec3; linearVelocity: Vec3;
angularVelocity: Vec3; angularVelocity: Vec3;
teleport(position: Vec3, rotation?: Vec3 | Quat): void; teleport(position: Vec3, rotation?: Vec3 | Quat): void;
@@ -192,6 +213,13 @@ export class SimulationUgvController {
private readonly smoothedCamera = new Vec3(); private readonly smoothedCamera = new Vec3();
private readonly limitedLinearVelocity = new Vec3(); private readonly limitedLinearVelocity = new Vec3();
private readonly limitedAngularVelocity = new Vec3(); private readonly limitedAngularVelocity = new Vec3();
private readonly tyreContactNormal = new Vec3();
private readonly tyreLateralDirection = new Vec3();
private readonly tyreLongitudinalDirection = new Vec3();
private readonly tyreContactPoint = new Vec3();
private readonly tyreRelativePosition = new Vec3();
private readonly tyreAngularContactVelocity = new Vec3();
private readonly tyreContactVelocity = new Vec3();
private readonly spawnPosition = UGV_SPAWN_POSITION.clone(); private readonly spawnPosition = UGV_SPAWN_POSITION.clone();
private orbitPointerId: number | null = null; private orbitPointerId: number | null = null;
private orbitPointerX = 0; private orbitPointerX = 0;
@@ -204,8 +232,9 @@ export class SimulationUgvController {
private active = false; private active = false;
private vehicleEntity: Entity | null = null; private vehicleEntity: Entity | null = null;
private vehicle: NativeRaycastVehicle | null = null; private vehicle: NativeRaycastVehicle | null = null;
private vehicleTuning: NativeObject | null = null;
private vehicleRaycaster: NativeObject | null = null; private vehicleRaycaster: NativeObject | null = null;
private tyreImpulseNative: NativeVector3 | null = null;
private tyreRelativePositionNative: NativeVector3 | null = null;
private dynamicsWorld: NativeDynamicsWorld | null = null; private dynamicsWorld: NativeDynamicsWorld | null = null;
private chassisMaterial: StandardMaterial | null = null; private chassisMaterial: StandardMaterial | null = null;
private wheelMaterial: StandardMaterial | null = null; private wheelMaterial: StandardMaterial | null = null;
@@ -308,9 +337,27 @@ export class SimulationUgvController {
const braking = this.pressed.has("Space"); const braking = this.pressed.has("Space");
const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody; const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody;
const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6; const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6;
const nativeForward = this.vehicle.getForwardVector();
const nativeForwardLength = Math.hypot(
nativeForward.x(),
nativeForward.y(),
nativeForward.z(),
);
const longitudinalSpeedMetersPerSecond = rigidbody && nativeForwardLength > 0.001
? Math.abs(
(
rigidbody.linearVelocity.x * nativeForward.x()
+ rigidbody.linearVelocity.y * nativeForward.y()
+ rigidbody.linearVelocity.z * nativeForward.z()
) / nativeForwardLength,
)
: Math.abs(speedMetersPerSecond);
const maxSpeed = this.settings.maxSpeedMetersPerSecond; const maxSpeed = this.settings.maxSpeedMetersPerSecond;
const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180; const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180;
const pureTurn = !braking && forwardInput === 0 && turnInput !== 0; const pureTurn = !braking && forwardInput === 0 && turnInput !== 0;
const holding = !braking && forwardInput === 0 && turnInput === 0;
const parkingBrakeEngaged = holding
&& longitudinalSpeedMetersPerSecond <= PARKING_BRAKE_ENGAGE_SPEED_MPS;
const desiredSpeed = forwardInput * maxSpeed; const desiredSpeed = forwardInput * maxSpeed;
const speedError = desiredSpeed - speedMetersPerSecond; const speedError = desiredSpeed - speedMetersPerSecond;
const speedResponseRange = Math.max(0.35, maxSpeed * 0.2); const speedResponseRange = Math.max(0.35, maxSpeed * 0.2);
@@ -325,13 +372,27 @@ export class SimulationUgvController {
const leftCommand = clamp(forwardCommand - turnCommand, -1, 1); const leftCommand = clamp(forwardCommand - turnCommand, -1, 1);
const rightCommand = clamp(forwardCommand + turnCommand, -1, 1); const rightCommand = clamp(forwardCommand + turnCommand, -1, 1);
const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel; const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel;
const brakeDeceleration = braking
? SERVICE_BRAKE_DECELERATION_MPS2
: holding
? parkingBrakeEngaged
? PARKING_BRAKE_HOLD_DECELERATION_MPS2
: COAST_DECELERATION_MPS2
: 0;
const wheelBrakeForce = this.settings.massKg * brakeDeceleration
/ Math.max(1, this.wheelDefinitions.length);
for (let index = 0; index < this.wheelDefinitions.length; index += 1) { for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
const definition = this.wheelDefinitions[index]; const definition = this.wheelDefinitions[index];
const command = definition.left ? leftCommand : rightCommand; const command = definition.left ? leftCommand : rightCommand;
// Parking contact is solved below with one 2D Coulomb limit; disable the
// raycast vehicle's parallel friction impulse so grip is not counted twice.
this.vehicle.getWheelInfo(index).set_m_frictionSlip(
parkingBrakeEngaged ? 0 : TYRE_FRICTION_SLIP,
);
this.vehicle.setSteeringValue(0, index); this.vehicle.setSteeringValue(0, index);
this.vehicle.applyEngineForce(command * engineForce, index); this.vehicle.applyEngineForce(command * engineForce, index);
this.vehicle.setBrake(0, index); this.vehicle.setBrake(wheelBrakeForce, index);
this.vehicle.updateWheelTransform(index, true); this.vehicle.updateWheelTransform(index, true);
const transform = this.vehicle.getWheelTransformWS(index); const transform = this.vehicle.getWheelTransformWS(index);
const position = transform.getOrigin(); const position = transform.getOrigin();
@@ -340,22 +401,21 @@ export class SimulationUgvController {
definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w()); definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w());
} }
const body = rigidbody?.body ?? null;
if (rigidbody && body) {
this.applyParkingTyreContact(
deltaSeconds,
rigidbody,
body,
parkingBrakeEngaged,
);
}
if (rigidbody) { if (rigidbody) {
const linearVelocity = rigidbody.linearVelocity; const linearVelocity = rigidbody.linearVelocity;
let nextLinearX = linearVelocity.x; let nextLinearX = linearVelocity.x;
let nextLinearZ = linearVelocity.z; let nextLinearZ = linearVelocity.z;
let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ); let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ);
const coasting = !braking && forwardInput === 0 && turnInput === 0;
if ((braking || coasting) && horizontalSpeed > 0.001) {
const deceleration = braking
? SERVICE_BRAKE_DECELERATION_MPS2
: COAST_DECELERATION_MPS2;
const nextSpeed = Math.max(0, horizontalSpeed - deceleration * Math.max(0, deltaSeconds));
const scale = nextSpeed / horizontalSpeed;
nextLinearX *= scale;
nextLinearZ *= scale;
horizontalSpeed = nextSpeed;
}
if (pureTurn && horizontalSpeed > 0.001) { if (pureTurn && horizontalSpeed > 0.001) {
const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8); const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8);
nextLinearX *= pivotDamping; nextLinearX *= pivotDamping;
@@ -372,9 +432,6 @@ export class SimulationUgvController {
rigidbody.linearVelocity = this.limitedLinearVelocity; rigidbody.linearVelocity = this.limitedLinearVelocity;
} }
const angularVelocity = rigidbody.angularVelocity; const angularVelocity = rigidbody.angularVelocity;
const attitudeDamping = braking
? Math.exp(-Math.max(0, deltaSeconds) * BRAKE_ATTITUDE_DAMPING)
: 1;
let nextAngularY = angularVelocity.y; let nextAngularY = angularVelocity.y;
if (pureTurn) { if (pureTurn) {
const desiredYawRate = -turnInput * maxTurnRate; const desiredYawRate = -turnInput * maxTurnRate;
@@ -387,24 +444,137 @@ export class SimulationUgvController {
} else if (Math.abs(angularVelocity.y) > maxTurnRate) { } else if (Math.abs(angularVelocity.y) > maxTurnRate) {
nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate; nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate;
} }
if (attitudeDamping !== 1 || nextAngularY !== angularVelocity.y) { if (nextAngularY !== angularVelocity.y) {
this.limitedAngularVelocity.set( this.limitedAngularVelocity.set(
angularVelocity.x * attitudeDamping, angularVelocity.x,
nextAngularY, nextAngularY,
angularVelocity.z * attitudeDamping, angularVelocity.z,
); );
rigidbody.angularVelocity = this.limitedAngularVelocity; rigidbody.angularVelocity = this.limitedAngularVelocity;
} }
} }
const body = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody?.body as { body?.setActivationState(DISABLE_DEACTIVATION);
setActivationState?: (state: number) => void;
} | null;
body?.setActivationState?.(DISABLE_DEACTIVATION);
if (this.vehicleEntity.getPosition().y < -8) this.reset(); if (this.vehicleEntity.getPosition().y < -8) this.reset();
this.updateCamera(deltaSeconds); this.updateCamera(deltaSeconds);
} }
private applyParkingTyreContact(
deltaSeconds: number,
rigidbody: NonNullable<NativeRigidBodyAccess["rigidbody"]>,
body: NativeRigidBody,
parkingBrakeEngaged: boolean,
): void {
if (!this.vehicle || !this.vehicleEntity || !this.tyreImpulseNative
|| !this.tyreRelativePositionNative) return;
if (!parkingBrakeEngaged) return;
const timeStep = Math.min(Math.max(0, deltaSeconds), 1 / 30);
if (timeStep === 0) return;
const wheelEffectiveMass = this.settings.massKg
/ Math.max(1, this.wheelDefinitions.length);
const chassisPosition = this.vehicleEntity.getPosition();
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
const wheel = this.vehicle.getWheelInfo(index);
const raycast = wheel.get_m_raycastInfo();
const normalForce = wheel.get_m_wheelsSuspensionForce();
if (!Number.isFinite(normalForce) || normalForce < MIN_TYRE_NORMAL_FORCE_NEWTONS) {
continue;
}
const nativeNormal = raycast.get_m_contactNormalWS();
this.tyreContactNormal.set(nativeNormal.x(), nativeNormal.y(), nativeNormal.z());
if (this.tyreContactNormal.lengthSq() < 0.001) continue;
this.tyreContactNormal.normalize();
const nativeAxle = raycast.get_m_wheelAxleWS();
this.tyreLateralDirection.set(nativeAxle.x(), nativeAxle.y(), nativeAxle.z());
this.tyreLateralDirection.addScaled(
this.tyreContactNormal,
-this.tyreLateralDirection.dot(this.tyreContactNormal),
);
if (this.tyreLateralDirection.lengthSq() < 0.001) continue;
this.tyreLateralDirection.normalize();
this.tyreLongitudinalDirection.cross(
this.tyreContactNormal,
this.tyreLateralDirection,
).normalize();
const nativeContactPoint = raycast.get_m_contactPointWS();
this.tyreContactPoint.set(
nativeContactPoint.x(),
nativeContactPoint.y(),
nativeContactPoint.z(),
);
this.tyreRelativePosition.sub2(this.tyreContactPoint, chassisPosition);
this.tyreAngularContactVelocity.cross(
rigidbody.angularVelocity,
this.tyreRelativePosition,
);
this.tyreContactVelocity.add2(
rigidbody.linearVelocity,
this.tyreAngularContactVelocity,
);
const lateralSlipSpeed = this.tyreContactVelocity.dot(this.tyreLateralDirection);
const longitudinalSlipSpeed = this.tyreContactVelocity.dot(
this.tyreLongitudinalDirection,
);
// Static tyre friction is a contact constraint: it balances the component
// of gravity along the surface and damps slip at the contact patch. The
// force still passes through a Coulomb circle and is applied at the wheel,
// so the chassis remains a fully dynamic rigid body.
const lateralGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
* this.tyreLateralDirection.y;
const longitudinalGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
* this.tyreLongitudinalDirection.y;
const trialLateralForce = -wheelEffectiveMass * (
lateralGravityAcceleration
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * lateralSlipSpeed
);
const trialLongitudinalForce = -wheelEffectiveMass * (
longitudinalGravityAcceleration
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * longitudinalSlipSpeed
);
const staticFrictionLimit = TYRE_STATIC_FRICTION_COEFFICIENT * normalForce;
let lateralForce = trialLateralForce;
let longitudinalForce = trialLongitudinalForce;
if (Math.hypot(trialLateralForce, trialLongitudinalForce) > staticFrictionLimit) {
const slipSpeed = Math.hypot(lateralSlipSpeed, longitudinalSlipSpeed);
const kineticFrictionLimit = TYRE_KINETIC_FRICTION_COEFFICIENT * normalForce;
if (slipSpeed > 0.0001) {
lateralForce = -(lateralSlipSpeed / slipSpeed) * kineticFrictionLimit;
longitudinalForce = -(longitudinalSlipSpeed / slipSpeed) * kineticFrictionLimit;
} else {
const forceScale = staticFrictionLimit
/ Math.hypot(trialLateralForce, trialLongitudinalForce);
lateralForce = trialLateralForce * forceScale;
longitudinalForce = trialLongitudinalForce * forceScale;
}
}
const lateralImpulse = lateralForce * timeStep;
const longitudinalImpulse = longitudinalForce * timeStep;
this.tyreImpulseNative.setValue(
this.tyreLateralDirection.x * lateralImpulse
+ this.tyreLongitudinalDirection.x * longitudinalImpulse,
this.tyreLateralDirection.y * lateralImpulse
+ this.tyreLongitudinalDirection.y * longitudinalImpulse,
this.tyreLateralDirection.z * lateralImpulse
+ this.tyreLongitudinalDirection.z * longitudinalImpulse,
);
this.tyreRelativePositionNative.setValue(
this.tyreRelativePosition.x,
this.tyreRelativePosition.y,
this.tyreRelativePosition.z,
);
body.applyImpulse(this.tyreImpulseNative, this.tyreRelativePositionNative);
}
}
private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> { private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> {
const models = collisionWorld.findComponents("model") as ModelComponent[]; const models = collisionWorld.findComponents("model") as ModelComponent[];
if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV."); if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV.");
@@ -428,7 +598,12 @@ export class SimulationUgvController {
this.app, this.app,
meshInstance.mesh, meshInstance.mesh,
new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()), new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()),
targetRatio, Math.max(
1,
Math.floor(
Math.floor((meshInstance.mesh.primitive[0]?.count ?? 0) / 3) * targetRatio,
),
),
); );
if (!physicsMesh) continue; if (!physicsMesh) continue;
const entity = new Entity(`UGV physics proxy ${index + 1}`); const entity = new Entity(`UGV physics proxy ${index + 1}`);
@@ -464,8 +639,9 @@ export class SimulationUgvController {
type: "dynamic", type: "dynamic",
mass: this.settings.massKg, mass: this.settings.massKg,
friction: 0.85, friction: 0.85,
linearDamping: 0.08, rollingFriction: 0.12,
angularDamping: 0.45, linearDamping: 0.12,
angularDamping: 0.6,
}); });
this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05)); this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05));
@@ -525,7 +701,10 @@ export class SimulationUgvController {
applyMaterial(wheelMesh, this.wheelMaterial); applyMaterial(wheelMesh, this.wheelMaterial);
anchor.addChild(wheelMesh); anchor.addChild(wheelMesh);
vehicle.addChild(anchor); vehicle.addChild(anchor);
this.wheelDefinitions.push({ ...definition, anchor }); this.wheelDefinitions.push({
...definition,
anchor,
});
} }
vehicle.setLocalPosition(this.spawnPosition); vehicle.setLocalPosition(this.spawnPosition);
@@ -559,16 +738,17 @@ export class SimulationUgvController {
wheel.set_m_suspensionStiffness(24); wheel.set_m_suspensionStiffness(24);
wheel.set_m_wheelsDampingRelaxation(3.2); wheel.set_m_wheelsDampingRelaxation(3.2);
wheel.set_m_wheelsDampingCompression(4.8); wheel.set_m_wheelsDampingCompression(4.8);
wheel.set_m_frictionSlip(5.5); wheel.set_m_frictionSlip(TYRE_FRICTION_SLIP);
wheel.set_m_rollInfluence(0.08); wheel.set_m_rollInfluence(0.08);
} }
this.ammo.destroy(axle); this.ammo.destroy(axle);
this.ammo.destroy(direction); this.ammo.destroy(direction);
this.ammo.destroy(connection); this.ammo.destroy(connection);
this.tyreImpulseNative = new this.ammo.btVector3(0, 0, 0);
this.tyreRelativePositionNative = new this.ammo.btVector3(0, 0, 0);
dynamicsWorld.addAction(nativeVehicle); dynamicsWorld.addAction(nativeVehicle);
this.vehicleEntity = vehicle; this.vehicleEntity = vehicle;
this.vehicleTuning = tuning;
this.vehicleRaycaster = raycaster; this.vehicleRaycaster = raycaster;
this.vehicle = nativeVehicle; this.vehicle = nativeVehicle;
this.dynamicsWorld = dynamicsWorld; this.dynamicsWorld = dynamicsWorld;
@@ -684,10 +864,12 @@ export class SimulationUgvController {
} }
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject)); if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject)); if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject));
if (this.vehicleTuning) runCleanup("destroy vehicle tuning", () => this.ammo.destroy(this.vehicleTuning as NativeObject)); if (this.tyreImpulseNative) runCleanup("destroy tyre impulse vector", () => this.ammo.destroy(this.tyreImpulseNative as NativeObject));
if (this.tyreRelativePositionNative) runCleanup("destroy tyre relative-position vector", () => this.ammo.destroy(this.tyreRelativePositionNative as NativeObject));
this.vehicle = null; this.vehicle = null;
this.vehicleRaycaster = null; this.vehicleRaycaster = null;
this.vehicleTuning = null; this.tyreImpulseNative = null;
this.tyreRelativePositionNative = null;
this.dynamicsWorld = null; this.dynamicsWorld = null;
if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy()); if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy());
@@ -824,7 +1006,7 @@ function createPhysicsProxyMesh(
app: Application, app: Application,
source: Mesh, source: Mesh,
localTransform: Mat4, localTransform: Mat4,
targetRatio: number, targetTriangleCount: number,
): Mesh | null { ): Mesh | null {
const primitive = source.primitive[0]; const primitive = source.primitive[0];
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0; const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
@@ -858,8 +1040,11 @@ function createPhysicsProxyMesh(
if (triangleIndexCount < 3) return null; if (triangleIndexCount < 3) return null;
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount); if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
if (targetRatio < 1) { const targetIndexCount = Math.max(
const targetIndexCount = Math.max(3, Math.floor(indices.length * targetRatio / 3) * 3); 3,
Math.min(indices.length, Math.floor(targetTriangleCount) * 3),
);
if (indices.length > targetIndexCount) {
const [simplifiedIndices] = MeshoptSimplifier.simplify( const [simplifiedIndices] = MeshoptSimplifier.simplify(
indices, indices,
transformed, transformed,
@@ -868,6 +1053,26 @@ function createPhysicsProxyMesh(
PHYSICS_PROXY_ERROR, PHYSICS_PROXY_ERROR,
); );
indices = new Uint32Array(simplifiedIndices); indices = new Uint32Array(simplifiedIndices);
if (indices.length > targetIndexCount) {
// Topologically noisy scanner meshes can make the quality simplifier stop
// above its requested target. Ammo must never receive that unbounded result:
// the spatial fallback preserves the surface envelope while enforcing the
// same deterministic physics budget for every imported location.
const [boundedIndices] = MeshoptSimplifier.simplifySloppy(
indices,
transformed,
3,
null,
targetIndexCount,
1,
);
indices = new Uint32Array(boundedIndices);
}
if (indices.length > targetIndexCount) {
throw new Error(
`Physics proxy превысил лимит: ${Math.floor(indices.length / 3)} > ${Math.floor(targetIndexCount / 3)} треугольников.`,
);
}
} }
const compactedIndices = new Uint32Array(indices); const compactedIndices = new Uint32Array(indices);
@@ -6,19 +6,18 @@ interface TelemetrySeriesProps {
ceiling?: number; ceiling?: number;
} }
function linePoints(values: Array<number | null>, ceiling?: number): string { export function lineSegments(values: Array<number | null>, ceiling?: number): string[] {
const finite = values.filter((value): value is number => const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
value !== null && Number.isFinite(value), if (!finite.length) return [];
); const maximum = Math.max(ceiling ?? 0, ...finite, 1), denominator = Math.max(1, values.length - 1);
if (finite.length === 0) return ""; const minimum = Math.min(0, ...finite), span = maximum - minimum;
const maximum = Math.max(ceiling ?? 0, ...finite, 1); const segments:string[]=[];let segment:string[]=[];
const denominator = Math.max(1, values.length - 1); values.forEach((value,index)=>{
return values.map((value, index) => { if(value===null||!Number.isFinite(value)){if(segment.length)segments.push(segment.join(' '));segment=[];return;}
const x = index / denominator * 100; segment.push(`${(index/denominator*100).toFixed(2)},${(36-(Math.min(maximum,value)-minimum)/span*34).toFixed(2)}`);
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value)); });
const y = 36 - normalized / maximum * 34; if(segment.length)segments.push(segment.join(' '));
return `${x.toFixed(2)},${y.toFixed(2)}`; return segments;
}).join(" ");
} }
export function TelemetrySeries({ export function TelemetrySeries({
@@ -28,7 +27,7 @@ export function TelemetrySeries({
resource, resource,
ceiling, ceiling,
}: TelemetrySeriesProps) { }: TelemetrySeriesProps) {
const points = linePoints(values, ceiling); const segments = lineSegments(values, ceiling);
return ( return (
<div className="system-telemetry-series"> <div className="system-telemetry-series">
<div> <div>
@@ -45,7 +44,7 @@ export function TelemetrySeries({
aria-label={`${label}: ${value}`} aria-label={`${label}: ${value}`}
> >
<path d="M0 37 H100" /> <path d="M0 37 H100" />
{points ? <polyline points={points} /> : null} {segments.map((points,index)=><polyline key={index} points={points} />)}
</svg> </svg>
</div> </div>
); );
@@ -5,12 +5,14 @@ import type { WorkspaceDefinition } from "../productModel";
interface ApplicationPanelActionsOptions { interface ApplicationPanelActionsOptions {
definition: WorkspaceDefinition | null; definition: WorkspaceDefinition | null;
onAddVehicle?: () => void;
refreshRuntime: () => void; refreshRuntime: () => void;
resetConnectionScenario?: () => Promise<boolean>; resetConnectionScenario?: () => Promise<boolean>;
connectionScenarioResetting: boolean; connectionScenarioResetting: boolean;
saveWorkspaceLayout: () => Promise<void>; saveWorkspaceLayout: () => Promise<void>;
workspaceLayoutSaving: boolean; workspaceLayoutSaving: boolean;
systemUtilityActions: readonly ApplicationPanelUtilityAction[]; systemUtilityActions: readonly ApplicationPanelUtilityAction[];
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
} }
export function deviceRuntimeUtilityAction({ export function deviceRuntimeUtilityAction({
@@ -43,15 +45,18 @@ export function deviceRuntimeUtilityAction({
export function useApplicationPanelActions({ export function useApplicationPanelActions({
definition, definition,
onAddVehicle,
refreshRuntime, refreshRuntime,
resetConnectionScenario, resetConnectionScenario,
connectionScenarioResetting, connectionScenarioResetting,
saveWorkspaceLayout, saveWorkspaceLayout,
workspaceLayoutSaving, workspaceLayoutSaving,
systemUtilityActions, systemUtilityActions,
sessionOverview,
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] { }: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => { return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = []; const actions: ApplicationPanelUtilityAction[] = [];
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
if (definition?.kind === "device") { if (definition?.kind === "device") {
actions.push(deviceRuntimeUtilityAction({ actions.push(deviceRuntimeUtilityAction({
refreshRuntime, refreshRuntime,
@@ -68,14 +73,21 @@ export function useApplicationPanelActions({
}); });
} }
if (definition?.root === "system") actions.push(...systemUtilityActions); if (definition?.root === "system") actions.push(...systemUtilityActions);
if (definition?.kind === "recordings" && sessionOverview) actions.push({
label: sessionOverview.open ? "Закрыть информацию о записи" : "Информация о записи",
icon: "info", pressed: sessionOverview.open, disabled: !sessionOverview.available,
onClick: sessionOverview.toggle,
});
return actions; return actions;
}, [ }, [
definition, definition,
onAddVehicle,
refreshRuntime, refreshRuntime,
resetConnectionScenario, resetConnectionScenario,
connectionScenarioResetting, connectionScenarioResetting,
saveWorkspaceLayout, saveWorkspaceLayout,
systemUtilityActions, systemUtilityActions,
workspaceLayoutSaving, workspaceLayoutSaving,
sessionOverview,
]); ]);
} }
@@ -1,8 +1,13 @@
import type { DeviceUiPlugin } from "../core/device-plugins/contracts"; import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin"; import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
// Composition root: this is the only place where Mission Core chooses which // Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build. // statically reviewed device plugins are shipped in the current build.
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([ export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
xgridsK1Plugin, xgridsK1Plugin,
]); ]);
// Node-executed camera controls use the existing sensor contribution contract;
// they do not register a desktop capture/AI runtime or a new product workspace.
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]);
@@ -11,6 +11,8 @@ import {
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext"; import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts"; import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry"; import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
const NO_NODE_SENSORS: readonly SensorUiContribution[] = Object.freeze([]);
interface DevicePluginHostValue { interface DevicePluginHostValue {
registry: DevicePluginRegistry; registry: DevicePluginRegistry;
@@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId(
export function DevicePluginHostProvider({ export function DevicePluginHostProvider({
plugins, plugins,
nodeSensorContributions = NO_NODE_SENSORS,
children, children,
}: { }: {
plugins: readonly DeviceUiPlugin[]; plugins: readonly DeviceUiPlugin[];
nodeSensorContributions?: readonly SensorUiContribution[];
children: ReactNode; children: ReactNode;
}) { }) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]); const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]);
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []); const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
const [selectedModelId, setSelectedModelId] = useState<string | null>(() => const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
restorePersistedDeviceModelId(registry, selectionStorage) restorePersistedDeviceModelId(registry, selectionStorage)
@@ -97,12 +97,24 @@ export interface DevicePluginHostActions {
activateAutomaticSpatialSource: () => void; activateAutomaticSpatialSource: () => void;
} }
/** Session-bound, presentation-only extension of an active spatial workflow. */
export interface SpatialActivityPresentation {
sessionId: string;
label: string;
detail: string;
busy: boolean;
}
export interface DevicePluginConnectionProps { export interface DevicePluginConnectionProps {
model: DeviceModelDefinition; model: DeviceModelDefinition;
host: DevicePluginHostActions; host: DevicePluginHostActions;
spatialActivity?: SpatialActivityPresentation;
} }
export interface DeviceUiPlugin { export interface DeviceUiPlugin {
sensorUi?: {
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
};
manifest: DevicePluginManifest; manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{ RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null; activeModel: DeviceModelDefinition | null;
@@ -6,8 +6,10 @@ import {
type DeviceUiPlugin, type DeviceUiPlugin,
type RegisteredDeviceModel, type RegisteredDeviceModel,
} from "./contracts"; } from "./contracts";
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
export interface DevicePluginRegistry { export interface DevicePluginRegistry {
readonly sensorContributions: readonly SensorUiContribution[];
readonly plugins: readonly DeviceUiPlugin[]; readonly plugins: readonly DeviceUiPlugin[];
readonly models: readonly RegisteredDeviceModel[]; readonly models: readonly RegisteredDeviceModel[];
resolveModel: (modelId: string) => RegisteredDeviceModel | null; resolveModel: (modelId: string) => RegisteredDeviceModel | null;
@@ -15,6 +17,7 @@ export interface DevicePluginRegistry {
export function createDevicePluginRegistry( export function createDevicePluginRegistry(
installedPlugins: readonly DeviceUiPlugin[], installedPlugins: readonly DeviceUiPlugin[],
nodeSensorContributions: readonly SensorUiContribution[] = [],
): DevicePluginRegistry { ): DevicePluginRegistry {
const pluginIds = new Set<string>(); const pluginIds = new Set<string>();
const modelIds = new Set<string>(); const modelIds = new Set<string>();
@@ -133,7 +136,15 @@ export function createDevicePluginRegistry(
} }
const modelById = new Map(models.map((registered) => [registered.model.id, registered])); const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
const sensorContributions = [
...installedPlugins.flatMap(plugin => plugin.sensorUi?.contributions ?? []),
...nodeSensorContributions,
];
if (new Set(sensorContributions.map(value => value.kind)).size !== sensorContributions.length) {
throw new Error('Плагины повторяют модель устройства БК.');
}
return { return {
sensorContributions: Object.freeze(sensorContributions),
plugins: Object.freeze([...installedPlugins]), plugins: Object.freeze([...installedPlugins]),
models: Object.freeze(models), models: Object.freeze(models),
resolveModel: (modelId) => modelById.get(modelId) ?? null, resolveModel: (modelId) => modelById.get(modelId) ?? null,
@@ -1,32 +1,9 @@
import { roots, type RootId } from "../../productModel"; import { roots, type RootId } from "../../productModel";
export type EnvironmentSurfaceId = "home" | RootId; export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video"; import { defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems, type EnvironmentBackground, type EnvironmentMediaItem, type EnvironmentMediaKind, type EnvironmentMediaSource, type EnvironmentPage } from "@nodedc/ui-core";
export type EnvironmentMediaSource = "file" | "url"; export { createEnvironmentMediaItem, appendEnvironmentMediaItem, removeEnvironmentMediaItem, inferEnvironmentMediaKind, defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems } from "@nodedc/ui-core";
export type { EnvironmentBackground, EnvironmentMediaItem, EnvironmentMediaKind, EnvironmentMediaSource, EnvironmentPage } from "@nodedc/ui-core";
export interface EnvironmentMediaItem {
id: string;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentBackground {
enabled: boolean;
imageDurationSeconds: number;
items: EnvironmentMediaItem[];
}
export interface EnvironmentPage {
headerLabel: string;
eyebrow: string;
title: string;
description: string;
primaryWorkspaceId: string | null;
secondaryWorkspaceId: string | null;
background: EnvironmentBackground;
}
export interface EnvironmentSettings { export interface EnvironmentSettings {
revision: number; revision: number;
@@ -54,72 +31,19 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon", "polygon",
]; ];
export const defaultEnvironmentImageDurationSeconds = 10; function emptyBackground(): EnvironmentBackground { return { enabled: false, imageDurationSeconds: defaultEnvironmentImageDurationSeconds, items: [] }; }
export const maxEnvironmentMediaItems = 24;
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
items: [],
};
}
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file",
url: null,
mediaKind: null,
fileName: null,
};
}
export function appendEnvironmentMediaItem(
background: EnvironmentBackground,
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
): EnvironmentBackground {
if (
background.items.length >= maxEnvironmentMediaItems
|| background.items.some((candidate) => candidate.id === item.id)
) {
return background;
}
return {
...background,
enabled: background.items.length === 0 ? true : background.enabled,
items: [...background.items, item],
};
}
export function removeEnvironmentMediaItem(
background: EnvironmentBackground,
itemId: string,
): EnvironmentBackground {
const items = background.items.filter((item) => item.id !== itemId);
if (items.length === background.items.length) return background;
return {
...background,
enabled: items.length > 0 && background.enabled,
items,
};
}
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
const defaultQuickActions: Record< const defaultQuickActions: Record<
EnvironmentSurfaceId, EnvironmentSurfaceId,
readonly [string | null, string | null] readonly [string | null, string | null]
> = { > = {
home: ["spatial-scene", "local-device"], home: ["spatial-scene", "vehicles"],
fleet: ["contour-health", "local-device"], fleet: ["vehicles", "contour-health"],
observation: ["spatial-scene", "cameras"], observation: ["cameras", "world-map"],
missions: ["mission-planner", null], missions: ["routes", null],
data: ["recordings", "datasets"], data: ["recordings", "datasets"],
system: ["modules", "integrations"], system: ["modules", "integrations"],
polygon: ["lab-archive", null], polygon: ["lab-archive", "local-device"],
}; };
export function defaultEnvironmentSettings(): EnvironmentSettings { export function defaultEnvironmentSettings(): EnvironmentSettings {
@@ -0,0 +1,49 @@
import {useEffect,useState} from 'react';
export interface MetricDefinition {label:string;group:string;resource:string;unit:string;reason:string|null}
export interface BoardMonitor {
available:boolean;fresh:boolean;storage:string;source_id:string;
latest:{seq:number;at:number;boot_id:string;values:Record<string,number|null>}|null;
definitions:Record<string,MetricDefinition>;received_at:number;backlog:number;
database_bytes:number|null;database_budget_bytes:number;archive_since:number|null;
start:number;end:number;bucket_seconds:number;
series:{at:number;min:number|null;max:number|null;mean:number|null;count:number}[];
events:{at:number;code:string;kind:string;locations:string[]}[];
}
export function useBoardMonitor(vehicle:string,metric:string,window:number,end:number|null){
const [value,setValue]=useState<BoardMonitor|null>(null),[error,setError]=useState('');
useEffect(()=>{
let active=true;let pending:AbortController|undefined;let timer:ReturnType<typeof setTimeout>|undefined;
setValue(null);setError('');
const read=async()=>{
const controller=new AbortController();pending=controller;
const deadline=setTimeout(()=>controller.abort(),12000);
try {
const query=new URLSearchParams({metric,window:String(window),...(end===null?{}:{end:String(end)})});
const response=await fetch(`/api/v1/fleet/${encodeURIComponent(vehicle)}/monitor?${query}`,{credentials:'same-origin',cache:'no-store',signal:controller.signal});
if(!response.ok)throw new Error('Мониторинг БК недоступен.');
const next=await response.json() as BoardMonitor;
if(active){setValue(next);setError('');}
}catch {if(active)setError('Не удалось обновить мониторинг БК.');}
finally {clearTimeout(deadline);if(active)timer=setTimeout(()=>void read(),5000);}
};
void read();return()=>{active=false;pending?.abort();clearTimeout(timer);};
},[vehicle,metric,window,end]);
return {value,error,dismissError:()=>setError('')};
}
export function monitorSeries(value:BoardMonitor,mode:'min'|'max'|'mean') {
const count=Math.ceil((value.end-value.start)/value.bucket_seconds)+1;
const values:Array<number|null>=Array(Math.min(count,302)).fill(null);
const first=Math.floor(value.start/value.bucket_seconds)*value.bucket_seconds;
for(const point of value.series){const index=Math.round((point.at-first)/value.bucket_seconds);if(index>=0&&index<values.length)values[index]=point[mode];}
return values;
}
export function monitorValue(value:number|null|undefined,unit=''){
if(value==null||!Number.isFinite(value))return 'Нет измерения';
if(unit==='bytes'||unit==='bytes/s'){
const labels=unit==='bytes'?['Б','КиБ','МиБ','ГиБ']:['Б/с','КиБ/с','МиБ/с','ГиБ/с'];let index=0;
while(value>=1024&&index<3){value/=1024;index++;}
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})} ${labels[index]}`;
}
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})}${unit?' '+unit:''}`;
}
@@ -0,0 +1,23 @@
export interface ObservationLayout {
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
}
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
export function decodeObservationLayout(value:unknown):ObservationLayout {
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
result.order=ids(source.order);result.hidden=ids(source.hidden);
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement;
return result;
}
export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{
const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))];
}
export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout {
const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to);
if(source<0||target<0||source===target)return layout;
order.splice(source,1);order.splice(target,0,from);return {...layout,order};
}
@@ -0,0 +1,18 @@
import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
import {fleetRequest} from './useFleet';
export function createFleetSensorTransport(vehicleID:string):SensorTransport {
const path=`/${encodeURIComponent(vehicleID)}/devices`;
const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{
const value=fleet.items.find(v=>v.id===vehicleID);
if(!value)throw new Error('Аппарат не найден.');
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
};
return {
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
inventory:async()=>inventory(await fleetRequest()),
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
submit:value=>fleetRequest(`${path}/operations`,'POST',value),
operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`),
};
}
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
export interface BoardHost {
hostname: string; os: string; architecture: string; cpus: number;
memory_kib: number | null; collected_at: string;
networks: { name: string; up: boolean; addresses: string[] }[];
usb: { port: string; product: string }[];
}
export interface Vehicle {
id: string; node_id: string; name: string; platform: string;
enrollment: "pending" | "paired" | "revoked" | "failed";
connectivity: "online" | "offline"; last_seen: number | null;
host: BoardHost | null; notice: string; revision: number;
}
export interface FleetPreview {
preview_id: string; node_id: string; name: string; host: BoardHost;
endpoint: string; expires_at: number;
}
export async function fleetRequest<T>(path = "", method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`/api/v1/fleet${path}`, {
method, credentials: "same-origin", cache: "no-store",
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(12000),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(typeof data.detail === "string" ? data.detail : "Не удалось выполнить действие с аппаратом.");
}
return response.json();
}
export function useFleet() {
const [items, setItems] = useState<Vehicle[] | null>(null);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
const value = await fleetRequest<{ items: Vehicle[] }>();
setItems(value.items); setError(""); return value.items;
}, []);
useEffect(() => {
let active = true;
let fallback: ReturnType<typeof setInterval> | undefined;
const read = async () => {
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
};
void read();
const events = new EventSource("/api/v1/fleet/events");
events.onmessage = event => {
if (!active) return;
try { const value = JSON.parse(event.data); setItems(value.items); setError(""); if (fallback) { clearInterval(fallback); fallback = undefined; } }
catch { unavailable(); }
};
const unavailable = () => {
if (!active) return;
setError("Связь обновляется. Показаны последние полученные сведения.");
if (!fallback) fallback = setInterval(() => void read(), 5000);
};
events.onerror = unavailable;
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
}, []);
return { items, error, refresh };
}
@@ -29,7 +29,6 @@ export interface E31LaboratoryResult {
limitations: readonly string[]; limitations: readonly string[];
access: "read-only"; access: "read-only";
} }
export interface E32LaboratoryResult { export interface E32LaboratoryResult {
resultId: string; resultId: string;
createdAtUtc: string | null; createdAtUtc: string | null;
@@ -53,7 +52,6 @@ export interface E32LaboratoryResult {
}; };
access: "read-only"; access: "read-only";
} }
export interface E33LaboratoryResult { export interface E33LaboratoryResult {
resultId: string; resultId: string;
createdAtUtc: string | null; createdAtUtc: string | null;
@@ -0,0 +1,134 @@
import type { ObservationSessionReplayLaunch } from "../observation/sessionArchive";
import type { RecordedRrdArtifactDescriptor } from "../observation/viewerProfile";
const SAFE_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
const SAFE_SESSION_SOURCE = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
const MAX_CANONICAL_REPLAY_BYTES = 1024 * 1024 * 1024;
export interface CanonicalLabReplayDescriptor extends RecordedRrdArtifactDescriptor {
blueprintSourceUrl: string;
}
/**
* Resolve the one immutable RRD used by the canonical recorded LAB.
*
* The server caches the merge of the sealed spatial recording and the LAB AI
* evidence. Rerun therefore opens one source and cannot present the base store
* before a second receiver has finished decoding the semantic layer.
*/
export async function resolveCanonicalLabReplay(
resultId: string,
launch: ObservationSessionReplayLaunch,
{
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
sourceKind = "legacy-vegetation",
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!(sourceKind === "portable-tgs"
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-semantic"
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-objects"
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
: SAFE_RESULT_ID.test(resultId))
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
}
const sourceUrl = sourceKind !== "legacy-vegetation"
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
if (sourceKind === "legacy-vegetation") descriptorUrl.searchParams.set("base_generation", launch.sha256);
if (descriptorUrl.origin !== base.origin) {
throw new Error("Канонический replay LAB должен быть same-origin.");
}
const response = await fetcher(descriptorUrl.href, {
method: "HEAD",
credentials: "same-origin",
headers: { Accept: "application/vnd.rerun.rrd" },
signal,
});
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
const byteLength = Number(response.headers.get("Content-Length"));
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
if (
response.status !== 200
|| contentType !== "application/vnd.rerun.rrd"
|| response.headers.get("X-Rerun-Format") !== "RRF2"
|| !sha256
|| !Number.isSafeInteger(byteLength)
|| byteLength < 4
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
) {
throw new Error("Единый replay LAB не прошёл проверку.");
}
return {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
byteLength,
sha256,
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
export async function resolveAICompositionReplay(
runId: string,
launch: ObservationSessionReplayLaunch,
{
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
}
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
const response = await fetcher(descriptorUrl.href, {
method: "HEAD",
credentials: "same-origin",
headers: { Accept: "application/vnd.rerun.rrd" },
signal,
});
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
const byteLength = Number(response.headers.get("Content-Length"));
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
if (
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
) {
throw new Error("Составной replay LAB не прошёл проверку.");
}
return {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
byteLength,
sha256,
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
@@ -0,0 +1,99 @@
export const CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS = 1;
export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v3";
export interface CanonicalRecordedLabPackedCellEvidence {
centersBodyXyM: Float32Array;
zBoundsM: Float32Array;
stateCodes: Uint8Array;
}
export interface CanonicalRecordedLabBodyGroundFrame {
originMapXyzM: readonly [number, number, number];
sensorOriginMapXyzM: readonly [number, number, number];
basisMapFromBody: readonly [
readonly [number, number, number],
readonly [number, number, number],
readonly [number, number, number],
];
}
export interface CanonicalRecordedLabTgsCostmap {
centersXyM: readonly (readonly [number, number])[];
stateCodes: readonly number[];
zBoundsM: readonly (readonly [number | null, number | null])[];
}
export function canonicalRecordedLabTgsIsCurrent(
currentTimeNs: number,
anchorTimeNs: number,
historySeconds = CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS,
): boolean {
if (
!Number.isSafeInteger(currentTimeNs)
|| !Number.isSafeInteger(anchorTimeNs)
|| !Number.isFinite(historySeconds)
|| historySeconds <= 0
) return false;
const ageNs = currentTimeNs - anchorTimeNs;
return ageNs >= 0 && ageNs <= Math.round(historySeconds * 1_000_000_000);
}
export function canonicalMapGravityLocalPointToBodyGround(
point: readonly [number, number, number],
anchor: CanonicalRecordedLabBodyGroundFrame,
current: CanonicalRecordedLabBodyGroundFrame,
): readonly [number, number, number] {
// TGS is translation-only map-gravity-local: its axes are map axes and its
// origin is the LiDAR at the source frame. It is not an anchor body frame.
const map: readonly [number, number, number] = [
anchor.sensorOriginMapXyzM[0] + point[0],
anchor.sensorOriginMapXyzM[1] + point[1],
anchor.sensorOriginMapXyzM[2] + point[2],
];
const delta: readonly [number, number, number] = [
map[0] - current.originMapXyzM[0],
map[1] - current.originMapXyzM[1],
map[2] - current.originMapXyzM[2],
];
return [
current.basisMapFromBody[0][0] * delta[0]
+ current.basisMapFromBody[1][0] * delta[1]
+ current.basisMapFromBody[2][0] * delta[2],
current.basisMapFromBody[0][1] * delta[0]
+ current.basisMapFromBody[1][1] * delta[1]
+ current.basisMapFromBody[2][1] * delta[2],
current.basisMapFromBody[0][2] * delta[0]
+ current.basisMapFromBody[1][2] * delta[1]
+ current.basisMapFromBody[2][2] * delta[2],
];
}
export function canonicalRecordedLabPackedTgsCells(
costmap: CanonicalRecordedLabTgsCostmap,
anchor: CanonicalRecordedLabBodyGroundFrame,
current: CanonicalRecordedLabBodyGroundFrame,
): CanonicalRecordedLabPackedCellEvidence {
if (
costmap.centersXyM.length !== costmap.stateCodes.length
|| costmap.centersXyM.length !== costmap.zBoundsM.length
) throw new Error("Canonical recorded LAB TGS accounting changed");
const centers: number[] = [];
const zBounds: number[] = [];
costmap.centersXyM.forEach(([x, y], index) => {
const bounds = costmap.zBoundsM[index] ?? [null, null];
const center = canonicalMapGravityLocalPointToBodyGround([x, y, 0], anchor, current);
centers.push(center[0], center[1]);
if (bounds[0] === null || bounds[1] === null) {
zBounds.push(Number.NaN, Number.NaN);
return;
}
const bottom = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[0]], anchor, current);
const top = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[1]], anchor, current);
zBounds.push(Math.min(bottom[2], top[2]), Math.max(bottom[2], top[2]));
});
return {
centersBodyXyM: Float32Array.from(centers),
zBoundsM: Float32Array.from(zBounds),
stateCodes: Uint8Array.from(costmap.stateCodes),
};
}
@@ -0,0 +1,259 @@
import type { LaboratoryFetch } from "./advancedResults";
import { CANONICAL_RECORDED_LAB_SPATIAL_PROFILE } from "./canonicalRecordedLab";
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SHA256 = /^[a-f0-9]{64}$/;
export class CanonicalRecordedLabSpatialContractError extends Error {}
export interface CanonicalRecordedLabSpatialFrame {
targetTimeNs: number;
sourceTimeNs: number;
poseTimeNs: number;
trajectoryTimeNs: number;
sourcePointCount: number;
coordinateFrame: "body-ground";
sensorHeight: {
meters: number;
source: "local-source-cloud-ground-quantile-median" | "session-source-cloud-fallback";
sampleCount: number;
madM: number;
authority: "visual-derived";
};
spatialProfile: {
profileId: typeof CANONICAL_RECORDED_LAB_SPATIAL_PROFILE;
localSlamHistorySeconds: number;
localSlamRadiusM: number;
localSlamVoxelSizeM: number;
localSlamPointLimit: number;
};
bodyFrame: {
originMapXyzM: readonly [number, number, number];
sensorOriginMapXyzM: readonly [number, number, number];
basisMapFromBody: readonly [
readonly [number, number, number],
readonly [number, number, number],
readonly [number, number, number],
];
};
sourcePointsBodyXyzM: readonly (readonly [number, number, number])[];
localSlamSourceFrameCount: number;
localSlamSourcePointCount: number;
localSlamBodyXyzM: readonly (readonly [number, number, number])[];
}
function objectValue(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function arrayValue(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался массив.`);
}
return value;
}
function exact(value: unknown, expected: unknown, label: string): void {
if (value !== expected) {
throw new CanonicalRecordedLabSpatialContractError(`${label}: контракт изменён.`);
}
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось число.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
const parsed = numberValue(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось целое значение.`);
}
return parsed;
}
function pointList(
value: unknown,
label: string,
): readonly (readonly [number, number, number])[] {
return arrayValue(value, label).map((entry, index) => {
const point = arrayValue(entry, `${label}[${index}]`).map(
(channel, channelIndex) => numberValue(channel, `${label}[${index}][${channelIndex}]`),
);
if (point.length !== 3) {
throw new CanonicalRecordedLabSpatialContractError(`${label}[${index}]: размер изменён.`);
}
return [point[0]!, point[1]!, point[2]!] as const;
});
}
export async function fetchCanonicalRecordedLabSpatialFrame(
sessionId: string,
generationSha256: string,
targetTimeNs: number,
{
fetcher = fetch,
signal,
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<CanonicalRecordedLabSpatialFrame> {
if (
!SAFE_SESSION_ID.test(sessionId)
|| !SHA256.test(generationSha256)
|| !Number.isSafeInteger(targetTimeNs)
|| targetTimeNs < 0
) {
throw new CanonicalRecordedLabSpatialContractError(
"Canonical LAB spatial identity недопустима.",
);
}
const query = new URLSearchParams({
generation: generationSha256,
time_ns: String(targetTimeNs),
profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
});
const response = await fetcher(
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`
+ `/canonical-lab/spatial-frame?${query.toString()}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new CanonicalRecordedLabSpatialContractError(
`Canonical LAB spatial frame недоступен: HTTP ${response.status}.`,
);
}
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
exact(
payload.schema_version,
"missioncore.canonical-recorded-lab-spatial-frame/v3",
"canonical_lab.spatial_frame.schema_version",
);
exact(payload.coordinate_frame, "body-ground", "canonical_lab.spatial_frame.coordinate_frame");
exact(payload.target_time_ns, targetTimeNs, "canonical_lab.spatial_frame.target_time_ns");
const sourcePoints = pointList(
payload.source_points_body_xyz_m,
"canonical_lab.spatial_frame.source_points",
);
const localSlam = pointList(
payload.local_slam_body_xyz_m,
"canonical_lab.spatial_frame.local_slam",
);
const sourcePointCount = integerValue(
payload.source_point_count,
"canonical_lab.spatial_frame.source_point_count",
);
const localSlamPointCount = integerValue(
payload.local_slam_point_count,
"canonical_lab.spatial_frame.local_slam_point_count",
);
if (
sourcePointCount !== sourcePoints.length
|| sourcePointCount > 100_000
|| localSlamPointCount !== localSlam.length
|| localSlam.length > 27_000
) {
throw new CanonicalRecordedLabSpatialContractError(
"Canonical LAB spatial accounting изменён.",
);
}
const bodyFrame = objectValue(payload.body_frame, "canonical_lab.spatial_frame.body_frame");
const origin = pointList(
[bodyFrame.origin_map_xyz_m],
"canonical_lab.spatial_frame.body_frame.origin",
)[0]!;
const sensorOrigin = pointList(
[bodyFrame.sensor_origin_map_xyz_m],
"canonical_lab.spatial_frame.body_frame.sensor_origin",
)[0]!;
const basisRows = pointList(
bodyFrame.basis_map_from_body,
"canonical_lab.spatial_frame.body_frame.basis",
);
if (basisRows.length !== 3) {
throw new CanonicalRecordedLabSpatialContractError(
"Canonical LAB spatial basis изменён.",
);
}
const sensorHeight = objectValue(payload.sensor_height, "canonical_lab.spatial_frame.sensor_height");
if (
sensorHeight.source !== "local-source-cloud-ground-quantile-median"
&& sensorHeight.source !== "session-source-cloud-fallback"
) {
throw new CanonicalRecordedLabSpatialContractError(
"canonical_lab.spatial_frame.sensor_height.source: контракт изменён.",
);
}
exact(
sensorHeight.authority,
"visual-derived",
"canonical_lab.spatial_frame.sensor_height.authority",
);
const spatialProfile = objectValue(
payload.spatial_profile,
"canonical_lab.spatial_frame.spatial_profile",
);
exact(
spatialProfile.profile_id,
CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
"canonical_lab.spatial_frame.spatial_profile.profile_id",
);
return {
targetTimeNs,
sourceTimeNs: integerValue(payload.source_time_ns, "canonical_lab.spatial_frame.source_time_ns"),
poseTimeNs: integerValue(payload.pose_time_ns, "canonical_lab.spatial_frame.pose_time_ns"),
trajectoryTimeNs: integerValue(
payload.trajectory_time_ns,
"canonical_lab.spatial_frame.trajectory_time_ns",
),
sourcePointCount,
coordinateFrame: "body-ground",
sensorHeight: {
meters: numberValue(sensorHeight.meters, "canonical_lab.spatial_frame.sensor_height.meters"),
source: sensorHeight.source,
sampleCount: integerValue(
sensorHeight.sample_count,
"canonical_lab.spatial_frame.sensor_height.sample_count",
),
madM: numberValue(sensorHeight.mad_m, "canonical_lab.spatial_frame.sensor_height.mad_m"),
authority: "visual-derived",
},
spatialProfile: {
profileId: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
localSlamHistorySeconds: numberValue(
spatialProfile.local_slam_history_seconds,
"canonical_lab.spatial_frame.spatial_profile.history",
),
localSlamRadiusM: numberValue(
spatialProfile.local_slam_radius_m,
"canonical_lab.spatial_frame.spatial_profile.radius",
),
localSlamVoxelSizeM: numberValue(
spatialProfile.local_slam_voxel_size_m,
"canonical_lab.spatial_frame.spatial_profile.voxel",
),
localSlamPointLimit: integerValue(
spatialProfile.local_slam_point_limit,
"canonical_lab.spatial_frame.spatial_profile.limit",
),
},
bodyFrame: {
originMapXyzM: origin,
sensorOriginMapXyzM: sensorOrigin,
basisMapFromBody: [basisRows[0]!, basisRows[1]!, basisRows[2]!],
},
sourcePointsBodyXyzM: sourcePoints,
localSlamSourceFrameCount: integerValue(
payload.local_slam_source_frame_count,
"canonical_lab.spatial_frame.local_slam_source_frames",
),
localSlamSourcePointCount: integerValue(
payload.local_slam_source_point_count,
"canonical_lab.spatial_frame.local_slam_source_points",
),
localSlamBodyXyzM: localSlam,
};
}
@@ -154,6 +154,9 @@ export interface M4ThreatTimelineFrame {
pointCloudSourceCount: number; pointCloudSourceCount: number;
pointCloudSampleCount: number; pointCloudSampleCount: number;
pointCloudLayer: "current-increment"; pointCloudLayer: "current-increment";
localSlamBodyXyzM?: readonly M4Point3[];
localSlamSourceFrameCount?: number;
localSlamSourcePointCount?: number;
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[]; cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
cameraProjectedSourceCount: number; cameraProjectedSourceCount: number;
cameraProjectedPointCount: number; cameraProjectedPointCount: number;
@@ -168,10 +171,11 @@ export interface M4ThreatTimelineFrame {
export interface M4ThreatTimeline { export interface M4ThreatTimeline {
resultId: string; resultId: string;
recordedSourceSessionId: "20260720T065719Z_viewer_live"; recordedSourceSessionId: string;
recordedSourceId: string;
imageWidth: 800; imageWidth: 800;
imageHeight: 600; imageHeight: 600;
frameCount: 4489; frameCount: number;
frameTimesNs: readonly number[]; frameTimesNs: readonly number[];
timelineStartSeconds: number; timelineStartSeconds: number;
timelineEndSeconds: number; timelineEndSeconds: number;
@@ -234,7 +238,7 @@ export interface M4ThreatPlaybackProgress {
export interface M4ThreatPlaybackPointPack { export interface M4ThreatPlaybackPointPack {
resultId: string; resultId: string;
frameCount: 4489; frameCount: number;
pointCount: number; pointCount: number;
pointOffsets: Uint32Array; pointOffsets: Uint32Array;
pointsMapXyzM: Float32Array; pointsMapXyzM: Float32Array;
@@ -257,7 +261,7 @@ export interface M4ThreatPlaybackChunkDescriptor {
export interface M4ThreatPlaybackManifest { export interface M4ThreatPlaybackManifest {
resultId: string; resultId: string;
frameCount: 4489; frameCount: number;
pointCount: number; pointCount: number;
pointOffsets: Uint32Array; pointOffsets: Uint32Array;
chunkFrameCount: 24; chunkFrameCount: 24;
@@ -337,7 +341,7 @@ const motion = (value: unknown): M4ThreatMotion => {
}; };
const resultId = (value: unknown): string => { const resultId = (value: unknown): string => {
const parsed = text(value, "M4.6 result id"); const parsed = text(value, "M4.6 result id");
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) { if (!/^[a-z0-9][a-z0-9-]{0,127}-[a-f0-9]{64}$/.test(parsed)) {
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность."); throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
} }
return parsed; return parsed;
@@ -704,12 +708,8 @@ export async function fetchM4ThreatTimeline(
exact(payload.result_id, result, "M4.6 timeline result"); exact(payload.result_id, result, "M4.6 timeline result");
exact(payload.authority, "replay-simulated", "M4.6 timeline authority"); exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
const recorded = object(payload.recorded_source, "M4.6 recorded source"); const recorded = object(payload.recorded_source, "M4.6 recorded source");
exact( const recordedSessionId = text(recorded.session_id, "M4.6 recorded session");
recorded.session_id, const recordedSourceId = text(recorded.source_id, "M4.6 recorded source id");
"20260720T065719Z_viewer_live",
"M4.6 recorded session",
);
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
exact( exact(
recorded.representation_id, recorded.representation_id,
"registered-map-increment-v1", "registered-map-increment-v1",
@@ -720,7 +720,10 @@ export async function fetchM4ThreatTimeline(
"host-arrival-best-effort", "host-arrival-best-effort",
"M4.6 recorded synchronization", "M4.6 recorded synchronization",
); );
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count"); const frameCount = integer(payload.frame_count, "M4.6 timeline frame count");
if (frameCount < 1) {
throw new M4ThreatContractError("M4.6 timeline frame count: пустой timeline.");
}
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map( const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
(value) => integer(value, "M4.6 timeline time"), (value) => integer(value, "M4.6 timeline time"),
); );
@@ -738,7 +741,8 @@ export async function fetchM4ThreatTimeline(
); );
return { return {
resultId: result, resultId: result,
recordedSourceSessionId: "20260720T065719Z_viewer_live", recordedSourceSessionId: recordedSessionId,
recordedSourceId,
imageWidth: exact(payload.image_width, 800, "M4.6 image width"), imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
imageHeight: exact(payload.image_height, 600, "M4.6 image height"), imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
frameCount, frameCount,
@@ -849,12 +853,14 @@ export async function fetchM4ThreatTimelineChunk(
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
cameraObstacleProjectionDelivery = null, cameraObstacleProjectionDelivery = null,
playbackPointPack, playbackPointPack,
includePoints = true,
}: { }: {
fetcher?: LaboratoryFetch; fetcher?: LaboratoryFetch;
signal?: AbortSignal; signal?: AbortSignal;
endpointRoot?: string; endpointRoot?: string;
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"]; cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
playbackPointPack?: M4ThreatPlaybackPointPack; playbackPointPack?: M4ThreatPlaybackPointPack;
includePoints?: boolean;
} = {}, } = {},
): Promise<M4ThreatTimelineChunk> { ): Promise<M4ThreatTimelineChunk> {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -864,7 +870,7 @@ export async function fetchM4ThreatTimelineChunk(
if (cameraObstacleProjectionDelivery !== null) { if (cameraObstacleProjectionDelivery !== null) {
params.set("obstacle_projection", cameraObstacleProjectionDelivery); params.set("obstacle_projection", cameraObstacleProjectionDelivery);
} }
if (playbackPointPack) params.set("include_points", "false"); if (playbackPointPack || !includePoints) params.set("include_points", "false");
const response = await fetcher( const response = await fetcher(
`${endpointRoot}/${result}/timeline/chunk?${params}`, `${endpointRoot}/${result}/timeline/chunk?${params}`,
{ headers: { Accept: "application/json" }, signal }, { headers: { Accept: "application/json" }, signal },
@@ -995,7 +1001,10 @@ export async function fetchM4ThreatPlaybackManifest(
exact(manifest.result_id, result, "M4.6 playback result"); exact(manifest.result_id, result, "M4.6 playback result");
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame"); exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access"); exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
const frameCount = exact(integer(manifest.frame_count, "M4.6 playback frames"), 4489, "M4.6 playback frames"); const frameCount = integer(manifest.frame_count, "M4.6 playback frames");
if (frameCount < 1) {
throw new M4ThreatContractError("M4.6 playback frames: пустой playback недопустим.");
}
const pointCount = integer(manifest.point_count, "M4.6 playback points"); const pointCount = integer(manifest.point_count, "M4.6 playback points");
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets"); const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
if (offsetsRaw.length !== frameCount + 1) { if (offsetsRaw.length !== frameCount + 1) {
@@ -1332,6 +1341,17 @@ function parseTimelineFrame(
"current-increment", "current-increment",
"M4.6 timeline point layer", "M4.6 timeline point layer",
), ),
localSlamBodyXyzM: item.local_slam_body_xyz_m === undefined
? []
: array(item.local_slam_body_xyz_m, "M4.6 local SLAM points").map(
(point) => vector(point, 3, "M4.6 local SLAM point") as [number, number, number],
),
localSlamSourceFrameCount: item.local_slam_source_frame_count === undefined
? undefined
: integer(item.local_slam_source_frame_count, "M4.6 local SLAM source frames"),
localSlamSourcePointCount: item.local_slam_source_point_count === undefined
? undefined
: integer(item.local_slam_source_point_count, "M4.6 local SLAM source points"),
cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined
? [] ? []
: array(item.camera_projected_points_xyd, "M4.6 camera points").map( : array(item.camera_projected_points_xyd, "M4.6 camera points").map(
@@ -0,0 +1,57 @@
import {
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
type LaboratoryRecordedEvidenceViewerProfile,
} from "../observation/viewerProfile";
export type LaboratoryRecordedMediaMode = "video" | "camera" | null;
export type LaboratoryRecordedSpatialMode = "3d" | "plan" | null;
export type LaboratoryClassifiedSpatialMode = "none" | "overlay" | "replace-source";
export interface LaboratoryRecordedEvidenceVisibility {
mediaMode: LaboratoryRecordedMediaMode;
spatialMode: LaboratoryRecordedSpatialMode;
showMediaSemantic: boolean;
showSpatialSemantic: boolean;
showMediaPoints: boolean;
classifiedSpatialMode: LaboratoryClassifiedSpatialMode;
}
export interface LaboratoryRecordedEvidenceDemand {
sourceTimelineMetadata: true;
recordedVideo: boolean;
exactCameraFrame: boolean;
sourceSpatialPoints: boolean;
cameraPointOverlay: boolean;
selectedSemanticMask: boolean;
selectedSemanticPoints: boolean;
classifiedSpatial: boolean;
}
/**
* Translate the already-visible M4 evidence composition into explicit data
* demand. The profile never starts a hidden point, semantic or camera channel
* merely because the selected LAB happens to publish that artifact.
*/
export function laboratoryRecordedEvidenceDemand(
visibility: LaboratoryRecordedEvidenceVisibility,
profile: LaboratoryRecordedEvidenceViewerProfile =
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
): LaboratoryRecordedEvidenceDemand {
if (profile.loadPolicy !== "explicit-legacy-comparison-only") {
throw new Error("Unsupported LAB recorded evidence load policy");
}
const mediaVisible = visibility.mediaMode !== null;
const spatialVisible = visibility.spatialMode !== null;
return {
sourceTimelineMetadata: true,
recordedVideo: visibility.mediaMode === "video",
exactCameraFrame: visibility.mediaMode === "camera",
sourceSpatialPoints:
spatialVisible && visibility.classifiedSpatialMode !== "replace-source",
cameraPointOverlay: mediaVisible && visibility.showMediaPoints,
selectedSemanticMask: mediaVisible && visibility.showMediaSemantic,
selectedSemanticPoints: spatialVisible && visibility.showSpatialSemantic,
classifiedSpatial:
spatialVisible && visibility.classifiedSpatialMode !== "none",
};
}
@@ -106,6 +106,18 @@ export interface VegetationMixedRouteReview {
cases: readonly VegetationMixedRouteCase[]; cases: readonly VegetationMixedRouteCase[];
} }
export interface VegetationRouteTgsAnchor {
sourceSequence: number;
slot: number;
currentPointsXyzM: readonly (readonly [number, number, number])[];
costmap: {
cellSizeM: 0.45;
centersXyM: readonly (readonly [number, number])[];
stateCodes: readonly number[];
zBoundsM: readonly (readonly [number | null, number | null])[];
};
}
export interface VegetationFullRouteLayer { export interface VegetationFullRouteLayer {
name: string; name: string;
resultId: string; resultId: string;
@@ -119,6 +131,7 @@ export interface VegetationFullRouteLayer {
export interface VegetationFullRouteReview { export interface VegetationFullRouteReview {
sourceId: "RAVNOVES004TREE"; sourceId: "RAVNOVES004TREE";
sessionId: "20260828T130511Z_viewer_live"; sessionId: "20260828T130511Z_viewer_live";
linkedRouteReviewResultId: string;
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0"; sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0";
sourceJobInputSha256: string; sourceJobInputSha256: string;
sourceStreamSha256: string; sourceStreamSha256: string;
@@ -692,6 +705,15 @@ function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null
"recorded-camera-eb2783c5480d56bda07c8af0", "recorded-camera-eb2783c5480d56bda07c8af0",
"vegetation.route_full_review.source_job_id", "vegetation.route_full_review.source_job_id",
); );
const linkedRouteReviewResultId = textValue(
row.linked_route_review_result_id,
"vegetation.route_full_review.linked_route_review_result_id",
);
if (!RESULT_ID.test(linkedRouteReviewResultId)) {
throw new VegetationShadowContractError(
"vegetation.route_full_review: linked route review identity invalid.",
);
}
exact(row.frame_count, 6830, "vegetation.route_full_review.frame_count"); exact(row.frame_count, 6830, "vegetation.route_full_review.frame_count");
exact(row.width, 800, "vegetation.route_full_review.width"); exact(row.width, 800, "vegetation.route_full_review.width");
exact(row.height, 600, "vegetation.route_full_review.height"); exact(row.height, 600, "vegetation.route_full_review.height");
@@ -810,6 +832,7 @@ function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null
return { return {
sourceId: "RAVNOVES004TREE", sourceId: "RAVNOVES004TREE",
sessionId: "20260828T130511Z_viewer_live", sessionId: "20260828T130511Z_viewer_live",
linkedRouteReviewResultId,
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0", sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0",
sourceJobInputSha256, sourceJobInputSha256,
sourceStreamSha256, sourceStreamSha256,
@@ -931,7 +954,79 @@ export function vegetationFullRouteMaskUrl(
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`; return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`;
} }
export async function fetchVegetationShadowResult( export async function fetchVegetationRouteTgsAnchor(
resultId: string,
sourceSequence: number,
{
fetcher = fetch,
signal,
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<VegetationRouteTgsAnchor> {
if (!RESULT_ID.test(resultId) || !Number.isInteger(sourceSequence) || sourceSequence < 1) {
throw new VegetationShadowContractError("Vegetation TGS anchor identity недопустима.");
}
const response = await fetcher(
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
+ `/route-tgs-anchor/${sourceSequence}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new VegetationShadowContractError(`Vegetation TGS anchor недоступен: HTTP ${response.status}.`);
}
const payload = objectValue(await response.json(), "vegetation.route_tgs_anchor");
exact(
payload.schema_version,
"missioncore.lab-v1-route-tgs-anchor/v1",
"vegetation.route_tgs_anchor.schema_version",
);
exact(payload.source_sequence, sourceSequence, "vegetation.route_tgs_anchor.source_sequence");
const pointValue = (value: unknown, label: string): readonly number[] => {
const point = arrayValue(value, label).map((item, index) => numberValue(item, `${label}[${index}]`));
if (point.length !== 2 && point.length !== 3) {
throw new VegetationShadowContractError(`${label}: размер изменён.`);
}
return point;
};
const points = arrayValue(payload.current_points_xyz_m, "vegetation.route_tgs_anchor.points")
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.points[${index}]`));
const costmap = objectValue(payload.costmap, "vegetation.route_tgs_anchor.costmap");
exact(costmap.cell_size_m, 0.45, "vegetation.route_tgs_anchor.costmap.cell_size_m");
const centers = arrayValue(costmap.centers_xy_m, "vegetation.route_tgs_anchor.costmap.centers")
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.costmap.centers[${index}]`));
const stateCodes = arrayValue(costmap.state_codes, "vegetation.route_tgs_anchor.costmap.states")
.map((value, index) => integerValue(value, `vegetation.route_tgs_anchor.costmap.states[${index}]`));
const zBounds = arrayValue(costmap.z_bounds_m, "vegetation.route_tgs_anchor.costmap.z_bounds")
.map((value, index) => {
const row = arrayValue(value, `vegetation.route_tgs_anchor.costmap.z_bounds[${index}]`);
if (row.length !== 2 || row.some((item) => item !== null && (typeof item !== "number" || !Number.isFinite(item)))) {
throw new VegetationShadowContractError("vegetation.route_tgs_anchor.costmap.z_bounds: контракт изменён.");
}
return row as readonly [number | null, number | null];
});
if (
centers.length !== 2244
|| stateCodes.length !== 2244
|| zBounds.length !== 2244
|| stateCodes.some((value) => value > 3)
|| points.some((point) => point.length !== 3)
|| centers.some((point) => point.length !== 2)
) {
throw new VegetationShadowContractError("Vegetation TGS anchor shape изменён.");
}
return {
sourceSequence,
slot: integerValue(payload.slot, "vegetation.route_tgs_anchor.slot"),
currentPointsXyzM: points.map((point) => [point[0]!, point[1]!, point[2]!] as const),
costmap: {
cellSizeM: 0.45,
centersXyM: centers.map((point) => [point[0]!, point[1]!] as const),
stateCodes,
zBoundsM: zBounds,
},
};
}
export async function fetchVegetationShadowResultMetadata(
resultId: string, resultId: string,
{ {
fetcher = fetch, fetcher = fetch,
@@ -953,6 +1048,17 @@ export async function fetchVegetationShadowResult(
resultId, resultId,
"/api/v1/laboratory/vegetation-shadow", "/api/v1/laboratory/vegetation-shadow",
); );
return result;
}
export async function fetchVegetationShadowResult(
resultId: string,
{
fetcher = fetch,
signal,
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<VegetationShadowResult> {
const result = await fetchVegetationShadowResultMetadata(resultId, { fetcher, signal });
if (!result.routeFullReview) return result; if (!result.routeFullReview) return result;
const timelineResponse = await fetcher( const timelineResponse = await fetcher(
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`, `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`,
@@ -0,0 +1,74 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { plannerBase, plannerRequest, type Draft } from './planner';
export interface PlanningLiveTest {
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
tracking_state?: 'acquiring'|'tracking'|'lost';
tracking_established?: boolean;
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
route_relocalization_policy?: {version?:string;query_radius_m?:number};
}
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
const endpoint = plannerBase+'/live-tests';
const terminal = new Set(['completed','cancelled','error','interrupted']);
const Context = createContext<{
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
}|null>(null);
/** Server owns the run and its frozen reference, never the workspace launch profile. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
const [pollError,setPollError]=useState<string|null>(null);
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
const generation=useRef(0);
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
const select=useCallback(async(id:string)=>{
generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
finally{generation.current+=1;setBusy(false);}
},[]);
useEffect(()=>{
let disposed=false, timer:ReturnType<typeof setTimeout>;
const poll=async()=>{
const expected=generation.current;
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
if(!disposed)timer=setTimeout(()=>void poll(),1500);
};void poll();return()=>{disposed=true;clearTimeout(timer);};
},[]);
const begin=useCallback(async(draft:Draft)=>{
generation.current+=1;setBusy(true);setError(null);
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
finally{generation.current+=1;setBusy(false);}
},[]);
const finish=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const retryInitialization=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
}
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
/** A prepared consumer owns the next capture until explicitly finished or used. */
export function planningAwaitsCapture(test:PlanningLiveTest|null){
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
}
@@ -0,0 +1,44 @@
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
export type Direction = "forward" | "reverse";
export interface Draft {
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
zone: { session_id: string; generation: string; label: string };
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
}
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
export const plannerBase = "/api/v1/mission-planner";
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
}
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
return response.json() as Promise<T>;
}
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
const poses = source.poses.slice(start, end + 1);
return direction === "reverse" ? poses.reverse() : poses;
}
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
export function endAtDistance(source: PlanningSource, start: number, length: number) {
const target = source.poses[start].distance_m + length;
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
return index < 0 ? source.poses.length - 1 : index;
}
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
return data;
}
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
let low = min, high = max;
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
}
@@ -0,0 +1,51 @@
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
export interface PlanningMatchState {
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
result:{status:string}|null;
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
presentation_state?:'live'|'historical'|'unlocalized';
}
/** Current geometry is insufficient: the temporal gate must also be tracking. */
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
&&(t.presentation_state===undefined||t.presentation_state==='live')
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
&&t.result?.status==='candidate';
}
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
type PlanningPresentation=PlanningMatchState & {message:string};
const phaseLabels:Record<string,string>={
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
};
/** Terminal failures and data freshness take precedence over successful old fits. */
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
if(t.planning_phase==='lost'&&!t.tracking_established)
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
if(t.planning_phase&&phaseLabels[t.planning_phase])
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
}
/** A plugin may show this only for its matching, authoritative acquiring session. */
export function planningActivity(
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
):SpatialActivityPresentation|undefined{
if(!t.query_session_id||!['running','error'].includes(t.state)
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
const status=planningStatus(t,error);
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
}
@@ -0,0 +1,93 @@
import type { PlanningSceneDelivery } from './planningSceneStream';
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
'missioncore.planning-browser-presentation/v1';
type BrowserPresentationSample = {
cloud_revision:number;
cloud_sequence:number;
display_epoch:string;
request_ms:number;
rerun_admission_ms:number;
first_animation_frame_ms:number|null;
second_animation_frame_ms:number|null;
frame_timeout:boolean;
source_to_second_animation_frame_upper_bound_ms:number|null;
};
type ReporterDependencies = {
url:string;
fetcher?:typeof fetch;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
const batchSize=8;
const flushDelayMs=250;
/**
* Bounded, best-effort browser observation. These samples never drive scene
* admission, registration, navigation, or device control.
*/
export function createPlanningPresentationReporter(deps:ReporterDependencies){
const fetcher=deps.fetcher??fetch;
const schedule=deps.schedule??setTimeout;
const cancel=deps.cancel??clearTimeout;
let pending:BrowserPresentationSample[]=[];
let timer:ReturnType<typeof setTimeout>|undefined;
let sending=false,stopped=false;
const flush=async()=>{
if(sending||stopped||!pending.length)return;
if(timer!==undefined){cancel(timer);timer=undefined;}
sending=true;
const samples=pending.splice(0,batchSize);
try{
// Deliberately best-effort: the presentation observer must not make the
// live Rerun channel wait for an unrelated report request.
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
headers:{'Content-Type':'application/json'},
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
});
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
}catch{
// A lost browser report is observable as fewer samples, never retried into
// an unbounded queue and never promoted into a capture/fit failure.
}finally{
sending=false;
if(!stopped&&pending.length)void flush();
}
};
const record=(delivery:PlanningSceneDelivery,timing:{
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
secondAnimationFrameMs:number|null;
})=>{
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
const second=timing.secondAnimationFrameMs;
pending.push({
cloud_revision:delivery.cloudRevision,
cloud_sequence:delivery.cloudSequence,
display_epoch:delivery.displayEpoch,
request_ms:delivery.requestMs,
rerun_admission_ms:timing.rerunAdmissionMs,
first_animation_frame_ms:timing.firstAnimationFrameMs,
second_animation_frame_ms:second,
frame_timeout:second===null,
source_to_second_animation_frame_upper_bound_ms:second===null?null:
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
});
if(pending.length>=batchSize)void flush();
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
};
return {
record,
dispose:()=>{
stopped=true;
if(timer!==undefined)cancel(timer);
pending=[];
},
};
}
@@ -0,0 +1,37 @@
import type { Draft } from './planner';
import type { RegistrationReport } from './useRegistrationTest';
import { plannerBase, plannerRequest } from './planner';
export interface PlanningProject {
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
state: string; created_at_utc: string; result_status: string|null;
reference_label: string; query_label: string|null; draft_id: string; revision: number;
}
export interface PlanningProjectDetail extends PlanningProject {
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
scene_note?:string|null;
}
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
export async function deletePlanningProject(project: PlanningProject) {
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
);
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
}
export function planningProjectStatus(p: PlanningProject) {
if (p.kind === 'draft') return 'Подготовка';
if (planningProjectPending(p)) return 'Выполняется';
if (p.result_status === 'candidate') return 'Кандидат совмещения';
if (p.result_status === 'rejected') return 'Совмещение отклонено';
return 'Без результата совмещения';
}
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
return items.find(p => p.key === last)?.key
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
?? items[0]?.key ?? '';
}
@@ -0,0 +1,87 @@
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
export type PlanningSceneDelivery = {
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
};
type Dependencies = {
url:string; snapshot:()=>Snapshot;
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
fetcher?:typeof fetch; now?:()=>number;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
export function startPlanningSceneStream(deps:Dependencies){
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
let timer:ReturnType<typeof setTimeout>|undefined;
let request:AbortController|undefined;
const poll=async()=>{
if(stopped)return;
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
timer=schedule(()=>void poll(),500);return;
}
request=new AbortController();
const deadline=schedule(()=>request?.abort(),2500);
let failed=false;
try{
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
params.set('base',String(needsBase||!cursor||key!==nextKey));
if(cursor)params.set('cursor',cursor);
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
if(!response.ok)throw new Error('scene unavailable');
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
if(stopped)return;
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
const elapsed=now()-started;
if(presentation==='live'){
// Include the entire request duration: a conservative upper bound, without
// comparing clocks on different hosts. Late bytes must not revive green.
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
throw new Error('scene expired during delivery');
}
const latest=deps.snapshot();
// Never apply a response from the previous display mode or live/ended state.
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
if(!nextCursor)throw new Error('scene cursor missing');
if(bytes)await deps.apply(bytes,{
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
}); // Native admission only; the caller may record a separate presentation proxy.
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
}catch{
// Keep the last admitted cursor (including camera intent). A full geometry
// repair after a missed/expired response is not permission to reset the eye.
if(!stopped){failed=true;needsBase=true;deps.error();}
}finally{
cancel(deadline);request=undefined;
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
}
};
void poll();
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
}
function parseNonnegativeInteger(value:string|null){
if(value===null||!/^\d+$/.test(value))return null;
const parsed=Number(value);
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
}
function parseFiniteNumber(value:string|null){
if(value===null||value.trim()==='')return null;
const parsed=Number(value);
return Number.isFinite(parsed)?parsed:null;
}
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
export function useMissionPlanner() {
const [sessions, setSessions] = useState<SessionOption[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Draft[]>([]);
const [saved, setSaved] = useState<Draft | null>(null);
const [name, setName] = useState("");
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [direction, setDirection] = useState<Direction>("forward");
const [error, setError] = useState<string | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [catalogBusy, setCatalogBusy] = useState(true);
const [loadVersion, setLoadVersion] = useState(0);
const [sourceVersion, setSourceVersion] = useState(0);
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
const [check, setCheck] = useState<RouteCheck | null>(null);
useEffect(() => {
const abort = new AbortController(); setCatalogBusy(true); setError(null);
void Promise.all([
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
return () => abort.abort();
}, [loadVersion]);
useEffect(() => {
const abort = new AbortController(); setSource(null); setSourceError(null);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
return () => abort.abort();
}, [sessionId, sourceVersion, pinnedGeneration]);
const currentSource = source?.session_id === sessionId ? source : null;
// New executions always own the complete reference. No independent interval
// state can survive a source change or collapse on same-source reselection.
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
const chooseSource = (id: string) => { setPinnedGeneration(null); setSessionId(id); setSourceVersion(n => n + 1); setCheck(null); };
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
const openDraft = async (id: string) => {
setBusy(true); setError(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
setDirection(next.route.direction); setCheck(null);
setSourceVersion(n => n + 1);
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const save = async () => {
if (!ready || !currentSource) return;
setBusy(true); setError(null); setCheck(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
generation: currentSource.generation, whole_recording: true, direction,
}) });
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
return next;
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const runCheck = async () => {
if (!saved || dirty || !ready) return;
setBusy(true); setError(null); setCheck(null);
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const loadMore = async () => {
if (!cursor) return;
setCatalogBusy(true);
try {
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
};
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
}
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { plannerBase, plannerRequest } from './planner';
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
const storageKey = 'missioncore.planning-project.selected.v1';
export function usePlanningProjects() {
const [items, setItems] = useState<PlanningProject[]>([]);
const [key, setKey] = useState('');
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
const [version, setVersion] = useState(0);
const initialized = useRef(false);
const selectedKey = useRef('');
const removedKeys = useRef(new Set<string>());
const removing = useRef(false);
const select = useCallback((value: string) => {
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
}, []);
const refresh = useCallback(() => setVersion(n=>n+1), []);
const remove = useCallback(async (project: PlanningProject) => {
if (removing.current) throw new Error('Удаление уже выполняется.');
removing.current = true;
try {
await deletePlanningProject(project);
removedKeys.current.add(project.key);
setItems(items => items.filter(item => item.key !== project.key));
if (selectedKey.current === project.key) select('');
} finally { removing.current = false; }
}, [select]);
useEffect(()=>{
const abort = new AbortController(); setLoading(true); setError(null);
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
if (abort.signal.aborted) return;
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
setItems(visible);
if (!initialized.current) {
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
select(defaultPlanningProject(visible,last));
}
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
return ()=>abort.abort();
},[version,select]);
useEffect(()=>{
if (!key) return;
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
setDetail(null); setError(null);
const [kind,id]=key.split(':');
const poll=async()=>{
try {
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
if(abort.signal.aborted || removedKeys.current.has(key))return;
if(next.key!==key)throw new Error('Получен результат другого исследования.');
setDetail(next);setError(null);
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
};
void poll(); return()=>{abort.abort();clearTimeout(timer);};
},[key,version]);
return {items,key,detail,loading,error,select,refresh,remove};
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
export interface RegistrationReport {
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
}
export function useRegistrationTest(draft: Draft | null) {
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
const [catalogBusy,setCatalogBusy]=useState(false);
// Repeat recordings belong to the planner, including its own captured passes;
// they are not restricted to the independent-reference catalog.
useEffect(()=>{
const abort=new AbortController();setCatalogBusy(true);
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
return()=>abort.abort();
},[]);
const loadMore=async()=>{
if(!cursor||catalogBusy)return;setCatalogBusy(true);
try{
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
};
useEffect(() => {
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [sessionId]);
const busy = submitting;
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
const run = async (savedDraft = draft) => {
if (!source || busy || !savedDraft) return;
setSubmitting(true); setError(null);
try {
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
});
return data;
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
};
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
}
@@ -0,0 +1,140 @@
export const OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA =
"missioncore.observatory-calculation-profile/v1" as const;
export type ObservationLabCalculationProfileOrigin =
| "archived-definition"
| "existing-result";
export interface ObservationLabCalculationProfile {
readonly schemaVersion: typeof OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA;
readonly setupId: string;
readonly displayName: string;
readonly origin: ObservationLabCalculationProfileOrigin;
readonly definitionId: string | null;
readonly definitionVersion: number | null;
readonly definitionSha256: string | null;
}
export class ObservationLabCalculationProfileContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationLabCalculationProfileContractError";
}
}
const PROFILE_KEYS = new Set([
"schema_version",
"setup_id",
"display_name",
"origin",
"definition_id",
"definition_version",
"definition_sha256",
]);
const IDENTIFIER = /^[a-z][a-z0-9-]{2,95}$/;
const SHA256 = /^[a-f0-9]{64}$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireText(value: unknown, field: string, maximum: number): string {
if (
typeof value !== "string"
|| value.length === 0
|| value.length > maximum
|| value !== value.trim()
) {
throw new ObservationLabCalculationProfileContractError(
`Поле calculation_profile.${field} должно быть непустой строкой.`,
);
}
return value;
}
function requireIdentifier(value: unknown, field: string): string {
const identifier = requireText(value, field, 96);
if (!IDENTIFIER.test(identifier)) {
throw new ObservationLabCalculationProfileContractError(
`Поле calculation_profile.${field} содержит некорректный идентификатор.`,
);
}
return identifier;
}
export function decodeObservationLabCalculationProfile(
value: unknown,
sessionId: string,
): ObservationLabCalculationProfile | null {
if (value === null) return null;
if (!isRecord(value) || Object.keys(value).some((key) => !PROFILE_KEYS.has(key))) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} нарушил контракт полей.`,
);
}
if (
Object.keys(value).length !== PROFILE_KEYS.size
|| value.schema_version !== OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестную версию.`,
);
}
const origin = value.origin;
if (origin !== "archived-definition" && origin !== "existing-result") {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестное происхождение.`,
);
}
const definitionId = value.definition_id === null
? null
: requireIdentifier(value.definition_id, "definition_id");
const definitionVersion = value.definition_version === null
? null
: value.definition_version;
if (
definitionVersion !== null
&& (
typeof definitionVersion !== "number"
|| !Number.isInteger(definitionVersion)
|| definitionVersion < 1
)
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} содержит некорректную версию определения.`,
);
}
const definitionSha256 = value.definition_sha256 === null
? null
: requireText(value.definition_sha256, "definition_sha256", 64);
if (definitionSha256 !== null && !SHA256.test(definitionSha256)) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} не содержит SHA-256 определения.`,
);
}
const definitionFieldCount = [
definitionId,
definitionVersion,
definitionSha256,
].filter((entry) => entry !== null).length;
if (
(origin === "existing-result" && definitionFieldCount !== 0)
|| (origin === "archived-definition" && definitionFieldCount !== 3)
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} содержит неполную идентичность определения.`,
);
}
return {
schemaVersion: OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA,
setupId: requireIdentifier(value.setup_id, "setup_id"),
displayName: requireText(value.display_name, "display_name", 256),
origin,
definitionId,
definitionVersion,
definitionSha256,
};
}
@@ -0,0 +1,280 @@
export interface ObservationCanonicalLabReplayCapability {
schemaVersion: "missioncore.observation-lab-replay-capability/v1";
kind: "canonical-recorded-rerun";
viewerProfile: "recorded-session";
timeline: "session_time";
activation: "explicit";
commandsEnabled: false;
}
export interface ObservationPortableLabReplayCapability {
schemaVersion: "missioncore.observation-lab-replay-capability/v2";
kind: "portable-result-review";
viewerProfile: "portable-result";
timeline: "result-defined";
activation: "explicit";
commandsEnabled: false;
}
export type ObservationLabReplayCapability =
| ObservationCanonicalLabReplayCapability
| ObservationPortableLabReplayCapability;
export class ObservationLabReplayCapabilityContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationLabReplayCapabilityContractError";
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const CAPABILITY_KEYS = new Set([
"schema_version",
"kind",
"viewer_profile",
"timeline",
"activation",
"commands_enabled",
]);
const CANONICAL_PROVENANCE_KEYS = new Set([
"schema_version",
"evidence_identity_sha256",
"result_document_sha256",
"replay_capability",
"authority",
"method",
]);
const PORTABLE_PROVENANCE_KEYS = new Set([
"schema_version",
"authority",
"calculation_profile",
"calculation_profile_sha256",
"job",
"source",
"run_definition",
"result_package",
"replay_capability",
"storage",
"method",
]);
const AUTHORITY_KEYS = new Set([
"commands_enabled",
"navigation_or_safety_accepted",
"actuation_accepted",
]);
const METHOD_KEYS = new Set([
"schema_version",
"completeness",
"execution_class",
"pipeline_id",
"components",
]);
const METHOD_COMPONENT_KEYS = new Set([
"kind",
"name",
"version",
"role",
"identity_sha256",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertExactKeys(
value: Record<string, unknown>,
expected: ReadonlySet<string>,
label: string,
): void {
const keys = Object.keys(value);
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
throw new ObservationLabReplayCapabilityContractError(
`${label} содержит неизвестные или пропущенные поля.`,
);
}
}
export function decodeLabReplayCapability(
value: unknown,
sessionId: string,
): ObservationLabReplayCapability | null {
if (value === null || value === undefined) return null;
if (!isRecord(value)) {
throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} должна быть объектом или null.`,
);
}
assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`);
if (
value.activation !== "explicit"
|| value.commands_enabled !== false
) {
throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`,
);
}
if (
value.schema_version === "missioncore.observation-lab-replay-capability/v1"
&& value.kind === "canonical-recorded-rerun"
&& value.viewer_profile === "recorded-session"
&& value.timeline === "session_time"
) {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
};
}
if (
value.schema_version === "missioncore.observation-lab-replay-capability/v2"
&& value.kind === "portable-result-review"
&& value.viewer_profile === "portable-result"
&& value.timeline === "result-defined"
) {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commandsEnabled: false,
};
}
throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} содержит неизвестный viewer capability.`,
);
}
export function decodeCanonicalLabReplayCapabilityProvenance(
provenance: Readonly<Record<string, unknown>>,
sessionId: string,
resultId: string,
): ObservationCanonicalLabReplayCapability {
assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`);
if (
provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1"
|| typeof provenance.evidence_identity_sha256 !== "string"
|| !SHA256.test(provenance.evidence_identity_sha256)
|| resultId !== `lab-v1-vegetation-shadow-${provenance.evidence_identity_sha256}`
|| typeof provenance.result_document_sha256 !== "string"
|| !SHA256.test(provenance.result_document_sha256)
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
);
}
if (!isRecord(provenance.authority)) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} не содержит authority.`,
);
}
assertExactKeys(provenance.authority, AUTHORITY_KEYS, `Canonical authority LAB-сессии ${sessionId}`);
if (
provenance.authority.commands_enabled !== false
|| provenance.authority.navigation_or_safety_accepted !== false
|| provenance.authority.actuation_accepted !== false
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} нарушает observation-only authority.`,
);
}
if (!isRecord(provenance.method)) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} не содержит method manifest.`,
);
}
assertExactKeys(provenance.method, METHOD_KEYS, `Canonical method LAB-сессии ${sessionId}`);
if (
provenance.method.schema_version !== "missioncore.laboratory-method/v1"
|| provenance.method.completeness !== "legacy-partial"
|| provenance.method.execution_class !== "ai-inference"
|| provenance.method.pipeline_id
!== "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|| !Array.isArray(provenance.method.components)
|| provenance.method.components.length !== 1
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical method LAB-сессии ${sessionId} не соответствует записанному прогону.`,
);
}
const component = provenance.method.components[0];
if (!isRecord(component)) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical method LAB-сессии ${sessionId} не содержит source component.`,
);
}
assertExactKeys(
component,
METHOD_COMPONENT_KEYS,
`Canonical source component LAB-сессии ${sessionId}`,
);
if (
component.kind !== "source"
|| component.name !== "sealed full-route LAB result"
|| component.version !== "missioncore.lab-v1-vegetation-shadow/v1"
|| component.role !== "immutable Session catalog projection"
|| component.identity_sha256 !== provenance.evidence_identity_sha256
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical source component LAB-сессии ${sessionId} потерял immutable identity.`,
);
}
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
if (capability === null || capability.kind !== "canonical-recorded-rerun") {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`,
);
}
return capability;
}
export function decodePortableLabReplayCapabilityProvenance(
provenance: Readonly<Record<string, unknown>>,
sessionId: string,
resultId: string,
sourceSessionId: string,
definitionSha256: string,
): ObservationPortableLabReplayCapability {
assertExactKeys(
provenance,
PORTABLE_PROVENANCE_KEYS,
`Portable provenance LAB-сессии ${sessionId}`,
);
if (
provenance.schema_version !== "missioncore.observatory-portable-result-publication/v1"
|| !isRecord(provenance.source)
|| provenance.source.session_id !== sourceSessionId
|| !isRecord(provenance.run_definition)
|| provenance.run_definition.definition_sha256 !== definitionSha256
|| !isRecord(provenance.result_package)
|| typeof provenance.result_package.manifest_sha256 !== "string"
|| !SHA256.test(provenance.result_package.manifest_sha256)
|| typeof provenance.result_package.artifact_manifest_id !== "string"
|| !SHA256.test(provenance.result_package.artifact_manifest_id)
|| sessionId !== resultId
) {
throw new ObservationLabReplayCapabilityContractError(
`Portable provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
);
}
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
if (capability === null || capability.kind !== "portable-result-review") {
throw new ObservationLabReplayCapabilityContractError(
`Portable provenance LAB-сессии ${sessionId} не содержит viewer capability.`,
);
}
return capability;
}
/** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */
export function decodeRollingCanonicalLabReplayCapability(
provenance: Readonly<Record<string, unknown>>,
sessionId: string,
resultId: string,
): ObservationLabReplayCapability | null {
if (!Object.prototype.hasOwnProperty.call(provenance, "replay_capability")) return null;
return decodeCanonicalLabReplayCapabilityProvenance(provenance, sessionId, resultId);
}
@@ -0,0 +1,70 @@
import { isUsableRecordedPlaybackRange } from "./recordedRerunLifecycle";
export function createReentrantViewerDisposer(
cleanupOnce: () => void,
releaseNativeViewer: () => void,
): () => void {
let cleanupComplete = false;
return () => {
try {
if (!cleanupComplete) {
cleanupComplete = true;
cleanupOnce();
}
} finally {
// A deferred native start can resolve after a pre-ready stop. Reapply
// release at every boundary so it cannot reopen after React unmount.
releaseNativeViewer();
}
};
}
interface ActiveLiveViewerOwner {
release: () => void;
}
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
/** Own exactly one native live receiver per application document. */
export function claimExclusiveLiveViewer(release: () => void): () => void {
const owner = { release };
const previous = activeLiveViewerOwner;
activeLiveViewerOwner = owner;
previous?.release();
return () => {
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
};
}
export function isLiveRerunPresentationReady(
viewerStarted: boolean,
rangeNs: { min: number; max: number } | null,
backendActivitySequence: number | null,
): boolean {
return viewerStarted
&& Number.isSafeInteger(backendActivitySequence)
&& (backendActivitySequence ?? 0) > 0
&& isUsableRecordedPlaybackRange(rangeNs);
}
export function liveTimelineNeedsSynchronization(
followLive: boolean,
activeTimeline: string | null | undefined,
rangeNs: { min: number; max: number } | null,
): boolean {
return followLive
&& isUsableRecordedPlaybackRange(rangeNs)
&& activeTimeline !== "stream_time";
}
export function liveRerunReceiverBindingIdentity(
sourceUrl: string,
liveStreamId: string | null,
followLive: boolean,
): string {
return JSON.stringify([
followLive ? "live" : "recorded",
sourceUrl.trim(),
followLive ? liveStreamId?.trim() ?? "" : "",
]);
}
@@ -327,6 +327,53 @@ export function verifyLiveViewerClientBuild(
}); });
} }
/**
* Recover a recorded viewer whose lazy module disappeared during a frontend
* deployment. Live acquisition deliberately never reloads automatically, but
* a saved recording has no device-side authority to preserve and can safely
* move to the current immutable application build.
*/
export async function reloadRecordedViewerAfterStaleModuleFailure({
loadedUiBuildId,
signal,
fetcher = globalThis.fetch,
reload = () => window.location.reload(),
}: {
loadedUiBuildId: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
reload?: () => void;
}): Promise<boolean> {
if (
signal?.aborted ||
loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID ||
!HASHED_UI_BUILD_ID.test(loadedUiBuildId)
) return false;
try {
const response = await fetcher("/api/v1/viewer/client-contract", {
method: "GET",
headers: {
Accept: "application/json",
"X-MissionCore-UI-Build": loadedUiBuildId,
},
cache: "no-store",
signal,
});
if (signal?.aborted || !response.ok) return false;
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
if (
!expectedUiBuildId ||
expectedUiBuildId === loadedUiBuildId ||
!HASHED_UI_BUILD_ID.test(expectedUiBuildId)
) return false;
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
reload();
return true;
} catch {
return false;
}
}
function startBuildMonitor(): void { function startBuildMonitor(): void {
if (buildMonitorAbort || typeof window === "undefined") return; if (buildMonitorAbort || typeof window === "undefined") return;
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1); const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
@@ -0,0 +1,53 @@
/** Small lease for the server's ephemeral blueprint, never for the recording. */
export function keepRecordedBlueprintSession({
endpointUrl,
origin,
applicationId,
recordingId,
ownerId,
fetcher = fetch,
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
cancel = (handle: number) => window.clearInterval(handle),
}: {
endpointUrl: string;
origin: string;
applicationId: string;
recordingId: string;
ownerId: string;
fetcher?: typeof fetch;
schedule?: (callback: () => void) => number;
cancel?: (handle: number) => void;
}): () => void {
const endpoint = new URL(endpointUrl, origin);
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
}
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
const identity = { application_id: applicationId, recording_id: recordingId,
blueprint_session_id: ownerId };
let closed = false;
let pending: AbortController | null = null;
const renew = () => {
if (closed) return;
pending?.abort();
pending = new AbortController();
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
};
const timer = schedule(renew);
return () => {
if (closed) return;
closed = true;
cancel(timer);
pending?.abort();
pending = null;
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "release" }),
keepalive: true, signal: AbortSignal.timeout(5_000),
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
};
}
@@ -0,0 +1,68 @@
import type {SceneSettings} from '../../sceneSettings';
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
export function pointDisplayKey(settings: PointDisplaySettings): string {
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
}
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
export async function streamRecordedPointDisplay(
blueprintUrl: string, settings: PointDisplaySettings,
identity: {applicationId: string; recordingId: string}, displayBank: string,
signal: AbortSignal, send: (chunk: Uint8Array) => void,
fetcher: typeof fetch = fetch,
sourceGeneration?: string,
): Promise<number> {
const endpoint = new URL(blueprintUrl, window.location.origin);
const percent = settings.pointDecimationPercent ?? 0;
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
point_decimation_percent: percent, display_bank: displayBank,
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
throw new Error('Не удалось подготовить прореженное облако');
}
const reader = response.body.getReader();
let bytes = 0;
let pending = new Uint8Array(0);
let admitted = false;
let complete = false;
try {
while (true) {
const {done, value} = await reader.read();
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
if (done) break;
bytes += value.byteLength;
const combined = new Uint8Array(pending.length + value.length);
combined.set(pending); combined.set(value, pending.length); pending = combined;
if (!admitted) {
if (pending.length < 4) continue;
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
pending = pending.slice(4); admitted = true;
}
while (pending.length >= 4 && !complete) {
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
if (length === 0) { pending = pending.slice(4); complete = true; break; }
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
if (pending.length < length + 4) break;
const payload = pending.slice(4, length + 4);
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
send(payload); pending = pending.slice(length + 4);
}
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
}
if (!complete || pending.length) throw new Error('Поток облака не завершён');
return bytes;
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
@@ -0,0 +1,303 @@
import type { SceneSettings } from "../../sceneSettings";
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
import type { RerunPlaybackState, RerunViewportStatus } from "./viewerProfile";
const BUFFER_END_TOLERANCE_NS = 1_000_000;
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
const RECORDED_OPEN_GRACE_MS = 30_000;
const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024;
export const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
export interface RecordedPlaybackBufferState {
bufferedEndNs: number | null;
expectedStartNs: number | null;
expectedEndNs: number | null;
bufferProgress: number | null;
fullyBuffered: boolean;
}
export function recordedPointColorKey(
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
): string {
const custom = settings.palette === "custom" || settings.colorMode === "class"
? settings.customColor.toLowerCase()
: "-";
return `${settings.colorMode}|${settings.palette}|${custom}`;
}
export function recordedOpenWatchdogTimeoutMs(byteLength: number): number {
if (!Number.isSafeInteger(byteLength) || byteLength < 4) {
throw new Error("Unsafe recorded RRD byte length");
}
const transferBudgetMs = Math.ceil(
(byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000,
);
return Math.min(
RECORDED_OPEN_MAX_TIMEOUT_MS,
Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS),
);
}
export function createRecordedOpenWatchdog<T>({
byteLength,
schedule,
cancel,
onTimeout,
}: {
byteLength: number;
schedule: (callback: () => void, timeoutMs: number) => T;
cancel: (handle: T) => void;
onTimeout: () => void;
}): { arm: () => void; clear: () => void; pending: () => boolean } {
const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength);
let handle: T | null = null;
return {
arm() {
if (handle !== null) return;
handle = schedule(() => {
handle = null;
onTimeout();
}, timeoutMs);
},
clear() {
if (handle === null) return;
cancel(handle);
handle = null;
},
pending: () => handle !== null,
};
}
export function isUsableRecordedPlaybackRange(
rangeNs: { min: number; max: number } | null,
): rangeNs is { min: number; max: number } {
return Boolean(
rangeNs
&& Number.isFinite(rangeNs.min)
&& Number.isFinite(rangeNs.max)
&& rangeNs.max >= rangeNs.min,
);
}
export function recordedPlaybackBufferState(
rangeNs: { min: number; max: number } | null,
expectedTimelineEndSeconds?: number,
expectedTimelineStartSeconds?: number,
): RecordedPlaybackBufferState {
const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null;
const bufferedEndNs = usableRange?.max ?? null;
if (expectedTimelineEndSeconds === undefined) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: usableRange !== null,
};
}
if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: false,
};
}
const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000;
const expectedStartNs = expectedTimelineStartSeconds === undefined
? 0
: expectedTimelineStartSeconds * 1_000_000_000;
if (
!Number.isFinite(expectedEndNs)
|| !Number.isFinite(expectedStartNs)
|| expectedStartNs < 0
|| expectedEndNs < expectedStartNs
) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: false,
};
}
const fullyBuffered = usableRange !== null
&& usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS
&& usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS;
const expectedDurationNs = expectedEndNs - expectedStartNs;
const bufferProgress = bufferedEndNs === null
? 0
: expectedDurationNs <= 0
? (fullyBuffered ? 1 : 0)
: Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs));
return {
bufferedEndNs,
expectedStartNs,
expectedEndNs,
bufferProgress,
fullyBuffered,
};
}
export function isRecordedPlaybackReady(
viewerStarted: boolean,
artifactVerified: boolean,
buffer: RecordedPlaybackBufferState,
): boolean {
return viewerStarted && artifactVerified && buffer.bufferedEndNs !== null;
}
export function recordedPlaybackRangeWhenReady(
rangeNs: { min: number; max: number } | null,
buffer: RecordedPlaybackBufferState,
artifactVerified: boolean,
): { min: number; max: number } | null {
if (
!artifactVerified
|| !buffer.fullyBuffered
|| !isUsableRecordedPlaybackRange(rangeNs)
) return null;
return {
min: buffer.expectedStartNs === null
? rangeNs.min
: Math.max(rangeNs.min, buffer.expectedStartNs),
max: buffer.expectedEndNs === null
? rangeNs.max
: Math.min(rangeNs.max, buffer.expectedEndNs),
};
}
export function isRecordedPlaybackPresentationReady(
status: RerunViewportStatus,
playback: RerunPlaybackState | null,
): boolean {
return status === "ready"
&& playback?.fullyBuffered === true
&& isUsableRecordedPlaybackRange(playback.rangeNs);
}
export function rerunPresentationStatus(
status: RerunViewportStatus,
_gate: RecordedAdmissionPhase,
recorded: boolean,
): RerunViewportStatus {
if (!recorded) return status;
return status === "error" ? "error" : status;
}
export function isRecordedPlaybackFullyBuffered(
rangeNs: { min: number; max: number } | null,
expectedTimelineEndSeconds?: number,
): boolean {
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
}
export function attemptRecordedAutoplay(
seekToStart: () => void,
startPlaying: () => void,
): boolean {
try {
seekToStart();
startPlaying();
return true;
} catch {
return false;
}
}
export function createRecordedInitialSeekGate(): {
attempt: (
viewerStarted: boolean,
fullyBuffered: boolean,
presentationReady: boolean,
rangeNs: { min: number; max: number } | null,
seekToStart: (startNs: number) => void,
preferredStartNs?: number,
) => boolean;
attempted: () => boolean;
} {
let consumed = false;
return {
attempt(
viewerStarted,
fullyBuffered,
presentationReady,
rangeNs,
seekToStart,
preferredStartNs,
) {
if (
consumed
|| !viewerStarted
|| !fullyBuffered
|| !presentationReady
|| !isUsableRecordedPlaybackRange(rangeNs)
) return false;
consumed = true;
const startNs = Number.isFinite(preferredStartNs)
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
: rangeNs.min;
try {
seekToStart(startNs);
return true;
} catch {
return false;
}
},
attempted: () => consumed,
};
}
export function createRecordedAutoplayGate(): {
attempt: (
viewerStarted: boolean,
fullyBuffered: boolean,
presentationReady: boolean,
rangeNs: { min: number; max: number } | null,
seekToStart: (startNs: number) => void,
startPlaying: () => void,
preferredStartNs?: number,
) => boolean;
attempted: () => boolean;
} {
let consumed = false;
return {
attempt(
viewerStarted,
fullyBuffered,
presentationReady,
rangeNs,
seekToStart,
startPlaying,
preferredStartNs,
) {
if (
consumed
|| !viewerStarted
|| !fullyBuffered
|| !presentationReady
|| !isUsableRecordedPlaybackRange(rangeNs)
) return false;
consumed = true;
const startNs = Number.isFinite(preferredStartNs)
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
: rangeNs.min;
return attemptRecordedAutoplay(
() => seekToStart(startNs),
startPlaying,
);
},
attempted: () => consumed,
};
}
export function canPublishRecordedPlaybackController(
readyToRender: boolean,
presentationGate: RecordedAdmissionPhase,
): boolean {
return readyToRender && presentationGate === "ready";
}
@@ -0,0 +1,43 @@
export interface ObservationReplayAttempt {
readonly signal: AbortSignal;
isCurrent: () => boolean;
finish: () => boolean;
}
export interface ObservationReplayCoordinator {
begin: () => ObservationReplayAttempt;
cancel: () => void;
}
/** Latest explicit admission owns the viewer, even if an older promise settles 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 settlement ownership until finish(). isCurrent() already fails,
// while begin() can replace this attempt immediately.
},
};
}
@@ -1,6 +1,20 @@
import { import {
MAX_RECORDED_CAMERA_SOURCES, MAX_RECORDED_CAMERA_SOURCES,
} from "./recordedSessionAdmission"; } from "./recordedSessionAdmission";
import {
decodeLabReplayCapability,
decodeRollingCanonicalLabReplayCapability,
ObservationLabReplayCapabilityContractError,
type ObservationLabReplayCapability,
} from "./labReplayCapability";
import {
decodeObservationLabCalculationProfile,
ObservationLabCalculationProfileContractError,
type ObservationLabCalculationProfile,
} from "./labCalculationProfile";
export type { ObservationLabReplayCapability } from "./labReplayCapability";
export type { ObservationLabCalculationProfile } from "./labCalculationProfile";
export type ObservationSessionStatus = export type ObservationSessionStatus =
| "recording" | "recording"
@@ -9,7 +23,7 @@ export type ObservationSessionStatus =
| "interrupted" | "interrupted"
| "failed"; | "failed";
export type ObservationSessionScope = "all" | "source" | "laboratory"; export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
export interface ObservationLabInstance { export interface ObservationLabInstance {
labId: string; labId: string;
@@ -20,6 +34,8 @@ export interface ObservationLabInstance {
configSha256: string | null; configSha256: string | null;
runCreatedAtUtc: string; runCreatedAtUtc: string;
publishedAtUtc: string; publishedAtUtc: string;
replayCapability: ObservationLabReplayCapability | null;
calculationProfile: ObservationLabCalculationProfile | null;
provenance: Readonly<Record<string, unknown>>; provenance: Readonly<Record<string, unknown>>;
} }
@@ -32,10 +48,21 @@ export interface ObservationSessionSummary {
modalities: readonly string[]; modalities: readonly string[];
durationSeconds: number; durationSeconds: number;
replayable: boolean; replayable: boolean;
captureAttestation: ObservationCaptureAttestation | null;
preparation: ObservationSessionCatalogPreparation | null; preparation: ObservationSessionCatalogPreparation | null;
lab: ObservationLabInstance | null; lab: ObservationLabInstance | null;
} }
export interface ObservationCaptureAttestation {
readonly equipmentModelId: string;
readonly equipmentModelSha256: string;
readonly equipmentDisplayName: string;
readonly captureProfileId: string;
readonly captureProfileSha256: string;
readonly captureAttestationSha256: string;
readonly state: "attested" | "attested-with-legacy-evidence" | "unknown";
}
export interface ObservationSessionCatalog { export interface ObservationSessionCatalog {
items: readonly ObservationSessionSummary[]; items: readonly ObservationSessionSummary[];
} }
@@ -52,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
seekable: true; seekable: true;
byteLength: number; byteLength: number;
sha256: string; sha256: string;
mapGeneration?: string;
playback: { playback: {
speed: number; speed: number;
loop: boolean; loop: boolean;
@@ -152,10 +180,11 @@ const ITEM_KEYS = new Set([
"modalities", "modalities",
"duration_seconds", "duration_seconds",
"replayable", "replayable",
"capture_attestation",
"preparation", "preparation",
"lab", "lab",
]); ]);
const LAB_KEYS = new Set([ const LEGACY_LAB_KEYS = new Set([
"lab_id", "lab_id",
"source_session_id", "source_session_id",
"result_kind", "result_kind",
@@ -166,6 +195,27 @@ const LAB_KEYS = new Set([
"published_at_utc", "published_at_utc",
"provenance", "provenance",
]); ]);
const LAB_V2_KEYS = new Set([...LEGACY_LAB_KEYS, "replay_capability"]);
const LAB_V3_KEYS = new Set([
...LAB_V2_KEYS,
"calculation_profile",
]);
const CAPTURE_ATTESTATION_KEYS = new Set([
"schema_version",
"equipment_model",
"capture_profile",
"capture_attestation_sha256",
"state",
]);
const EQUIPMENT_MODEL_KEYS = new Set([
"equipment_model_id",
"equipment_model_sha256",
"display_name",
]);
const CAPTURE_PROFILE_KEYS = new Set([
"capture_profile_id",
"capture_profile_sha256",
]);
const CATALOG_PREPARATION_KEYS = new Set([ const CATALOG_PREPARATION_KEYS = new Set([
"preparation_id", "preparation_id",
"state", "state",
@@ -188,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
"seekable", "seekable",
"byte_length", "byte_length",
"sha256", "sha256",
"map_generation",
"playback", "playback",
"media_sources", "media_sources",
]); ]);
@@ -379,11 +430,103 @@ function decodeItem(value: unknown, index: number): ObservationSessionSummary {
modalities: requireModalities(value.modalities), modalities: requireModalities(value.modalities),
durationSeconds: value.duration_seconds, durationSeconds: value.duration_seconds,
replayable: value.replayable, replayable: value.replayable,
captureAttestation: decodeCaptureAttestation(value.capture_attestation, id),
preparation: decodeCatalogPreparation(value.preparation, id), preparation: decodeCatalogPreparation(value.preparation, id),
lab: decodeLabInstance(value.lab, id), lab: decodeLabInstance(value.lab, id),
}; };
} }
function decodeCaptureAttestation(
value: unknown,
sessionId: string,
): ObservationCaptureAttestation | null {
if (value === null || value === undefined) return null;
if (!isRecord(value)) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} должна быть объектом или null.`,
);
}
assertExactKeys(value, CAPTURE_ATTESTATION_KEYS, `Аттестация записи ${sessionId}`);
if (value.schema_version !== "missioncore.recorded-capture-attestation/v1") {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} имеет неизвестную схему.`,
);
}
if (!isRecord(value.equipment_model) || !isRecord(value.capture_profile)) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} не содержит оборудование или профиль записи.`,
);
}
assertExactKeys(
value.equipment_model,
EQUIPMENT_MODEL_KEYS,
`Оборудование записи ${sessionId}`,
);
assertExactKeys(
value.capture_profile,
CAPTURE_PROFILE_KEYS,
`Профиль записи ${sessionId}`,
);
const equipmentModelId = requireString(
value.equipment_model.equipment_model_id,
"equipment_model_id",
128,
);
const equipmentModelSha256 = requireString(
value.equipment_model.equipment_model_sha256,
"equipment_model_sha256",
64,
);
const captureProfileId = requireString(
value.capture_profile.capture_profile_id,
"capture_profile_id",
128,
);
const captureProfileSha256 = requireString(
value.capture_profile.capture_profile_sha256,
"capture_profile_sha256",
64,
);
const captureAttestationSha256 = requireString(
value.capture_attestation_sha256,
"capture_attestation_sha256",
64,
);
if (
!SAFE_ID.test(equipmentModelId)
|| !SAFE_ID.test(captureProfileId)
|| !SHA256.test(equipmentModelSha256)
|| !SHA256.test(captureProfileSha256)
|| !SHA256.test(captureAttestationSha256)
) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} содержит некорректную immutable identity.`,
);
}
if (
value.state !== "attested"
&& value.state !== "attested-with-legacy-evidence"
&& value.state !== "unknown"
) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} содержит неизвестное состояние.`,
);
}
return {
equipmentModelId,
equipmentModelSha256,
equipmentDisplayName: requireString(
value.equipment_model.display_name,
"equipment display_name",
128,
),
captureProfileId,
captureProfileSha256,
captureAttestationSha256,
state: value.state,
};
}
function decodeLabInstance( function decodeLabInstance(
value: unknown, value: unknown,
sessionId: string, sessionId: string,
@@ -394,7 +537,23 @@ function decodeLabInstance(
`LAB-привязка сессии ${sessionId} должна быть объектом или null.`, `LAB-привязка сессии ${sessionId} должна быть объектом или null.`,
); );
} }
assertExactKeys(value, LAB_KEYS, `LAB-привязка сессии ${sessionId}`); const hasTypedCapability = Object.prototype.hasOwnProperty.call(
value,
"replay_capability",
);
const hasCalculationProfile = Object.prototype.hasOwnProperty.call(
value,
"calculation_profile",
);
assertExactKeys(
value,
hasCalculationProfile
? LAB_V3_KEYS
: hasTypedCapability
? LAB_V2_KEYS
: LEGACY_LAB_KEYS,
`LAB-привязка сессии ${sessionId}`,
);
const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36); const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36);
if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) { if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) {
throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер."); throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер.");
@@ -430,6 +589,28 @@ function decodeLabInstance(
if (!isRecord(value.provenance)) { if (!isRecord(value.provenance)) {
throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом."); throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом.");
} }
let replayCapability: ObservationLabReplayCapability | null;
try {
replayCapability = hasTypedCapability
? decodeLabReplayCapability(value.replay_capability, sessionId)
: decodeRollingCanonicalLabReplayCapability(value.provenance, sessionId, resultId);
} catch (error) {
if (error instanceof ObservationLabReplayCapabilityContractError) {
throw new ObservationSessionContractError(error.message);
}
throw error;
}
let calculationProfile: ObservationLabCalculationProfile | null;
try {
calculationProfile = hasCalculationProfile
? decodeObservationLabCalculationProfile(value.calculation_profile, sessionId)
: null;
} catch (error) {
if (error instanceof ObservationLabCalculationProfileContractError) {
throw new ObservationSessionContractError(error.message);
}
throw error;
}
return { return {
labId, labId,
sourceSessionId, sourceSessionId,
@@ -445,6 +626,8 @@ function decodeLabInstance(
value.published_at_utc, value.published_at_utc,
`lab(${sessionId}).published_at_utc`, `lab(${sessionId}).published_at_utc`,
), ),
replayCapability,
calculationProfile,
provenance: value.provenance, provenance: value.provenance,
}; };
} }
@@ -606,6 +789,9 @@ export function decodeObservationSessionReplay(
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) { if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256."); throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
} }
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
}
const viewerSourceUrl = requireString( const viewerSourceUrl = requireString(
launch.viewer_source_url, launch.viewer_source_url,
"launch.viewer_source_url", "launch.viewer_source_url",
@@ -664,6 +850,7 @@ export function decodeObservationSessionReplay(
integer: true, integer: true,
}), }),
sha256: launch.sha256, sha256: launch.sha256,
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
playback: { speed, loop: launch.playback.loop }, playback: { speed, loop: launch.playback.loop },
mediaSources, mediaSources,
}; };
@@ -1181,6 +1368,7 @@ export async function fetchObservationSessionCatalog({
queryParameters.set("limit", String(Number(limit))); queryParameters.set("limit", String(Number(limit)));
} }
if (scope !== "all") queryParameters.set("scope", scope); if (scope !== "all") queryParameters.set("scope", scope);
if (scope === "laboratory") queryParameters.set("lab_contract", "v3");
const serializedQuery = queryParameters.toString(); const serializedQuery = queryParameters.toString();
const query = serializedQuery ? `?${serializedQuery}` : ""; const query = serializedQuery ? `?${serializedQuery}` : "";
let response: Response; let response: Response;
@@ -0,0 +1,72 @@
import {
decodeObservationSessionCatalog,
ObservationSessionApiError,
ObservationSessionContractError,
type ObservationSessionCatalog,
type ObservationSessionFetch,
type ObservationSessionScope,
} from "./sessionArchive";
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
readonly nextCursor: string | null;
}
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
export async function fetchObservationSessionCatalogPage({
scope,
limit = 100,
cursor = null,
signal,
fetcher = globalThis.fetch,
}: {
scope: ObservationSessionScope;
limit?: number;
cursor?: string | null;
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
}): Promise<ObservationSessionCatalogPage> {
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|| (cursor !== null && !CURSOR.test(cursor))) {
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
}
signal?.throwIfAborted();
const query = new URLSearchParams({ limit: String(limit), scope });
if (scope === "laboratory") query.set("lab_contract", "v3");
query.set("pagination", "cursor-v1");
if (cursor !== null) query.set("cursor", cursor);
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
method: "GET", headers: { Accept: "application/json" }, signal,
});
if (!response.ok) {
throw new ObservationSessionApiError(
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
}
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
}
const page = body as Record<string, unknown>;
if (Object.keys(page).length !== 3
|| page.schema_version !== PAGE_SCHEMA
|| !Object.hasOwn(page, "items")
|| !(page.next_cursor === null
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
}
const catalog = decodeObservationSessionCatalog({ items: page.items });
if (catalog.items.length > limit) {
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
}
signal?.throwIfAborted();
return { ...catalog, nextCursor: page.next_cursor };
}
@@ -0,0 +1,54 @@
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
const schema = 'missioncore.session-display-profile/v1';
const fields = {
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
} as const;
function endpoint(sessionId: string) {
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
}
export function encodeSessionDisplay(settings: SceneSettings) {
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
}
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
}
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
}
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
throw new Error('Некорректные настройки отображения');
}
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
}
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
const document = await response.json();
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
: decodeSessionDisplay(document, sessionId);
}
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
decodeSessionDisplay(await response.json(), sessionId);
}
@@ -0,0 +1,41 @@
export interface SessionOverviewMetrics {
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
interval_statistics_complete: boolean; gaps_over_second: number | null;
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
spatial_available: boolean;
}
export interface SessionOverview {
schema_version: "missioncore.session-overview/v1";
state: "queued" | "preparing" | "ready" | "error";
session: { session_id: string; display_name: string; duration_seconds: number | null;
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
metrics?: SessionOverviewMetrics | null;
scene_url?: string | null;
message?: string;
messages_processed?: number;
}
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
signal, method: retry ? "POST" : "GET", cache: "no-store",
});
if (!response.ok) throw new Error("Обзор записи недоступен.");
const data = await response.json() as SessionOverview;
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
if (data.scene_url) {
const url = new URL(data.scene_url, window.location.origin);
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
}
return data;
}
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
const xmax = Math.max(1, ...valid.map(p => p[0]));
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
}
@@ -0,0 +1,38 @@
export type OverviewViewMode = "3d" | "top";
export type OverviewRepresentation = "original" | "corrected";
export interface OverviewComparison {
generation: string; map_generation: string; sample_points: number; source_points: number;
original_path_m: number; corrected_path_m: number; height_min_m: number; height_max_m: number;
}
export interface OverviewSpatialMetadata {
default_representation?: OverviewRepresentation;
height_min_m: number | null; height_max_m: number | null; sample_points: number;
comparison?: OverviewComparison;
}
function endpoint(source: string) {
const url = new URL(source, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
return url;
}
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
if (!response.ok) throw new Error("Параметры среза недоступны.");
return response.json();
}
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal,
comparisonGeneration: string | null = null, representation: OverviewRepresentation = "original") {
if (representation === "corrected" && !comparisonGeneration) throw new Error("Исправленная версия недоступна.");
const url = endpoint(source);
const generation = url.searchParams.get("generation");
url.search = "";
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
comparison_generation: comparisonGeneration, representation }) });
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
}
@@ -12,6 +12,16 @@ import {
type ObservationSessionScope, type ObservationSessionScope,
type ObservationSessionSummary, type ObservationSessionSummary,
} from "./sessionArchive"; } from "./sessionArchive";
import {
createObservationReplayCoordinator,
type ObservationReplayCoordinator,
} from "./replayCoordinator";
export {
createObservationReplayCoordinator,
type ObservationReplayAttempt,
type ObservationReplayCoordinator,
} from "./replayCoordinator";
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error"; export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled"; export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
@@ -41,17 +51,6 @@ export interface ObservationSessionsController {
remove: (sessionId: string) => 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 async function deleteObservationSessionAfterTeardown( export async function deleteObservationSessionAfterTeardown(
sessionId: string, sessionId: string,
{ {
@@ -93,41 +92,6 @@ export class ObservationPreparationStalledError extends Error {
} }
} }
/** 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 { function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim() return error instanceof Error && error.message.trim()
? error.message ? error.message
@@ -0,0 +1,38 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
const [error, setError] = useState<string | null>(null);
const applyRef = useRef(apply); applyRef.current = apply;
const current = useRef(sessionId); current.current = sessionId;
const writeTail = useRef(Promise.resolve());
const revision = useRef(0);
useEffect(() => {
setError(null);
if (!sessionId) return;
const abort = new AbortController();
const readRevision = revision.current;
applyRef.current({...defaultSceneSettings});
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
if (abort.signal.aborted || current.current !== sessionId) return;
if (revision.current === readRevision) applyRef.current(settings);
}).catch(reason => {
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
});
return () => abort.abort();
}, [sessionId]);
const edited = useCallback(() => { revision.current += 1; }, []);
const save = useCallback((settings: SceneSettings) => {
const id = current.current;
if (!id) return;
const snapshot = {...settings};
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
if (current.current === id) setError(null);
}).catch(reason => {
if (current.current === id) setError(String(reason.message ?? reason));
});
}, []);
return {save, edited, error, dismissError: () => setError(null)};
}
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
export function useSessionOverview(sessionId: string) {
const [data, setData] = useState<SessionOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [retryGeneration, setRetryGeneration] = useState(0);
useEffect(() => {
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
setData(null); setError(null);
const load = async (retry = false) => {
try {
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
if (abort.signal.aborted) return;
setData(next);
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
} catch (reason) {
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
}
};
void load(retryGeneration > 0);
return () => { abort.abort(); clearTimeout(timer); };
}, [sessionId, retryGeneration]);
return { data: data?.session.session_id === sessionId ? data : null, error,
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
}
export function useSessionOverviewMode(enabled: boolean) {
const [open, setOpen] = useState(false);
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
useEffect(() => {
if (!open) return;
const close = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
};
window.addEventListener("keydown", close, true);
return () => window.removeEventListener("keydown", close, true);
}, [open]);
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
}
@@ -0,0 +1,144 @@
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
export interface RerunPlaybackState {
recordingId: string;
timeline: string;
rangeNs: { min: number; max: number } | null;
currentNs: number;
playing: boolean;
bufferedEndNs: number | null;
expectedStartNs: number | null;
expectedEndNs: number | null;
bufferProgress: number | null;
fullyBuffered: boolean;
}
export interface RerunPlaybackController {
seek: (timeNs: number) => void;
setPlaying: (playing: boolean) => void;
jumpToEnd: () => void;
}
export interface RecordedPerceptionLayers {
enabled: boolean;
cameraImage?: boolean;
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
costmap?: boolean;
}
export interface RecordedRrdArtifactDescriptor {
sourceUrl: string;
viewerSourceUrl: string;
byteLength: number;
sha256: string;
}
/**
* The native live receiver owns one acquisition lineage and never exposes
* recorded transport or autoplay policy.
*/
export interface LiveAcquisitionRerunProfile {
kind: "live-acquisition";
clock: "stream_time";
sourceUrl: string;
liveActivitySequence: number | null;
liveStreamId: string | null;
liveRecoveryAuthorityIdentity: string | null;
}
/**
* A sealed session is progressively presentable, while its shared playback
* controls remain fenced by the aggregate session admission gate.
*/
export interface RecordedSessionRerunProfile {
kind: "recorded-session";
clock: "session_time";
sourceUrl: string;
artifact: RecordedRrdArtifactDescriptor | null;
autoplayWhenReady: boolean;
presentationGate: RecordedAdmissionPhase;
expectedTimelineStartSeconds?: number;
expectedTimelineEndSeconds?: number;
initialPlaybackStartSeconds?: number;
view: RecordedRerunView;
viewResetGeneration: 0 | 1;
followTrajectory: boolean;
perceptionLayers: RecordedPerceptionLayers;
/** Explicit small blueprint endpoint when the viewer source is a merged LAB RRD. */
blueprintSourceUrl?: string;
/** Optional immutable RRD sidecar for LAB/model evidence on the same recording clock. */
perceptionSourceUrl?: string;
/** Selects one semantic entity without changing the sealed sidecar. */
semanticLayer?: "city" | "vegetation";
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
unifiedPerception?: boolean;
/** Controlled camera-column share for the native horizontal Rerun container. */
unifiedCameraShare?: number;
/** Requests the canonical top-down eye without changing the world coordinate frame. */
planView?: boolean;
perceptionRetryGeneration: number;
lockPerceptionCameraInteraction: boolean;
}
/** A LAB owns its result presentation; it does not inherit session-view settings. */
export interface LaboratoryResultRerunProfile
extends Omit<RecordedSessionRerunProfile, "kind"> {
kind: "laboratory-result";
resultId: string;
}
/**
* Deprecated comparison-only contract for LAB artifacts that have not yet
* been republished as a native Rerun sidecar. It must never be selected by a
* canonical LAB route or start work in the background.
*/
export interface LaboratoryRecordedEvidenceViewerProfile {
kind: "lab-recorded-evidence";
clock: "source-sequence";
cameraTransport: "generation-bound-fmp4";
spatialTransport: "bounded-sealed-artifacts";
loadPolicy: "explicit-legacy-comparison-only";
workerRequired: false;
}
export type RerunViewerProfile =
| LiveAcquisitionRerunProfile
| RecordedSessionRerunProfile
| LaboratoryResultRerunProfile;
export type ObservationViewerProfile =
| RerunViewerProfile
| LaboratoryRecordedEvidenceViewerProfile;
export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
kind: "lab-recorded-evidence",
clock: "source-sequence",
cameraTransport: "generation-bound-fmp4",
spatialTransport: "bounded-sealed-artifacts",
loadPolicy: "explicit-legacy-comparison-only",
workerRequired: false,
} satisfies LaboratoryRecordedEvidenceViewerProfile);
export function liveAcquisitionRerunProfile(
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
): LiveAcquisitionRerunProfile {
return { ...input, kind: "live-acquisition", clock: "stream_time" };
}
export function recordedSessionRerunProfile(
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
): RecordedSessionRerunProfile {
return { ...input, kind: "recorded-session", clock: "session_time" };
}
export function laboratoryResultRerunProfile(
input: Omit<LaboratoryResultRerunProfile, "kind" | "clock">,
): LaboratoryResultRerunProfile {
if (!input.resultId.trim()) throw new Error("LAB result identity is required");
return { ...input, kind: "laboratory-result", clock: "session_time" };
}
@@ -0,0 +1,10 @@
/** Entry intent belongs to application navigation, not a retained server job. */
export type WorkspaceLaunchProfile = 'direct' | 'planning';
export function workspaceLaunchProfile(
current: WorkspaceLaunchProfile,
kind: string,
requested: WorkspaceLaunchProfile = 'direct',
): WorkspaceLaunchProfile {
return kind === 'device' || kind === 'spatial' ? requested : current;
}
@@ -0,0 +1,347 @@
import {
decodeObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
const SHA256 = /^[a-f0-9]{64}$/;
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
export interface AIModule {
readonly moduleId: string;
readonly moduleSha256: string;
readonly label: string;
readonly dockerName: string;
readonly requires: readonly string[];
readonly provides: readonly string[];
readonly defaults: Readonly<Record<string, unknown>>;
}
export interface AIModuleGroup {
readonly group: AIGroupId;
readonly modules: readonly AIModule[];
}
export interface AIModuleCatalog {
readonly groups: readonly AIModuleGroup[];
}
export interface AICompositionReceipt {
readonly compositionSha256: string;
readonly created: boolean;
readonly dispatchReady: boolean;
readonly dispatchReason: string;
readonly setupIds: readonly string[];
readonly jobs: readonly ObservatoryRecordedJob[];
readonly run: AICompositionRun;
}
export interface AIViewerLayer {
readonly layerId: string;
readonly paneId: "camera" | "spatial";
readonly label: string;
readonly control: "toggle" | "toggle-with-settings";
readonly order: number;
}
export interface AICompositionPresentation {
readonly configurationLabel: string;
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
readonly viewerLayers: readonly AIViewerLayer[];
}
export interface AICompositionRun {
readonly runId: string;
readonly sourceSessionId: string;
readonly compositionSha256: string;
readonly moduleIds: readonly string[];
readonly setupIds: readonly string[];
readonly jobIds: readonly string[];
readonly resultIds: readonly string[];
readonly createdAtUtc: string;
readonly state: "running" | "ready" | "failed";
readonly configurationLabel: string;
readonly displayName: string | null;
readonly presentation: AICompositionPresentation;
readonly jobs: readonly ObservatoryRecordedJob[];
}
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
ddrnet: "ai-segmentation-ddrnet-v1",
eomt: "ai-segmentation-eomt-v1",
tgs: "m49-tgs-portable-v2",
"rf-detr": "ai-detection-rf-detr-v1",
"object-distance": "ai-range-object-distance-v1",
});
export function aiModuleIdForSetup(setupId: string): string | null {
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
}
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "каталог AI-модулей");
exactKeys(root, ["authority", "groups", "schema_version"]);
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
const groups = array(root.groups).map((value): AIModuleGroup => {
const group = record(value, "группа AI-модулей");
exactKeys(group, ["group", "modules"]);
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
}
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
});
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
}
return { groups };
}
export async function saveAIComposition(
sourceSessionId: string,
selections: readonly { group: AIGroupId; module: AIModule }[],
): Promise<AICompositionReceipt> {
const response = await fetch("/api/v1/observatory/ai-compositions", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: COMPOSITION_SCHEMA,
source_session_id: sourceSessionId,
idempotency_key: createIdempotencyKey(),
selections: selections.map(({ group, module }) => ({
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
parameters: module.defaults,
})),
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "конфигурация AI-слоя");
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
throw new Error("Сервер вернул конфигурацию для другой записи.");
}
const digest = text(root.composition_sha256);
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
const dispatch = record(root.dispatch, "готовность композиции");
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
if (dispatch.ready !== true) {
throw new Error(text(dispatch.reason));
}
const setupIds = array(dispatch.setup_ids).map(text);
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
if (!jobs.length || jobs.length !== setupIds.length
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
throw new Error("Сервер вернул неполную очередь композиции.");
}
const run = compositionRunOf(root.run);
return {
compositionSha256: digest,
created: boolean(dispatch && root.created),
dispatchReady: boolean(dispatch.ready),
dispatchReason: text(dispatch.reason),
setupIds,
jobs,
run,
};
}
export async function fetchAICompositionRuns(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly AICompositionRun[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "запуски композиций");
exactKeys(root, ["items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
throw new Error("Версия запусков AI-композиций изменилась.");
}
return array(root.items).map(compositionRunOf);
}
export async function renameAICompositionRunProjection(
runId: string,
displayName: string,
): Promise<string> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI Inference.");
}
const normalized = displayName.trim();
if (!normalized || normalized.length > 160) {
throw new Error("Название результата должно содержать от 1 до 160 символов.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "PATCH",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
display_name: normalized,
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "результат AI Inference");
exactKeys(row, ["display_name", "run_id", "schema_version"]);
if (
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|| row.run_id !== runId
|| row.display_name !== normalized
) throw new Error("Сервер не подтвердил переименование результата AI Inference.");
return normalized;
}
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI Inference.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!response.ok || response.status !== 204) {
throw apiError(await bodyOf(response), response.status);
}
}
function compositionRunOf(value: unknown): AICompositionRun {
const row = record(value, "запуск AI-композиции");
const expected = [
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
"source_session_id", "state",
];
// The receipt embeds the immutable run before its live state projection.
const receiptShape = [
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
"run_id", "schema_version", "setup_ids", "source_session_id",
];
const keys = Object.keys(row);
const projected = keys.length === expected.length;
exactKeys(row, projected ? expected : receiptShape);
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
throw new Error("Версия запуска AI-композиции изменилась.");
}
const sourceSessionId = text(row.source_session_id);
const compositionSha256 = text(row.composition_sha256);
const runId = text(row.run_id);
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
throw new Error("Некорректная идентичность запуска AI-композиции.");
}
const presentation = presentationOf(row.presentation);
const state = projected ? text(row.state) : "running";
if (!new Set(["running", "ready", "failed"]).has(state)) {
throw new Error("Неизвестное состояние запуска AI-композиции.");
}
return {
runId,
sourceSessionId,
compositionSha256,
moduleIds: array(row.module_ids).map(text),
setupIds: array(row.setup_ids).map(text),
jobIds: array(row.job_ids).map(text),
resultIds: projected ? array(row.result_ids).map(text) : [],
createdAtUtc: text(row.created_at_utc),
state: state as AICompositionRun["state"],
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
displayName: projected
? row.display_name === null ? null : text(row.display_name)
: null,
presentation,
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
};
}
function presentationOf(value: unknown): AICompositionPresentation {
const row = record(value, "проекция AI-композиции");
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
throw new Error("Версия проекции AI-композиции изменилась.");
}
const modules = array(row.modules).map((value) => {
const module = record(value, "модуль проекции");
exactKeys(module, ["label", "module_id"]);
return { moduleId: text(module.module_id), label: text(module.label) };
});
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
const layer = record(value, "слой viewer");
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
const paneId = text(layer.pane_id);
const control = text(layer.control);
if (!new Set(["camera", "spatial"]).has(paneId)
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|| typeof layer.order !== "number") {
throw new Error("Некорректная проекция слоя viewer.");
}
return {
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
};
});
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
}
export async function fetchAICompositionJobs(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "очередь AI-слоёв");
exactKeys(root, ["authority", "items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
throw new Error("Версия очереди AI-слоёв изменилась.");
}
return array(root.items).map(decodeObservatoryRecordedJob);
}
function moduleOf(value: unknown): AIModule {
const row = record(value, "AI-модуль");
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
const digest = text(row.module_sha256);
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
return {
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
};
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
return value as Record<string, unknown>;
}
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
}
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
function apiError(body: unknown, status: number): Error {
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `ai-layer:${entropy}`;
}
@@ -0,0 +1,321 @@
import {
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
import {
observatoryRecordedRunBinding,
type ObservatoryRecordedRunBinding,
} from "./recordedRun";
export interface ObservatoryEvidence {
readonly sessionId: string;
readonly label: string;
readonly status: ObservationSessionSummary["status"];
readonly publishedAtUtc: string;
readonly lab: ObservationLabInstance;
readonly recordedRun: ObservatoryRecordedRunBinding | null;
}
export interface ObservatorySession {
readonly source: ObservationSessionSummary;
readonly evidence: readonly ObservatoryEvidence[];
}
export interface ObservatoryCatalog {
readonly items: readonly ObservatorySession[];
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
readonly window: {
readonly limit: number;
readonly sourceCount: number;
readonly laboratoryCount: number;
readonly sourceLimitReached: boolean;
readonly laboratoryLimitReached: boolean;
};
}
export type ObservatoryCatalogMutation =
| {
readonly kind: "rename";
readonly displayName: string;
readonly revision: number;
}
| {
readonly kind: "delete";
readonly revision: number;
};
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
string,
ObservatoryCatalogMutation
>;
export interface ObservatoryCatalogReconciliation {
readonly catalog: ObservatoryCatalog;
readonly overlay: ObservatoryCatalogMutationOverlay;
}
export class ObservatoryCatalogContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryCatalogContractError";
}
}
export function findObservatoryEvidence(
catalog: ObservatoryCatalog,
sessionId: string,
): ObservatoryEvidence | null {
for (const item of catalog.items) {
const evidence = item.evidence.find(
(candidate) => candidate.sessionId === sessionId,
);
if (evidence) return evidence;
}
return catalog.unresolvedEvidence.find(
(candidate) => candidate.sessionId === sessionId,
) ?? null;
}
export function observatoryCatalogConfirmsEvidenceDeletion(
catalog: ObservatoryCatalog,
sessionId: string,
): boolean {
return !catalog.window.laboratoryLimitReached
&& findObservatoryEvidence(catalog, sessionId) === null;
}
export function applyObservatoryCatalogMutationOverlay(
catalog: ObservatoryCatalog,
overlay: ObservatoryCatalogMutationOverlay,
): ObservatoryCatalog {
if (overlay.size === 0) return catalog;
const removedSessionIds = new Set<string>();
const projectEvidence = (
evidence: ObservatoryEvidence,
): ObservatoryEvidence | null => {
const mutation = overlay.get(evidence.sessionId);
if (!mutation) return evidence;
if (mutation.kind === "delete") {
removedSessionIds.add(evidence.sessionId);
return null;
}
return evidence.label === mutation.displayName
? evidence
: { ...evidence, label: mutation.displayName };
};
const projectEvidenceList = (
evidence: readonly ObservatoryEvidence[],
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
const projected = projectEvidence(candidate);
return projected ? [projected] : [];
});
const items = catalog.items.map((item) => ({
...item,
evidence: projectEvidenceList(item.evidence),
}));
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
return {
...catalog,
items,
unresolvedEvidence,
window: {
...catalog.window,
laboratoryCount: Math.max(
0,
catalog.window.laboratoryCount - removedSessionIds.size,
),
},
};
}
export function reconcileObservatoryCatalogMutationOverlay(
serverCatalog: ObservatoryCatalog,
overlay: ObservatoryCatalogMutationOverlay,
requestMutationRevision: number,
): ObservatoryCatalogReconciliation {
const remaining = new Map(overlay);
for (const [sessionId, mutation] of overlay) {
if (mutation.revision > requestMutationRevision) continue;
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
const confirmed = mutation.kind === "rename"
? evidence?.label === mutation.displayName
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
if (confirmed) remaining.delete(sessionId);
}
return {
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
overlay: remaining,
};
}
function newestFirst(left: string, right: string): number {
return Date.parse(right) - Date.parse(left);
}
function boundedLimit(limit: number): number {
return Number.isFinite(limit)
? Math.min(100, Math.max(1, Math.floor(limit)))
: 100;
}
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence | null {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
// Historical LABs retain their archive and replay adapters. Observatory is
// the portable-profile product; a familiar LAB label is not admission.
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
recordedRun,
};
}
function sortEvidence(
evidence: readonly ObservatoryEvidence[],
): ObservatoryEvidence[] {
return [...evidence].sort((left, right) =>
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|| left.sessionId.localeCompare(right.sessionId));
}
export function buildObservatoryCatalog(
sourceSessions: readonly ObservationSessionSummary[],
laboratorySessions: readonly ObservationSessionSummary[],
limit = 100,
): ObservatoryCatalog {
const safeLimit = boundedLimit(limit);
const sources = new Map<string, ObservationSessionSummary>();
for (const source of sourceSessions) {
if (source.lab !== null) {
throw new ObservatoryCatalogContractError(
`Каталог источников вернул LAB-сессию ${source.id}.`,
);
}
sources.set(source.id, source);
}
const linked = new Map<string, ObservatoryEvidence[]>();
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
if (!evidence) continue;
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// A bounded traversal may stop before this source. Absence alone is
// not proof of a broken relationship or permission to remove data.
unresolvedEvidence.push(evidence);
continue;
}
const current = linked.get(sourceId) ?? [];
current.push(evidence);
linked.set(sourceId, current);
}
const items = [...sources.values()]
.sort((left, right) =>
newestFirst(left.startedAtUtc, right.startedAtUtc)
|| left.id.localeCompare(right.id))
.map((source) => ({
source,
evidence: sortEvidence(linked.get(source.id) ?? []),
}));
return {
items,
unresolvedEvidence: sortEvidence(unresolvedEvidence),
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
+ unresolvedEvidence.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
};
}
export async function fetchObservatoryCatalog({
signal,
limit = 100,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
limit?: number;
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservatoryCatalog> {
const safeLimit = boundedLimit(limit);
const request = new AbortController();
const abort = () => request.abort(signal?.reason);
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
try {
const [sources, laboratories] = await Promise.all([
readCatalogPages("source", safeLimit, fetcher, request.signal),
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
]);
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
return {
...catalog,
window: {
...catalog.window,
sourceLimitReached: sources.hasMore,
laboratoryLimitReached: laboratories.hasMore,
},
};
} finally {
request.abort();
signal?.removeEventListener("abort", abort);
}
}
// Metadata only, never recordings or result artifacts. The cap is an explicit
// partial window, not silent completeness or an unbounded background crawler.
const MAX_CATALOG_PAGES = 256;
async function readCatalogPages(
scope: "source" | "laboratory",
limit: number,
fetcher: ObservationSessionFetch,
signal: AbortSignal,
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
const items: ObservationSessionSummary[] = [];
const seenIds = new Set<string>();
const cursors = new Set<string>();
let cursor: string | null = null;
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
for (const item of page.items) {
if (seenIds.has(item.id)) {
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
}
seenIds.add(item.id);
// Do not accumulate legacy provenance while walking the shared catalog.
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
items.push(item);
} else if (!item.lab) {
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
}
}
cursor = page.nextCursor;
if (cursor === null) return { items, hasMore: false };
if (cursors.has(cursor)) {
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
}
cursors.add(cursor);
}
return { items, hasMore: true };
}

Some files were not shown because too many files have changed in this diff Show More