Author SHA1 Message Date
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
418 changed files with 118234 additions and 2156 deletions
+4 -5
View File
@@ -7,7 +7,6 @@
"": { "": {
"name": "@nodedc/mission-core-control-station", "name": "@nodedc/mission-core-control-station",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react", "@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
@@ -15,7 +14,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 +899,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": {
+1 -2
View File
@@ -4,7 +4,6 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
@@ -18,7 +17,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",
+2
View File
@@ -806,6 +806,8 @@ export default function App() {
<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}
@@ -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();
let disposed = false;
void (async () => {
for (const candidate of archive.manifest.epochs) {
const probe = document.createElement("video");
probe.muted = true;
probe.playsInline = true;
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
cleanup();
if (disposed || abort.signal.aborted) return;
}
if (disposed || abort.signal.aborted) return;
reportAdmission({
phase: "ready",
byteLength: archive.byteLength,
message: null,
});
})().catch((error: unknown) => {
if ( if (
disposed || requestedSegmentSequence >= segmentRecovery.failedSequence
abort.signal.aborted || && requestedSegmentSequence < segmentRecovery.recoverySequence
(error instanceof DOMException && error.name === "AbortError")
) return; ) return;
setReadyGeneration(null); lastSegmentRecoveryRef.current = null;
setState("error"); setSegmentRecovery(null);
reportAdmission({ }, [requestedSegmentSequence, segmentRecovery]);
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,257 @@
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,
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;
mediaMultiLayer?: boolean;
mediaContent?: ReactNode;
spatialContent?: ReactNode;
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
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 ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControls}
</div>
) : null}
{mediaContent}
</section>
);
const spatialPane = spatialMode !== "none" ? (
<section
className="m4-replay-threat-visual__pane"
data-pane="spatial"
aria-label={spatialAriaLabel}
>
{splitView ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
>
{spatialLeadingControl}
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
</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}
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 && !unifiedContent}
separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
<div className="l3-visual-audit__state" role="status">
{emptyMessage}
</div>
) : null}
{deckOverlays}
</div>
</LaboratoryEvidenceViewer>
</div>
);
}
@@ -0,0 +1,383 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ActivityIndicator,
Button,
Icon,
SegmentedControl,
} 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 {
resolveCanonicalLabReplay,
type CanonicalLabReplayDescriptor,
} from "../../core/laboratory/canonicalLabReplay";
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import { defaultSceneSettings } from "../../sceneSettings";
type MediaMode = "video" | "camera";
type SpatialMode = "3d" | "plan";
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
type SemanticLayer = "city" | "vegetation";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
export function CanonicalVegetationRerunReplay({
resultId,
review,
}: {
resultId: string;
review: VegetationFullRouteReview;
}) {
const {
mediaMode,
spatialMode,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "video",
initialSpatialMode: "3d",
});
const splitView = mediaMode !== null && spatialMode !== null;
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
const [showSemantics, setShowSemantics] = useState(true);
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 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 viewerFrameRef = useRef<HTMLDivElement>(null);
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
const stopNativeSplitTracking = useCallback(() => {
nativeSplitTrackingCleanupRef.current?.();
nativeSplitTrackingCleanupRef.current = null;
}, []);
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (
!splitView
|| event.button !== 0
|| !(event.target instanceof HTMLCanvasElement)
) return;
const frame = viewerFrameRef.current;
if (!frame) return;
const bounds = frame.getBoundingClientRect();
if (bounds.width <= 0) return;
const dividerX = bounds.left
+ bounds.width * nativeSplitPercentRef.current / 100;
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
stopNativeSplitTracking();
const pointerId = event.pointerId;
const update = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
const currentBounds = frame.getBoundingClientRect();
if (currentBounds.width <= 0) return;
const next = Math.min(90, Math.max(
10,
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
));
nativeSplitPercentRef.current = next;
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
};
const stop = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
stopNativeSplitTracking();
};
window.addEventListener("pointermove", update, true);
window.addEventListener("pointerup", stop, true);
window.addEventListener("pointercancel", stop, true);
nativeSplitTrackingCleanupRef.current = () => {
window.removeEventListener("pointermove", update, true);
window.removeEventListener("pointerup", stop, true);
window.removeEventListener("pointercancel", stop, true);
};
}, [splitView, stopNativeSplitTracking]);
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
useEffect(() => {
if (!splitView) stopNativeSplitTracking();
const launchSha256 = launch?.replay.sha256 ?? null;
if (
trackedLaunchSha256Ref.current !== launchSha256
|| (splitView && !previousSplitViewRef.current)
) {
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
const cameraPanePercent = mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100;
viewerFrameRef.current?.style.setProperty(
"--canonical-rerun-camera-pane",
`${cameraPanePercent}%`,
);
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(review.sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveCanonicalLabReplay(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 : "Каноническая запись RAV004 недоступна.",
);
}
});
return () => controller.abort();
}, [resultId, review.sessionId]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady
? "ready"
: "loading";
const sceneSettings = useMemo(() => ({
...defaultSceneSettings,
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
showPoints: spatialMode !== null,
showTrajectory: spatialMode !== null,
showGrid: spatialMode !== null,
pointSize: 3.8,
}), [spatialLayer, spatialMode]);
const profile = launch ? recordedSessionRerunProfile({
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: review.timelineStartSeconds,
view: mediaMode !== null ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: true,
semanticLayer,
unifiedPerception: splitView,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: mediaMode !== null,
detections2d: mediaMode === "video",
segmentation: mediaMode === "video" && showSemantics,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: mediaMode !== null,
}) : null;
const mediaLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Слои камеры и видео"
>
<Button
size="dense"
shape="pill"
variant={showSemantics ? "primary" : "secondary"}
aria-pressed={showSemantics}
onClick={() => setShowSemantics((visible) => !visible)}
>
СЕМАНТИКА
</Button>
<SegmentedControl
value={semanticLayer}
items={[
{ value: "city", label: "ГОРОД · EoMT" },
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
]}
label="Источник семантики"
size="dense"
onChange={(value) => {
setSemanticLayer(value);
setShowSemantics(true);
}}
/>
</div>
);
const spatialLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Пространственные слои RAV004"
>
<SegmentedControl
value={spatialLayer}
items={[
{ value: "source", label: "ИСХ. ТОЧКИ" },
{ value: "local", label: "ЛОК. SLAM" },
{ value: "tgs", label: "TGS", disabled: true },
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
]}
label="Пространственные слои"
size="dense"
onChange={setSpatialLayer}
/>
</div>
);
const resetSpatialView = (
<Button
size="dense"
variant="ghost"
icon={<Icon name="refresh" size={14} />}
aria-label="Сбросить положение 3D камеры"
title="Сбросить положение 3D камеры"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
>
</Button>
);
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="RAVNOVES004TREE · канонический повтор Rerun"
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "ВИДЕО" },
{ value: "camera", label: "КАМЕРА" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "ПЛАН" },
]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer
unifiedContent={profile ? (
<div
ref={viewerFrameRef}
className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}
style={{
"--canonical-rerun-camera-pane": `${
mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100
}%`,
} as CSSProperties}
onPointerDownCapture={splitView ? trackNativeSplit : 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="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
</div>
);
}
@@ -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}
/> />
@@ -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}
{overlaysPresented ? (
<RecordedEvidenceBoxOverlay <RecordedEvidenceBoxOverlay
imageWidth={imageWidth} imageWidth={imageWidth}
imageHeight={imageHeight} imageHeight={imageHeight}
boxes={boxes} boxes={boxes}
ariaLabel={ariaLabel} 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,
@@ -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);
@@ -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,76 @@
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,
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!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 =
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
+ "/canonical-replay.rrd";
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
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"),
};
}
@@ -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,56 @@ 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 {
name: string;
resultId: string;
frameCount: 6830;
taxonomy: readonly VegetationVideoSemanticClass[];
inferenceFps: number;
latencyP95Ms: number;
peakReservedVramBytes: number;
}
export interface VegetationFullRouteReview {
sourceId: "RAVNOVES004TREE";
sessionId: "20260828T130511Z_viewer_live";
linkedRouteReviewResultId: string;
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0";
sourceJobInputSha256: string;
sourceStreamSha256: string;
recordedMediaSourceId: "recorded.camera.6a3945242828a038";
recordedMediaGenerationSha256: string;
frameCount: 6830;
width: 800;
height: 600;
timelineStartSeconds: number;
timelineEndSeconds: number;
timelineArtifact: {
sha256: string;
byteLength: number;
};
frameSourceTimesNs: readonly number[];
decodeRepair: {
repairedFrameCount: 1;
sequence: 6092;
method: "duplicate-previous-decoded-frame";
};
city: VegetationFullRouteLayer;
vegetation: VegetationFullRouteLayer;
}
export interface VegetationShadowResult { export interface VegetationShadowResult {
resultId: string; resultId: string;
createdAtUtc: string; createdAtUtc: string;
@@ -116,6 +166,7 @@ export interface VegetationShadowResult {
validationCases: readonly VegetationVisualCase[]; validationCases: readonly VegetationVisualCase[];
routeVideo: VegetationRouteVideo | null; routeVideo: VegetationRouteVideo | null;
routeReview: VegetationMixedRouteReview | null; routeReview: VegetationMixedRouteReview | null;
routeFullReview: VegetationFullRouteReview | null;
limitations: readonly string[]; limitations: readonly string[];
visualShadowReady: true; visualShadowReady: true;
missionPolicyReadyForConfiguration: true; missionPolicyReadyForConfiguration: true;
@@ -545,6 +596,265 @@ function mixedRouteReviewValue(
}; };
} }
function fullRouteTaxonomyValue(
value: unknown,
label: string,
schema: string,
classCount: number,
): readonly VegetationVideoSemanticClass[] {
const taxonomy = objectValue(value, `${label}.taxonomy`);
exact(taxonomy.schema_version, schema, `${label}.taxonomy.schema`);
const classes = arrayValue(taxonomy.classes, `${label}.taxonomy.classes`).map(
(raw, expectedId): VegetationVideoSemanticClass => {
const item = objectValue(raw, `${label}.taxonomy[${expectedId}]`);
const classId = integerValue(item.class_id, `${label}.class_id[${expectedId}]`);
if (classId !== expectedId) {
throw new VegetationShadowContractError(`${label}: taxonomy order changed.`);
}
const color = arrayValue(item.color_rgb, `${label}.color[${expectedId}]`)
.map((channel, index) => integerValue(channel, `${label}.color[${expectedId}][${index}]`));
if (color.length !== 3 || color.some((channel) => channel > 255)) {
throw new VegetationShadowContractError(`${label}: taxonomy color invalid.`);
}
const disposition = item.disposition;
if (
disposition !== "labeled"
&& disposition !== "ambiguous"
&& disposition !== "prediction"
&& disposition !== "undefined"
) {
throw new VegetationShadowContractError(`${label}: taxonomy disposition changed.`);
}
return {
classId,
label: textValue(item.label, `${label}.label[${expectedId}]`),
colorRgb: color as unknown as readonly [number, number, number],
disposition,
materialClass: item.material_class === null || item.material_class === undefined
? null
: textValue(item.material_class, `${label}.material[${expectedId}]`),
evidenceState: item.evidence_state === null || item.evidence_state === undefined
? null
: textValue(item.evidence_state, `${label}.evidence[${expectedId}]`),
};
},
);
if (classes.length !== classCount) {
throw new VegetationShadowContractError(`${label}: taxonomy size changed.`);
}
return classes;
}
function fullRouteLayerValue(
value: unknown,
layer: "city" | "vegetation",
): VegetationFullRouteLayer {
const label = `vegetation.route_full_review.layers.${layer}`;
const row = objectValue(value, label);
const resultId = textValue(row.result_id, `${label}.result_id`);
const identity = layer === "city"
? /^result-[a-f0-9]{64}$/
: /^lab-v1-ravnoves-video-ddrnet-[a-f0-9]{64}$/;
if (!identity.test(resultId)) {
throw new VegetationShadowContractError(`${label}: identity invalid.`);
}
exact(row.frame_count, 6830, `${label}.frame_count`);
const archive = objectValue(row.mask_archive, `${label}.mask_archive`);
exact(
archive.path,
layer === "city" ? "video/eomt-semantic-masks.zip" : "video/ddrnet-semantic-masks.zip",
`${label}.mask_archive.path`,
);
const digest = textValue(archive.sha256, `${label}.mask_archive.sha256`);
if (!SHA256.test(digest)) {
throw new VegetationShadowContractError(`${label}: archive digest invalid.`);
}
integerValue(archive.byte_length, `${label}.mask_archive.byte_length`);
return {
name: textValue(row.name, `${label}.name`),
resultId,
frameCount: 6830,
taxonomy: fullRouteTaxonomyValue(
row.taxonomy,
label,
layer === "city"
? "missioncore.recorded-eomt-taxonomy/v1"
: "missioncore.lab-v1-vegetation-taxonomy/v1",
layer === "city" ? 16 : 64,
),
inferenceFps: numberValue(row.inference_fps, `${label}.inference_fps`),
latencyP95Ms: numberValue(row.latency_p95_ms, `${label}.latency_p95_ms`),
peakReservedVramBytes: integerValue(
row.peak_reserved_vram_bytes,
`${label}.peak_reserved_vram_bytes`,
),
};
}
function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null {
if (value === null || value === undefined) return null;
const row = objectValue(value, "vegetation.route_full_review");
exact(row.source_id, "RAVNOVES004TREE", "vegetation.route_full_review.source_id");
exact(
row.session_id,
"20260828T130511Z_viewer_live",
"vegetation.route_full_review.session_id",
);
exact(
row.source_job_id,
"recorded-camera-eb2783c5480d56bda07c8af0",
"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.width, 800, "vegetation.route_full_review.width");
exact(row.height, 600, "vegetation.route_full_review.height");
exact(row.ground_truth, false, "vegetation.route_full_review.ground_truth");
const sourceJobInputSha256 = textValue(
row.source_job_input_sha256,
"vegetation.route_full_review.source_job_input_sha256",
);
const sourceStreamSha256 = textValue(
row.source_stream_sha256,
"vegetation.route_full_review.source_stream_sha256",
);
exact(
sourceJobInputSha256,
"eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
"vegetation.route_full_review.source_job_input_sha256",
);
exact(
sourceStreamSha256,
"e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
"vegetation.route_full_review.source_stream_sha256",
);
exact(
row.recorded_media_source_id,
"recorded.camera.6a3945242828a038",
"vegetation.route_full_review.recorded_media_source_id",
);
const recordedMediaGenerationSha256 = textValue(
row.recorded_media_generation_sha256,
"vegetation.route_full_review.recorded_media_generation_sha256",
);
exact(
recordedMediaGenerationSha256,
"b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded",
"vegetation.route_full_review.recorded_media_generation_sha256",
);
if (
!SHA256.test(sourceJobInputSha256)
|| !SHA256.test(sourceStreamSha256)
|| !SHA256.test(recordedMediaGenerationSha256)
) {
throw new VegetationShadowContractError("vegetation.route_full_review: source digest invalid.");
}
const timelineStartSeconds = numberValue(
row.timeline_start_seconds,
"vegetation.route_full_review.timeline_start_seconds",
);
const timelineEndSeconds = numberValue(
row.timeline_end_seconds,
"vegetation.route_full_review.timeline_end_seconds",
);
if (timelineEndSeconds <= timelineStartSeconds) {
throw new VegetationShadowContractError("vegetation.route_full_review: timeline invalid.");
}
const timeline = objectValue(row.timeline, "vegetation.route_full_review.timeline");
exact(
timeline.path,
"video/frame-source-times-ns.bin",
"vegetation.route_full_review.timeline.path",
);
exact(
timeline.encoding,
"uint64-le-nanoseconds",
"vegetation.route_full_review.timeline.encoding",
);
exact(timeline.frame_count, 6830, "vegetation.route_full_review.timeline.frame_count");
const timelineSha256 = textValue(
timeline.sha256,
"vegetation.route_full_review.timeline.sha256",
);
if (!SHA256.test(timelineSha256)) {
throw new VegetationShadowContractError("vegetation.route_full_review: timeline digest invalid.");
}
const timelineByteLength = integerValue(
timeline.byte_length,
"vegetation.route_full_review.timeline.byte_length",
);
exact(timelineByteLength, 6830 * 8, "vegetation.route_full_review.timeline.byte_length");
const decodeRepair = objectValue(
row.decode_repair,
"vegetation.route_full_review.decode_repair",
);
exact(decodeRepair.repaired_frame_count, 1, "vegetation.route_full_review.decode_repair.count");
exact(decodeRepair.sequence, 6092, "vegetation.route_full_review.decode_repair.sequence");
exact(
decodeRepair.method,
"duplicate-previous-decoded-frame",
"vegetation.route_full_review.decode_repair.method",
);
const repairProofs = objectValue(
decodeRepair.proofs,
"vegetation.route_full_review.decode_repair.proofs",
);
for (const [key, expectedPath] of Object.entries({
eomt: "proofs/decode_repair.json",
ddrnet: "proofs/ddrnet_decode_repair.json",
})) {
const proof = objectValue(
repairProofs[key],
`vegetation.route_full_review.decode_repair.proofs.${key}`,
);
exact(
proof.path,
expectedPath,
`vegetation.route_full_review.decode_repair.proofs.${key}.path`,
);
const digest = textValue(
proof.sha256,
`vegetation.route_full_review.decode_repair.proofs.${key}.sha256`,
);
if (!SHA256.test(digest)) {
throw new VegetationShadowContractError("vegetation.route_full_review: repair proof invalid.");
}
}
const layers = objectValue(row.layers, "vegetation.route_full_review.layers");
return {
sourceId: "RAVNOVES004TREE",
sessionId: "20260828T130511Z_viewer_live",
linkedRouteReviewResultId,
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0",
sourceJobInputSha256,
sourceStreamSha256,
recordedMediaSourceId: "recorded.camera.6a3945242828a038",
recordedMediaGenerationSha256,
frameCount: 6830,
width: 800,
height: 600,
timelineStartSeconds,
timelineEndSeconds,
timelineArtifact: { sha256: timelineSha256, byteLength: timelineByteLength },
frameSourceTimesNs: [],
decodeRepair: {
repairedFrameCount: 1,
sequence: 6092,
method: "duplicate-previous-decoded-frame",
},
city: fullRouteLayerValue(layers.city, "city"),
vegetation: fullRouteLayerValue(layers.vegetation, "vegetation"),
};
}
function parseResult( function parseResult(
value: unknown, value: unknown,
resultId: string, resultId: string,
@@ -589,9 +899,10 @@ function parseResult(
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose") const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot)); .map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot); const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot);
const routeFullReview = fullRouteReviewValue(payload.route_full_review);
if ( if (
routeCases.length !== 0 routeCases.length !== 0
|| (routeReview ? validationCases.length !== 0 : validationCases.length !== 12) || (routeReview || routeFullReview ? validationCases.length !== 0 : validationCases.length !== 12)
) { ) {
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer."); throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
} }
@@ -605,6 +916,7 @@ function parseResult(
validationCases, validationCases,
routeVideo: routeVideoValue(payload.route_video), routeVideo: routeVideoValue(payload.route_video),
routeReview, routeReview,
routeFullReview,
limitations: arrayValue(payload.limitations, "vegetation.limitations") limitations: arrayValue(payload.limitations, "vegetation.limitations")
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)), .map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
visualShadowReady: true, visualShadowReady: true,
@@ -625,7 +937,96 @@ export function vegetationVideoMaskUrl(resultId: string, sequence: number): stri
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`; return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`;
} }
export async function fetchVegetationShadowResult( export function vegetationFullRouteMaskUrl(
resultId: string,
layer: "city" | "vegetation",
sequence: number,
): string {
if (
!RESULT_ID.test(resultId)
|| (layer !== "city" && layer !== "vegetation")
|| !Number.isInteger(sequence)
|| sequence < 0
|| sequence >= 6830
) {
throw new VegetationShadowContractError("Vegetation full-route mask identity недопустима.");
}
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`;
}
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,
@@ -642,11 +1043,60 @@ export async function fetchVegetationShadowResult(
if (!response.ok) { if (!response.ok) {
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`); throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
} }
return parseResult( const result = parseResult(
await response.json(), await response.json(),
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;
const timelineResponse = await fetcher(
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`,
{ method: "GET", headers: { Accept: "application/octet-stream" }, signal },
);
if (!timelineResponse.ok) {
throw new VegetationShadowContractError(
`Vegetation LAB timeline недоступна: HTTP ${timelineResponse.status}.`,
);
}
if (
timelineResponse.headers.get("etag")
!== `"${result.routeFullReview.timelineArtifact.sha256}"`
) {
throw new VegetationShadowContractError("Vegetation LAB timeline digest изменён.");
}
const timelinePayload = await timelineResponse.arrayBuffer();
if (timelinePayload.byteLength !== result.routeFullReview.timelineArtifact.byteLength) {
throw new VegetationShadowContractError("Vegetation LAB timeline size изменён.");
}
const timelineView = new DataView(timelinePayload);
const frameSourceTimesNs = Array.from({ length: result.routeFullReview.frameCount }, (_, index) => {
const value = Number(timelineView.getBigUint64(index * 8, true));
if (!Number.isSafeInteger(value)) {
throw new VegetationShadowContractError("Vegetation LAB timeline содержит unsafe time.");
}
return value;
});
if (
frameSourceTimesNs[0] !== Math.round(result.routeFullReview.timelineStartSeconds * 1_000_000_000)
|| frameSourceTimesNs.some((time, index) => index > 0 && time <= frameSourceTimesNs[index - 1]!)
) {
throw new VegetationShadowContractError("Vegetation LAB timeline нарушена.");
}
return {
...result,
routeFullReview: { ...result.routeFullReview, frameSourceTimesNs },
};
} }
export async function fetchVegetationBenchmarkResult( export async function fetchVegetationBenchmarkResult(
@@ -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,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"
@@ -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[];
} }
@@ -152,10 +179,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 +194,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",
@@ -379,11 +428,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 +535,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 +587,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 +624,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,
}; };
} }
@@ -1181,6 +1362,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;
@@ -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,125 @@
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;
detections2d: boolean;
segmentation: boolean;
cuboids3d: 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;
/** Requests the canonical top-down eye without changing the world coordinate frame. */
planView?: boolean;
perceptionRetryGeneration: number;
lockPerceptionCameraInteraction: boolean;
}
/**
* 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;
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 { kind: "live-acquisition", clock: "stream_time", ...input };
}
export function recordedSessionRerunProfile(
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
): RecordedSessionRerunProfile {
return { kind: "recorded-session", clock: "session_time", ...input };
}
@@ -0,0 +1,261 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
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 {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
};
}
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);
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// Both API projections are bounded newest-first windows without a
// cursor. Absence from the source window is not proof of a broken
// relationship and must not be presented as an integrity fault.
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: laboratorySessions.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 [sourceCatalog, laboratoryCatalog] = await Promise.all([
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
]);
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
}
@@ -0,0 +1,210 @@
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
export type ObservatoryCatalogMutationFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export interface ObservatoryLabProjectionMutationResult {
readonly schemaVersion: typeof PROJECTION_SCHEMA;
readonly sessionId: string;
readonly displayName: string;
}
export class ObservatoryCatalogMutationError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryCatalogMutationError";
this.status = status;
}
}
export async function renameObservatoryLabProjection(
binding: ObservatoryRecordedRunBinding,
displayName: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryCatalogMutationFetch;
} = {},
): Promise<ObservatoryLabProjectionMutationResult> {
const sessionId = admittedProjectionId(binding);
const normalizedName = displayName.trim();
if (normalizedName.length < 1 || normalizedName.length > 160) {
throw new ObservatoryCatalogMutationError(
"Название лабораторного результата должно содержать от 1 до 160 символов.",
);
}
const response = await request(
fetcher,
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
schema_version: RENAME_SCHEMA,
display_name: normalizedName,
}),
signal,
},
"Не удалось переименовать лабораторный результат.",
);
const body = await responseBody(response);
if (!response.ok || response.status !== 200) {
throw apiError(
body,
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
response.status,
);
}
const decoded = decodeProjection(body);
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
throw new ObservatoryCatalogMutationError(
"Сервер не подтвердил точное переименование выбранного результата.",
);
}
return decoded;
}
export async function deleteObservatoryLabProjection(
binding: ObservatoryRecordedRunBinding,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryCatalogMutationFetch;
} = {},
): Promise<void> {
const sessionId = admittedProjectionId(binding);
const response = await request(
fetcher,
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { Accept: "application/json" },
signal,
},
"Не удалось удалить лабораторный результат из Обсерватории.",
);
const body = await responseBody(response);
if (!response.ok || response.status !== 204) {
throw apiError(
body,
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
response.status,
);
}
if (body !== undefined) {
throw new ObservatoryCatalogMutationError(
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
);
}
}
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
const sessionId = binding.evidenceSessionId;
const admittedViewer = (
binding.kind === "canonical-recorded-rerun"
&& binding.viewerProfile === "recorded-session"
&& binding.timeline === "session_time"
) || (
binding.kind === "portable-result-review"
&& binding.viewerProfile === "portable-result"
&& binding.timeline === "result-defined"
);
if (
!admittedViewer
|| binding.activation !== "explicit"
|| binding.resultId !== sessionId
|| binding.sourceSessionId === sessionId
|| !SAFE_SESSION_ID.test(sessionId)
) {
throw new ObservatoryCatalogMutationError(
"Выбранный результат не допущен к изменению каталога Обсерватории.",
);
}
return sessionId;
}
async function request(
fetcher: ObservatoryCatalogMutationFetch,
input: string,
init: RequestInit,
fallback: string,
): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryCatalogMutationError(fallback);
}
}
async function responseBody(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) return undefined;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function apiError(body: unknown, fallback: string, status: number): Error {
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
}
if (typeof body === "string" && body.trim()) {
return new ObservatoryCatalogMutationError(body.trim(), status);
}
return new ObservatoryCatalogMutationError(fallback, status);
}
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
if (!isRecord(value)) {
throw new ObservatoryCatalogMutationError(
"Сервер вернул некорректное подтверждение лабораторного результата.",
);
}
const keys = Object.keys(value).sort();
const expected = ["display_name", "schema_version", "session_id"];
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
throw new ObservatoryCatalogMutationError(
"Подтверждение лабораторного результата содержит неизвестные поля.",
);
}
if (
value.schema_version !== PROJECTION_SCHEMA
|| typeof value.session_id !== "string"
|| !SAFE_SESSION_ID.test(value.session_id)
|| typeof value.display_name !== "string"
|| value.display_name.trim() !== value.display_name
|| value.display_name.length < 1
|| value.display_name.length > 160
) {
throw new ObservatoryCatalogMutationError(
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
);
}
return {
schemaVersion: PROJECTION_SCHEMA,
sessionId: value.session_id,
displayName: value.display_name,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,449 @@
export { fetchObservatoryPortableLaboratorySetups } from "./portableLaboratorySetups";
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1";
const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1";
const SHA256 = /^[a-f0-9]{64}$/;
export type ObservatoryLaboratorySetupOrigin =
| "archived-definition"
| "existing-result"
| "portable-definition";
export type ObservatoryLaboratorySetupAction =
| "open-existing"
| "open-legacy"
| "check"
| "blocked";
export interface ObservatoryLaboratoryRunDefinition {
readonly definitionId: string;
readonly version: number;
readonly workId: string | null;
readonly definitionSha256: string;
readonly resultSchema: string | null;
readonly resultKind: string | null;
readonly models: readonly {
readonly name: string;
readonly releaseId: string;
readonly modelId: string;
readonly architecture: string;
}[];
readonly configuration: readonly {
readonly role: string;
readonly sha256: string;
}[];
}
export interface ObservatoryLaboratoryPreservedResult {
readonly resultId: string;
readonly resultKind: string;
readonly relation: string;
readonly access: "legacy-lab" | "observatory" | "evidence-only";
readonly createdAtUtc: string;
readonly observatoryProjectionAvailable: boolean;
}
export interface ObservatoryLaboratorySetup {
readonly setupId: string;
readonly displayName: string;
readonly description: string;
readonly origin: ObservatoryLaboratorySetupOrigin;
readonly runDefinition: ObservatoryLaboratoryRunDefinition | null;
readonly compatibility: {
readonly compatible: boolean;
readonly reasons: readonly { readonly code: string; readonly message: string }[];
};
readonly executor: {
readonly contourId: string;
readonly state: "not-installed" | "ready";
readonly reasonCode: string;
readonly reason: string;
};
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
readonly preflight: {
readonly outcome: "existing" | "ready" | "blocked";
readonly action: ObservatoryLaboratorySetupAction;
readonly reason: string;
readonly submissionAllowed: boolean;
readonly existingResultIds: readonly string[];
};
}
export interface ObservatoryLaboratorySetupCatalog {
readonly sourceSessionId: string;
readonly setups: readonly ObservatoryLaboratorySetup[];
}
// Cache identity is verified by the portable catalog decoder/server, never by a LAB label.
export function selectableObservatorySetups(catalog: ObservatoryLaboratorySetupCatalog | null) {
return catalog?.setups.filter((setup) => (
setup.origin === "portable-definition"
&& setup.runDefinition !== null
&& setup.compatibility.compatible
&& setup.preflight.outcome !== "existing"
)) ?? [];
}
export interface ObservatoryLaboratoryRunPreflight {
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string | null;
readonly checkSha256: string | null;
readonly outcome: "existing" | "queueable" | "blocked";
readonly submissionAllowed: boolean;
readonly checks: readonly {
readonly checkId: string;
readonly outcome: "pass" | "fail" | "not-applicable";
readonly reasonCode: string;
readonly message: string;
}[];
readonly existingResultIds: readonly string[];
}
export type ObservatoryLaboratorySetupFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export class ObservatoryLaboratorySetupContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryLaboratorySetupContractError";
this.status = status;
}
}
export async function fetchObservatoryLaboratorySetups(
sourceSessionId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryLaboratorySetupFetch;
} = {},
): Promise<ObservatoryLaboratorySetupCatalog> {
const response = await request(
fetcher,
`/api/v1/observatory/laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const catalog = decodeCatalog(body);
if (catalog.sourceSessionId !== sourceSessionId) {
throw new ObservatoryLaboratorySetupContractError(
"Каталог сетапов относится к другой исходной сессии.",
);
}
return catalog;
}
export async function preflightObservatoryLaboratorySetup(
sourceSessionId: string,
setup: ObservatoryLaboratorySetup,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryLaboratorySetupFetch;
} = {},
): Promise<ObservatoryLaboratoryRunPreflight> {
const response = await request(
fetcher,
"/api/v1/observatory/run-preflights",
{
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: PREFLIGHT_REQUEST_SCHEMA,
source_session_id: sourceSessionId,
setup_id: setup.setupId,
definition_sha256: setup.runDefinition?.definitionSha256 ?? null,
}),
signal,
},
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const preflight = decodePreflight(body);
if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) {
throw new ObservatoryLaboratorySetupContractError(
"Preflight относится к другому источнику или сетапу.",
);
}
if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) {
throw new ObservatoryLaboratorySetupContractError(
"Preflight относится к другой версии сетапа.",
);
}
return preflight;
}
function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
const row = record(value, "каталог сетапов");
exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов");
exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов");
observationAuthority(row.authority);
return {
sourceSessionId: text(row.source_session_id, "source_session_id"),
setups: array(row.setups, "setups").map(decodeSetup),
};
}
function decodeSetup(value: unknown): ObservatoryLaboratorySetup {
const row = record(value, "сетап");
exactKeys(row, [
"authority", "compatibility", "description", "display_name", "executor", "origin",
"preflight", "preserved_results", "run_definition", "setup_id", "source",
], "сетап");
observationAuthority(row.authority);
const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin");
const compatibility = record(row.compatibility, "compatibility");
const executor = record(row.executor, "executor");
const preflight = record(row.preflight, "preflight");
const source = record(row.source, "source");
const runDefinition = row.run_definition === null
? null
: decodeRunDefinition(row.run_definition);
if ((origin === "archived-definition") !== (runDefinition !== null)) {
throw new ObservatoryLaboratorySetupContractError(
"Происхождение сетапа не совпадает с RunDefinition.",
);
}
exactKeys(source, ["label", "required_modalities", "session_id"], "source");
text(source.session_id, "source.session_id");
text(source.label, "source.label");
array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality"));
exactKeys(compatibility, ["compatible", "reasons"], "compatibility");
exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor");
exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight");
return {
setupId: text(row.setup_id, "setup_id"),
displayName: text(row.display_name, "display_name"),
description: text(row.description, "description"),
origin,
runDefinition,
compatibility: {
compatible: boolean(row.compatibility && compatibility.compatible, "compatible"),
reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => {
const reason = record(item, "compatibility reason");
exactKeys(reason, ["code", "message"], "compatibility reason");
return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") };
}),
},
executor: {
contourId: text(executor.contour_id, "executor.contour_id"),
state: exact(executor.state, "not-installed", "executor.state"),
reasonCode: text(executor.reason_code, "executor.reason_code"),
reason: text(executor.reason, "executor.reason"),
},
preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult),
preflight: {
outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"),
action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"),
reason: text(preflight.reason, "preflight.reason"),
submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"),
existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")),
},
};
}
function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
const row = record(value, "RunDefinition");
exactKeys(row, [
"authority", "configuration", "definition_id", "definition_sha256", "schema_version",
"source", "version", "work_id",
], "RunDefinition");
exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema");
observationAuthority(row.authority);
const source = record(row.source, "RunDefinition source");
exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source");
text(source.session_id, "RunDefinition source.session_id");
text(source.label, "RunDefinition source.label");
array(source.required_modalities, "RunDefinition source.required_modalities")
.forEach((item) => text(item, "RunDefinition source modality"));
const digest = text(row.definition_sha256, "definition_sha256");
if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition.");
return {
definitionId: text(row.definition_id, "definition_id"),
version: positiveInteger(row.version, "definition version"),
workId: text(row.work_id, "work_id"),
definitionSha256: digest,
resultSchema: null,
resultKind: null,
models: [],
configuration: array(row.configuration, "configuration").map((item) => {
const reference = record(item, "configuration reference");
exactKeys(reference, ["role", "sha256"], "configuration reference");
const sha256 = text(reference.sha256, "configuration sha256");
if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации.");
return { role: text(reference.role, "configuration role"), sha256 };
}),
};
}
function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult {
const row = record(value, "preserved result");
exactKeys(row, [
"access", "created_at_utc", "observatory_projection_available", "relation",
"result_id", "result_kind",
], "preserved result");
return {
resultId: text(row.result_id, "result_id"),
resultKind: text(row.result_kind, "result_kind"),
relation: text(row.relation, "relation"),
access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"),
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"),
};
}
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
const row = record(value, "preflight");
const baseKeys = [
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
] as const;
const hasPortableCheck = Object.hasOwn(row, "check_sha256");
exactKeys(
row,
hasPortableCheck ? [...baseKeys, "check_sha256"] : baseKeys,
"preflight",
);
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
observationAuthority(row.authority);
const digest = row.definition_sha256;
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
}
const checkDigest = hasPortableCheck ? row.check_sha256 : null;
if (
checkDigest !== null
&& (typeof checkDigest !== "string" || !SHA256.test(checkDigest))
) {
throw new ObservatoryLaboratorySetupContractError(
"Некорректный check digest preflight.",
);
}
const outcome = oneOf(
row.outcome,
["existing", "queueable", "blocked"] as const,
"preflight outcome",
);
const submissionAllowed = boolean(
row.submission_allowed,
"preflight submission_allowed",
);
if (
submissionAllowed !== (outcome === "queueable")
|| (hasPortableCheck && outcome === "queueable" && checkDigest === null)
) {
throw new ObservatoryLaboratorySetupContractError(
"Preflight содержит противоречивое разрешение постановки в очередь.",
);
}
return {
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
setupId: text(row.setup_id, "preflight setup_id"),
definitionSha256: digest,
checkSha256: checkDigest,
outcome,
submissionAllowed,
checks: array(row.checks, "preflight checks").map((item) => {
const check = record(item, "preflight check");
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
return {
checkId: text(check.check_id, "check_id"),
outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"),
reasonCode: text(check.reason_code, "check reason_code"),
message: text(check.message, "check message"),
};
}),
existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")),
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
exactKeys(row, keys, "authority");
for (const key of keys) {
exact(row[key], false, `authority.${key}`);
}
}
async function request(fetcher: ObservatoryLaboratorySetupFetch, input: string, init: RequestInit): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен.");
}
}
async function responseBody(response: Response): Promise<unknown> {
const textBody = await response.text();
if (!textBody) return undefined;
try { return JSON.parse(textBody) as unknown; } catch { return textBody; }
}
function apiError(body: unknown, status: number): ObservatoryLaboratorySetupContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryLaboratorySetupContractError(
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
status,
);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`);
return value;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`);
return value;
}
function positiveInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`);
return Number(value);
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${label}: значение изменилось.`);
return expected;
}
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryLaboratorySetupContractError(`${label}: значение не поддерживается.`);
}
return value as T[number];
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
throw new ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`);
}
}
@@ -0,0 +1,373 @@
import type {
ObservatoryLaboratoryPreservedResult,
ObservatoryLaboratoryRunDefinition,
ObservatoryLaboratorySetup,
ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
const PORTABLE_CATALOG_SCHEMA = "missioncore.observatory-portable-setup-catalog/v2";
const SHA256 = /^[a-f0-9]{64}$/;
export class ObservatoryPortableSetupDecodeError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryPortableSetupDecodeError";
}
}
export function decodePortableCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
const row = record(value, "portable-каталог сетапов");
exactKeys(
row,
["authority", "schema_version", "setups", "source_session_id"],
"portable-каталог сетапов",
);
exact(row.schema_version, PORTABLE_CATALOG_SCHEMA, "schema_version portable-каталога");
observationAuthority(row.authority);
const sourceSessionId = text(row.source_session_id, "portable source_session_id");
return {
sourceSessionId,
setups: array(row.setups, "portable setups").map((item) => decodePortableSetup(item, sourceSessionId)),
};
}
function decodePortableSetup(value: unknown, sourceSessionId: string): ObservatoryLaboratorySetup {
const row = record(value, "portable-сетап");
exactKeys(row, [
"authority", "description", "display_name", "executor", "existing_results",
"origin", "preflight", "run_definition", "setup_id", "source_compatibility",
"source_requirements",
], "portable-сетап");
exact(row.origin, "portable-definition", "portable origin");
observationAuthority(row.authority);
decodePortableSourceRequirements(row.source_requirements);
const setupId = text(row.setup_id, "portable setup_id");
const runDefinition = decodePortableRunDefinition(row.run_definition);
const compatibility = record(row.source_compatibility, "portable source_compatibility");
exactKeys(
compatibility,
["compatible", "outcome", "reason", "reason_code"],
"portable source_compatibility",
);
const compatible = boolean(compatibility.compatible, "portable compatible");
exact(
compatibility.outcome,
compatible ? "pass" : "blocked",
"portable compatibility outcome",
);
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
const compatibilityReasonCode = text(
compatibility.reason_code,
"portable compatibility reason_code",
);
const executor = record(row.executor, "portable executor");
exactKeys(
executor,
["contour_id", "ready", "reason", "reason_code", "state"],
"portable executor",
);
const executorState = oneOf(
executor.state,
["not-installed", "ready"] as const,
"portable executor state",
);
const executorReady = boolean(executor.ready, "portable executor ready");
if (executorReady !== (executorState === "ready")) {
throw new ObservatoryPortableSetupDecodeError(
"Portable executor: состояние готовности противоречиво.",
);
}
const executorReason = executor.reason === null
? "Исполнитель установлен."
: text(executor.reason, "portable executor reason");
const executorReasonCode = executor.reason_code === null
? "portable-executor-ready"
: text(executor.reason_code, "portable executor reason_code");
if (
(executorReady && (executor.reason !== null || executor.reason_code !== null))
|| (!executorReady && (executor.reason === null || executor.reason_code === null))
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable executor: причина недоступности противоречит состоянию.",
);
}
const preflight = record(row.preflight, "portable preflight");
exactKeys(preflight, [
"action", "existing_result_ids", "outcome", "reason", "submission_allowed",
], "portable preflight");
const preflightOutcome = oneOf(
preflight.outcome,
["existing", "ready", "blocked"] as const,
"portable preflight outcome",
);
const preflightAction = oneOf(
preflight.action,
["open-existing", "check", "blocked"] as const,
"portable preflight action",
);
const submissionAllowed = boolean(
preflight.submission_allowed,
"portable preflight submission_allowed",
);
if (
submissionAllowed !== (preflightOutcome === "ready")
|| (preflightOutcome === "ready" && preflightAction !== "check")
|| (preflightOutcome === "blocked" && preflightAction !== "blocked")
|| (preflightOutcome === "existing" && preflightAction !== "open-existing")
|| (submissionAllowed && (!compatible || !executorReady))
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable preflight: состояние запуска противоречиво.",
);
}
const existingResults = array(row.existing_results, "portable existing_results").map(
(item) => decodePortableResult(item, sourceSessionId, setupId, runDefinition),
);
const existingResultIds = array(
preflight.existing_result_ids,
"portable existing_result_ids",
).map((item) => text(item, "portable existing result id"));
if (
(existingResults.length > 0) !== (preflightOutcome === "existing")
|| existingResultIds.length !== existingResults.length
|| new Set(existingResultIds).size !== existingResultIds.length
|| existingResults.some((result, index) => result.resultId !== existingResultIds[index])
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable result: готовность не соответствует проверенным результатам.",
);
}
return {
setupId,
displayName: text(row.display_name, "portable display_name"),
description: text(row.description, "portable description"),
origin: "portable-definition",
runDefinition,
compatibility: {
compatible,
reasons: compatible
? []
: [{ code: compatibilityReasonCode, message: compatibilityReason }],
},
executor: {
contourId: text(executor.contour_id, "portable executor contour_id"),
state: executorState,
reasonCode: executorReasonCode,
reason: executorReason,
},
preservedResults: existingResults,
preflight: {
outcome: preflightOutcome,
action: preflightAction,
reason: text(preflight.reason, "portable preflight reason"),
submissionAllowed,
existingResultIds,
},
};
}
function decodePortableResult(
value: unknown, sourceSessionId: string, setupId: string,
definition: ObservatoryLaboratoryRunDefinition,
): ObservatoryLaboratoryPreservedResult {
const row = record(value, "portable result");
exactKeys(row, [
"result_id", "result_kind", "relation", "access", "created_at_utc",
"observatory_projection_available", "identity",
], "portable result");
exact(row.result_kind, definition.resultKind, "portable result kind");
exact(row.relation, "exact-recorded-computation", "portable result relation");
exact(row.access, "observatory", "portable result access");
exact(row.observatory_projection_available, true, "portable result availability");
const identity = record(row.identity, "portable result identity");
exactKeys(identity, [
"job_id", "source_session_id", "source_catalog_sha256", "source_bundle_sha256",
"source_capability_manifest_sha256", "setup_id", "definition_sha256",
"package_sha256", "artifact_manifest_id",
], "portable result identity");
exact(identity.source_session_id, sourceSessionId, "portable result source");
exact(identity.setup_id, setupId, "portable result setup");
exact(identity.definition_sha256, definition.definitionSha256, "portable result definition");
text(identity.job_id, "portable result job");
for (const key of [
"source_catalog_sha256", "source_bundle_sha256", "source_capability_manifest_sha256",
"definition_sha256", "package_sha256", "artifact_manifest_id",
]) {
if (!SHA256.test(text(identity[key], `portable result ${key}`))) {
throw new ObservatoryPortableSetupDecodeError(`Portable result: некорректный ${key}.`);
}
}
return {
resultId: text(row.result_id, "portable result id"),
resultKind: text(row.result_kind, "portable result kind"),
relation: "exact-recorded-computation",
access: "observatory",
createdAtUtc: text(row.created_at_utc, "portable result created_at_utc"),
observatoryProjectionAvailable: true,
};
}
function decodePortableSourceRequirements(value: unknown): void {
const row = record(value, "portable source_requirements");
exactKeys(row, [
"archive_id", "calibration_identity_sha256", "calibration_slot", "camera_height",
"camera_semantic_channel_id", "camera_source_id", "camera_width",
"exactly_one_media_epoch", "plugin_id", "recorded_media_init_sha256",
"recorded_media_type", "required_modalities", "seekable",
], "portable source_requirements");
for (const key of [
"archive_id", "calibration_slot", "camera_semantic_channel_id", "camera_source_id",
"plugin_id", "recorded_media_type",
]) text(row[key], `portable source_requirements.${key}`);
for (const key of ["calibration_identity_sha256", "recorded_media_init_sha256"]) {
const digest = text(row[key], `portable source_requirements.${key}`);
if (!SHA256.test(digest)) {
throw new ObservatoryPortableSetupDecodeError(
`portable source_requirements.${key}: некорректный digest.`,
);
}
}
positiveInteger(row.camera_width, "portable camera_width");
positiveInteger(row.camera_height, "portable camera_height");
exact(row.exactly_one_media_epoch, true, "portable exactly_one_media_epoch");
exact(row.seekable, true, "portable seekable");
array(row.required_modalities, "portable required_modalities")
.forEach((item) => text(item, "portable required modality"));
}
function decodePortableRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
const row = record(value, "portable RunDefinition");
exactKeys(row, [
"definition_id", "definition_sha256", "models", "result_kind", "result_schema",
"version",
], "portable RunDefinition");
const digest = text(row.definition_sha256, "portable definition_sha256");
if (!SHA256.test(digest)) {
throw new ObservatoryPortableSetupDecodeError(
"Portable RunDefinition: некорректный digest.",
);
}
return {
definitionId: text(row.definition_id, "portable definition_id"),
version: positiveInteger(row.version, "portable definition version"),
workId: null,
definitionSha256: digest,
resultSchema: text(row.result_schema, "portable result_schema"),
resultKind: text(row.result_kind, "portable result_kind"),
models: array(row.models, "portable models").map((item) => {
const model = record(item, "portable model");
exactKeys(
model,
["architecture", "model_id", "name", "release_id"],
"portable model",
);
return {
name: text(model.name, "portable model name"),
releaseId: text(model.release_id, "portable model release_id"),
modelId: text(model.model_id, "portable model model_id"),
architecture: text(model.architecture, "portable model architecture"),
};
}),
configuration: [],
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = [
"commands_enabled",
"actuation_allowed",
"navigation_or_safety_accepted",
"production_accepted",
];
exactKeys(row, keys, "authority");
for (const key of keys) exact(row[key], false, `authority.${key}`);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался объект.`,
);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался массив.`,
);
}
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался текст.`,
);
}
return value;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался boolean.`,
);
}
return value;
}
function positiveInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 1) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидалось положительное целое.`,
);
}
return Number(value);
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: значение изменилось.`,
);
}
return expected;
}
function oneOf<const T extends readonly string[]>(
value: unknown,
allowed: T,
label: string,
): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: значение не поддерживается.`,
);
}
return value as T[number];
}
function exactKeys(
value: Record<string, unknown>,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (
actual.length !== sortedExpected.length
|| actual.some((key, index) => key !== sortedExpected[index])
) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: обнаружены неизвестные поля.`,
);
}
}
@@ -0,0 +1,85 @@
import { decodePortableCatalog } from "./portableLaboratorySetupDecoder";
import type {
ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
export type ObservatoryPortableLaboratorySetupFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
class ObservatoryPortableLaboratorySetupContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryPortableLaboratorySetupContractError";
this.status = status;
}
}
export async function fetchObservatoryPortableLaboratorySetups(
sourceSessionId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryPortableLaboratorySetupFetch;
} = {},
): Promise<ObservatoryLaboratorySetupCatalog> {
const response = await request(
fetcher,
`/api/v1/observatory/portable-laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const catalog = decodePortableCatalog(body);
if (catalog.sourceSessionId !== sourceSessionId) {
throw new ObservatoryPortableLaboratorySetupContractError(
"Portable-каталог сетапов относится к другой исходной сессии.",
);
}
return catalog;
}
async function request(
fetcher: ObservatoryPortableLaboratorySetupFetch,
input: string,
init: RequestInit,
): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryPortableLaboratorySetupContractError(
"Portable-каталог сетапов недоступен.",
);
}
}
async function responseBody(response: Response): Promise<unknown> {
const textBody = await response.text();
if (!textBody) return undefined;
try {
return JSON.parse(textBody) as unknown;
} catch {
return textBody;
}
}
function apiError(
body: unknown,
status: number,
): ObservatoryPortableLaboratorySetupContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryPortableLaboratorySetupContractError(
typeof detail === "string" && detail.trim()
? detail
: `Observatory API вернул HTTP ${status}.`,
status,
);
}
@@ -0,0 +1,336 @@
const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1";
const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1";
const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1";
const SHA256 = /^[a-f0-9]{64}$/;
export type ObservatoryRecordedJobState =
| "accepted"
| "queued"
| "claimed"
| "running"
| "paused"
| "preemption-pending"
| "succeeded"
| "failed"
| "reconciliation-required";
export type ObservatoryRecordedJobPublicationState =
| "not-required"
| "pending"
| "failed"
| "published";
export interface ObservatoryRecordedJob {
readonly jobId: string;
readonly idempotencyKey: string;
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string;
readonly state: ObservatoryRecordedJobState;
readonly restartFromZero: boolean;
readonly resultId: string | null;
readonly terminalMessage: string | null;
readonly publication: {
readonly state: ObservatoryRecordedJobPublicationState;
readonly attempts: number;
readonly error: string | null;
readonly publishedAtUtc: string | null;
};
readonly createdAtUtc: string;
readonly updatedAtUtc: string;
}
export type ObservatoryRecordedJobFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export class ObservatoryRecordedJobContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryRecordedJobContractError";
this.status = status;
}
}
export async function fetchObservatoryRecordedJobs(
sourceSessionId: string,
setupId: string,
{
signal,
definitionSha256,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
definitionSha256?: string;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({
source_session_id: sourceSessionId,
setup_id: setupId,
limit: "20",
});
if (definitionSha256 !== undefined) query.set("definition_sha256", definitionSha256);
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "список расчётов");
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
observationAuthority(row.authority);
return array(row.items, "items").map(decodeJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
));
}
export async function submitObservatoryRecordedJob(
sourceSessionId: string,
setupId: string,
idempotencyKey: string,
portableBinding: {
readonly definitionSha256: string;
readonly checkSha256: string;
} | null,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<ObservatoryRecordedJob> {
const response = await request(fetcher, "/api/v1/observatory/runs", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: SUBMIT_SCHEMA,
idempotency_key: idempotencyKey,
source_session_id: sourceSessionId,
setup_id: setupId,
...(portableBinding === null ? {} : {
definition_sha256: portableBinding.definitionSha256,
check_sha256: portableBinding.checkSha256,
}),
}),
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
throw new ObservatoryRecordedJobContractError(
"Расчёт относится к другой сессии, сетапу или версии профиля.",
);
}
return job;
}
export async function retryObservatoryRecordedJobPublication(
jobId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<ObservatoryRecordedJob> {
const response = await request(
fetcher,
`/api/v1/observatory/runs/${encodeURIComponent(jobId)}/publication/retry`,
{
method: "POST",
headers: { Accept: "application/json" },
signal,
},
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.jobId !== jobId) {
throw new ObservatoryRecordedJobContractError(
"Повтор публикации вернул другой расчёт.",
);
}
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
"preemption_requested", "priority", "publication", "request_sha256", "restart_from_zero", "result",
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
"updated_at_utc",
], "расчёт");
exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта");
observationAuthority(row.authority);
digest(row.request_sha256, "request_sha256");
digest(row.identity_sha256, "identity_sha256");
digest(row.submission_receipt_sha256, "submission_receipt_sha256");
const source = record(row.source, "source");
exactKeys(source, [
"adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id",
], "source");
digest(source.catalog_sha256, "source.catalog_sha256");
digest(source.bundle_sha256, "source.bundle_sha256");
digest(source.capability_manifest_sha256, "source.capability_manifest_sha256");
const setup = record(row.setup, "setup");
exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup");
digest(setup.definition_sha256, "setup.definition_sha256");
const state = oneOf(row.state, [
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"succeeded", "failed", "reconciliation-required",
] as const, "state");
if (row.claim_lease !== null) {
const lease = record(row.claim_lease, "claim_lease");
exactKeys(
lease,
["claimed_at_utc", "expires_at_utc", "heartbeat_at_utc", "renewal_count"],
"claim_lease",
);
text(lease.claimed_at_utc, "claim_lease.claimed_at_utc");
text(lease.expires_at_utc, "claim_lease.expires_at_utc");
text(lease.heartbeat_at_utc, "claim_lease.heartbeat_at_utc");
nonNegativeInteger(lease.renewal_count, "claim_lease.renewal_count");
}
const result = row.result === null ? null : record(row.result, "result");
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
const publication = record(row.publication, "publication");
exactKeys(
publication,
["attempts", "error", "published_at_utc", "state"],
"publication",
);
const publicationState = oneOf(publication.state, [
"not-required", "pending", "failed", "published",
] as const, "publication.state");
const publicationError = publication.error === null
? null
: text(publication.error, "publication.error");
const publishedAtUtc = publication.published_at_utc === null
? null
: text(publication.published_at_utc, "publication.published_at_utc");
return {
jobId: text(row.job_id, "job_id"),
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
sourceSessionId: text(source.session_id, "source.session_id"),
setupId: text(setup.setup_id, "setup.setup_id"),
definitionSha256: String(setup.definition_sha256),
state,
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
resultId: result === null ? null : text(result.result_id, "result.result_id"),
terminalMessage: terminal === null || terminal.message === null
? null
: text(terminal.message, "terminal.message"),
publication: {
state: publicationState,
attempts: nonNegativeInteger(publication.attempts, "publication.attempts"),
error: publicationError,
publishedAtUtc,
},
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
exactKeys(row, keys, "authority");
keys.forEach((key) => exact(row[key], false, `authority.${key}`));
}
async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна.");
}
}
async function responseBody(response: Response): Promise<unknown> {
const body = await response.text();
if (!body) return undefined;
try { return JSON.parse(body) as unknown; } catch { return body; }
}
function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryRecordedJobContractError(
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
status,
);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`);
}
return value;
}
function digest(value: unknown, label: string): string {
const valueText = text(value, label);
if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`);
return valueText;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`);
return value;
}
function nonNegativeInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 0) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидалось целое число.`);
}
return Number(value);
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
return expected;
}
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`);
}
return value as T[number];
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`);
}
}
@@ -0,0 +1,335 @@
import type { LaboratoryFetch } from "../laboratory/advancedResults";
import {
fetchVegetationShadowResultMetadata,
type VegetationFullRouteReview,
type VegetationShadowResult,
} from "../laboratory/vegetationShadow";
import type { ObservationLabInstance } from "../observation/sessionArchive";
import {
decodeLabReplayCapability,
decodeCanonicalLabReplayCapabilityProvenance,
decodePortableLabReplayCapabilityProvenance,
ObservationLabReplayCapabilityContractError,
} from "../observation/labReplayCapability";
const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live";
interface ObservatoryRecordedRunBindingBase {
readonly evidenceSessionId: string;
readonly sourceSessionId: string;
readonly resultId: string;
readonly activation: "explicit";
}
export interface ObservatoryCanonicalRecordedRunBinding
extends ObservatoryRecordedRunBindingBase {
readonly kind: "canonical-recorded-rerun";
readonly viewerProfile: "recorded-session";
readonly timeline: "session_time";
}
export interface ObservatoryPortableResultBinding
extends ObservatoryRecordedRunBindingBase {
readonly kind: "portable-result-review";
readonly viewerProfile: "portable-result";
readonly timeline: "result-defined";
readonly definitionSha256: string;
}
export type ObservatoryRecordedRunBinding =
| ObservatoryCanonicalRecordedRunBinding
| ObservatoryPortableResultBinding;
export interface ObservatoryPortableResultReview {
readonly kind: "portable-result";
readonly resultId: string;
readonly sourceSessionId: string;
readonly resultKind: string;
readonly definitionSha256: string;
readonly calculationProfile: Readonly<Record<string, unknown>> | null;
readonly artifactManifestId: string;
readonly artifacts: readonly {
readonly role: string;
readonly mediaType: string;
readonly sha256: string;
readonly byteLength: number;
}[];
readonly resultDocument: Readonly<Record<string, unknown>>;
}
export type ObservatoryRecordedRunReview =
| { readonly kind: "canonical-recorded-rerun"; readonly review: VegetationFullRouteReview }
| ObservatoryPortableResultReview;
export class ObservatoryRecordedRunContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryRecordedRunContractError";
}
}
export function observatoryRecordedRunBinding(
evidenceSessionId: string,
lab: ObservationLabInstance,
): ObservatoryRecordedRunBinding | null {
const capability = lab.replayCapability;
if (!capability) return null;
if (capability.kind === "portable-result-review") {
if (
evidenceSessionId !== lab.resultId
|| lab.sourceSessionId === evidenceSessionId
|| lab.configSha256 === null
|| !/^[a-f0-9]{64}$/.test(lab.configSha256)
|| capability.viewerProfile !== "portable-result"
|| capability.timeline !== "result-defined"
|| capability.activation !== "explicit"
|| capability.commandsEnabled !== false
) {
throw new ObservatoryRecordedRunContractError(
`Portable-результат ${lab.resultId} не допущен к универсальному viewer.`,
);
}
try {
decodePortableLabReplayCapabilityProvenance(
lab.provenance,
evidenceSessionId,
lab.resultId,
lab.sourceSessionId,
lab.configSha256,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
return {
kind: "portable-result-review",
evidenceSessionId,
sourceSessionId: lab.sourceSessionId,
resultId: lab.resultId,
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
definitionSha256: lab.configSha256,
};
}
try {
decodeCanonicalLabReplayCapabilityProvenance(
lab.provenance,
evidenceSessionId,
lab.resultId,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
if (
evidenceSessionId !== lab.resultId
|| lab.sourceSessionId !== CANONICAL_SOURCE_SESSION_ID
|| lab.labId !== "LAB V1"
|| lab.resultKind !== "recorded-perception-qualification"
|| !CANONICAL_RESULT_ID.test(lab.resultId)
|| lab.configSha256 !== null
|| lab.sourceResultId === null
|| !CANONICAL_RESULT_ID.test(lab.sourceResultId)
|| capability.kind !== "canonical-recorded-rerun"
|| capability.viewerProfile !== "recorded-session"
|| capability.timeline !== "session_time"
|| capability.activation !== "explicit"
|| capability.commandsEnabled !== false
) {
throw new ObservatoryRecordedRunContractError(
`LAB-результат ${lab.resultId} не допущен к каноническому recorded replay.`,
);
}
return {
kind: "canonical-recorded-rerun",
evidenceSessionId,
sourceSessionId: lab.sourceSessionId,
resultId: lab.resultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
};
}
export async function fetchObservatoryRecordedRunReview(
binding: ObservatoryRecordedRunBinding,
{
selectedSourceSessionId,
fetcher = fetch,
signal,
}: {
selectedSourceSessionId: string;
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
},
): Promise<ObservatoryRecordedRunReview> {
if (binding.sourceSessionId !== selectedSourceSessionId) {
throw new ObservatoryRecordedRunContractError(
"Запуск не связан с выбранной исходной сессией.",
);
}
if (binding.kind === "portable-result-review") {
return fetchPortableResultReview(binding, { fetcher, signal });
}
const result = await fetchVegetationShadowResultMetadata(binding.resultId, {
fetcher,
signal,
});
return {
kind: "canonical-recorded-rerun",
review: admitObservatoryRecordedRunReview(
binding,
selectedSourceSessionId,
result,
),
};
}
export function admitObservatoryRecordedRunReview(
binding: ObservatoryCanonicalRecordedRunBinding,
selectedSourceSessionId: string,
result: VegetationShadowResult,
): VegetationFullRouteReview {
if (binding.sourceSessionId !== selectedSourceSessionId) {
throw new ObservatoryRecordedRunContractError(
"Запуск не связан с выбранной исходной сессией.",
);
}
if (result.resultId !== binding.resultId) {
throw new ObservatoryRecordedRunContractError(
"Запечатанный результат не совпадает с выбранным запуском.",
);
}
const review = result.routeFullReview;
if (!review || review.sessionId !== binding.sourceSessionId) {
throw new ObservatoryRecordedRunContractError(
"Запечатанный результат потерял точную связь с исходной сессией.",
);
}
return review;
}
async function fetchPortableResultReview(
binding: ObservatoryPortableResultBinding,
{
fetcher,
signal,
}: {
fetcher: LaboratoryFetch;
signal?: AbortSignal;
},
): Promise<ObservatoryPortableResultReview> {
const response = await fetcher(
`/api/v1/observatory/portable-results/${encodeURIComponent(binding.resultId)}`,
{ headers: { Accept: "application/json" }, signal },
);
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservatoryRecordedRunContractError(
"Portable viewer получил некорректный ответ сервера.",
);
}
if (!response.ok) {
throw new ObservatoryRecordedRunContractError(
isRecord(body) && typeof body.detail === "string"
? body.detail
: `Portable viewer вернул HTTP ${response.status}.`,
);
}
return decodePortableResultReview(body, binding);
}
function decodePortableResultReview(
value: unknown,
binding: ObservatoryPortableResultBinding,
): ObservatoryPortableResultReview {
if (!isRecord(value)) {
throw new ObservatoryRecordedRunContractError("Portable viewer вернул не объект.");
}
const expected = new Set([
"schema_version",
"result_id",
"source_session_id",
"result_kind",
"definition_sha256",
"viewer_capability",
"calculation_profile",
"artifact_manifest_id",
"artifacts",
"result_document",
]);
if (Object.keys(value).some((key) => !expected.has(key)) || Object.keys(value).length !== expected.size) {
throw new ObservatoryRecordedRunContractError("Portable viewer изменил контракт ответа.");
}
let viewerCapability;
try {
viewerCapability = decodeLabReplayCapability(
value.viewer_capability,
binding.evidenceSessionId,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
if (
value.schema_version !== "missioncore.observatory-portable-result-view/v1"
|| value.result_id !== binding.resultId
|| value.source_session_id !== binding.sourceSessionId
|| value.definition_sha256 !== binding.definitionSha256
|| typeof value.result_kind !== "string"
|| typeof value.artifact_manifest_id !== "string"
|| !/^[a-f0-9]{64}$/.test(value.artifact_manifest_id)
|| !Array.isArray(value.artifacts)
|| !isRecord(value.result_document)
|| !isRecord(value.calculation_profile)
|| viewerCapability === null
|| viewerCapability.kind !== "portable-result-review"
|| viewerCapability.viewerProfile !== binding.viewerProfile
|| viewerCapability.timeline !== binding.timeline
) {
throw new ObservatoryRecordedRunContractError("Portable viewer потерял identity результата.");
}
const artifacts = value.artifacts.map((item) => {
if (
!isRecord(item)
|| Object.keys(item).length !== 4
|| !["role", "media_type", "sha256", "byte_length"].every(
(key) => Object.prototype.hasOwnProperty.call(item, key),
)
|| typeof item.role !== "string"
|| typeof item.media_type !== "string"
|| typeof item.sha256 !== "string"
|| !/^[a-f0-9]{64}$/.test(item.sha256)
|| typeof item.byte_length !== "number"
|| !Number.isSafeInteger(item.byte_length)
|| item.byte_length < 0
) {
throw new ObservatoryRecordedRunContractError("Portable viewer получил неверный artifact.");
}
return {
role: item.role,
mediaType: item.media_type,
sha256: item.sha256,
byteLength: item.byte_length,
};
});
return {
kind: "portable-result",
resultId: binding.resultId,
sourceSessionId: binding.sourceSessionId,
resultKind: value.result_kind,
definitionSha256: binding.definitionSha256,
calculationProfile: value.calculation_profile,
artifactManifestId: value.artifact_manifest_id,
artifacts,
resultDocument: value.result_document,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
applyObservatoryCatalogMutationOverlay,
fetchObservatoryCatalog,
reconcileObservatoryCatalogMutationOverlay,
type ObservatoryCatalog,
type ObservatoryCatalogMutationOverlay,
} from "./catalog";
export type ObservatoryCatalogState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "error";
export interface ObservatoryCatalogController {
readonly catalog: ObservatoryCatalog | null;
readonly state: ObservatoryCatalogState;
readonly error: string | null;
readonly refresh: () => Promise<ObservatoryCatalog | null>;
readonly applyEvidenceRename: (
sessionId: string,
displayName: string,
) => void;
readonly applyEvidenceDeletion: (sessionId: string) => void;
}
interface ActiveCatalogRequest {
readonly id: number;
readonly controller: AbortController;
}
type ObservatoryCatalogMutationDraft =
| { readonly kind: "rename"; readonly displayName: string }
| { readonly kind: "delete" };
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Каталог Обсерватории недоступен.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function useObservatoryCatalog(): ObservatoryCatalogController {
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
const [state, setState] = useState<ObservatoryCatalogState>("idle");
const [error, setError] = useState<string | null>(null);
const catalogRef = useRef<ObservatoryCatalog | null>(null);
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
const mutationRevisionRef = useRef(0);
const requestSequenceRef = useRef(0);
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
const id = requestSequenceRef.current + 1;
requestSequenceRef.current = id;
activeRequestRef.current?.controller.abort();
const controller = new AbortController();
activeRequestRef.current = { id, controller };
const requestMutationRevision = mutationRevisionRef.current;
setState(catalogRef.current ? "refreshing" : "loading");
setError(null);
try {
const serverCatalog = await fetchObservatoryCatalog({
signal: controller.signal,
});
if (controller.signal.aborted || requestSequenceRef.current !== id) {
return null;
}
const reconciled = reconcileObservatoryCatalogMutationOverlay(
serverCatalog,
overlayRef.current,
requestMutationRevision,
);
overlayRef.current = reconciled.overlay;
catalogRef.current = reconciled.catalog;
activeRequestRef.current = null;
setCatalog(reconciled.catalog);
setState("ready");
return reconciled.catalog;
} catch (loadError: unknown) {
if (
controller.signal.aborted
|| requestSequenceRef.current !== id
|| isAbortError(loadError)
) {
return null;
}
activeRequestRef.current = null;
setError(errorMessage(loadError));
setState("error");
return null;
}
}, []);
useEffect(() => {
void loadCatalog();
return () => {
requestSequenceRef.current += 1;
activeRequestRef.current?.controller.abort();
activeRequestRef.current = null;
};
}, [loadCatalog]);
const applyMutation = useCallback((
sessionId: string,
mutation: ObservatoryCatalogMutationDraft,
) => {
const revision = mutationRevisionRef.current + 1;
mutationRevisionRef.current = revision;
const overlay = new Map(overlayRef.current);
overlay.set(sessionId, { ...mutation, revision });
overlayRef.current = overlay;
if (!catalogRef.current) return;
const projected = applyObservatoryCatalogMutationOverlay(
catalogRef.current,
overlay,
);
catalogRef.current = projected;
setCatalog(projected);
}, []);
const applyEvidenceRename = useCallback((
sessionId: string,
displayName: string,
) => {
applyMutation(sessionId, { kind: "rename", displayName });
}, [applyMutation]);
const applyEvidenceDeletion = useCallback((sessionId: string) => {
applyMutation(sessionId, { kind: "delete" });
}, [applyMutation]);
return {
catalog,
state,
error,
refresh: loadCatalog,
applyEvidenceRename,
applyEvidenceDeletion,
};
}
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservatoryPortableLaboratorySetups,
preflightObservatoryLaboratorySetup,
selectableObservatorySetups,
type ObservatoryLaboratoryRunPreflight,
type ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error";
export type SetupPreflightState =
| { readonly kind: "idle" }
| { readonly kind: "checking" }
| { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight }
| { readonly kind: "error"; readonly message: string };
export function useObservatoryLaboratorySetups(sourceSessionId: string) {
const [catalog, setCatalog] = useState<ObservatoryLaboratorySetupCatalog | null>(null);
const [state, setState] = useState<SetupCatalogState>("idle");
const [error, setError] = useState<string | null>(null);
const [selectedSetupId, setSelectedSetupId] = useState("");
const [revision, setRevision] = useState(0);
const [preflight, setPreflight] = useState<SetupPreflightState>({ kind: "idle" });
const requestSequence = useRef(0);
const preflightRequest = useRef<AbortController | null>(null);
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
const selectableSetups = useMemo(() => selectableObservatorySetups(activeCatalog), [activeCatalog]);
const selectedSetup = selectableSetups.find((setup) => setup.setupId === selectedSetupId) ?? null;
const selectedDefinitionSha256 = selectedSetup?.runDefinition?.definitionSha256 ?? null;
useEffect(() => {
preflightRequest.current?.abort();
preflightRequest.current = null;
if (!sourceSessionId) {
setCatalog(null);
setState("idle");
setError(null);
setSelectedSetupId("");
setPreflight({ kind: "idle" });
return;
}
const sequence = ++requestSequence.current;
const request = new AbortController();
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
setError(null);
setPreflight({ kind: "idle" });
// Archived definitions belong to evidence, not to the calculation selector.
void fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal })
.then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setCatalog(next);
const selectable = selectableObservatorySetups(next);
setSelectedSetupId((current) => (
selectable.some((setup) => setup.setupId === current)
? current
: selectable[0]?.setupId ?? ""
));
setState("ready");
})
.catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setState("error");
setError(caught instanceof Error && caught.message.trim()
? caught.message
: "Каталог профилей недоступен.");
});
return () => request.abort();
}, [revision, sourceSessionId]);
useEffect(() => () => preflightRequest.current?.abort(), []);
useEffect(() => {
preflightRequest.current?.abort();
preflightRequest.current = null;
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
}, [selectedSetupId, sourceSessionId, selectedDefinitionSha256]);
const selectSetup = useCallback((setupId: string) => {
preflightRequest.current?.abort();
preflightRequest.current = null;
setSelectedSetupId(setupId);
setPreflight({ kind: "idle" });
}, []);
const refresh = useCallback(() => setRevision((value) => value + 1), []);
const check = useCallback(async () => {
if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return;
preflightRequest.current?.abort();
const request = new AbortController();
preflightRequest.current = request;
setPreflight({ kind: "checking" });
try {
const value = await preflightObservatoryLaboratorySetup(
sourceSessionId,
selectedSetup,
{ signal: request.signal },
);
if (request.signal.aborted || preflightRequest.current !== request) return;
setPreflight({ kind: "ready", value });
} catch (caught) {
if (request.signal.aborted || preflightRequest.current !== request) return;
setPreflight({
kind: "error",
message: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось проверить возможность запуска.",
});
} finally {
if (preflightRequest.current === request) preflightRequest.current = null;
}
}, [preflight.kind, selectedSetup, sourceSessionId]);
useEffect(() => {
if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return;
void check();
}, [check, preflight.kind, selectedSetup, state]);
return {
catalog: activeCatalog,
selectableSetups,
state,
error,
selectedSetupId: selectedSetup?.setupId ?? "",
selectedSetup,
selectSetup,
refresh,
preflight,
check,
};
}
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservatoryRecordedJobs,
retryObservatoryRecordedJobPublication,
submitObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
type RecordedJobsState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "submitting"
| "retrying-publication"
| "error";
interface JobSnapshot {
readonly selectionKey: string;
readonly jobs: readonly ObservatoryRecordedJob[];
readonly state: RecordedJobsState;
readonly error: string | null;
}
const EMPTY_JOBS = [] as const;
const OPEN_STATES = new Set([
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"reconciliation-required",
]);
const POLL_INTERVAL_MS = 1_500;
export function useObservatoryRecordedJobs(
sourceSessionId: string,
setupId: string,
definitionSha256: string,
) {
const selectionKey = JSON.stringify([sourceSessionId, setupId, definitionSha256]);
const [snapshot, setSnapshot] = useState<JobSnapshot>({
selectionKey: "", jobs: EMPTY_JOBS, state: "idle", error: null,
});
const [revision, setRevision] = useState(0);
const requestRef = useRef<AbortController | null>(null);
const requestSequence = useRef(0);
const idempotencyKeys = useRef(new Map<string, string>());
const observedJobId = useRef<string | null>(null);
const hasSelection = Boolean(sourceSessionId && setupId && definitionSha256);
// A source/profile change must not display even one render of the previous queue.
const current = snapshot.selectionKey === selectionKey ? snapshot : null;
const jobs = current?.jobs ?? EMPTY_JOBS;
const state = current?.state ?? (hasSelection ? "loading" : "idle");
const error = current?.error ?? null;
useEffect(() => {
requestRef.current?.abort();
requestRef.current = null;
if (!hasSelection) {
setSnapshot({ selectionKey, jobs: EMPTY_JOBS, state: "idle", error: null });
return;
}
const sequence = ++requestSequence.current;
const request = new AbortController();
requestRef.current = request;
setSnapshot({
selectionKey, jobs,
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
});
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
definitionSha256, signal: request.signal,
}).then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
const pending = next.find((job) => OPEN_STATES.has(job.state));
if (pending) observedJobId.current = pending.jobId;
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
}).catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Очередь расчётов недоступна.",
});
}).finally(() => {
if (requestRef.current === request) requestRef.current = null;
});
return () => request.abort();
}, [revision, selectionKey]);
useEffect(() => () => requestRef.current?.abort(), []);
const latestJob = jobs[0] ?? null;
const activeJob = jobs.find((job) => OPEN_STATES.has(job.state)) ?? null;
const publicationPending = jobs.some((job) => job.publication.state === "pending");
// Do not promote historical terminal logs to a new operator action's error.
const computationFailed = latestJob?.state === "failed"
&& latestJob.jobId === observedJobId.current;
useEffect(() => {
if (state !== "ready" || (!activeJob && !publicationPending)) return;
const timer = globalThis.setTimeout(
() => setRevision((value) => value + 1),
POLL_INTERVAL_MS,
);
return () => globalThis.clearTimeout(timer);
}, [activeJob, publicationPending, revision, state]);
useEffect(() => {
if (!latestJob || activeJob) return;
idempotencyKeys.current.delete(selectionKey);
}, [activeJob, latestJob, selectionKey]);
const refresh = useCallback(() => setRevision((value) => value + 1), []);
const submit = useCallback(async (
portableBinding: {
readonly definitionSha256: string;
readonly checkSha256: string;
} | null = null,
): Promise<ObservatoryRecordedJob | null> => {
if (!hasSelection || state !== "ready"
|| portableBinding?.definitionSha256 !== definitionSha256
|| publicationPending || latestJob?.publication.state === "failed") return null;
if (activeJob) return activeJob;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
const key = idempotencyKeys.current.get(selectionKey) ?? createIdempotencyKey();
idempotencyKeys.current.set(selectionKey, key);
setSnapshot({ selectionKey, jobs, state: "submitting", error: null });
try {
const job = await submitObservatoryRecordedJob(
sourceSessionId, setupId, key, portableBinding, { signal: request.signal },
);
if (request.signal.aborted || requestRef.current !== request) return null;
observedJobId.current = job.jobId;
setSnapshot({
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
state: "ready", error: null,
});
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось поставить расчёт в очередь.",
});
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [activeJob, definitionSha256, hasSelection, jobs, latestJob,
publicationPending, selectionKey, setupId, sourceSessionId, state]);
const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
if (!latestJob || latestJob.publication.state !== "failed"
|| state === "retrying-publication") return null;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
setSnapshot({ selectionKey, jobs, state: "retrying-publication", error: null });
try {
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
signal: request.signal,
});
if (request.signal.aborted || requestRef.current !== request) return null;
setSnapshot({
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
state: "ready", error: null,
});
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось повторить публикацию результата.",
});
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [jobs, latestJob, selectionKey, state]);
return {
jobs, latestJob, activeJob, publicationPending, computationFailed,
state, error, refresh, submit, retryPublication,
};
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
return `observatory-ui:${entropy}`;
}
export type ObservatoryRecordedJobsController = ReturnType<typeof useObservatoryRecordedJobs>;
@@ -38,6 +38,8 @@ export function useWorkerTelemetry(
setError(null); setError(null);
return; return;
} }
setTelemetry(null);
setError(null);
const controller = new AbortController(); const controller = new AbortController();
const stopPolling = startSequentialPolling(async () => { const stopPolling = startSequentialPolling(async () => {
setLoading(true); setLoading(true);
@@ -48,6 +50,7 @@ export function useWorkerTelemetry(
setError(null); setError(null);
} catch (reason: unknown) { } catch (reason: unknown) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setTelemetry(null);
setError( setError(
reason instanceof Error reason instanceof Error
? reason.message ? reason.message
@@ -114,6 +114,9 @@ export interface WorkerTelemetry {
power_watts?: number; power_watts?: number;
temperature_celsius?: number; temperature_celsius?: number;
memory_used_percent?: number; memory_used_percent?: number;
sm_clock_mhz?: number | null;
memory_clock_mhz?: number | null;
driver_version?: string | null;
} | null; } | null;
triton: { triton: {
ready: boolean; ready: boolean;
@@ -131,6 +134,13 @@ export interface WorkerTelemetry {
completed_runs: number | null; completed_runs: number | null;
failed_runs: number | null; failed_runs: number | null;
model_load_seconds: number | null; model_load_seconds: number | null;
profile_name?: string | null;
input_state?: string | null;
wait_reason?: string | null;
live_children?: number | null;
input_pauses?: number | null;
pending_bundles?: number | null;
buffer_bytes?: number | null;
stages: WorkerPipelineStage[]; stages: WorkerPipelineStage[];
}; };
network: { network: {
@@ -189,7 +199,9 @@ export async function fetchWorkerTelemetry(
`/api/v1/system/contours/${encodeURIComponent(contourId)}/telemetry?history=90`, `/api/v1/system/contours/${encodeURIComponent(contourId)}/telemetry?history=90`,
{ {
method: "GET", method: "GET",
signal, signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(5_000)])
: AbortSignal.timeout(5_000),
}, },
), ),
"missioncore.worker-telemetry/v1", "missioncore.worker-telemetry/v1",
+13 -1
View File
@@ -23,7 +23,8 @@ export type WorkspaceKind =
| "datasets" | "datasets"
| "artifact-health" | "artifact-health"
| "lab-archive" | "lab-archive"
| "simulations"; | "simulations"
| "observatory";
export type CapabilityStatus = "active" | "ready" | "contract" | "later"; export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@@ -166,6 +167,17 @@ export const workspaces: WorkspaceDefinition[] = [
kind: "simulations", kind: "simulations",
groups: [], groups: [],
}, },
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
{ {
id: "contour-health", id: "contour-health",
root: "fleet", root: "fleet",
+1
View File
@@ -21,6 +21,7 @@
@import "./styles/device.css"; @import "./styles/device.css";
@import "./styles/responsive.css"; @import "./styles/responsive.css";
@import "./styles/observation.css"; @import "./styles/observation.css";
@import "./styles/observatory.css";
@import "./styles/environment-settings.css"; @import "./styles/environment-settings.css";
@import "./styles/system-telemetry.css"; @import "./styles/system-telemetry.css";
@import "./styles/artifact-health.css"; @import "./styles/artifact-health.css";
@@ -27,6 +27,88 @@
display: block; display: block;
} }
.m4-replay-threat-visual__unified-content {
position: absolute;
z-index: 1;
inset: 0;
min-width: 0;
min-height: 0;
}
.m4-replay-threat-visual__unified-content > *,
.m4-replay-threat-visual__unified-content .rerun-viewport {
width: 100%;
height: 100%;
}
.m4-replay-threat-visual__deck[data-native-rerun="true"] > .nodedc-split-pane {
position: relative;
z-index: 2;
pointer-events: none;
}
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.m4-replay-threat-visual__pane {
background: transparent;
pointer-events: none;
}
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.nodedc-split-pane__separator,
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.m4-replay-threat-visual__pane-toolbar,
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.m4-replay-threat-visual__pane-toolbar * {
pointer-events: auto;
}
.canonical-vegetation-rerun-replay {
width: 100%;
min-width: 0;
}
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
.m4-replay-threat-visual__pane-toolbar,
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
.laboratory-evidence-viewer__controls,
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
.laboratory-evidence-viewer__transport {
visibility: hidden;
pointer-events: none;
}
.canonical-vegetation-rerun-replay[data-presentation-state="loading"]
.m4-replay-threat-visual__unified-content {
visibility: hidden;
}
.canonical-vegetation-rerun-replay__viewport-lock {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.canonical-vegetation-rerun-replay__viewport-lock
.rerun-viewport__camera-lock {
width: var(--canonical-rerun-camera-pane, 100%);
}
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
.rerun-viewport__camera-lock {
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
}
.canonical-vegetation-rerun-replay__loading {
position: absolute;
z-index: 6;
inset: 0;
display: grid;
place-items: center;
background: var(--nodedc-canvas);
pointer-events: none;
}
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state { .m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
@@ -247,6 +329,10 @@
grid-template-columns: auto auto auto minmax(0, 1fr) auto; grid-template-columns: auto auto auto minmax(0, 1fr) auto;
} }
.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback {
grid-template-columns: auto auto minmax(0, 1fr) auto;
}
.m4-replay-threat-visual__overlay { .m4-replay-threat-visual__overlay {
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
@@ -0,0 +1,401 @@
.observatory-workspace {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 1rem;
min-width: 0;
min-height: 100%;
padding: 1rem;
container-name: observatory-workspace;
container-type: inline-size;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
.observatory-evidence > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.observatory-lead,
.observatory-notice {
flex-wrap: wrap;
}
.observatory-lead > div,
.observatory-catalog-bar__copy,
.observatory-evidence header > div {
min-width: 0;
}
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
margin: 0.3rem 0 0;
}
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
font-size: var(--nodedc-font-size-lg);
letter-spacing: -0.02em;
line-height: 1.15;
}
.observatory-lead p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-catalog-bar,
.observatory-session-summary {
align-self: start;
min-height: 0;
}
.observatory-catalog-bar__copy {
display: grid;
flex: 0 0 auto;
align-content: center;
gap: 0.1rem;
}
.observatory-catalog-bar h3 {
margin-top: 0.1rem;
}
.observatory-catalog-bar__controls {
min-width: 0;
flex: 1 1 auto;
flex-wrap: nowrap;
justify-content: flex-end;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 1 1 18rem;
min-width: 0;
}
.observatory-catalog-bar__controls > .nodedc-button {
flex: 0 0 auto;
}
.observatory-catalog-bar__run {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.55rem;
}
.observatory-state {
display: grid;
min-height: 19rem;
place-items: center;
align-content: center;
gap: 0.7rem;
text-align: center;
}
.observatory-state h3 {
max-width: 42rem;
margin: 0;
}
.observatory-session-stack {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 0.9rem;
container-name: observatory-session;
container-type: inline-size;
}
.observatory-session-summary {
display: grid;
grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto);
align-items: center;
gap: 0.75rem;
}
.observatory-session-summary__identity {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.observatory-session-summary__identity h3 {
margin: 0.1rem 0 0;
}
.observatory-session-summary__identity code {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.observatory-session-summary__facts {
display: grid;
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
gap: 0.75rem;
margin: 0;
}
.observatory-session-summary__facts > div {
display: grid;
min-width: 0;
gap: 0.24rem;
}
.observatory-session-summary__facts dt {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-session-summary__facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-session-summary__end {
display: grid;
min-width: 0;
justify-items: end;
gap: 0.4rem;
}
.observatory-modalities {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.45rem;
align-items: center;
color: var(--nodedc-text-muted);
}
.observatory-evidence {
display: grid;
gap: 0.65rem;
}
.observatory-evidence > header {
padding: 0 0.2rem;
}
.observatory-evidence-list {
display: grid;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.observatory-evidence-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.8rem;
min-height: 4.25rem;
padding: 0.7rem 0.85rem;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
}
.observatory-evidence-card__icon {
display: grid;
width: 2.15rem;
height: 2.15rem;
place-items: center;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-panel-icon-bg);
color: var(--nodedc-text-secondary);
}
.observatory-evidence-card__copy {
display: grid;
min-width: 0;
gap: 0.16rem;
}
.observatory-evidence-card__copy strong,
.observatory-evidence-card__copy span,
.observatory-evidence-card__copy small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.observatory-evidence-card__copy span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
}
.observatory-evidence-card__copy small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-evidence-card__actions,
.observatory-replay__header,
.observatory-replay-state,
.observatory-replay-state__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
}
.observatory-replay-state {
flex-wrap: wrap;
}
.observatory-replay__header > div,
.observatory-replay-state > div {
min-width: 0;
}
.observatory-evidence-card__actions:empty {
display: none;
}
.observatory-replay,
.observatory-replay-state {
min-width: 0;
}
.observatory-replay {
display: grid;
gap: 0.85rem;
}
.observatory-replay__header {
padding: 0.25rem 0.25rem 0;
}
.observatory-replay__header h3,
.observatory-replay-state h3 {
margin: 0.3rem 0 0;
}
.observatory-replay__header p,
.observatory-replay-state p {
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
}
.observatory-evidence-empty {
display: grid;
min-height: 13rem;
place-items: center;
align-content: center;
gap: 0.55rem;
text-align: center;
}
.observatory-notice {
justify-content: flex-start;
color: var(--nodedc-text-secondary);
}
.observatory-notice__copy {
min-width: 0;
flex: 1 1 auto;
}
.observatory-rename-form,
.observatory-delete-confirmation {
display: grid;
gap: 0.75rem;
}
.observatory-delete-confirmation p,
.observatory-mutation-error {
margin: 0;
line-height: 1.5;
}
.observatory-mutation-error {
color: rgb(var(--nodedc-danger-rgb));
font-size: var(--nodedc-font-size-sm);
}
@container observatory-session (max-width: 46rem) {
.observatory-session-summary {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.observatory-session-summary__end,
.observatory-modalities {
justify-items: start;
justify-content: flex-start;
}
}
@container observatory-session (max-width: 48rem) {
.observatory-evidence-card {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observatory-evidence-card > .nodedc-status {
display: none;
}
}
@container observatory-session (max-width: 38rem) {
.observatory-session-summary__facts {
grid-template-columns: minmax(0, 1fr);
}
.observatory-evidence-card__copy small {
white-space: normal;
}
}
@container observatory-workspace (max-width: 920px) {
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
align-items: stretch;
flex-direction: column;
}
.observatory-catalog-bar__controls {
flex-basis: auto;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 0 0 auto;
width: 100%;
min-width: 0;
}
.observatory-catalog-bar__run {
justify-content: flex-end;
}
}
@media (max-width: 920px) {
.observatory-replay__header,
.observatory-replay-state {
align-items: stretch;
flex-direction: column;
}
}
+1 -1
View File
@@ -78,7 +78,7 @@
overflow: hidden; overflow: hidden;
} }
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its /* The upstream Rerun canvas keeps three fixed 24px rows even after its
panels are overridden: the native top row, recording tab and view tab. panels are overridden: the native top row, recording tab and view tab.
They are drawn inside WASM and cannot be styled independently, so crop the They are drawn inside WASM and cannot be styled independently, so crop the
fixed native chrome while keeping the actual 3D viewport full-height. */ fixed native chrome while keeping the actual 3D viewport full-height. */
@@ -12,6 +12,7 @@ import type {
RecordedCameraAdmissionState, RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission"; } from "../core/observation/recordedSessionAdmission";
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog"; import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts"; import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { import {
RerunViewport, RerunViewport,
@@ -40,13 +41,13 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace"; import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace"; import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace"; import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" { function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success"; if (status === "active") return "success";
if (status === "ready") return "accent"; if (status === "ready") return "accent";
if (status === "contract") return "warning"; if (status === "contract") return "warning";
return "neutral"; return "neutral";
} }
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) { function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
return ( return (
<div className="feature-inventory"> <div className="feature-inventory">
@@ -76,7 +77,6 @@ function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
</div> </div>
); );
} }
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) { function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
return ( return (
<section className="workspace-lead workspace-lead--compact"> <section className="workspace-lead workspace-lead--compact">
@@ -89,7 +89,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
</section> </section>
); );
} }
function EmptySpatialStage({ settings }: { settings: SceneSettings }) { function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
return ( return (
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}> <div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
@@ -181,7 +180,7 @@ function SpatialWorkspace({
); );
const recordedPerceptionSupported = const recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable"; recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready"; const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
const recordedPerceptionEnabled = const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d; showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D // The native recorded camera remains the authoritative original. Only 2D
@@ -261,7 +260,7 @@ function SpatialWorkspace({
const shouldPrepareRecordedSource = useCallback((sourceId: string) => { const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedSessionAdmission) return false; if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) || return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready"; ["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedSessionAdmission]); }, [recordedSessionAdmission]);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []); const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback( const onPlaybackChange = useCallback(
@@ -381,6 +380,35 @@ function SpatialWorkspace({
: presentedViewerStatus === "error" : presentedViewerStatus === "error"
? "danger" ? "danger"
: "neutral"; : "neutral";
const rerunViewerProfile = recordedSource
? recordedSessionRerunProfile({
sourceUrl,
artifact: recordedReplay,
autoplayWhenReady: true,
presentationGate: recordedSessionGate,
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
view: "spatial",
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: followRecordedTrajectory,
perceptionLayers: {
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
},
perceptionRetryGeneration,
lockPerceptionCameraInteraction: unifiedPerception,
})
: liveAcquisitionRerunProfile({
sourceUrl,
liveActivitySequence: livePresentationActivitySequence,
liveStreamId: state?.spatialSource?.id ?? null,
liveRecoveryAuthorityIdentity: streamActive
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
: null,
});
return ( return (
<div <div
@@ -405,7 +433,7 @@ function SpatialWorkspace({
variant={detections2dActive ? "primary" : "secondary"} variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />} icon={<Icon name="target" />}
aria-pressed={detections2dActive} aria-pressed={detections2dActive}
disabled={recordedSource && !recordedPerceptionReady} disabled={recordedPerceptionLoading}
onClick={() => recordedSource onClick={() => recordedSource
? setShowDetections2d((current) => !current) ? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({ : onLivePerceptionLayersChange({
@@ -420,7 +448,7 @@ function SpatialWorkspace({
variant={segmentationActive ? "primary" : "secondary"} variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />} icon={<Icon name="image" />}
aria-pressed={segmentationActive} aria-pressed={segmentationActive}
disabled={recordedSource && !recordedPerceptionReady} disabled={recordedPerceptionLoading}
onClick={() => recordedSource onClick={() => recordedSource
? setShowSegmentation((current) => !current) ? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({ : onLivePerceptionLayersChange({
@@ -435,7 +463,7 @@ function SpatialWorkspace({
variant={cuboids3dActive ? "primary" : "secondary"} variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />} icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive} aria-pressed={cuboids3dActive}
disabled={recordedSource && !recordedPerceptionReady} disabled={recordedPerceptionLoading}
onClick={() => recordedSource onClick={() => recordedSource
? setShowCuboids3d((current) => !current) ? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({ : onLivePerceptionLayersChange({
@@ -489,35 +517,8 @@ function SpatialWorkspace({
> >
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? ( {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport <RerunViewport
sourceUrl={sourceUrl} profile={rerunViewerProfile}
recordedArtifact={recordedSource ? recordedReplay : null}
followLive={liveRerunSource}
liveActivitySequence={livePresentationActivitySequence}
liveStreamId={state?.spatialSource?.id}
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
autoplayWhenReady={recordedSource}
presentationGate={recordedSessionGate}
expectedTimelineStartSeconds={recordedSource
? state?.observationTimeline?.range?.startSeconds
: undefined}
expectedTimelineEndSeconds={recordedSource
? state?.observationTimeline?.range?.endSeconds
: undefined}
initialPlaybackStartSeconds={initialRecordedPlaybackStartSeconds}
sceneSettings={sceneSettings} sceneSettings={sceneSettings}
recordedViewResetGeneration={recordedViewResetGeneration}
recordedFollowTrajectory={followRecordedTrajectory}
recordedPerceptionLayers={{
enabled:
recordedPerceptionSupported &&
recordedPerceptionReady &&
recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
}}
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
lockPerceptionCameraInteraction={unifiedPerception}
onPerceptionLoadChange={onPerceptionLoadChange} onPerceptionLoadChange={onPerceptionLoadChange}
onPointColorLoadChange={onPointColorLoadChange} onPointColorLoadChange={onPointColorLoadChange}
onStatusChange={onStatusChange} onStatusChange={onStatusChange}
@@ -905,7 +906,7 @@ function CamerasWorkspace({
if (!recordedReplay) return true; if (!recordedReplay) return true;
if (!recordedSessionAdmission) return false; if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) || return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready"; ["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedReplay, recordedSessionAdmission]); }, [recordedReplay, recordedSessionAdmission]);
return ( return (
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}> <div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
@@ -1191,6 +1192,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
); );
case "simulations": case "simulations":
return <SimulationWorkspace />; return <SimulationWorkspace />;
case "observatory":
return <ObservatoryWorkspace definition={props.definition} />;
case "device": case "device":
return null; return null;
} }
@@ -0,0 +1 @@
export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
@@ -54,6 +54,7 @@ import {
buildLaboratoryProfiles, buildLaboratoryProfiles,
experimentOptionsForProfile, experimentOptionsForProfile,
freshestLaboratorySelection, freshestLaboratorySelection,
isLegacyPublishedLaboratoryWork,
workOptionsForExperiment, workOptionsForExperiment,
} from "./laboratoryArchiveProfiles"; } from "./laboratoryArchiveProfiles";
import { import {
@@ -499,12 +500,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
onDeleteBegin: props.sessionArchive.onDeleteBegin, onDeleteBegin: props.sessionArchive.onDeleteBegin,
}); });
const publishedWorks = useMemo( const publishedWorks = useMemo(
() => sessions.items.filter((session) => ( () => sessions.items.filter(isLegacyPublishedLaboratoryWork).sort((left, right) => (
session.lab !== null
&& session.status === "ready"
&& session.replayable
&& session.modalities.includes("point-cloud")
)).sort((left, right) => (
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc) Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc) - Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
)), )),
@@ -215,12 +215,24 @@ export function M49TgsFullShadowEvidence({
controlLabel: semanticOverride.controlLabel ?? "ПРИРОДА · DDRNet", controlLabel: semanticOverride.controlLabel ?? "ПРИРОДА · DDRNet",
}] : []), }] : []),
], [semantic, semanticOverride]); ], [semantic, semanticOverride]);
const spatialSemantic = useMemo<M4ReplayThreatSemanticLayer | undefined>(() => (
semantic ? {
id: "spatial-urban",
controlLabel: "SEMANTICS",
resultId: semantic.resultId,
spatialResultId: semantic.resultId,
taxonomy: semantic.taxonomy,
label: "EoMT Cityscapes semantic · point-aligned E47",
maskAriaLabel: "EoMT urban semantic prediction",
} : undefined
), [semantic]);
return ( return (
<> <>
<M4ReplayThreatVisual <M4ReplayThreatVisual
resultId={result.source.linkedVisualResultId} resultId={result.source.linkedVisualResultId}
semanticLayers={semanticLayers} semanticLayers={semanticLayers}
spatialSemantic={spatialSemantic}
initialSemanticLayerId={semanticOverride ? "vegetation" : "urban"} initialSemanticLayerId={semanticOverride ? "vegetation" : "urban"}
showReviewAnchorBoxes={false} showReviewAnchorBoxes={false}
reviewLabel="4 489 source-paced TGS frames" reviewLabel="4 489 source-paced TGS frames"
@@ -1,15 +1,25 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { import {
Button, Button,
Icon, Icon,
IconButton, IconButton,
Select, Select,
SegmentedControl, SegmentedControl,
SplitPane,
type SplitPaneOrientation,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { ObservationTimeline } from "../../components/ObservationTimeline"; import { ObservationTimeline } from "../../components/ObservationTimeline";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../../components/laboratory/CanonicalRecordedLabReplay";
import { import {
LaboratoryMetricEvidenceScene, LaboratoryMetricEvidenceScene,
type LaboratoryMetricCellEvidence, type LaboratoryMetricCellEvidence,
@@ -35,6 +45,7 @@ import {
type E47SemanticClass, type E47SemanticClass,
type E47SemanticTimelineFrame, type E47SemanticTimelineFrame,
} from "../../core/laboratory/e47SemanticSlam"; } from "../../core/laboratory/e47SemanticSlam";
import { laboratoryRecordedEvidenceDemand } from "../../core/laboratory/recordedEvidenceProfile";
import type { import type {
M4ThreatCameraProposal, M4ThreatCameraProposal,
M4ThreatTimelineFrame, M4ThreatTimelineFrame,
@@ -52,8 +63,6 @@ import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes"; import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
type M4ThreatMediaMode = "video" | "camera"; type M4ThreatMediaMode = "video" | "camera";
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none";
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] { function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
if (proposal.threatDecision === "threat") return "danger"; if (proposal.threatDecision === "threat") return "danger";
@@ -144,6 +153,7 @@ export interface M4ReplayClassifiedSpatialLayer {
label: string; label: string;
pointLayerLabel: string; pointLayerLabel: string;
cellLayerLabel: string; cellLayerLabel: string;
cellLayerAvailable?: boolean;
expectedAtSequence: boolean; expectedAtSequence: boolean;
frame: M4ReplayClassifiedSpatialFrame | null; frame: M4ReplayClassifiedSpatialFrame | null;
loading: boolean; loading: boolean;
@@ -158,6 +168,7 @@ export function M4ReplayThreatVisual({
resultId, resultId,
semantic, semantic,
semanticLayers, semanticLayers,
spatialSemantic,
initialSemanticLayerId, initialSemanticLayerId,
reviewAnchors = EMPTY_REVIEW_ANCHORS, reviewAnchors = EMPTY_REVIEW_ANCHORS,
showReviewAnchorBoxes = true, showReviewAnchorBoxes = true,
@@ -168,11 +179,15 @@ export function M4ReplayThreatVisual({
classifiedSpatialLayer, classifiedSpatialLayer,
showReferenceMediaLayers = true, showReferenceMediaLayers = true,
showSpatialOverlaySummary = true, showSpatialOverlaySummary = true,
playbackTransport = "epoch-stream",
spatialPlaybackTransport = "auto",
recoverTimestampStalls = false,
onActiveSequenceChange, onActiveSequenceChange,
}: { }: {
resultId: string; resultId: string;
semantic?: M4ReplayThreatSemanticLayer; semantic?: M4ReplayThreatSemanticLayer;
semanticLayers?: readonly M4ReplayThreatSemanticLayer[]; semanticLayers?: readonly M4ReplayThreatSemanticLayer[];
spatialSemantic?: M4ReplayThreatSemanticLayer;
initialSemanticLayerId?: string; initialSemanticLayerId?: string;
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[]; reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
showReviewAnchorBoxes?: boolean; showReviewAnchorBoxes?: boolean;
@@ -183,12 +198,26 @@ export function M4ReplayThreatVisual({
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer; classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
showReferenceMediaLayers?: boolean; showReferenceMediaLayers?: boolean;
showSpatialOverlaySummary?: boolean; showSpatialOverlaySummary?: boolean;
playbackTransport?: "segmented" | "epoch-stream";
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
recoverTimestampStalls?: boolean;
onActiveSequenceChange?: (sequence: number | null) => void; onActiveSequenceChange?: (sequence: number | null) => void;
}) { }) {
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video"); const {
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>( mediaMode,
spatialMode,
splitView,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange: handleMediaModeChange,
onSpatialModeChange: handleSpatialModeChange,
onSplitPrimarySizeChange: setSplitPrimarySize,
onExpandedChange: setExpanded,
} = useCanonicalRecordedLabReplayState<M4ThreatMediaMode, LaboratoryMetricSceneMode>({
initialMediaMode: "video",
initialSpatialMode, initialSpatialMode,
); });
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true); const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
const [showLocalSurface, setShowLocalSurface] = useState(true); const [showLocalSurface, setShowLocalSurface] = useState(true);
const [showRollingMap, setShowRollingMap] = useState(true); const [showRollingMap, setShowRollingMap] = useState(true);
@@ -197,13 +226,6 @@ export function M4ReplayThreatVisual({
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true); const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
const [showMediaPoints, setShowMediaPoints] = useState(false); const [showMediaPoints, setShowMediaPoints] = useState(false);
const [showStaticObstacles, setShowStaticObstacles] = useState(true); const [showStaticObstacles, setShowStaticObstacles] = useState(true);
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);
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0); const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>( const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
() => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [], () => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [],
@@ -234,6 +256,28 @@ export function M4ReplayThreatVisual({
const activeSemantic = availableSemanticLayers.find( const activeSemantic = availableSemanticLayers.find(
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId, (layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
) ?? availableSemanticLayers[0]; ) ?? availableSemanticLayers[0];
const activeSpatialSemantic = spatialSemantic ?? activeSemantic;
const evidenceDemand = useMemo(() => laboratoryRecordedEvidenceDemand({
mediaMode,
spatialMode,
showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic,
showSpatialSemantic: Boolean(activeSpatialSemantic) && showSpatialSemantic,
showMediaPoints,
classifiedSpatialMode: !classifiedSpatialLayer || classifiedSpatialLayer.cellLayerAvailable === false
? "none"
: classifiedSpatialLayer.replacePointCloud
? "replace-source"
: "overlay",
}), [
activeSemantic,
activeSpatialSemantic,
classifiedSpatialLayer,
mediaMode,
showMediaPoints,
showMediaSemantic,
showSpatialSemantic,
spatialMode,
]);
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null); const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot); const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
const playbackRange = useMemo(() => metadata.timeline ? ({ const playbackRange = useMemo(() => metadata.timeline ? ({
@@ -241,7 +285,7 @@ export function M4ReplayThreatVisual({
endSeconds: metadata.timeline.timelineEndSeconds, endSeconds: metadata.timeline.timelineEndSeconds,
}) : null, [metadata.timeline]); }) : null, [metadata.timeline]);
const playbackController = useRecordedEvidencePlayback(playbackRange, { const playbackController = useRecordedEvidencePlayback(playbackRange, {
clock: mediaMode === "video" ? "external" : "animation", clock: "external",
}); });
const seekPlayback = playbackController.seek; const seekPlayback = playbackController.seek;
const setPlaybackPlaying = playbackController.setPlaying; const setPlaybackPlaying = playbackController.setPlaying;
@@ -249,7 +293,9 @@ export function M4ReplayThreatVisual({
resultId, resultId,
timeline: metadata.timeline, timeline: metadata.timeline,
currentSeconds: playbackController.playback.currentSeconds, currentSeconds: playbackController.playback.currentSeconds,
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
endpointRoot: timelineEndpointRoot, endpointRoot: timelineEndpointRoot,
spatialPlaybackTransport,
}); });
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null); const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
const [videoLoading, setVideoLoading] = useState(false); const [videoLoading, setVideoLoading] = useState(false);
@@ -260,16 +306,12 @@ export function M4ReplayThreatVisual({
setVideoError(null); setVideoError(null);
}, [resultId]); }, [resultId]);
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);
}, []);
useEffect(() => { useEffect(() => {
const timeline = metadata.timeline; const timeline = metadata.timeline;
if (!evidenceDemand.recordedVideo) {
setVideoLoading(false);
return;
}
if (!timeline || videoSource) return; if (!timeline || videoSource) return;
const controller = new AbortController(); const controller = new AbortController();
setVideoLoading(true); setVideoLoading(true);
@@ -304,7 +346,7 @@ export function M4ReplayThreatVisual({
if (!controller.signal.aborted) setVideoLoading(false); if (!controller.signal.aborted) setVideoLoading(false);
}); });
return () => controller.abort(); return () => controller.abort();
}, [metadata.timeline, videoSource]); }, [evidenceDemand.recordedVideo, metadata.timeline, videoSource]);
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null); const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
useEffect(() => { useEffect(() => {
@@ -319,41 +361,53 @@ export function M4ReplayThreatVisual({
resultId: string; resultId: string;
frame: M4ThreatTimelineFrame; frame: M4ThreatTimelineFrame;
} | null>(null); } | null>(null);
if (frame?.spatialAvailable) { useEffect(() => {
lastSpatialFrameRef.current = { resultId, frame }; lastSpatialFrameRef.current = null;
}, [evidenceDemand.sourceSpatialPoints, resultId]);
const latestAvailableSpatialFrame = [...timelineFrame.availableFrames]
.reverse()
.find((candidate) => (
candidate.spatialAvailable
&& (timelineFrame.activeSequence === null
|| candidate.sequence <= timelineFrame.activeSequence)
)) ?? null;
const currentSpatialFrame = frame?.spatialAvailable ? frame : latestAvailableSpatialFrame;
if (currentSpatialFrame) {
lastSpatialFrameRef.current = { resultId, frame: currentSpatialFrame };
} }
const spatialFrame = frame?.spatialAvailable const spatialFrame = currentSpatialFrame
? frame ? currentSpatialFrame
: lastSpatialFrameRef.current?.resultId === resultId : lastSpatialFrameRef.current?.resultId === resultId
? lastSpatialFrameRef.current.frame ? lastSpatialFrameRef.current.frame
: null; : null;
const cameraPointOverlay = useM4ThreatCameraPointOverlay({ const cameraPointOverlay = useM4ThreatCameraPointOverlay({
enabled: showReferenceMediaLayers && showMediaPoints, enabled: showReferenceMediaLayers && evidenceDemand.cameraPointOverlay,
resultId, resultId,
sequence: frame?.sequence ?? null, sequence: frame?.sequence ?? null,
endpointRoot: timelineEndpointRoot, endpointRoot: timelineEndpointRoot,
}); });
const semanticSpatialResultId = activeSemantic const semanticSpatialResultId = activeSpatialSemantic
? activeSemantic.spatialResultId === undefined ? activeSpatialSemantic.spatialResultId === undefined
? activeSemantic.resultId ? activeSpatialSemantic.resultId
: activeSemantic.spatialResultId : activeSpatialSemantic.spatialResultId
: null; : null;
const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>( const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>(
() => semanticSpatialResultId && activeSemantic () => semanticSpatialResultId && activeSpatialSemantic
? activeSemantic.taxonomy.map((item) => ({ ? activeSpatialSemantic.taxonomy.map((item) => ({
classId: item.classId, classId: item.classId,
label: item.label, label: item.label,
disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled", disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled",
colorRgb: item.colorRgb, colorRgb: item.colorRgb,
})) }))
: [], : [],
[activeSemantic, semanticSpatialResultId], [activeSpatialSemantic, semanticSpatialResultId],
); );
const semanticTimeline = useE47SemanticTimelineFrame({ const semanticTimeline = useE47SemanticTimelineFrame({
resultId: semanticSpatialResultId, resultId: semanticSpatialResultId,
activeSequence: frame?.sequence ?? timelineFrame.activeSequence, activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
frameCount: metadata.timeline?.frameCount ?? 0, frameCount: metadata.timeline?.frameCount ?? 0,
taxonomy: spatialSemanticTaxonomy, taxonomy: spatialSemanticTaxonomy,
enabled: evidenceDemand.selectedSemanticPoints,
}); });
const displayingBufferedFrame = Boolean( const displayingBufferedFrame = Boolean(
frame frame
@@ -443,6 +497,27 @@ export function M4ReplayThreatVisual({
})) ?? [], })) ?? [],
[activeSemantic?.taxonomy], [activeSemantic?.taxonomy],
); );
const spatialSemanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
() => activeSpatialSemantic?.taxonomy.map((item) => ({
id: item.classId,
label: `semantic: ${item.label}`,
})) ?? [],
[activeSpatialSemantic?.taxonomy],
);
const spatialSemanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
() => activeSpatialSemantic?.taxonomy.map((item) => ({
classId: item.classId,
color: item.disposition === "undefined"
? { kind: "transparent" as const }
: item.disposition === "ambiguous"
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
: { kind: "diagnostic" as const, rgb: item.colorRgb },
opacity: item.disposition === "undefined"
? 0
: item.disposition === "ambiguous" ? 0.52 : 0.92,
})) ?? [],
[activeSpatialSemantic?.taxonomy],
);
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
? semanticTimeline.activeFrame ? semanticTimeline.activeFrame
: null; : null;
@@ -459,7 +534,7 @@ export function M4ReplayThreatVisual({
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence && lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
? lastSpatialSemanticFrameRef.current.frame ? lastSpatialSemanticFrameRef.current.frame
: null; : null;
const semanticIntegrityError = activeSemantic && spatialFrame && spatialSemanticFrame && ( const semanticIntegrityError = activeSpatialSemantic && spatialFrame && spatialSemanticFrame && (
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount || spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount || spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
@@ -468,7 +543,7 @@ export function M4ReplayThreatVisual({
: null; : null;
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => { const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
if ( if (
!activeSemantic !activeSpatialSemantic
|| !showSpatialSemantic || !showSpatialSemantic
|| !spatialFrame || !spatialFrame
|| !spatialSemanticFrame || !spatialSemanticFrame
@@ -478,18 +553,24 @@ export function M4ReplayThreatVisual({
const status = spatialSemanticFrame.statusCodes[index]; const status = spatialSemanticFrame.statusCodes[index];
return status === 2 || status === 3 ? classId : null; return status === 2 || status === 3 ? classId : null;
}); });
}, [activeSemantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]); }, [activeSpatialSemantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
? spatialFrame ? spatialFrame
: null; : null;
const classifiedSpatialFrame = classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence const hasClassifiedSpatialOutput = Boolean(
classifiedSpatialLayer && classifiedSpatialLayer.cellLayerAvailable !== false,
);
const classifiedSpatialFrame = hasClassifiedSpatialOutput
&& classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
? classifiedSpatialLayer?.frame ?? null ? classifiedSpatialLayer?.frame ?? null
: null; : null;
const lastClassifiedSpatialFrameRef = useRef<{ const lastClassifiedSpatialFrameRef = useRef<{
resultId: string; resultId: string;
frame: M4ReplayClassifiedSpatialFrame; frame: M4ReplayClassifiedSpatialFrame;
} | null>(null); } | null>(null);
const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null; const incomingClassifiedSpatialFrame = hasClassifiedSpatialOutput
? classifiedSpatialLayer?.frame ?? null
: null;
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) { if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame }; lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
} }
@@ -518,7 +599,9 @@ export function M4ReplayThreatVisual({
? spatialFrame ? spatialFrame
: null) : null)
: null; : null;
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true; const replaceClassifiedPointCloud = hasClassifiedSpatialOutput
? classifiedSpatialLayer?.replacePointCloud ?? true
: false;
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0; const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
const mapGravityLocalSensorToBodyGround = useCallback(( const mapGravityLocalSensorToBodyGround = useCallback((
point: readonly [number, number, number], point: readonly [number, number, number],
@@ -636,7 +719,12 @@ export function M4ReplayThreatVisual({
.map((item) => item.assessment.closestApproachM) .map((item) => item.assessment.closestApproachM)
.filter((value): value is number => value !== null) .filter((value): value is number => value !== null)
.sort((left, right) => left - right)[0] ?? null; .sort((left, right) => left - right)[0] ?? null;
const localSurface = useMemo(() => buildM4LocalSurface( const localSurface = useMemo(() => spatialFrame?.localSlamBodyXyzM?.length ? ({
pointsBodyXyzM: spatialFrame.localSlamBodyXyzM,
sourceFrameCount: spatialFrame.localSlamSourceFrameCount ?? 0,
sourcePointCount: spatialFrame.localSlamSourcePointCount ?? 0,
voxelCount: spatialFrame.localSlamBodyXyzM.length,
}) : buildM4LocalSurface(
timelineFrame.availableFrames, timelineFrame.availableFrames,
spatialFrame, spatialFrame,
metadata.timeline?.localSurfaceVisualization ?? { metadata.timeline?.localSurfaceVisualization ?? {
@@ -647,7 +735,7 @@ export function M4ReplayThreatVisual({
}, },
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]); ), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined = const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
activeSemantic && showMediaSemantic && frame activeSemantic && evidenceDemand.selectedSemanticMask && frame
? { ? {
src: activeSemantic.maskUrl?.(frame.sequence) src: activeSemantic.maskUrl?.(frame.sequence)
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence), ?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
@@ -684,50 +772,15 @@ export function M4ReplayThreatVisual({
} }
: undefined; : undefined;
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
if (next === "none") return;
setMediaMode((current) => current === next ? null : next);
};
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
if (next === "none") return;
setSpatialMode((current) => current === next ? null : next);
};
useEffect(() => { useEffect(() => {
if (playbackController.playback.playing || !frame) return; if (
playbackController.playback.playing
|| !evidenceDemand.exactCameraFrame
|| !frame
) return;
const image = new Image(); const image = new Image();
image.src = frame.cameraUrl; image.src = frame.cameraUrl;
}, [frame?.cameraUrl, playbackController.playback.playing]); }, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]);
const splitView = mediaMode !== null && spatialMode !== null;
const mediaModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
<SegmentedControl
value={mediaMode ?? "none"}
items={[
{ value: "video", label: "VIDEO" },
{ value: "camera", label: "CAMERA" },
]}
label="Видео и камера"
onChange={handleMediaModeChange}
/>
</div>
);
const spatialModeControls = (
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
<SegmentedControl
value={spatialMode ?? "none"}
items={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "PLAN" },
]}
label="3D и план"
onChange={handleSpatialModeChange}
/>
</div>
);
const mediaLayerControls = activeSemantic const mediaLayerControls = activeSemantic
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery) || (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
@@ -819,16 +872,24 @@ export function M4ReplayThreatVisual({
shape="pill" shape="pill"
variant={showRollingMap ? "primary" : "secondary"} variant={showRollingMap ? "primary" : "secondary"}
aria-pressed={showRollingMap} aria-pressed={showRollingMap}
disabled={classifiedSpatialLayer.cellLayerAvailable === false}
title={classifiedSpatialLayer.cellLayerAvailable === false
? `${classifiedSpatialLayer.cellLayerLabel} недоступен: для этой записи нет запечатанного полного результата`
: undefined}
onClick={() => setShowRollingMap((visible) => !visible)} onClick={() => setShowRollingMap((visible) => !visible)}
> >
{classifiedSpatialLayer.cellLayerLabel} {classifiedSpatialLayer.cellLayerLabel}
</Button> </Button>
{semanticSpatialResultId ? ( {activeSpatialSemantic ? (
<Button <Button
size="compact" size="compact"
shape="pill" shape="pill"
variant={showSpatialSemantic ? "primary" : "secondary"} variant={showSpatialSemantic ? "primary" : "secondary"}
aria-pressed={showSpatialSemantic} aria-pressed={showSpatialSemantic}
disabled={!semanticSpatialResultId}
title={semanticSpatialResultId
? "Point-aligned semantic evidence"
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
onClick={() => setShowSpatialSemantic((visible) => !visible)} onClick={() => setShowSpatialSemantic((visible) => !visible)}
> >
SEMANTICS SEMANTICS
@@ -881,12 +942,16 @@ export function M4ReplayThreatVisual({
LOW-STEP LOW-STEP
</Button> </Button>
) : null} ) : null}
{semanticSpatialResultId ? ( {activeSpatialSemantic ? (
<Button <Button
size="compact" size="compact"
shape="pill" shape="pill"
variant={showSpatialSemantic ? "primary" : "secondary"} variant={showSpatialSemantic ? "primary" : "secondary"}
aria-pressed={showSpatialSemantic} aria-pressed={showSpatialSemantic}
disabled={!semanticSpatialResultId}
title={semanticSpatialResultId
? "Point-aligned semantic evidence"
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
onClick={() => setShowSpatialSemantic((visible) => !visible)} onClick={() => setShowSpatialSemantic((visible) => !visible)}
> >
SEMANTICS SEMANTICS
@@ -990,14 +1055,14 @@ export function M4ReplayThreatVisual({
<> <>
<div> <div>
<span>Spatial evidence</span> <span>Spatial evidence</span>
<strong>{classifiedSpatialLayer <strong>{hasClassifiedSpatialOutput
? classifiedSpatialFrame ? classifiedSpatialFrame
? replaceClassifiedPointCloud ? replaceClassifiedPointCloud
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells` ? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells` : `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
: "TGS spatial buffer" : "TGS spatial buffer"
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong> : `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
<small>{classifiedSpatialLayer <small>{hasClassifiedSpatialOutput
? classifiedSpatialFrame ? classifiedSpatialFrame
? classifiedSpatialFrame.sampleAvailable === false ? classifiedSpatialFrame.sampleAvailable === false
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame ? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
@@ -1006,9 +1071,9 @@ export function M4ReplayThreatVisual({
: activeSpatialFrame : activeSpatialFrame
? "map-gravity-local · all eligible points accounted · causal rolling 1 s" ? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
: "TGS рассчитан · linked source cloud недоступен для этого кадра" : "TGS рассчитан · linked source cloud недоступен для этого кадра"
: classifiedSpatialLayer.error : classifiedSpatialLayer?.error
?? classifiedSpatialLayer.loadingLabel ?? classifiedSpatialLayer?.loadingLabel
?? `Открываем ${classifiedSpatialLayer.label}` ?? `Открываем ${classifiedSpatialLayer?.label ?? "spatial evidence"}`
: ( : (
<> <>
{spatialFrame {spatialFrame
@@ -1033,13 +1098,13 @@ export function M4ReplayThreatVisual({
)}</small> )}</small>
</div> </div>
<div> <div>
<span>{classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"}</span> <span>{hasClassifiedSpatialOutput ? "TGS fail-closed" : "Virtual corridor"}</span>
<strong>{classifiedSpatialLayer <strong>{hasClassifiedSpatialOutput
? classifiedSpatialFrame ? classifiedSpatialFrame
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved` ? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
: classifiedSpatialLayer.loading || displayingBufferedFrame ? "loading" : "unavailable" : classifiedSpatialLayer?.loading || displayingBufferedFrame ? "loading" : "unavailable"
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong> : `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
<small>{classifiedSpatialLayer <small>{hasClassifiedSpatialOutput
? classifiedSpatialFrame ? classifiedSpatialFrame
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF` ? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
: "visual review only · navigation authority OFF" : "visual review only · navigation authority OFF"
@@ -1051,7 +1116,12 @@ export function M4ReplayThreatVisual({
) : undefined; ) : undefined;
const timeline = metadata.timeline; const timeline = metadata.timeline;
let content; let content: ReactNode = null;
let canonicalContent: {
mediaContent: ReactNode;
spatialContent: ReactNode;
deckOverlays: ReactNode;
} | null = null;
if (metadata.error) { if (metadata.error) {
content = <SpatialState message={metadata.error} />; content = <SpatialState message={metadata.error} />;
} else if (!timeline) { } else if (!timeline) {
@@ -1062,23 +1132,8 @@ export function M4ReplayThreatVisual({
</div> </div>
); );
} else { } else {
const mediaPane = ( const mediaContent = (
<section <>
className="m4-replay-threat-visual__pane"
data-pane="media"
aria-label={mediaMode === "camera" ? "Камера" : "Видео"}
hidden={!mediaMode}
>
{splitView ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={availableSemanticLayers.length > 1 ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControls}
</div>
) : null}
<div <div
className="m4-replay-threat-visual__media-layer" className="m4-replay-threat-visual__media-layer"
data-media="video" data-media="video"
@@ -1091,6 +1146,7 @@ export function M4ReplayThreatVisual({
imageWidth={timeline.imageWidth} imageWidth={timeline.imageWidth}
imageHeight={timeline.imageHeight} imageHeight={timeline.imageHeight}
boxes={activeBoxes} boxes={activeBoxes}
overlaySeconds={frame?.sessionSeconds}
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined} semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined} pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
@@ -1102,7 +1158,9 @@ export function M4ReplayThreatVisual({
} }
segmentCount={timeline.frameCount} segmentCount={timeline.frameCount}
onPlaybackChange={playbackController.synchronize} onPlaybackChange={playbackController.synchronize}
onPlayingRejected={() => playbackController.setPlaying(false)} playbackAuthority="media"
playbackTransport={playbackTransport}
recoverTimestampStalls={recoverTimestampStalls}
/> />
) : videoError ? ( ) : videoError ? (
<SpatialState message={videoError} /> <SpatialState message={videoError} />
@@ -1124,34 +1182,20 @@ export function M4ReplayThreatVisual({
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`} ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
/> />
) : null} ) : null}
</section> </>
); );
const spatialPane = spatialMode ? ( const spatialContent = spatialMode ? (
<section <>
className="m4-replay-threat-visual__pane"
data-pane="spatial"
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
>
{splitView ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
>
{resetSpatialView}
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
</div>
</div>
) : null}
<LaboratoryMetricEvidenceScene <LaboratoryMetricEvidenceScene
ref={metricSceneRef} ref={metricSceneRef}
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? classifiedPointsBody ? classifiedPointsBody
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []} : classifiedContextSpatialFrame?.pointCloudBodyXyzM
?? activeSpatialFrame?.pointCloudBodyXyzM
?? []}
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM} localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
obstacles={classifiedSpatialLayer ? [] : sceneObstacles} obstacles={hasClassifiedSpatialOutput ? [] : sceneObstacles}
rig={timeline.rig} rig={timeline.rig}
corridor={timeline.corridor} corridor={timeline.corridor}
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM} occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
@@ -1160,22 +1204,22 @@ export function M4ReplayThreatVisual({
showCurrentIncrement={showCurrentIncrement} showCurrentIncrement={showCurrentIncrement}
showLocalSurface={showLocalSurface} showLocalSurface={showLocalSurface}
showRollingMap={showRollingMap} showRollingMap={showRollingMap}
showLowStep={classifiedSpatialLayer ? false : showLowStep} showLowStep={hasClassifiedSpatialOutput ? false : showLowStep}
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.pointClassIds ? displayedClassifiedSpatialFrame.pointClassIds
: alignedSemanticPointIds} : alignedSemanticPointIds}
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.classes ? displayedClassifiedSpatialFrame.classes
: semanticClasses} : spatialSemanticClasses}
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
? displayedClassifiedSpatialFrame.palette ? displayedClassifiedSpatialFrame.palette
: semanticPalette} : spatialSemanticPalette}
classifiedCells={classifiedCellsBody} classifiedCells={classifiedCellsBody}
classifiedPackedCells={classifiedPackedCellsBody} classifiedPackedCells={classifiedPackedCellsBody}
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM} classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
showClassifiedCells={showRollingMap} showClassifiedCells={showRollingMap}
/> />
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? ( {hasClassifiedSpatialOutput && classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}> <div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
{classifiedSpatialLayer.loading || displayingBufferedFrame {classifiedSpatialLayer.loading || displayingBufferedFrame
? <span className="busy-indicator" aria-hidden="true" /> ? <span className="busy-indicator" aria-hidden="true" />
@@ -1206,31 +1250,11 @@ export function M4ReplayThreatVisual({
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`} : `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
</div> </div>
) : null} ) : null}
</section> </>
) : null; ) : null;
content = ( const deckOverlays = (
<div <>
className="m4-replay-threat-visual__deck"
data-split={splitView ? "true" : undefined}
data-empty={!mediaMode && !spatialMode ? "true" : undefined}
>
<SplitPane
primary={mediaPane}
secondary={spatialPane ?? <div />}
primarySize={splitView ? splitPrimarySize : mediaMode ? 100 : 0}
onPrimarySizeChange={setSplitPrimarySize}
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView}
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
/>
{!mediaMode && !spatialMode ? (
<div className="l3-visual-audit__state" role="status">
Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте.
</div>
) : null}
{timelineFrame.loading || displayingBufferedFrame ? ( {timelineFrame.loading || displayingBufferedFrame ? (
<div className="m4-replay-threat-visual__buffering" role="status"> <div className="m4-replay-threat-visual__buffering" role="status">
<span className="busy-indicator" aria-hidden="true" /> <span className="busy-indicator" aria-hidden="true" />
@@ -1261,8 +1285,9 @@ export function M4ReplayThreatVisual({
<span>{semanticIntegrityError}</span> <span>{semanticIntegrityError}</span>
</div> </div>
) : null} ) : null}
</div> </>
); );
canonicalContent = { mediaContent, spatialContent, deckOverlays };
} }
const transport = timeline ? ( const transport = timeline ? (
@@ -1287,38 +1312,59 @@ export function M4ReplayThreatVisual({
/> />
) : undefined; ) : undefined;
if (!timeline || !canonicalContent) {
return ( return (
<div className="l3-visual-audit m4-replay-threat-visual"> <div className="l3-visual-audit m4-replay-threat-visual">
<LaboratoryEvidenceViewer <LaboratoryEvidenceViewer
label={activeSemantic label={`${evidenceLabel} recorded-realtime replay`}
? activeSemantic.label ?? "Semantic diagnostic replay"
: `${evidenceLabel} recorded-realtime replay`}
className="m4-replay-threat-evidence-viewer" className="m4-replay-threat-evidence-viewer"
mode={mediaMode ?? "none"} mode="video"
modes={[ modes={[{ value: "video", label: "VIDEO" }]}
{ value: "video", label: "VIDEO" },
{ value: "camera", label: "CAMERA" },
]}
secondaryMode={{
value: spatialMode ?? "none",
modes: [
{ value: "3d", label: "3D" },
{ value: "plan", label: "PLAN" },
],
label: "3D и план",
onChange: handleSpatialModeChange,
}}
expanded={expanded} expanded={expanded}
onModeChange={handleMediaModeChange} onModeChange={() => undefined}
onExpandedChange={setExpanded} onExpandedChange={setExpanded}
modeControlsVisible={!splitView}
actions={actions}
overlay={overlay}
transport={transport}
trailingActions={trailingActions}
> >
{content} {content}
</LaboratoryEvidenceViewer> </LaboratoryEvidenceViewer>
</div> </div>
); );
}
return (
<CanonicalRecordedLabReplay
label={activeSemantic
? activeSemantic.label ?? "Semantic diagnostic replay"
: `${evidenceLabel} recorded-realtime replay`}
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "VIDEO" },
{ value: "camera", label: "CAMERA" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "PLAN" },
]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer={availableSemanticLayers.length > 1}
mediaContent={canonicalContent.mediaContent}
spatialContent={canonicalContent.spatialContent}
emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте."
deckOverlays={canonicalContent.deckOverlays}
actions={actions}
overlay={overlay}
transport={transport}
trailingActions={trailingActions}
onMediaModeChange={handleMediaModeChange}
onSpatialModeChange={handleSpatialModeChange}
onExpandedChange={setExpanded}
onSplitPrimarySizeChange={setSplitPrimarySize}
/>
);
} }
@@ -1,7 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
import { import {
LaboratoryEvidence, LaboratoryEvidence,
LaboratoryResultSummary, LaboratoryResultSummary,
@@ -10,135 +8,99 @@ import {
} from "../../components/laboratory/LaboratoryPresentation"; } from "../../components/laboratory/LaboratoryPresentation";
import { import {
vegetationVideoMaskUrl, vegetationVideoMaskUrl,
type VegetationMixedRouteReview, type VegetationFullRouteReview,
type VegetationShadowResult, type VegetationShadowResult,
} from "../../core/laboratory/vegetationShadow"; } from "../../core/laboratory/vegetationShadow";
import { import {
fetchM49TgsFullShadowResult, fetchM49TgsFullShadowResult,
type M49TgsFullShadowResult, type M49TgsFullShadowResult,
} from "../../core/laboratory/m49TgsFullShadow"; } from "../../core/laboratory/m49TgsFullShadow";
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence"; import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
import { CanonicalVegetationRerunReplay } from "./CanonicalVegetationRerunReplay";
function decimal(value: number, digits = 1): string { function decimal(value: number, digits = 1): string {
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
} }
const MIXED_ROUTE_MODES = [ function FullRouteReviewEvidence({
{ value: "source", label: "SOURCE" }, resultId,
{ value: "city", label: "ГОРОД · EoMT" }, review,
{ value: "vegetation", label: "ПРИРОДА · DDRNet" }, }: {
{ value: "tgs", label: "TGS" }, resultId: string;
] as const; review: VegetationFullRouteReview;
}) {
function MixedRouteReviewEvidence({ review }: { review: VegetationMixedRouteReview }) { return <CanonicalVegetationRerunReplay resultId={resultId} review={review} />;
const [index, setIndex] = useState(0);
const [mode, setMode] = useState<typeof MIXED_ROUTE_MODES[number]["value"]>("vegetation");
const [expanded, setExpanded] = useState(false);
const item = review.cases[index]!;
return (
<LaboratoryEvidenceViewer
label="RAVNOVES004TREE mixed route review"
className="m48-atlas-visual"
mode={mode}
modes={MIXED_ROUTE_MODES}
expanded={expanded}
onModeChange={setMode}
onExpandedChange={setExpanded}
chromeLayout="stacked"
actions={(
<>
<IconButton label="Предыдущая сцена" onClick={() => setIndex((index - 1 + review.cases.length) % review.cases.length)}>
<Icon name="chevron-left" size={16} />
</IconButton>
<IconButton label="Следующая сцена" onClick={() => setIndex((index + 1) % review.cases.length)}>
<Icon name="chevron-right" size={16} />
</IconButton>
</>
)}
overlay={(
<div className="m48-atlas-visual__case">
<StatusBadge tone={item.phase === "urban" ? "accent" : item.phase === "transition" ? "warning" : "neutral"}>
{item.phase.toUpperCase()} · {index + 1}/{review.cases.length}
</StatusBadge>
<strong>sequence {item.sourceSequence} · +{decimal(item.sessionSeconds, 2)} s</strong>
<small>
TGS: {item.tgs.groundCells} ground · {item.tgs.occupiedCells} occupied · {item.tgs.unobservedCells} unobserved
</small>
</div>
)}
>
<div className="recorded-evidence-image-scene">
<img src={item.assets[mode]} alt="" draggable={false} />
</div>
</LaboratoryEvidenceViewer>
);
} }
function MixedRouteReviewResult({ function FullRouteReviewResult({
rigLabel, rigLabel,
resultId,
review, review,
}: { }: {
rigLabel: string; rigLabel: string;
review: VegetationMixedRouteReview; resultId: string;
review: VegetationFullRouteReview;
}) { }) {
return ( return (
<LaboratoryWorkTemplate <LaboratoryWorkTemplate
summary={( summary={(
<LaboratorySummary <LaboratorySummary
title="LAB V1 · RAVNOVES004TREE · село → город" title="LAB V1 · RAVNOVES004TREE · полный маршрут"
description="Существующий LAB-шаблон показывает 10 синхронных camera/LiDAR сцен одной записи. EoMT и DDRNet остаются независимыми слоями; TGS показывает отдельную геометрию и не может быть очищен семантической маской." description="Принятый инструмент записанной LAB воспроизводит RAV004 без отдельного viewer: единый таймлайн, правая камера, исходные точки, ограниченный Local SLAM и переключаемые EoMT/DDRNet."
status="BOUNDED RECORDED REVIEW · truth отсутствует · commands OFF" status="ПОЛНЫЙ ПРОСМОТР ЗАПИСИ · эталон отсутствует · команды ВЫКЛ"
statusTone="warning" statusTone="warning"
facts={[ facts={[
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount} camera/LiDAR islands` }, { label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} кадров камеры` },
{ label: "Переход", value: "5 rural · 1 transition · 4 urban" }, { label: "3D", value: "1444 приращения исходного облака · стабильная по гравитации RFU → корпус" },
{ label: "Слои", value: "SOURCE · EoMT CITY · DDRNet VEGETATION · causal TGS" }, { label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} кадра/с` },
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` }, { label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} кадра/с` },
{ label: "TGS", value: "существуют 10 контрольных якорей · артефакт полного маршрута отсутствует" },
{ label: "Полномочия", value: `${rigLabel} · ТОЛЬКО ВИЗУАЛЬНЫЙ ПРОСМОТР · команды ВЫКЛ` },
]} ]}
brief={{ brief={{
question: "Сохраняются ли городская семантика, растительность и геометрия при переходе из сельской среды в город?", question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
approach: "Выбраны десять соседних с исходными сцен camera-кадров, каждый синхронизирован с LiDAR в пределах 100 мс. Все три вычислительных слоя прогнаны на Worker 006 и запечатаны локально.", approach: "RAV004 поставляет только data/provider configuration в тот же M4 recorded viewer. Видеодекодер владеет clock; новые source increments проецируются в gravity-stable forward/left/up frame, Local SLAM ограничен пятью секундами.",
principalResult: "Все 10 сцен обработаны EoMT, DDRNet и causal TGS. Слои можно переключать без наложения цветов и без зависимости LAB от воркера.", principalResult: "RAV004 больше не имеет отдельной логики окон, таймера, seek, cache или 3D controls. Модели и подписи меняются конфигурацией, архитектура переключения остаётся общей.",
limitation: "Это bounded islands без ручной truth. DDRNet шумит по подтипам растительности; TGS не доказывает обнаружение кювета или отрицательного препятствия.", limitation: "Full-route TGS, независимый person/vehicle detector, ручной truth и point-aligned 3D semantics пока не запечатаны. Semantic-derived рамки диагностические и не являются STOP-authority.",
}} }}
method={{ method={{
completeness: "complete", completeness: "complete",
executionClass: "ai-inference", executionClass: "ai-inference",
pipelineId: "ravnoves004tree-eomt-ddrnet-causal-tgs-review/v1", pipelineId: "canonical-recorded-lab-rav004tree/v3",
components: [ components: [
{ kind: "model", name: review.models.city.name, version: "sealed Worker run", role: "urban semantic review", identitySha256: null }, { kind: "algorithm", name: "Canonical recorded replay", version: "media-clock / one viewer", role: "shared camera + spatial transport", identitySha256: null },
{ kind: "model", name: review.models.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null }, { kind: "algorithm", name: "Recorded source points + bounded Local SLAM", version: "source-paced-ground-v3", role: "gravity-stable spatial evidence", identitySha256: null },
{ kind: "algorithm", name: review.models.tgs.name, version: "TRAVEL compatibility runner", role: "independent local geometry", identitySha256: null }, { kind: "model", name: review.city.name, version: "sealed Worker 006 run", role: "urban semantic review", identitySha256: null },
{ kind: "model", name: review.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
], ],
}} }}
/> />
)} )}
evidence={( evidence={(
<LaboratoryEvidence <LaboratoryEvidence
eyebrow="M4.7 TEMPLATE · RAVNOVES004TREE" eyebrow="КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE"
title="SOURCE / ГОРОД / ПРИРОДА / TGS · 10/10 · TRUTH отсутствует" title="КАМЕРА + ИСХОДНЫЕ ТОЧКИ + ЛОКАЛЬНЫЙ SLAM + КАРТА TGS + СЕМАНТИКА · 6830/6830"
kind="diagnostic-model" kind="recorded-replay"
resizable resizable
> >
<MixedRouteReviewEvidence review={review} /> <FullRouteReviewEvidence resultId={resultId} review={review} />
</LaboratoryEvidence> </LaboratoryEvidence>
)} )}
result={( result={(
<LaboratoryResultSummary <LaboratoryResultSummary
title="Переход село → город воспроизведён; safety gate не закрыт" title="RAV004 переведён на общий replay-каркас; safety evidence ещё не полно"
status="Review ready · navigation/actuation OFF" status="Записанные доказательства · навигация/управление ВЫКЛ"
statusTone="warning" statusTone="warning"
metrics={[ metrics={[
{ label: "Aligned scenes", value: "10/10", hint: "camera + LiDAR + pose · автономный archive" }, { label: "Таймлайн камеры", value: "6830 кадров · ≈9,51 Гц", hint: "единые часы управляют видео, слоями и пространством" },
{ label: "EoMT end-to-end p95", value: `${decimal(review.models.city.endToEndP95Ms, 2)} ms`, hint: `${decimal(review.models.city.inferenceFps, 2)} fps в изолированном прогоне` }, { label: "Исходная геометрия", value: "1444 приращения · ≈2 Гц", hint: "между поступлениями удерживается последний подтверждённый пространственный кадр" },
{ label: "DDRNet inference p95", value: `${decimal(review.models.vegetation.latencyP95Ms, 2)} ms`, hint: "candidate review · не совместный realtime stack" }, { label: "Пропускная способность EoMT", value: `${decimal(review.city.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; не стек реального времени" },
{ label: "TGS p95", value: `${decimal(review.models.tgs.latencyP95Ms, 2)} ms`, hint: `${review.models.tgs.cellSizeM} m cells · ${review.models.tgs.radiusM} m radius` }, { label: "Пропускная способность DDRNet", value: `${decimal(review.vegetation.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; временная стабильность не принята" },
]} ]}
conclusion={{ conclusion={{
proved: "Оба semantic слоя и causal TGS воспроизводимо работают на сельской, переходной и городской части новой записи.", proved: "Камера, перемотка, пространственные слои и переключение семантики используют один принятый переиспользуемый viewer и единые часы; исходная геометрия RFU больше не наследует крен и тангаж LiDAR.",
notProved: "Не доказаны accuracy без truth, временная стабильность по всему видео, детект кюветов и безопасное совместное realtime-управление ровером.", notProved: "Не доказаны непрерывная TGS, независимый детектор/STOP, точность относительно эталона, временная стабильность DDRNet и ≥10 кадров/с совместного стека реального времени.",
decision: "Оставить navigation/actuation OFF. Следующий короткий gate — непрерывный realtime-load двух моделей плюс независимый person/vehicle STOP; кюветы проверять отдельной записью.", decision: "Продолжать как визуальный аудит. До запечатанной TGS полного маршрута и барьера детектора/нагрузки навигация и управление остаются выключенными.",
}} }}
/> />
)} )}
@@ -148,6 +110,7 @@ function MixedRouteReviewResult({
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) { function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
const route = result.routeVideo!; const route = result.routeVideo!;
const linkedTgsResultId = route.linkedTgsResultId;
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null); const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
const [tgsError, setTgsError] = useState<string | null>(null); const [tgsError, setTgsError] = useState<string | null>(null);
@@ -155,8 +118,8 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
const controller = new AbortController(); const controller = new AbortController();
setTgs(null); setTgs(null);
setTgsError(null); setTgsError(null);
if (!route.linkedTgsResultId) return () => controller.abort(); if (!linkedTgsResultId) return () => controller.abort();
void fetchM49TgsFullShadowResult(route.linkedTgsResultId, { void fetchM49TgsFullShadowResult(linkedTgsResultId, {
signal: controller.signal, signal: controller.signal,
}).then((next) => { }).then((next) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
@@ -170,7 +133,11 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
} }
}); });
return () => controller.abort(); return () => controller.abort();
}, [route.baseM4ResultId, route.linkedTgsResultId]); }, [linkedTgsResultId, route.baseM4ResultId]);
if (!linkedTgsResultId) {
throw new Error("Vegetation LAB result has no linked canonical M4.9 TGS evidence.");
}
const semantic = { const semantic = {
id: "vegetation", id: "vegetation",
@@ -183,16 +150,14 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
maskAriaLabel: "DDRNet vegetation material prediction", maskAriaLabel: "DDRNet vegetation material prediction",
} as const; } as const;
if (route.linkedTgsResultId && tgs) { if (tgsError) {
return ( return (
<M49TgsFullShadowEvidence <div className="m4-replay-threat-visual__pane-status" role="alert">
result={tgs} Канонический M4.9 TGS слой недоступен: {tgsError}
semanticOverride={semantic} </div>
evidenceLabel="LAB V1 · EoMT + DDRNet + YOLOX + TGS"
/>
); );
} }
if (route.linkedTgsResultId && !tgsError) { if (!tgs) {
return ( return (
<div className="m4-replay-threat-visual__pane-status" role="status"> <div className="m4-replay-threat-visual__pane-status" role="status">
Открываем sealed EoMT, TGS и coarse vegetation timeline… Открываем sealed EoMT, TGS и coarse vegetation timeline…
@@ -200,20 +165,11 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
); );
} }
return ( return (
<> <M49TgsFullShadowEvidence
<M4ReplayThreatVisual result={tgs}
resultId={route.baseM4ResultId} semanticOverride={semantic}
evidenceLabel="LAB V1 · DDRNet" evidenceLabel="LAB V1 · EoMT + DDRNet + YOLOX + TGS"
showReferenceMediaLayers
showSpatialOverlaySummary={false}
semantic={semantic}
/> />
{tgsError ? (
<div className="m4-replay-threat-visual__pane-status" role="alert">
TGS слой недоступен: {tgsError}
</div>
) : null}
</>
); );
} }
@@ -224,8 +180,19 @@ export function VegetationShadowResultView({
rigLabel: string; rigLabel: string;
result: VegetationShadowResult; result: VegetationShadowResult;
}) { }) {
if (result.routeReview) { if (result.routeFullReview) {
return <MixedRouteReviewResult rigLabel={rigLabel} review={result.routeReview} />; return (
<FullRouteReviewResult
rigLabel={rigLabel}
resultId={result.resultId}
review={result.routeFullReview}
/>
);
}
if (!result.routeVideo?.linkedTgsResultId) {
throw new Error(
"Vegetation LAB result has no canonical M4 source timeline and linked M4.9 TGS evidence.",
);
} }
const route = result.routeVideo; const route = result.routeVideo;
const selected = result.candidates.find( const selected = result.candidates.find(
@@ -238,9 +205,7 @@ export function VegetationShadowResultView({
<LaboratorySummary <LaboratorySummary
title="LAB V1 · карта ровера · город + растительность" title="LAB V1 · карта ровера · город + растительность"
description="Один recorded-контур RAVNOVES00 синхронно показывает городской EoMT, природный DDRNet, frozen YOLOX detections и causal TGS. Семантические маски переключаются, чтобы их цвета не скрывали друг друга; геометрическое veto остаётся независимым." description="Один recorded-контур RAVNOVES00 синхронно показывает городской EoMT, природный DDRNet, frozen YOLOX detections и causal TGS. Семантические маски переключаются, чтобы их цвета не скрывали друг друга; геометрическое veto остаётся независимым."
status={route status="MULTILAYER RECORDED REVIEW · commands OFF · route truth отсутствует"
? "MULTILAYER RECORDED REVIEW · commands OFF · route truth отсутствует"
: "ROUTE EVIDENCE MISSING · commands OFF"}
statusTone="warning" statusTone="warning"
facts={[ facts={[
{ label: "Источник", value: "RAVNOVES00 · sensor.camera.right · 4489 recorded frames" }, { label: "Источник", value: "RAVNOVES00 · sensor.camera.right · 4489 recorded frames" },
@@ -251,61 +216,31 @@ export function VegetationShadowResultView({
]} ]}
brief={{ brief={{
question: "Можно ли одновременно видеть городской и природный semantic stack, не теряя независимую геометрическую защиту?", question: "Можно ли одновременно видеть городской и природный semantic stack, не теряя независимую геометрическую защиту?",
approach: "EoMT и DDRNet сохранены как два независимых sealed слоя на одной M4 timeline. В штатном M4.7 viewer пользователь переключает только отображаемую маску; YOLOX и TGS остаются активными слоями evidence.", approach: "EoMT и DDRNet сохранены как два независимых sealed слоя на одной M4 timeline. В штатном M4.9 viewer пользователь переключает только отображаемую маску; YOLOX и TGS остаются активными слоями evidence.",
principalResult: route principalResult: "Оба semantic archive доступны в одном viewer. Это не пиксельный fusion и не единая новая модель: городской и природный ответы остаются раздельными.",
? "Оба semantic archive доступны в одном viewer. Это не пиксельный fusion и не единая новая модель: городской и природный ответы остаются раздельными."
: "Route archive для этой immutable identity отсутствует.",
limitation: "RAVNOVES00 не имеет ручной truth. DDRNet заметно прыгает между HIGH GRASS, WOODY и UNKNOWN; поэтому subtype нельзя подавать напрямую в planner. Отсутствие класса никогда не означает свободный путь.", limitation: "RAVNOVES00 не имеет ручной truth. DDRNet заметно прыгает между HIGH GRASS, WOODY и UNKNOWN; поэтому subtype нельзя подавать напрямую в planner. Отсутствие класса никогда не означает свободный путь.",
}} }}
method={{ method={{
completeness: route ? "complete" : "legacy-partial", completeness: "complete",
executionClass: "ai-inference", executionClass: "ai-inference",
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1", pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
components: [ components: [
{ { kind: "model", name: "EoMT Cityscapes semantic", version: "sealed E47 archive", role: "urban semantic review", identitySha256: null },
kind: "model", { kind: "model", name: selected.loadedModelName, version: selected.candidate, role: "vegetation material candidate", identitySha256: selected.checkpointSha256 },
name: "EoMT Cityscapes semantic", { kind: "algorithm", name: "Frozen YOLOX + causal TGS", version: "linked M4/M4.9 archives", role: "independent object and geometry veto", identitySha256: null },
version: "sealed E47 archive",
role: "urban semantic review",
identitySha256: null,
},
{
kind: "model",
name: selected.loadedModelName,
version: selected.candidate,
role: "vegetation material candidate",
identitySha256: selected.checkpointSha256,
},
{
kind: "algorithm",
name: "Frozen YOLOX + causal TGS",
version: "linked M4/M4.9 archives",
role: "independent object and geometry veto",
identitySha256: null,
},
], ],
}} }}
/> />
)} )}
evidence={route ? ( evidence={(
<LaboratoryEvidence <LaboratoryEvidence
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO" eyebrow="M4.9 · RAVNOVES00 FULL VIDEO"
title="EoMT CITY / DDRNet VEGETATION + YOLOX + CAUSAL TGS · 4489/4489 · TRUTH отсутствует" title="EoMT CITY / DDRNet VEGETATION + YOLOX + CAUSAL TGS · 4489/4489 · TRUTH отсутствует"
kind="diagnostic-model" kind="diagnostic-model"
resizable resizable
> >
<VegetationRouteEvidence result={result} /> <VegetationRouteEvidence result={result} />
</LaboratoryEvidence> </LaboratoryEvidence>
) : (
<LaboratoryEvidence
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO"
title="ROUTE ARCHIVE отсутствует"
kind="diagnostic-model"
>
<div className="m4-replay-threat-visual__pane-status" role="alert">
Для этой immutable identity нет полного route video evidence.
</div>
</LaboratoryEvidence>
)} )}
result={( result={(
<LaboratoryResultSummary <LaboratoryResultSummary
@@ -313,26 +248,10 @@ export function VegetationShadowResultView({
status="Semantics advisory · YOLOX/TGS veto cannot be cleared" status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
statusTone="warning" statusTone="warning"
metrics={[ metrics={[
{ { label: "Route masks", value: `${route.frameCount}/${route.frameCount}`, hint: "sealed local playback · Worker для открытия не нужен" },
label: "Route masks", { label: "Semantic sources", value: "2 independent layers", hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse" },
value: route ? `${route.frameCount}/${route.frameCount}` : "0/4489", { label: "Vegetation worker p95", value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`, hint: "изолированный DDRNet inference; не совместный realtime stack" },
hint: "sealed local playback · Worker для открытия не нужен", { label: "Vegetation peak VRAM", value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`, hint: "DDRNet candidate на Worker 006" },
},
{
label: "Semantic sources",
value: route ? "2 independent layers" : "0",
hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse",
},
{
label: "Vegetation worker p95",
value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`,
hint: "изолированный DDRNet inference; не совместный realtime stack",
},
{
label: "Vegetation peak VRAM",
value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`,
hint: "DDRNet candidate на Worker 006",
},
]} ]}
conclusion={{ conclusion={{
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.", proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
@@ -10,6 +10,7 @@ interface M48EvidenceModeControlProps {
mode: M48BlindEvidenceMode; mode: M48BlindEvidenceMode;
cameraVisible: boolean; cameraVisible: boolean;
spatialAvailable: boolean; spatialAvailable: boolean;
planAvailable?: boolean;
onModeChange: (mode: M48BlindEvidenceMode) => void; onModeChange: (mode: M48BlindEvidenceMode) => void;
onCameraVisibleChange: (visible: boolean) => void; onCameraVisibleChange: (visible: boolean) => void;
} }
@@ -34,6 +35,7 @@ export function M48EvidenceModeControls({
mode, mode,
cameraVisible, cameraVisible,
spatialAvailable, spatialAvailable,
planAvailable = spatialAvailable,
onModeChange, onModeChange,
onCameraVisibleChange, onCameraVisibleChange,
}: M48EvidenceModeControlProps) { }: M48EvidenceModeControlProps) {
@@ -68,9 +70,9 @@ export function M48EvidenceModeControls({
<IconButton <IconButton
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"} label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
aria-pressed={spatialMode === "plan"} aria-pressed={spatialMode === "plan"}
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")} disabled={!planAvailable || (!cameraVisible && spatialMode === "plan")}
onClick={() => { onClick={() => {
if (!spatialAvailable) return; if (!planAvailable) return;
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan")); onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
}} }}
> >
@@ -459,6 +459,17 @@ function pipelineIdForSession(session: ObservationSessionSummary): string {
return session.lab?.resultKind ?? "legacy-perception"; return session.lab?.resultKind ?? "legacy-perception";
} }
/** Keep capability projections owned by Observatory out of the legacy LAB surface. */
export function isLegacyPublishedLaboratoryWork(
session: ObservationSessionSummary,
): boolean {
return session.lab !== null
&& session.lab.replayCapability === null
&& session.status === "ready"
&& session.replayable
&& session.modalities.includes("point-cloud");
}
export function buildLaboratoryCatalog({ export function buildLaboratoryCatalog({
rigLabel, rigLabel,
knownWorks, knownWorks,
@@ -11,11 +11,30 @@ const CHUNK_SIZE = 24;
const RETAINED_CHUNK_COUNT = 8; const RETAINED_CHUNK_COUNT = 8;
const PREFETCH_CHUNKS_AHEAD = 2; const PREFETCH_CHUNKS_AHEAD = 2;
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] { export function e47SemanticChunkWindowStarts(
return Array.from( activeStart: number,
{ length: PREFETCH_CHUNKS_AHEAD + 2 }, frameCount: number,
(_, index) => activeStart + (index - 1) * CHUNK_SIZE, ): readonly number[] {
).filter((start) => start >= 0 && start < frameCount); return [
activeStart,
...Array.from(
{ length: PREFETCH_CHUNKS_AHEAD },
(_, index) => activeStart + (index + 1) * CHUNK_SIZE,
),
activeStart - CHUNK_SIZE,
].filter((start) => start >= 0 && start < frameCount);
}
export function cancelE47SemanticRequestsOutsideWindow<T extends { abort(): void }>(
inFlight: Map<number, T>,
desiredStarts: readonly number[],
): void {
const desired = new Set(desiredStarts);
for (const [start, controller] of inFlight) {
if (desired.has(start)) continue;
controller.abort();
inFlight.delete(start);
}
} }
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
@@ -29,11 +48,13 @@ export function useE47SemanticTimelineFrame({
activeSequence, activeSequence,
frameCount, frameCount,
taxonomy, taxonomy,
enabled = true,
}: { }: {
resultId: string | null; resultId: string | null;
activeSequence: number | null; activeSequence: number | null;
frameCount: number; frameCount: number;
taxonomy: readonly E47SemanticClass[]; taxonomy: readonly E47SemanticClass[];
enabled?: boolean;
}) { }) {
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>( const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
() => new Map(), () => new Map(),
@@ -55,16 +76,21 @@ export function useE47SemanticTimelineFrame({
for (const controller of inFlight.current.values()) controller.abort(); for (const controller of inFlight.current.values()) controller.abort();
inFlight.current.clear(); inFlight.current.clear();
}; };
}, [resultId]); }, [enabled, resultId]);
const activeStart = activeSequence === null const activeStart = !enabled || activeSequence === null
? null ? null
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE; : Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
activeStartRef.current = activeStart; activeStartRef.current = activeStart;
useEffect(() => { useEffect(() => {
if (!resultId || activeStart === null || frameCount < 1) return; if (!enabled || !resultId || activeStart === null || frameCount < 1) {
for (const start of chunkWindowStarts(activeStart, frameCount)) { cancelE47SemanticRequestsOutsideWindow(inFlight.current, []);
return;
}
const starts = e47SemanticChunkWindowStarts(activeStart, frameCount);
cancelE47SemanticRequestsOutsideWindow(inFlight.current, starts);
for (const start of starts) {
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue; if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
const controller = new AbortController(); const controller = new AbortController();
inFlight.current.set(start, controller); inFlight.current.set(start, controller);
@@ -95,8 +121,11 @@ export function useE47SemanticTimelineFrame({
.finally(() => { .finally(() => {
if (inFlight.current.get(start) === controller) inFlight.current.delete(start); if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
}); });
// Semantic point arrays are large JSON payloads. Admit the active chunk
// first, then advance the bounded prefetch window one request per render.
break;
} }
}, [activeStart, frameCount, resultId, taxonomy]); }, [activeStart, chunks, enabled, frameCount, resultId, taxonomy]);
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => { const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
if (activeSequence === null || activeStart === null) return null; if (activeSequence === null || activeStart === null) return null;
@@ -107,7 +136,7 @@ export function useE47SemanticTimelineFrame({
return { return {
activeFrame, activeFrame,
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error, loading: enabled && Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
error, error,
}; };
} }
@@ -18,6 +18,7 @@ import {
const REQUESTED_CHUNK_FRAMES = 24; const REQUESTED_CHUNK_FRAMES = 24;
const RETAINED_CHUNK_COUNT = 4; const RETAINED_CHUNK_COUNT = 4;
const RETAINED_CHUNKS_BEHIND = 1;
const PREFETCH_CHUNKS_AHEAD = 1; const PREFETCH_CHUNKS_AHEAD = 1;
const RETAINED_CAMERA_POINT_OVERLAYS = 12; const RETAINED_CAMERA_POINT_OVERLAYS = 12;
@@ -31,10 +32,17 @@ export function m4ThreatChunkWindowStarts(
frameCount: number, frameCount: number,
): readonly number[] { ): readonly number[] {
if (chunkSize < 1 || frameCount < 1) return []; if (chunkSize < 1 || frameCount < 1) return [];
return Array.from( return [
{ length: PREFETCH_CHUNKS_AHEAD + 1 }, activeChunkStart,
(_, index) => activeChunkStart + index * chunkSize, ...Array.from(
).filter((start) => start >= 0 && start < frameCount); { length: PREFETCH_CHUNKS_AHEAD },
(_, index) => activeChunkStart + (index + 1) * chunkSize,
),
...Array.from(
{ length: RETAINED_CHUNKS_BEHIND },
(_, index) => activeChunkStart - (index + 1) * chunkSize,
),
].filter((start) => start >= 0 && start < frameCount);
} }
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>( export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
@@ -76,12 +84,16 @@ export function useM4ThreatTimelineFrame({
resultId, resultId,
timeline, timeline,
currentSeconds, currentSeconds,
includeSpatialPoints = true,
endpointRoot, endpointRoot,
spatialPlaybackTransport = "auto",
}: { }: {
resultId: string; resultId: string;
timeline: M4ThreatTimeline | null; timeline: M4ThreatTimeline | null;
currentSeconds: number; currentSeconds: number;
includeSpatialPoints?: boolean;
endpointRoot?: string; endpointRoot?: string;
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
}) { }) {
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>( const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
() => new Map(), () => new Map(),
@@ -94,8 +106,10 @@ export function useM4ThreatTimelineFrame({
totalBytes: 0, totalBytes: 0,
}); });
const [playbackError, setPlaybackError] = useState<string | null>(null); const [playbackError, setPlaybackError] = useState<string | null>(null);
const binaryPlayback = endpointRoot === undefined const binaryPlayback = spatialPlaybackTransport === "sealed-binary"
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT; || (spatialPlaybackTransport === "auto" && (
endpointRoot === undefined || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT
));
const inFlight = useRef(new Map<number, AbortController>()); const inFlight = useRef(new Map<number, AbortController>());
const chunksRef = useRef(chunks); const chunksRef = useRef(chunks);
const activeChunkStartRef = useRef<number | null>(null); const activeChunkStartRef = useRef<number | null>(null);
@@ -107,7 +121,7 @@ export function useM4ThreatTimelineFrame({
setPlaybackError(null); setPlaybackError(null);
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 }); setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
if (!timeline) return () => controller.abort(); if (!timeline) return () => controller.abort();
if (!binaryPlayback) { if (!binaryPlayback || !includeSpatialPoints) {
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 }); setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
return () => controller.abort(); return () => controller.abort();
} }
@@ -127,7 +141,7 @@ export function useM4ThreatTimelineFrame({
} }
}); });
return () => controller.abort(); return () => controller.abort();
}, [binaryPlayback, endpointRoot, resultId, timeline]); }, [binaryPlayback, endpointRoot, includeSpatialPoints, resultId, timeline]);
useEffect(() => { useEffect(() => {
for (const controller of inFlight.current.values()) controller.abort(); for (const controller of inFlight.current.values()) controller.abort();
@@ -140,7 +154,7 @@ export function useM4ThreatTimelineFrame({
for (const controller of inFlight.current.values()) controller.abort(); for (const controller of inFlight.current.values()) controller.abort();
inFlight.current.clear(); inFlight.current.clear();
}; };
}, [resultId, timeline]); }, [includeSpatialPoints, resultId, timeline]);
const activeSequence = useMemo( const activeSequence = useMemo(
() => timeline () => timeline
@@ -158,7 +172,11 @@ export function useM4ThreatTimelineFrame({
activeChunkStartRef.current = activeChunkStart; activeChunkStartRef.current = activeChunkStart;
useEffect(() => { useEffect(() => {
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return; if (
!timeline
|| activeChunkStart === null
|| (binaryPlayback && includeSpatialPoints && !playbackManifest)
) return;
const starts = m4ThreatChunkWindowStarts( const starts = m4ThreatChunkWindowStarts(
activeChunkStart, activeChunkStart,
chunkSize, chunkSize,
@@ -170,7 +188,7 @@ export function useM4ThreatTimelineFrame({
const controller = new AbortController(); const controller = new AbortController();
inFlight.current.set(start, controller); inFlight.current.set(start, controller);
void (async () => { void (async () => {
const playbackPointPack = binaryPlayback && playbackManifest const playbackPointPack = binaryPlayback && includeSpatialPoints && playbackManifest
? await fetchM4ThreatPlaybackPointChunk( ? await fetchM4ThreatPlaybackPointChunk(
playbackManifest, playbackManifest,
Math.floor(start / playbackManifest.chunkFrameCount), Math.floor(start / playbackManifest.chunkFrameCount),
@@ -189,6 +207,7 @@ export function useM4ThreatTimelineFrame({
endpointRoot, endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery, cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
playbackPointPack, playbackPointPack,
includePoints: includeSpatialPoints,
}); });
})() })()
.then((chunk) => { .then((chunk) => {
@@ -220,7 +239,7 @@ export function useM4ThreatTimelineFrame({
// loaded first, then the next chunk is prefetched on the following render. // loaded first, then the next chunk is prefetched on the following render.
break; break;
} }
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]); }, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, includeSpatialPoints, playbackManifest, resultId, timeline]);
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
if (activeSequence === null || activeChunkStart === null) return null; if (activeSequence === null || activeChunkStart === null) return null;
@@ -0,0 +1,853 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import {
ActivityIndicator,
Button,
ConfirmationModal,
GlassSurface,
Icon,
IconButton,
Select,
StatusBadge,
TextField,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
import {
fetchObservatoryRecordedRunReview,
type ObservatoryRecordedRunBinding,
} from "../../core/observatory/recordedRun";
import {
findObservatoryEvidence,
observatoryCatalogConfirmsEvidenceDeletion,
type ObservatoryEvidence,
} from "../../core/observatory/catalog";
import {
deleteObservatoryLabProjection,
renameObservatoryLabProjection,
} from "../../core/observatory/catalogMutations";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
import type { WorkspaceDefinition } from "../../productModel";
const EMPTY_OBSERVATORY_ITEMS = [] as const;
type ObservatoryRecordedRunReview = Awaited<
ReturnType<typeof fetchObservatoryRecordedRunReview>
>;
type ObservatoryMutationReconciliation =
| {
readonly kind: "rename";
readonly sessionId: string;
readonly displayName: string;
}
| {
readonly kind: "delete";
readonly sessionId: string;
};
type ObservatoryReplayState =
| { readonly kind: "closed" }
| {
readonly kind: "loading";
readonly binding: ObservatoryRecordedRunBinding;
}
| {
readonly kind: "ready";
readonly binding: ObservatoryRecordedRunBinding;
readonly review: ObservatoryRecordedRunReview;
}
| {
readonly kind: "error";
readonly binding: ObservatoryRecordedRunBinding;
readonly message: string;
};
const statusLabel: Record<ObservationSessionStatus, string> = {
recording: "Запись идёт",
ready: "Готова",
degraded: "С деградацией",
interrupted: "Прервана",
failed: "Ошибка",
};
const modalityLabel: Record<string, string> = {
"point-cloud": "Облако точек",
pose: "Поза",
trajectory: "Траектория",
video: "Видео",
image: "Изображения",
depth: "Глубина",
telemetry: "Телеметрия",
};
function statusTone(
status: ObservationSessionStatus,
): "success" | "accent" | "warning" | "danger" | "neutral" {
if (status === "ready") return "success";
if (status === "recording") return "accent";
if (status === "degraded" || status === "interrupted") return "warning";
return "danger";
}
function formatTimestamp(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function mutationErrorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось изменить лабораторный результат в Обсерватории.";
}
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
const profileName = evidence.lab.calculationProfile?.displayName;
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
}
export function ObservatoryWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
const recordedJobsController = useObservatoryRecordedJobs(
selectedSessionId,
setupController.selectedSetupId,
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
);
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutationReconciliation, setMutationReconciliation] = useState<
ObservatoryMutationReconciliation | null
>(null);
const overviewRef = useRef<HTMLElement | null>(null);
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
const refreshedPublishedJobRef = useRef<string | null>(null);
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
const closeReplay = useCallback(() => {
replayCoordinatorRef.current.cancel();
setReplay((current) => current.kind === "closed" ? current : { kind: "closed" });
}, []);
const returnToOverview = useCallback(() => {
closeReplay();
overviewRef.current?.scrollIntoView({ block: "start" });
}, [closeReplay]);
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
closeReplay();
setSelectedSessionId(items[0]?.source.id ?? "");
}, [closeReplay, items, selectedSessionId]);
useEffect(() => () => replayCoordinatorRef.current.cancel(), []);
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence ?? [];
const options = useMemo(() => items.map(({ source, evidence }) => ({
value: source.id,
label: source.label,
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
})), [items]);
const setupOptions = useMemo(() => (
setupController.selectableSetups.map((setup) => ({
value: setup.setupId,
label: setup.displayName,
description: setup.description,
}))
), [setupController.selectableSetups]);
const preflightCandidate = setupController.preflight.kind === "ready"
? setupController.preflight.value
: null;
const runPreflight = preflightCandidate
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
&& preflightCandidate.definitionSha256
=== setupController.selectedSetup?.runDefinition?.definitionSha256
? preflightCandidate
: null;
const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible
&& runPreflight?.outcome === "queueable"
&& runPreflight.submissionAllowed,
);
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
const publicationFailed = presentedJob?.publication.state === "failed";
const calculationPending = recordedJobsController.activeJob !== null
|| recordedJobsController.publicationPending
|| recordedJobsController.state === "submitting"
|| recordedJobsController.state === "retrying-publication";
const showCalculate = setupController.selectedSetup !== null
&& !calculationPending && !publicationFailed;
const canSubmitRecordedJob = queueSubmissionAllowed
&& setupController.state === "ready"
&& recordedJobsController.state === "ready"
&& !calculationPending && !publicationFailed;
const queueStatusError = setupController.preflight.kind === "error"
? setupController.preflight.message
: recordedJobsController.error
?? (publicationFailed
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
: recordedJobsController.computationFailed
? "Не удалось выполнить расчёт. Можно повторить запуск."
: runPreflight?.outcome === "blocked"
? runPreflight.checks.filter((check) => check.outcome === "fail")
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
: null);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
const replayEvidenceId = replay.kind === "closed"
? null
: replay.binding.evidenceSessionId;
const replayEvidence = replayEvidenceId === null
? null
: selectedSession?.evidence.find(
(evidence) => evidence.sessionId === replayEvidenceId,
) ?? null;
useEffect(() => {
const publishedJob = recordedJobsController.jobs.find(
(job) => job.publication.state === "published" && job.resultId !== null,
);
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
refreshedPublishedJobRef.current = publishedJob.jobId;
void controller.refresh();
setupController.refresh();
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
useEffect(() => {
if (
replayEvidenceId === null
|| replayEvidence
) return;
closeReplay();
}, [closeReplay, replayEvidence, replayEvidenceId]);
useEffect(() => {
if (
mutationPending !== null
|| mutationReconciliation === null
|| controller.catalog === null
) return;
const evidence = findObservatoryEvidence(
controller.catalog,
mutationReconciliation.sessionId,
);
const confirmed = mutationReconciliation.kind === "rename"
? evidence?.label === mutationReconciliation.displayName
: observatoryCatalogConfirmsEvidenceDeletion(
controller.catalog,
mutationReconciliation.sessionId,
);
if (!confirmed) return;
setRenameTarget(null);
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
if (mutationReconciliation.kind === "delete") setupController.refresh();
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
const attempt = replayCoordinatorRef.current.begin();
setReplay({ kind: "loading", binding });
void fetchObservatoryRecordedRunReview(binding, {
selectedSourceSessionId: selectedSessionId,
signal: attempt.signal,
}).then((review) => {
if (!attempt.isCurrent() || !attempt.finish()) return;
setReplay({ kind: "ready", binding, review });
}).catch((caught: unknown) => {
if (!attempt.isCurrent() || !attempt.finish()) return;
setReplay({
kind: "error",
binding,
message: caught instanceof Error && caught.message.trim()
? caught.message
: "Канонический визуальный разбор недоступен.",
});
});
}, [selectedSessionId]);
const selectSession = useCallback((sessionId: string) => {
closeReplay();
setSelectedSessionId(sessionId);
}, [closeReplay]);
const openRename = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setRenameValue(evidence.label);
setRenameTarget(evidence);
}, []);
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setDeleteTarget(evidence);
}, []);
const submitRename = useCallback(async () => {
if (!renameTarget?.recordedRun || mutationPending !== null) return;
const requestedDisplayName = renameValue.trim();
const reconciliation: ObservatoryMutationReconciliation = {
kind: "rename",
sessionId: renameTarget.sessionId,
displayName: requestedDisplayName,
};
setMutationPending("rename");
setMutationError(null);
setMutationReconciliation(reconciliation);
try {
const result = await renameObservatoryLabProjection(
renameTarget.recordedRun,
renameValue,
);
controller.applyEvidenceRename(result.sessionId, result.displayName);
setRenameTarget(null);
setMutationReconciliation(null);
void controller.refresh();
} catch (error) {
const reconciled = await controller.refresh();
const evidence = reconciled
? findObservatoryEvidence(reconciled, reconciliation.sessionId)
: null;
if (reconciled && evidence?.label === reconciliation.displayName) {
setRenameTarget(null);
setMutationReconciliation(null);
} else {
setMutationError(mutationErrorMessage(error));
}
} finally {
setMutationPending(null);
}
}, [controller, mutationPending, renameTarget, renameValue]);
const confirmDelete = useCallback(async () => {
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
const reconciliation: ObservatoryMutationReconciliation = {
kind: "delete",
sessionId: deleteTarget.sessionId,
};
setMutationPending("delete");
setMutationError(null);
setMutationReconciliation(reconciliation);
try {
if (
replay.kind !== "closed"
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
) {
flushSync(() => {
closeReplay();
});
}
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
controller.applyEvidenceDeletion(deleteTarget.sessionId);
setupController.refresh();
setDeleteTarget(null);
setMutationReconciliation(null);
void controller.refresh();
} catch (error) {
const reconciled = await controller.refresh();
if (
reconciled
&& observatoryCatalogConfirmsEvidenceDeletion(
reconciled,
reconciliation.sessionId,
)
) {
setDeleteTarget(null);
setMutationReconciliation(null);
} else {
setMutationError(mutationErrorMessage(error));
}
} finally {
setMutationPending(null);
}
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
return (
<div
className="observatory-workspace"
data-observatory-authority="observation-only"
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
>
<section ref={overviewRef} className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>
Записанные источники и строго связанные результаты без запуска тяжёлого
визуализатора и без доступа к управлению аппаратом.
</p>
</div>
</section>
<GlassSurface className="observatory-catalog-bar" padding="sm">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
</div>
<div className="observatory-catalog-bar__controls">
<Select
label="Выбрать сохранённую сессию"
value={selectedSessionId}
options={options}
disabled={items.length === 0}
searchable
searchPlaceholder="Поиск по сессиям"
emptyLabel="Сессия не найдена"
minMenuWidth={360}
menuWidth={460}
onChange={selectSession}
/>
<Select
label="Выбрать сетап лаборатории"
value={setupController.selectedSetupId}
options={setupOptions}
disabled={!selectedSessionId || setupOptions.length === 0}
searchable
searchPlaceholder="Поиск по сетапам"
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
minMenuWidth={360}
menuWidth={500}
onChange={setupController.selectSetup}
/>
<Button
size="compact"
variant="secondary"
disabled={
controller.state === "loading"
|| controller.state === "refreshing"
|| setupController.state === "loading"
|| setupController.state === "refreshing"
}
icon={<Icon name="refresh" />}
onClick={() => {
void controller.refresh();
setupController.refresh();
recordedJobsController.refresh();
}}
>
Обновить
</Button>
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
{calculationPending ? (
<ActivityIndicator size="compact" label="Ожидание результата расчёта" />
) : null}
{showCalculate ? (
<Button
size="compact"
variant="primary"
disabled={!canSubmitRecordedJob}
onClick={() => {
void recordedJobsController.submit(
setupController.selectedSetup?.origin === "portable-definition"
&& runPreflight?.definitionSha256
&& runPreflight.checkSha256
? {
definitionSha256: runPreflight.definitionSha256,
checkSha256: runPreflight.checkSha256,
}
: null,
);
}}
>
Рассчитать
</Button>
) : null}
</div>
</div>
</GlassSurface>
{queueStatusError ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{queueStatusError}</span>
{publicationFailed ? (
<Button
size="compact"
variant="ghost"
disabled={recordedJobsController.state === "retrying-publication"}
onClick={() => void recordedJobsController.retryPublication()}
>
Повторить публикацию
</Button>
) : null}
</GlassSurface>
) : null}
{controller.error && controller.catalog ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
<span className="observatory-notice__copy">{controller.error}</span>
<Button size="compact" variant="ghost" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{setupController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{setupController.error}</span>
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{controller.catalog
&& (controller.catalog.window.sourceLimitReached
|| controller.catalog.window.laboratoryLimitReached) ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Срез ограничен</StatusBadge>
<span className="observatory-notice__copy">
Загружены последние {controller.catalog.window.sourceCount} исходных сессий и{
" "
}{controller.catalog.window.laboratoryCount} LAB-результатов. Полнота исторических
связей не подтверждена.
</span>
</GlassSurface>
) : null}
{initialLoading ? (
<GlassSurface className="observatory-state" padding="lg">
<ActivityIndicator label="Читаем каталог сессий" />
<h3>Читаем источники и связи результатов</h3>
<p>Визуализатор и лабораторные сцены при этом не запускаются.</p>
</GlassSurface>
) : unavailable ? (
<GlassSurface className="observatory-state" padding="lg" role="alert">
<Icon name="alert" size={20} />
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
<h3>{controller.error}</h3>
<Button size="compact" variant="secondary" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : items.length === 0 ? (
<GlassSurface className="observatory-state" padding="lg">
<Icon name="database" size={20} />
<h3>Сохранённых сессий пока нет</h3>
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
</GlassSurface>
) : selectedSession ? (
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="sm">
<div className="observatory-session-summary__identity">
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
<code>{selectedSession.source.id}</code>
</div>
<dl className="observatory-session-summary__facts">
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
<div>
<dt>Чтение</dt>
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
</div>
<div>
<dt>Оборудование</dt>
<dd>
{selectedSession.source.captureAttestation?.equipmentDisplayName
?? "Не аттестовано"}
</dd>
</div>
<div>
<dt>Профиль записи</dt>
<dd title={selectedSession.source.captureAttestation?.captureProfileSha256}>
{selectedSession.source.captureAttestation?.captureProfileId
?? "Не определён"}
</dd>
</div>
</dl>
<div className="observatory-session-summary__end">
<StatusBadge tone={statusTone(selectedSession.source.status)}>
{statusLabel[selectedSession.source.status]}
</StatusBadge>
<div className="observatory-modalities" aria-label="Каналы сессии">
{selectedSession.source.modalities.length > 0
? selectedSession.source.modalities.map((modality) => (
<StatusBadge key={modality} tone="neutral">
{modalityLabel[modality] ?? modality}
</StatusBadge>
))
: <span>Каналы не зафиксированы</span>}
</div>
</div>
</GlassSurface>
<section className="observatory-evidence">
<header>
<div>
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
</header>
{selectedSession.evidence.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
<li key={evidence.sessionId}>
<div className="observatory-evidence-card">
<span className="observatory-evidence-card__icon" aria-hidden="true">
<Icon name="clipboard" size={18} />
</span>
<div className="observatory-evidence-card__copy">
<strong>{evidence.lab.labId}</strong>
<span>{evidenceResultSubtitle(evidence)}</span>
<small>
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
</small>
</div>
<div className="observatory-evidence-card__actions">
{evidence.recordedRun ? (
<>
<IconButton
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
onClick={() => openDelete(evidence)}
>
<Icon name="trash" size={16} />
</IconButton>
<IconButton
label={`Переименовать ${evidence.lab.labId}`}
onClick={() => openRename(evidence)}
>
<Icon name="edit" size={16} />
</IconButton>
<IconButton
label={`Открыть визуальный разбор: ${evidence.lab.labId}`}
disabled={
replay.kind === "loading"
&& replay.binding.evidenceSessionId === evidence.sessionId
}
onClick={() => openReplay(evidence.recordedRun!)}
>
{replay.kind === "loading"
&& replay.binding.evidenceSessionId === evidence.sessionId
? <ActivityIndicator size="compact" />
: <Icon name="eye" size={16} />}
</IconButton>
</>
) : null}
</div>
</div>
</li>
))}
</ol>
) : (
<div className="observatory-evidence-empty">
<Icon name="clipboard" size={18} />
<strong>Связанных результатов нет</strong>
<p>
Наличие исходной записи само по себе не является выводом о качестве
компьютерного зрения или безопасности прохождения.
</p>
</div>
)}
</section>
</section>
) : null}
{replay.kind === "loading" ? (
<GlassSurface className="observatory-replay-state" padding="lg" role="status">
<ActivityIndicator label="Проверяем запечатанный результат" />
<div>
<h3>Проверяем точную связь результата с исходной сессией</h3>
<p>Визуализатор и данные маршрута ещё не запущены.</p>
</div>
<Button size="compact" variant="ghost" onClick={returnToOverview}>Отменить</Button>
</GlassSurface>
) : replay.kind === "error" ? (
<GlassSurface className="observatory-replay-state" padding="lg" role="alert">
<Icon name="alert" size={20} />
<div>
<StatusBadge tone="danger">Просмотр недоступен</StatusBadge>
<h3>{replay.message}</h3>
</div>
<div className="observatory-replay-state__actions">
<Button
size="compact"
variant="secondary"
onClick={() => openReplay(replay.binding)}
>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
</div>
</GlassSurface>
) : replay.kind === "ready" ? (
<section className="observatory-replay" aria-label="Визуальный разбор результата">
<header className="observatory-replay__header">
<div>
<span className="section-eyebrow">
{replay.review.kind === "canonical-recorded-rerun"
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
</span>
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
<p>
{replay.review.kind === "canonical-recorded-rerun"
? "Записанный маршрут синхронизирован по общей временной шкале."
: "Показан проверенный документ результата и связанные с ним артефакты."}
</p>
</div>
<Button
size="compact"
variant="secondary"
icon={<Icon name="close" size={14} />}
onClick={returnToOverview}
>
Закрыть разбор
</Button>
</header>
{replay.review.kind === "canonical-recorded-rerun" ? (
<CanonicalVegetationRerunReplay
resultId={replay.binding.resultId}
review={replay.review.review}
/>
) : (
<GlassSurface className="observatory-replay-state" padding="lg">
<div>
<StatusBadge tone="success">Проверено</StatusBadge>
<h3>{replay.review.resultKind}</h3>
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
<details>
<summary>Документ результата</summary>
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
</details>
</div>
</GlassSurface>
)}
</section>
) : null}
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Вне среза</StatusBadge>
<span className="observatory-notice__copy">
{controller.catalog?.unresolvedEvidence.length} результатов ссылаются на источники
вне текущего загруженного среза и не приписаны к квалификационным доказательствам.
</span>
</GlassSurface>
) : null}
<Window
open={renameTarget !== null}
title="Переименовать лабораторный результат"
subtitle="Меняется только отображаемое название в Обсерватории"
size="sm"
closeOnBackdrop={mutationPending !== "rename"}
closeOnEscape={mutationPending !== "rename"}
onClose={() => {
if (mutationPending === "rename") return;
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
footer={(
<WindowFooterActions>
<Button
disabled={mutationPending === "rename"}
onClick={() => {
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
>
Отмена
</Button>
<Button
type="submit"
form="observatory-rename-form"
variant="primary"
disabled={mutationPending === "rename" || renameValue.trim().length === 0}
>
{mutationPending === "rename" ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<form
id="observatory-rename-form"
className="observatory-rename-form"
onSubmit={(event) => {
event.preventDefault();
void submitRename();
}}
>
<TextField
label="Название"
value={renameValue}
maxLength={160}
autoComplete="off"
autoFocus
disabled={mutationPending === "rename"}
onChange={(event) => setRenameValue(event.currentTarget.value)}
/>
{mutationError && renameTarget ? (
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
) : null}
</form>
</Window>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить результат из Обсерватории?"
description={deleteTarget ? (
<div className="observatory-delete-confirmation">
<p>
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
{" "}и её отображаемое название в Обсерватории.
</p>
<p>
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
останутся неизменными.
</p>
{mutationError ? (
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
) : null}
</div>
) : null}
confirmLabel="Удалить из Обсерватории"
pendingLabel="Удаляем…"
danger
onClose={() => {
if (mutationPending === "delete") return;
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
onConfirm={confirmDelete}
/>
</div>
);
}
@@ -17,9 +17,14 @@ import {
} from "../../core/system/useWorkerTelemetry"; } from "../../core/system/useWorkerTelemetry";
function pipelineStateLabel(state: string): string { function pipelineStateLabel(state: string): string {
if (state === "busy") return "Выполняет задачу"; if (state === "busy" || state === "running") return "Выполняет задачу";
if (state === "waiting") return "Ожидает восстановления данных";
if (state === "synchronizing") return "Синхронизирует поток";
if (state === "starting") return "Загружает профиль";
if (state === "stopped" || state === "cancelled") return "Профиль остановлен";
if (state === "failed") return "Ошибка профиля";
if (state === "ready") return "Готов к задаче"; if (state === "ready") return "Готов к задаче";
return "Нет live-состояния"; return "Нет свежих данных о задаче";
} }
function formatResourcePair( function formatResourcePair(
@@ -116,7 +121,7 @@ export function ComputeModulesWorkspace() {
{!legacyDiagnostic && !agentTelemetry ? ( {!legacyDiagnostic && !agentTelemetry ? (
<GlassSurface className="system-workspace__notice" padding="md" tone="soft"> <GlassSurface className="system-workspace__notice" padding="md" tone="soft">
<StatusBadge tone="warning"> <StatusBadge tone="warning">
Агентный data-plane ещё не опубликовал нормализованный срез этого контура. Ожидаем свежую телеметрию выбранного узла.
</StatusBadge> </StatusBadge>
</GlassSurface> </GlassSurface>
) : null} ) : null}
@@ -187,6 +192,8 @@ export function ComputeModulesWorkspace() {
? `${node.gpu.temperature_celsius} °C` ? `${node.gpu.temperature_celsius} °C`
: "—" : "—"
}</dd></div> }</dd></div>
<div><dt>Частота GPU</dt><dd>{node?.gpu?.sm_clock_mhz == null ? "—" : `${node.gpu.sm_clock_mhz} МГц`}</dd></div>
<div><dt>Частота памяти GPU</dt><dd>{node?.gpu?.memory_clock_mhz == null ? "—" : `${node.gpu.memory_clock_mhz} МГц`}</dd></div>
<div><dt>Мощность</dt><dd>{ <div><dt>Мощность</dt><dd>{
typeof node?.gpu?.power_watts === "number" typeof node?.gpu?.power_watts === "number"
? `${node.gpu.power_watts.toFixed(1)} Вт` ? `${node.gpu.power_watts.toFixed(1)} Вт`
@@ -214,7 +221,7 @@ export function ComputeModulesWorkspace() {
<div> <div>
<span className="section-eyebrow">PROCESSING RUNTIME</span> <span className="section-eyebrow">PROCESSING RUNTIME</span>
<h3>Контейнеры Mission Core</h3> <h3>Контейнеры Mission Core</h3>
<p>Два ограниченных runtime: inference server и прикладной perception pipeline.</p> <p>Состояние контейнера и готовность вычислительного профиля проверяются отдельно.</p>
</div> </div>
<StatusBadge tone={node?.triton.ready ? "success" : "danger"}> <StatusBadge tone={node?.triton.ready ? "success" : "danger"}>
{node?.triton.ready ? "Triton ready" : "Triton недоступен"} {node?.triton.ready ? "Triton ready" : "Triton недоступен"}
@@ -233,19 +240,31 @@ export function ComputeModulesWorkspace() {
<span className="section-eyebrow">ТЕКУЩАЯ ЗАДАЧА</span> <span className="section-eyebrow">ТЕКУЩАЯ ЗАДАЧА</span>
<h3>{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}</h3> <h3>{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}</h3>
<p> <p>
{telemetry?.pipeline.profile_name ?? "Perception"}
{" · "}
{telemetry?.pipeline.active_request_id {telemetry?.pipeline.active_request_id
? `Run ${telemetry.pipeline.active_request_id}` ? `Run ${telemetry.pipeline.active_request_id}`
: "Очередь свободна; модели остаются загруженными в persistent worker."} : telemetry?.pipeline.service_state === "ready"
? "Готов к приёму данных."
: "Готовность моделей не подтверждена."}
</p> </p>
</div> </div>
<StatusBadge tone={ <StatusBadge tone={
telemetry?.pipeline.service_state === "busy" ? "warning" ["busy", "running", "waiting", "synchronizing", "starting"].includes(telemetry?.pipeline.service_state ?? "") ? "warning"
: telemetry?.pipeline.service_state === "ready" ? "success" : telemetry?.pipeline.service_state === "ready" ? "success"
: "danger" : "danger"
}> }>
{telemetry?.pipeline.service_state ?? "unavailable"} {pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}
</StatusBadge> </StatusBadge>
</header> </header>
{telemetry?.pipeline.profile_name ? (
<div className="worker-pipeline__summary">
<div><span>Процессы профиля</span><strong>{telemetry.pipeline.live_children ?? "—"}</strong></div>
<div><span>Ожиданий потока</span><strong>{telemetry.pipeline.input_pauses ?? "—"}</strong></div>
<div><span>Пакетов в очереди</span><strong>{telemetry.pipeline.pending_bundles ?? "—"}</strong></div>
<div><span>Входные буферы</span><strong>{formatBytes(telemetry.pipeline.buffer_bytes)}</strong></div>
</div>
) : null}
<div className="worker-pipeline__summary"> <div className="worker-pipeline__summary">
<div><span>Завершено запусков</span><strong>{telemetry?.pipeline.completed_runs ?? "—"}</strong></div> <div><span>Завершено запусков</span><strong>{telemetry?.pipeline.completed_runs ?? "—"}</strong></div>
<div><span>Ошибок запусков</span><strong>{telemetry?.pipeline.failed_runs ?? "—"}</strong></div> <div><span>Ошибок запусков</span><strong>{telemetry?.pipeline.failed_runs ?? "—"}</strong></div>
@@ -11,6 +11,7 @@ let fetchM49TgsAnchorSpatial;
let AdvancedLaboratoryContractError; let AdvancedLaboratoryContractError;
let buildLaboratoryCatalog; let buildLaboratoryCatalog;
let buildLaboratoryProfiles; let buildLaboratoryProfiles;
let isLegacyPublishedLaboratoryWork;
let experimentOptionsForProfile; let experimentOptionsForProfile;
let freshestLaboratorySelection; let freshestLaboratorySelection;
let workOptionsForExperiment; let workOptionsForExperiment;
@@ -928,6 +929,7 @@ before(async () => {
({ ({
buildLaboratoryCatalog, buildLaboratoryCatalog,
buildLaboratoryProfiles, buildLaboratoryProfiles,
isLegacyPublishedLaboratoryWork,
experimentOptionsForProfile, experimentOptionsForProfile,
freshestLaboratorySelection, freshestLaboratorySelection,
workOptionsForExperiment, workOptionsForExperiment,
@@ -962,6 +964,31 @@ after(async () => {
await server?.close(); await server?.close();
}); });
test("capability-owned projections never enter the legacy LAB catalog", () => {
const legacy = {
status: "ready",
replayable: true,
modalities: ["point-cloud", "trajectory"],
lab: { replayCapability: null },
};
const capabilityProjection = {
...legacy,
lab: {
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
},
},
};
assert.equal(isLegacyPublishedLaboratoryWork(legacy), true);
assert.equal(isLegacyPublishedLaboratoryWork(capabilityProjection), false);
});
test("LAB catalog is pipeline-scoped and ordered by real run time", () => { test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
const catalog = buildLaboratoryCatalog({ const catalog = buildLaboratoryCatalog({
rigLabel: "K1", rigLabel: "K1",
@@ -105,23 +105,45 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
assert.match(e30HumanReviewCss, /\.e30-human-review/); assert.match(e30HumanReviewCss, /\.e30-human-review/);
}); });
test("central composition files cannot silently become monoliths again", async () => { test("Observatory owns a bounded explicit-open lifecycle around the shared recorded viewer", async () => {
const ratchets = [ const workspaceHub = await read("workspaces/Workspaces.tsx");
["App.tsx", 1_250], const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
["workspaces/Workspaces.tsx", 1_200], const observatoryCore = await read("core/observatory/catalog.ts");
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000], const recordedRun = await read("core/observatory/recordedRun.ts");
["core/laboratory/advancedResults.ts", 1_000], const sharedReplay = await read(
["core/laboratory/e40ProductGate.ts", 500], "components/laboratory/CanonicalVegetationRerunReplay.tsx",
["styles/workspaces.css", 4_350],
["styles/laboratory.css", 900],
["styles/laboratory-reporting.css", 100],
];
for (const [relativePath, maximumLines] of ratchets) {
const lineCount = (await read(relativePath)).split("\n").length;
assert.ok(
lineCount <= maximumLines,
`${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
); );
} const workspaceCss = await read("styles/workspaces.css");
const observatoryCss = await read("styles/observatory.css");
assert.match(
workspaceHub,
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
);
assert.match(observatory, /export function ObservatoryWorkspace/);
assert.match(observatory, /useObservatoryCatalog/);
assert.match(observatory, /data-observatory-authority="observation-only"/);
assert.match(
observatory,
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
);
assert.doesNotMatch(
`${observatory}\n${observatoryCore}`,
/(?:core|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
);
assert.match(observatory, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(observatory, /closeReplay\(\);[\s\S]*setSelectedSessionId/);
assert.doesNotMatch(
observatory,
/replayObservationSession|deleteObservationSession|useObservationSessions|useAdvancedLaboratoryCatalog/,
);
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
assert.doesNotMatch(
recordedRun,
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.doesNotMatch(workspaceCss, /\.observatory-/);
assert.match(observatoryCss, /\.observatory-workspace/);
}); });
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let canonicalMapGravityLocalPointToBodyGround;
let canonicalRecordedLabPackedTgsCells;
let canonicalRecordedLabTgsIsCurrent;
let resolveCanonicalLabReplay;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
canonicalMapGravityLocalPointToBodyGround,
canonicalRecordedLabPackedTgsCells,
canonicalRecordedLabTgsIsCurrent,
} = await server.ssrLoadModule("/src/core/laboratory/canonicalRecordedLab.ts"));
({ resolveCanonicalLabReplay } = await server.ssrLoadModule(
"/src/core/laboratory/canonicalLabReplay.ts",
));
});
after(async () => {
await server?.close();
});
const identity = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
];
test("canonical TGS validity never retains a sparse anchor beyond its sealed history", () => {
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_000, 1_000_000_000), true);
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_001, 1_000_000_000), false);
assert.equal(canonicalRecordedLabTgsIsCurrent(999_999_999, 1_000_000_000), false);
});
test("map-gravity-local TGS uses sensor translation and current ground body exactly once", () => {
const anchor = {
originMapXyzM: [10, 20, 0.68],
sensorOriginMapXyzM: [10, 20, 1],
basisMapFromBody: identity,
};
const current = {
originMapXyzM: [8, 20, 0],
sensorOriginMapXyzM: [8, 20, 0.32],
basisMapFromBody: identity,
};
assert.deepEqual(
canonicalMapGravityLocalPointToBodyGround([1, 2, -1], anchor, current),
[3, 2, 0],
);
const packed = canonicalRecordedLabPackedTgsCells({
centersXyM: [[1, 2]],
stateCodes: [2],
zBoundsM: [[-1, 0]],
}, anchor, current);
assert.deepEqual([...packed.centersBodyXyM], [3, 2]);
assert.deepEqual([...packed.zBoundsM], [0, 1]);
assert.deepEqual([...packed.stateCodes], [2]);
});
test("recorded LAB spatial loading is shared, profile-bound and experiment-neutral", async () => {
const [contract, scheduler, vegetation] = await Promise.all([
readFile(new URL("../src/core/laboratory/canonicalRecordedLabSpatial.ts", import.meta.url), "utf8"),
readFile(new URL("../src/components/laboratory/useCanonicalRecordedLabSpatialFrame.ts", import.meta.url), "utf8"),
readFile(new URL("../src/core/laboratory/vegetationShadow.ts", import.meta.url), "utf8"),
]);
assert.match(contract, /CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
assert.match(contract, /profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
assert.match(scheduler, /Shared latest-request-wins scheduler/);
assert.match(scheduler, /identityRef\.current !== requestIdentity/);
assert.doesNotMatch(scheduler, /RAVNOVES|vegetation|DDRNet/);
assert.doesNotMatch(vegetation, /fetchCanonicalRecordedLabSpatialFrame|CanonicalRecordedLabSpatialFrame/);
});
test("canonical LAB resolves one generation-bound merged RRD", async () => {
const baseGeneration = "a".repeat(64);
const replayGeneration = "b".repeat(64);
const resultId = `lab-v1-vegetation-shadow-${"c".repeat(64)}`;
let request;
const replay = await resolveCanonicalLabReplay(resultId, {
kind: "rerun-recording",
sessionId: "session-001",
sourceUrl: "/api/v1/observation-sessions/session-001/recording.rrd",
viewerSourceUrl:
`/api/v1/observation-sessions/session-001/recording.rrd?generation=${baseGeneration}`,
mediaType: "application/vnd.rerun.rrd",
timeline: "session_time",
timelineStartSeconds: 0,
timelineEndSeconds: 10,
seekable: true,
byteLength: 100,
sha256: baseGeneration,
playback: { speed: 1, loop: false },
mediaSources: [],
}, {
origin: "http://mission-core.test",
fetcher: async (url, options) => {
request = { url, options };
return new Response(null, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "234567",
"ETag": `"${replayGeneration}"`,
"X-Rerun-Format": "RRF2",
},
});
},
});
const sourceUrl =
`/api/v1/laboratory/vegetation-shadow/${resultId}/canonical-replay.rrd`;
assert.equal(
request.url,
`http://mission-core.test${sourceUrl}?base_generation=${baseGeneration}`,
);
assert.equal(request.options.method, "HEAD");
assert.deepEqual(replay, {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${replayGeneration}`,
byteLength: 234567,
sha256: replayGeneration,
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
});
});
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let laboratoryRecordedEvidenceDemand;
let e47SemanticChunkWindowStarts;
let cancelE47SemanticRequestsOutsideWindow;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ laboratoryRecordedEvidenceDemand } = await server.ssrLoadModule(
"/src/core/laboratory/recordedEvidenceProfile.ts",
));
({
e47SemanticChunkWindowStarts,
cancelE47SemanticRequestsOutsideWindow,
} = await server.ssrLoadModule(
"/src/workspaces/laboratory/useE47SemanticTimeline.ts",
));
});
after(async () => {
await server?.close();
});
test("camera-only LAB presentation does not acquire hidden spatial or semantic payloads", () => {
assert.deepEqual(laboratoryRecordedEvidenceDemand({
mediaMode: "video",
spatialMode: null,
showMediaSemantic: false,
showSpatialSemantic: true,
showMediaPoints: false,
classifiedSpatialMode: "overlay",
}), {
sourceTimelineMetadata: true,
recordedVideo: true,
exactCameraFrame: false,
sourceSpatialPoints: false,
cameraPointOverlay: false,
selectedSemanticMask: false,
selectedSemanticPoints: false,
classifiedSpatial: false,
});
});
test("visible M4 layers acquire only their selected camera and spatial evidence", () => {
assert.deepEqual(laboratoryRecordedEvidenceDemand({
mediaMode: "camera",
spatialMode: "plan",
showMediaSemantic: true,
showSpatialSemantic: true,
showMediaPoints: true,
classifiedSpatialMode: "overlay",
}), {
sourceTimelineMetadata: true,
recordedVideo: false,
exactCameraFrame: true,
sourceSpatialPoints: true,
cameraPointOverlay: true,
selectedSemanticMask: true,
selectedSemanticPoints: true,
classifiedSpatial: true,
});
});
test("a classified replacement does not also load the hidden source point track", () => {
const demand = laboratoryRecordedEvidenceDemand({
mediaMode: null,
spatialMode: "3d",
showMediaSemantic: false,
showSpatialSemantic: false,
showMediaPoints: false,
classifiedSpatialMode: "replace-source",
});
assert.equal(demand.sourceSpatialPoints, false);
assert.equal(demand.classifiedSpatial, true);
});
test("semantic point chunks are ordered active-first and stale requests are cancelled", () => {
assert.deepEqual(e47SemanticChunkWindowStarts(48, 4_489), [48, 72, 96, 24]);
assert.deepEqual(e47SemanticChunkWindowStarts(0, 4_489), [0, 24, 48]);
const cancelled = [];
const requests = new Map([
[0, { abort: () => cancelled.push(0) }],
[24, { abort: () => cancelled.push(24) }],
[240, { abort: () => cancelled.push(240) }],
]);
cancelE47SemanticRequestsOutsideWindow(requests, [240, 264]);
assert.deepEqual(cancelled, [0, 24]);
assert.deepEqual([...requests.keys()], [240]);
});
@@ -7,6 +7,7 @@ let createLiveViewerDiagnosticLifecycle;
let createAbortFencedBuildVerifier; let createAbortFencedBuildVerifier;
let createUiBuildStaleCoordinator; let createUiBuildStaleCoordinator;
let liveViewerDiagnosticBody; let liveViewerDiagnosticBody;
let reloadRecordedViewerAfterStaleModuleFailure;
let server; let server;
let uiBuildIdFromModuleScripts; let uiBuildIdFromModuleScripts;
let verifyLiveViewerClientBuild; let verifyLiveViewerClientBuild;
@@ -22,6 +23,7 @@ before(async () => {
createLiveViewerDiagnosticLifecycle, createLiveViewerDiagnosticLifecycle,
createUiBuildStaleCoordinator, createUiBuildStaleCoordinator,
liveViewerDiagnosticBody, liveViewerDiagnosticBody,
reloadRecordedViewerAfterStaleModuleFailure,
uiBuildIdFromModuleScripts, uiBuildIdFromModuleScripts,
verifyLiveViewerClientBuild, verifyLiveViewerClientBuild,
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts")); } = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
@@ -204,6 +206,36 @@ test("build drift is reported once with the exact loaded build", async () => {
); );
}); });
test("a recorded viewer reloads only when its failed lazy module belongs to a stale build", async () => {
const reloads = [];
const loadedUiBuildId = "/assets/index-abcdefgh.js";
const currentUiBuildId = "/assets/index-ijklmnop.js";
const fetcher = async (_url, options) => {
assert.equal(options.headers["X-MissionCore-UI-Build"], loadedUiBuildId);
return new Response(JSON.stringify({}), {
status: 200,
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
});
};
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
loadedUiBuildId,
fetcher,
reload: () => reloads.push("reload"),
}), true);
assert.deepEqual(reloads, ["reload"]);
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
loadedUiBuildId: currentUiBuildId,
fetcher: async () => new Response(JSON.stringify({}), {
status: 200,
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
}),
reload: () => reloads.push("unexpected"),
}), false);
assert.deepEqual(reloads, ["reload"]);
});
test("last unsubscribe fences an already queued build verification callback", () => { test("last unsubscribe fences an already queued build verification callback", () => {
const controller = new AbortController(); const controller = new AbortController();
const observedSignals = []; const observedSignals = [];
@@ -18,6 +18,7 @@ let nextM48ObjectId;
let laboratoryMetricLegendEntries; let laboratoryMetricLegendEntries;
let nearestLaboratoryRecordedClipFrame; let nearestLaboratoryRecordedClipFrame;
let laboratoryRecordedClipEndExclusiveNs; let laboratoryRecordedClipEndExclusiveNs;
let laboratoryRecordedClipClockGate;
let m48SpatialPlaybackWindow; let m48SpatialPlaybackWindow;
let trimM48SpatialPlaybackCache; let trimM48SpatialPlaybackCache;
let nextM48CameraVisibility; let nextM48CameraVisibility;
@@ -45,6 +46,7 @@ before(async () => {
({ ({
nearestLaboratoryRecordedClipFrame, nearestLaboratoryRecordedClipFrame,
laboratoryRecordedClipEndExclusiveNs, laboratoryRecordedClipEndExclusiveNs,
laboratoryRecordedClipClockGate,
} = await server.ssrLoadModule( } = await server.ssrLoadModule(
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx", "/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
)); ));
@@ -342,6 +344,21 @@ test("shared recorded clip clock selects exact frames and one stable loop bounda
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000); assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
}); });
test("shared recorded clip rejects stale media callbacks until an explicit seek lands", () => {
assert.deepEqual(laboratoryRecordedClipClockGate(1, 123), {
accept: false,
pendingSequence: 1,
});
assert.deepEqual(laboratoryRecordedClipClockGate(1, 1), {
accept: true,
pendingSequence: null,
});
assert.deepEqual(laboratoryRecordedClipClockGate(null, 124), {
accept: true,
pendingSequence: null,
});
});
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => { test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
assert.equal(nextM48CameraVisibility("camera", true), true); assert.equal(nextM48CameraVisibility("camera", true), true);
assert.equal(nextM48CameraVisibility("3d", true), false); assert.equal(nextM48CameraVisibility("3d", true), false);
@@ -379,14 +379,16 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
}); });
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => { test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
const pointOffsets = [0, ...Array(4489).fill(2)]; const playbackResultId = `lab-v1-vegetation-shadow-${"b".repeat(64)}`;
const frameCount = 48;
const pointOffsets = [0, ...Array(frameCount).fill(2)];
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]); const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
const pointBytes = points.buffer; const pointBytes = points.buffer;
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex"); const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
const endpointRoot = "/api/v1/laboratory/m4-threat/results"; const endpointRoot = "/api/v1/laboratory/m4-threat/results";
const chunks = Array.from({ length: 188 }, (_, index) => { const chunks = Array.from({ length: Math.ceil(frameCount / 24) }, (_, index) => {
const start = index * 24; const start = index * 24;
const count = Math.min(24, 4489 - start); const count = Math.min(24, frameCount - start);
const pointStart = pointOffsets[start]; const pointStart = pointOffsets[start];
const pointStop = pointOffsets[start + count]; const pointStop = pointOffsets[start + count];
const pointCount = pointStop - pointStart; const pointCount = pointStop - pointStart;
@@ -396,7 +398,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
count, count,
point_start: pointStart, point_start: pointStart,
point_count: pointCount, point_count: pointCount,
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`, url: `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/${index}`,
media_type: "application/octet-stream", media_type: "application/octet-stream",
dtype: "<f4", dtype: "<f4",
shape: [pointCount, 3], shape: [pointCount, 3],
@@ -406,8 +408,8 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
}); });
const manifestPayload = { const manifestPayload = {
schema_version: "missioncore.recorded-spatial-playback/v1", schema_version: "missioncore.recorded-spatial-playback/v1",
result_id: resultId, result_id: playbackResultId,
frame_count: 4489, frame_count: frameCount,
point_count: 2, point_count: 2,
point_offsets: pointOffsets, point_offsets: pointOffsets,
chunk_frame_count: 24, chunk_frame_count: 24,
@@ -416,7 +418,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
chunks, chunks,
track: { track: {
id: "points-map-f32", id: "points-map-f32",
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`, url: `${endpointRoot}/${playbackResultId}/timeline/playback/tracks/points-map-f32`,
media_type: "application/octet-stream", media_type: "application/octet-stream",
dtype: "<f4", dtype: "<f4",
shape: [2, 3], shape: [2, 3],
@@ -434,12 +436,12 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0)); if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
}; };
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher }); const manifest = await fetchM4ThreatPlaybackManifest(playbackResultId, { fetcher });
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher }); const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
assert.deepEqual(requested, [ assert.deepEqual(requested, [
`${endpointRoot}/${resultId}/timeline/playback`, `${endpointRoot}/${playbackResultId}/timeline/playback`,
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`, `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/0`,
]); ]);
assert.equal(chunk.pointCount, 2); assert.equal(chunk.pointCount, 2);
assert.equal(chunk.pointStart, 0); assert.equal(chunk.pointStart, 0);
@@ -769,7 +771,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b
}); });
test("M4.6 spatial buffering keeps the active and one future chunk", () => { test("M4.6 spatial buffering keeps the active and one future chunk", () => {
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]); assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72, 24]);
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]); assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
}); });
@@ -786,8 +788,8 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps",
if (!inFlight.has(start)) inFlight.set(start, controller(start)); if (!inFlight.has(start)) inFlight.set(start, controller(start));
} }
} }
assert.deepEqual([...inFlight.keys()], [4488]); assert.deepEqual([...inFlight.keys()], [4488, 4464]);
assert.deepEqual(aborted, [0, 24, 1488, 1512]); assert.deepEqual(aborted, [0, 24, 1488, 1512, 1464]);
}); });
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => { test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
@@ -847,8 +849,9 @@ test("recorded VIDEO clock cannot reverse an explicit operator pause", () => {
}); });
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => { test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
const [visual, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([ const [visual, canonical, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"), readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"), readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"), readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"), readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
@@ -858,10 +861,11 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /<RecordedEvidenceVideoScene/); assert.match(visual, /<RecordedEvidenceVideoScene/);
assert.match(visual, /<RecordedEvidenceImageScene/); assert.match(visual, /<RecordedEvidenceImageScene/);
assert.match(visual, /<LaboratoryMetricEvidenceScene/); assert.match(visual, /<LaboratoryMetricEvidenceScene/);
assert.match(visual, /m4-replay-threat-visual__deck/); assert.match(visual, /<CanonicalRecordedLabReplay/);
assert.match(canonical, /m4-replay-threat-visual__deck/);
assert.match(visual, /lastFrameRef/); assert.match(visual, /lastFrameRef/);
assert.match(visual, /lastSpatialFrameRef/); assert.match(visual, /lastSpatialFrameRef/);
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/); assert.match(visual, /const spatialFrame = currentSpatialFrame/);
assert.match(visual, /<ObservationTimeline/); assert.match(visual, /<ObservationTimeline/);
assert.match(visual, /useM4ThreatTimelineFrame/); assert.match(visual, /useM4ThreatTimelineFrame/);
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/); assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
@@ -876,19 +880,33 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /pointCloudOverlay=/); assert.match(visual, /pointCloudOverlay=/);
assert.match(visual, /mediaMode/); assert.match(visual, /mediaMode/);
assert.match(visual, /spatialMode/); assert.match(visual, /spatialMode/);
assert.match(visual, /current === next \? null : next/); assert.match(canonical, /current === next \? null : next/);
assert.match(visual, /data-split=\{splitView \? "true" : undefined\}/); assert.match(canonical, /data-split=\{splitView \? "true" : undefined\}/);
assert.match(visual, /<SplitPane/); assert.match(canonical, /<SplitPane/);
assert.match(visual, /primarySize=\{splitView \? splitPrimarySize : mediaMode \? 100 : 0\}/); assert.match(canonical, /primary=\{mediaPane\}/);
assert.match(visual, /resizable=\{splitView\}/); assert.match(canonical, /secondary=\{spatialPane/);
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/); assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
assert.match(visual, /secondaryMode=\{\{/); assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/); assert.match(visual, /playback=\{playbackController\.playback\}/);
assert.match(visual, /clock: mediaMode === "video" \? "external" : "animation"/); assert.match(visual, /clock: "external"/);
assert.match(visual, /useCanonicalRecordedLabReplayState/);
assert.match(canonical, /current === next \? null : next/);
assert.match(visual, /timelineFrame\.activeSequence \+ 1/); assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/); assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/); assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
assert.match(visual, /onPlayingRejected=\{\(\) => playbackController\.setPlaying\(false\)\}/); assert.match(
await readFile(new URL("../src/components/laboratory/useRecordedEvidencePlayback.ts", import.meta.url), "utf8"),
/if \(clock === "animation"\) return;/,
);
assert.match(visual, /playbackAuthority="media"/);
assert.match(visual, /playbackTransport = "epoch-stream"/);
assert.match(visual, /playbackTransport=\{playbackTransport\}/);
assert.match(
await readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
/if \(playbackAuthority === "host"\) return;/,
);
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/); assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/); assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/); assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
@@ -912,6 +930,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/); assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
assert.match(visualCss, /bottom: auto/); assert.match(visualCss, /bottom: auto/);
assert.match(videoScene, /<RecordedFmp4Player/); assert.match(videoScene, /<RecordedFmp4Player/);
assert.match(videoScene, /!playback\.playing && Math\.abs\(next\.currentSeconds - playback\.currentSeconds\) > 0\.35/);
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/); assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/); assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/); assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
@@ -920,19 +939,20 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(metricScene, /OrbitControls/); assert.match(metricScene, /OrbitControls/);
assert.match(visual, /LOCAL SLAM/); assert.match(visual, /LOCAL SLAM/);
assert.match(visual, /showLocalSurface/); assert.match(visual, /showLocalSurface/);
assert.match(visual, /const latestAvailableSpatialFrame = \[\.\.\.timelineFrame\.availableFrames\][\s\S]*candidate\.spatialAvailable[\s\S]*candidate\.sequence <= timelineFrame\.activeSequence/);
assert.match( assert.match(
visual, visual,
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/, /pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? activeSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? \[\]\}/,
); );
assert.match( assert.match(
visual, visual,
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/, /const classifiedSpatialFrame = hasClassifiedSpatialOutput[\s\S]*classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
); );
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/); assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
assert.match(visual, /timelineFrame\.availableFrames\.find/); assert.match(visual, /timelineFrame\.availableFrames\.find/);
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/); assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/); assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/); assert.match(visual, /\{activeSemantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/); assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
assert.match( assert.match(
visual, visual,
@@ -951,12 +971,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
}); });
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => { test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
const visual = await readFile( const [visual, canonical] = await Promise.all([
readFile(
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
"utf8", "utf8",
); ),
assert.match(visual, /const mediaPane = \(/); readFile(
assert.match(visual, /hidden=\{!mediaMode\}/); new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
"utf8",
),
]);
assert.match(canonical, /const mediaPane = \(/);
assert.match(canonical, /hidden=\{mediaMode === "none"\}/);
assert.match(visual, /data-media="video"/); assert.match(visual, /data-media="video"/);
assert.match(visual, /hidden=\{mediaMode !== "video"\}/); assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
assert.match(visual, /\{videoSource \? \(/); assert.match(visual, /\{videoSource \? \(/);
@@ -77,6 +77,70 @@ function session(overrides = {}) {
}; };
} }
function canonicalLab(overrides = {}) {
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
return {
lab_id: "LAB V1",
source_session_id: "20260828T130511Z_viewer_live",
result_kind: "recorded-perception-qualification",
result_id: resultId,
source_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
config_sha256: null,
run_created_at_utc: "2026-08-29T18:05:11.329061+00:00",
published_at_utc: "2026-08-30T12:00:00.000Z",
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewer_profile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commands_enabled: false,
},
provenance: {},
...overrides,
};
}
function legacyCalculationProfile(overrides = {}) {
return {
schema_version: "missioncore.observatory-calculation-profile/v1",
setup_id: "lab-v1-ravnoves004tree-final",
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
origin: "existing-result",
definition_id: null,
definition_version: null,
definition_sha256: null,
...overrides,
};
}
function canonicalProjectionProvenance(resultId, replayCapability) {
return {
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
evidence_identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
result_document_sha256: "a".repeat(64),
replay_capability: replayCapability,
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
actuation_accepted: false,
},
method: {
schema_version: "missioncore.laboratory-method/v1",
completeness: "legacy-partial",
execution_class: "ai-inference",
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
components: [{
kind: "source",
name: "sealed full-route LAB result",
version: "missioncore.lab-v1-vegetation-shadow/v1",
role: "immutable Session catalog projection",
identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
}],
},
};
}
function replay(overrides = {}) { function replay(overrides = {}) {
const sessionId = overrides.session_id ?? "session-20260716T205632Z"; const sessionId = overrides.session_id ?? "session-20260716T205632Z";
const sourceUrl = overrides.source_url ?? const sourceUrl = overrides.source_url ??
@@ -134,6 +198,7 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
modalities: ["point-cloud", "pose"], modalities: ["point-cloud", "pose"],
durationSeconds: 1_450.744, durationSeconds: 1_450.744,
replayable: true, replayable: true,
captureAttestation: null,
preparation: null, preparation: null,
lab: null, lab: null,
}]); }]);
@@ -141,6 +206,137 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
assert.equal("path" in catalog.items[0], false); assert.equal("path" in catalog.items[0], false);
}); });
test("session catalog strictly decodes the explicit recorded LAB replay capability", () => {
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const decoded = decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: canonicalLab() })],
}).items[0].lab;
assert.deepEqual(decoded.replayCapability, {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
});
const rollingUpgradeLab = canonicalLab();
rollingUpgradeLab.provenance = canonicalProjectionProvenance(
resultId,
rollingUpgradeLab.replay_capability,
);
delete rollingUpgradeLab.replay_capability;
assert.deepEqual(
decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: rollingUpgradeLab })],
}).items[0].lab.replayCapability,
decoded.replayCapability,
);
const invalidComponents = [
{ kind: "tool" },
{ name: "unsealed result" },
{ version: "missioncore.lab-v1-vegetation-shadow/v0" },
{ role: "mutable projection" },
{ identity_sha256: "0".repeat(64) },
{ unexpected: true },
];
for (const componentOverride of invalidComponents) {
const invalidRollingUpgradeLab = canonicalLab();
invalidRollingUpgradeLab.provenance = canonicalProjectionProvenance(
resultId,
invalidRollingUpgradeLab.replay_capability,
);
invalidRollingUpgradeLab.provenance.method.components = [{
...invalidRollingUpgradeLab.provenance.method.components[0],
...componentOverride,
}];
delete invalidRollingUpgradeLab.replay_capability;
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: invalidRollingUpgradeLab })],
}),
ObservationSessionContractError,
);
}
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({
replay_capability: {
...canonicalLab().replay_capability,
commands_enabled: true,
},
}),
})],
}),
ObservationSessionContractError,
);
});
test("session catalog decodes a typed calculation profile without inferring unknown results", () => {
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const decoded = decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({ calculation_profile: legacyCalculationProfile() }),
})],
}).items[0].lab;
assert.deepEqual(decoded.calculationProfile, {
schemaVersion: "missioncore.observatory-calculation-profile/v1",
setupId: "lab-v1-ravnoves004tree-final",
displayName: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
origin: "existing-result",
definitionId: null,
definitionVersion: null,
definitionSha256: null,
});
assert.equal(
decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: canonicalLab() })],
}).items[0].lab.calculationProfile,
null,
);
assert.equal(
decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({ calculation_profile: null }),
})],
}).items[0].lab.calculationProfile,
null,
);
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({
calculation_profile: legacyCalculationProfile({
origin: "archived-definition",
definition_id: "lab-v1-portable",
}),
}),
})],
}),
ObservationSessionContractError,
);
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({
calculation_profile: {
...legacyCalculationProfile(),
guessed_from_provenance: true,
},
}),
})],
}),
ObservationSessionContractError,
);
});
test("opened archive is named in the scene header and trash hover has no pill", async () => { test("opened archive is named in the scene header and trash hover has no pill", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8"); const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const styles = await readFile( const styles = await readFile(
@@ -246,7 +442,7 @@ test("source and laboratory catalogs are requested as disjoint backend projectio
assert.deepEqual(calls, [ assert.deepEqual(calls, [
"/api/v1/observation-sessions?limit=100&scope=source", "/api/v1/observation-sessions?limit=100&scope=source",
"/api/v1/observation-sessions?limit=100&scope=laboratory", "/api/v1/observation-sessions?limit=100&scope=laboratory&lab_contract=v3",
]); ]);
}); });
@@ -42,6 +42,8 @@ let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd; let fetchRecordedBlueprintRrd;
let resolveRecordedPerceptionUrl; let resolveRecordedPerceptionUrl;
let fetchRecordedPerceptionRrd; let fetchRecordedPerceptionRrd;
let resolveRecordedPerceptionViewerSourceUrl;
let probeRecordedPerceptionViewerSource;
let resolveRecordedPointColorsUrl; let resolveRecordedPointColorsUrl;
let fetchRecordedPointColorsRrd; let fetchRecordedPointColorsRrd;
let recordedPointColorKey; let recordedPointColorKey;
@@ -110,6 +112,8 @@ before(async () => {
fetchRecordedBlueprintRrd, fetchRecordedBlueprintRrd,
resolveRecordedPerceptionUrl, resolveRecordedPerceptionUrl,
fetchRecordedPerceptionRrd, fetchRecordedPerceptionRrd,
resolveRecordedPerceptionViewerSourceUrl,
probeRecordedPerceptionViewerSource,
resolveRecordedPointColorsUrl, resolveRecordedPointColorsUrl,
fetchRecordedPointColorsRrd, fetchRecordedPointColorsRrd,
recordedPointColorKey, recordedPointColorKey,
@@ -407,6 +411,15 @@ test("recorded blueprint endpoint is derived only from canonical same-origin RRD
), ),
null, null,
); );
assert.equal(
resolveRecordedBlueprintUrl(
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"a".repeat(64)}`
+ "/canonical-replay.rrd",
"http://127.0.0.1:5174",
"/api/v1/observation-sessions/session-1/blueprint.rrd",
),
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
);
}); });
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => { test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
@@ -485,6 +498,8 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
view_reset_generation: 1, view_reset_generation: 1,
follow_trajectory: true, follow_trajectory: true,
unified_perception: true, unified_perception: true,
semantic_layer: null,
plan_view: false,
show_detections_2d: true, show_detections_2d: true,
show_segmentation: false, show_segmentation: false,
show_cuboids_3d: true, show_cuboids_3d: true,
@@ -664,6 +679,74 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
assert.equal(absent, null); assert.equal(absent, null);
}); });
test("LAB perception sidecar is streamed by native Rerun from one generation-bound URL", async () => {
const endpoint =
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
"b".repeat(64),
"http://127.0.0.1:5174",
);
assert.equal(
sourceUrl,
`${endpoint}?application_id=nodedc_mission_core_recorded` +
`&recording_id=recording-001&generation=${"b".repeat(64)}`,
);
const overlayGeneration = "c".repeat(64);
const probe = await probeRecordedPerceptionViewerSource(sourceUrl, {
origin: "http://127.0.0.1:5174",
fetcher: async (input, init) => {
assert.equal(String(input), sourceUrl);
assert.equal(init.method, "HEAD");
assert.equal(new Headers(init.headers).get("Range"), null);
return new Response(null, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "186058411",
"ETag": `"${overlayGeneration}"`,
"X-Rerun-Format": "RRF2",
},
});
},
});
assert.deepEqual(probe, {
sourceUrl: `${sourceUrl}&overlay_generation=${overlayGeneration}`,
byteLength: 186_058_411,
});
});
test("LAB native source rejects an unsealed overlay descriptor", async () => {
const endpoint =
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
"b".repeat(64),
"http://127.0.0.1:5174",
);
await assert.rejects(
probeRecordedPerceptionViewerSource(sourceUrl, {
origin: "http://127.0.0.1:5174",
fetcher: async () => new Response(null, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "186058411",
"ETag": `"${"c".repeat(64)}"`,
"X-Rerun-Format": "RRF1",
},
}),
}),
/Invalid recorded perception viewer response/,
);
});
test("recorded replay creates an isolated source catalog without live device bindings", () => { test("recorded replay creates an isolated source catalog without live device bindings", () => {
const sources = recordedObservationSources({ const sources = recordedObservationSources({
kind: "rerun-recording", kind: "rerun-recording",
@@ -0,0 +1,375 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let applyObservatoryCatalogMutationOverlay;
let buildObservatoryCatalog;
let fetchObservatoryCatalog;
let findObservatoryEvidence;
let observatoryCatalogConfirmsEvidenceDeletion;
let reconcileObservatoryCatalogMutationOverlay;
let ObservatoryCatalogContractError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
applyObservatoryCatalogMutationOverlay,
buildObservatoryCatalog,
fetchObservatoryCatalog,
findObservatoryEvidence,
observatoryCatalogConfirmsEvidenceDeletion,
reconcileObservatoryCatalogMutationOverlay,
ObservatoryCatalogContractError,
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
});
after(async () => {
await server?.close();
});
function source(id, startedAtUtc) {
return {
id,
label: `Источник ${id}`,
startedAtUtc,
completedAtUtc: startedAtUtc,
status: "ready",
modalities: ["point-cloud", "video"],
durationSeconds: 42,
replayable: true,
preparation: null,
lab: null,
};
}
function evidence(id, sourceSessionId, publishedAtUtc) {
return {
id,
label: `Результат ${id}`,
startedAtUtc: publishedAtUtc,
completedAtUtc: publishedAtUtc,
status: "ready",
modalities: ["video"],
durationSeconds: 12,
replayable: true,
preparation: null,
lab: {
labId: `LAB-${id}`,
sourceSessionId,
resultKind: "recorded-evidence",
resultId: `result-${id}`,
sourceResultId: null,
configSha256: "a".repeat(64),
runCreatedAtUtc: publishedAtUtc,
publishedAtUtc,
replayCapability: null,
calculationProfile: null,
provenance: { verdict: "must-not-be-inferred" },
},
};
}
function canonicalProvenance(resultId) {
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
return {
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
evidence_identity_sha256: identitySha256,
result_document_sha256: "a".repeat(64),
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewer_profile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commands_enabled: false,
},
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
actuation_accepted: false,
},
method: {
schema_version: "missioncore.laboratory-method/v1",
completeness: "legacy-partial",
execution_class: "ai-inference",
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
components: [{
kind: "source",
name: "sealed full-route LAB result",
version: "missioncore.lab-v1-vegetation-shadow/v1",
role: "immutable Session catalog projection",
identity_sha256: identitySha256,
}],
},
};
}
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
const catalog = buildObservatoryCatalog(
[
source("older", "2026-08-28T10:00:00Z"),
source("newer", "2026-08-29T10:00:00Z"),
],
[
evidence("old-result", "newer", "2026-08-29T11:00:00Z"),
evidence("new-result", "newer", "2026-08-29T12:00:00Z"),
evidence("orphan", "missing", "2026-08-29T13:00:00Z"),
],
);
assert.deepEqual(catalog.items.map(({ source: item }) => item.id), ["newer", "older"]);
assert.deepEqual(
catalog.items[0].evidence.map((item) => item.sessionId),
["new-result", "old-result"],
);
assert.deepEqual(catalog.unresolvedEvidence.map((item) => item.sessionId), ["orphan"]);
assert.deepEqual(catalog.window, {
limit: 100,
sourceCount: 2,
laboratoryCount: 3,
sourceLimitReached: false,
laboratoryLimitReached: false,
});
assert.equal("verdict" in catalog.items[0].evidence[0], false);
assert.equal(catalog.items[1].evidence.length, 0);
});
test("Observatory fails visibly when the laboratory projection loses its typed link", () => {
assert.throws(
() => buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[source("not-lab", "2026-08-29T11:00:00Z")],
),
ObservatoryCatalogContractError,
);
});
test("Observatory fetches disjoint read-only source and laboratory projections", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const catalog = await fetchObservatoryCatalog({ fetcher, limit: 50 });
assert.deepEqual(catalog, {
items: [],
unresolvedEvidence: [],
window: {
limit: 50,
sourceCount: 0,
laboratoryCount: 0,
sourceLimitReached: false,
laboratoryLimitReached: false,
},
});
assert.deepEqual(
calls.map(({ input }) => input).sort(),
[
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
"/api/v1/observation-sessions?limit=50&scope=source",
],
);
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
});
test("Observatory exposes bounded-window uncertainty without inventing a broken link", () => {
const catalog = buildObservatoryCatalog(
[
source("newer", "2026-08-29T10:00:00Z"),
source("older", "2026-08-28T10:00:00Z"),
],
[
evidence("linked", "newer", "2026-08-29T11:00:00Z"),
evidence("outside-window", "older-than-window", "2026-08-29T12:00:00Z"),
],
2,
);
assert.equal(catalog.window.sourceLimitReached, true);
assert.equal(catalog.window.laboratoryLimitReached, true);
assert.deepEqual(
catalog.unresolvedEvidence.map((item) => item.sessionId),
["outside-window"],
);
});
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const canonical = evidence(
canonicalResultId,
"20260828T130511Z_viewer_live",
"2026-08-29T18:05:11Z",
);
canonical.lab = {
...canonical.lab,
labId: "LAB V1",
resultKind: "recorded-perception-qualification",
resultId: canonicalResultId,
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
configSha256: null,
provenance: canonicalProvenance(canonicalResultId),
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
},
};
const catalog = buildObservatoryCatalog(
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
[canonical],
);
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
kind: "canonical-recorded-rerun",
evidenceSessionId: canonicalResultId,
sourceSessionId: "20260828T130511Z_viewer_live",
resultId: canonicalResultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
});
});
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
const catalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("result", "source", "2026-08-29T11:00:00Z")],
);
const original = catalog.items[0].evidence[0];
const projected = applyObservatoryCatalogMutationOverlay(
catalog,
new Map([["result", {
kind: "rename",
displayName: "Операторское имя",
revision: 1,
}]]),
);
const renamed = findObservatoryEvidence(projected, "result");
assert.equal(renamed.label, "Операторское имя");
assert.equal(renamed.sessionId, original.sessionId);
assert.equal(renamed.lab, original.lab);
assert.equal(renamed.recordedRun, original.recordedRun);
assert.equal(projected.items[0].source, catalog.items[0].source);
assert.equal(projected.window.laboratoryCount, 1);
});
test("Observatory tombstone removes only its catalog evidence projection", () => {
const catalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[
evidence("remove", "source", "2026-08-29T12:00:00Z"),
evidence("keep", "source", "2026-08-29T11:00:00Z"),
],
);
const projected = applyObservatoryCatalogMutationOverlay(
catalog,
new Map([["remove", { kind: "delete", revision: 1 }]]),
);
assert.equal(projected.items.length, 1);
assert.equal(projected.items[0].source, catalog.items[0].source);
assert.deepEqual(
projected.items[0].evidence.map(({ sessionId }) => sessionId),
["keep"],
);
assert.equal(projected.window.sourceCount, 1);
assert.equal(projected.window.laboratoryCount, 1);
});
test("Observatory confirms an ambiguous delete only from a complete LAB window", () => {
const bounded = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("other", "source", "2026-08-29T11:00:00Z")],
1,
);
const complete = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[],
);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(bounded, "result"), false);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(complete, "result"), true);
});
test("Observatory reconciliation cannot let a stale in-flight fetch undo a local mutation", () => {
const oldCatalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("result", "source", "2026-08-29T11:00:00Z")],
);
const renameOverlay = new Map([["result", {
kind: "rename",
displayName: "Новое имя",
revision: 1,
}]]);
const staleRename = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
renameOverlay,
0,
);
assert.equal(findObservatoryEvidence(staleRename.catalog, "result").label, "Новое имя");
assert.equal(staleRename.overlay.has("result"), true);
const reconciledOldRename = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
renameOverlay,
1,
);
assert.equal(findObservatoryEvidence(reconciledOldRename.catalog, "result").label, "Новое имя");
assert.equal(reconciledOldRename.overlay.has("result"), true);
const renamedEvidence = evidence("result", "source", "2026-08-29T11:00:00Z");
renamedEvidence.label = "Новое имя";
const confirmedRename = reconcileObservatoryCatalogMutationOverlay(
buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[renamedEvidence],
),
staleRename.overlay,
1,
);
assert.equal(findObservatoryEvidence(confirmedRename.catalog, "result").label, "Новое имя");
assert.equal(confirmedRename.overlay.size, 0);
const deleteOverlay = new Map([["result", { kind: "delete", revision: 2 }]]);
const staleDelete = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
deleteOverlay,
1,
);
assert.equal(findObservatoryEvidence(staleDelete.catalog, "result"), null);
assert.equal(staleDelete.overlay.has("result"), true);
const reconciledOldDelete = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
deleteOverlay,
2,
);
assert.equal(findObservatoryEvidence(reconciledOldDelete.catalog, "result"), null);
assert.equal(reconciledOldDelete.overlay.has("result"), true);
const confirmedDelete = reconcileObservatoryCatalogMutationOverlay(
buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[],
),
staleDelete.overlay,
2,
);
assert.equal(findObservatoryEvidence(confirmedDelete.catalog, "result"), null);
assert.equal(confirmedDelete.overlay.size, 0);
});
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let deleteObservatoryLabProjection;
let renameObservatoryLabProjection;
let ObservatoryCatalogMutationError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
deleteObservatoryLabProjection,
renameObservatoryLabProjection,
ObservatoryCatalogMutationError,
} = await server.ssrLoadModule("/src/core/observatory/catalogMutations.ts"));
});
after(async () => {
await server?.close();
});
const sessionId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const binding = {
kind: "canonical-recorded-rerun",
evidenceSessionId: sessionId,
sourceSessionId: "20260828T130511Z_viewer_live",
resultId: sessionId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
};
test("Observatory rename sends the closed projection-only contract", async () => {
const calls = [];
const result = await renameObservatoryLabProjection(binding, " Новый разбор ", {
fetcher: async (input, init) => {
calls.push({ input, init });
return Response.json({
schema_version: "missioncore.observatory-lab-projection/v1",
session_id: sessionId,
display_name: "Новый разбор",
});
},
});
assert.deepEqual(result, {
schemaVersion: "missioncore.observatory-lab-projection/v1",
sessionId,
displayName: "Новый разбор",
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
assert.equal(calls[0].init.method, "PATCH");
assert.equal(calls[0].init.headers.Accept, "application/json");
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
assert.deepEqual(JSON.parse(calls[0].init.body), {
schema_version: "missioncore.observatory-lab-projection-rename/v1",
display_name: "Новый разбор",
});
});
test("Observatory delete accepts only an empty 204 response", async () => {
const calls = [];
await deleteObservatoryLabProjection(binding, {
fetcher: async (input, init) => {
calls.push({ input, init });
return new Response(null, { status: 204 });
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
assert.equal(calls[0].init.method, "DELETE");
await assert.rejects(
deleteObservatoryLabProjection(binding, {
fetcher: async () => new Response("unexpected", { status: 200 }),
}),
ObservatoryCatalogMutationError,
);
});
test("Observatory mutations fail closed before network access", async () => {
let calls = 0;
const fetcher = async () => {
calls += 1;
return new Response(null, { status: 204 });
};
await assert.rejects(
renameObservatoryLabProjection(binding, " ", { fetcher }),
/от 1 до 160/,
);
await assert.rejects(
deleteObservatoryLabProjection({ ...binding, resultId: "different" }, { fetcher }),
/не допущен/,
);
assert.equal(calls, 0);
});
test("Observatory mutations reject response drift and preserve API detail", async () => {
await assert.rejects(
renameObservatoryLabProjection(binding, "Разбор", {
fetcher: async () => Response.json({
schema_version: "missioncore.observatory-lab-projection/v1",
session_id: sessionId,
display_name: "Разбор",
unexpected: true,
}),
}),
/неизвестные поля/,
);
await assert.rejects(
deleteObservatoryLabProjection(binding, {
fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }),
}),
(error) => error instanceof ObservatoryCatalogMutationError
&& error.status === 409
&& error.message === "Проекция не принадлежит Обсерватории.",
);
});
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import React from "react";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let useObservatoryRecordedJobs;
let useObservatoryLaboratorySetups;
before(async () => {
server = await createServer({
appType: "custom", logLevel: "silent", server: { middlewareMode: true },
});
({ useObservatoryRecordedJobs } = await server.ssrLoadModule("/src/core/observatory/useObservatoryRecordedJobs.ts"));
({ useObservatoryLaboratorySetups } = await server.ssrLoadModule("/src/core/observatory/useObservatoryLaboratorySetups.ts"));
});
after(async () => { await server?.close(); });
// Deliberately inspect the render BEFORE effects/cleanup: stale state must already be hidden.
function renderBeforeEffects(hook, args, stateSlots) {
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
const previous = internals.H;
let stateIndex = 0;
internals.H = {
useState: (initial) => [
stateIndex < stateSlots.length ? stateSlots[stateIndex++]
: typeof initial === "function" ? initial() : initial,
() => {},
],
useRef: (value) => ({ current: value }),
useMemo: (factory) => factory(),
useCallback: (fn) => fn,
useEffect: () => {},
};
try { return hook(...args); } finally { internals.H = previous; }
}
test("queue data and errors cannot survive a source/setup/version change for one render", () => {
const original = ["source-a", "profile-a", "a".repeat(64)];
const activeJob = { state: "running", publication: { state: "not-required" } };
const snapshot = {
selectionKey: JSON.stringify(original), jobs: [activeJob], state: "ready", error: "old error",
};
assert.equal(renderBeforeEffects(useObservatoryRecordedJobs, original, [snapshot]).activeJob, activeJob);
for (const next of [
["source-b", original[1], original[2]],
[original[0], "profile-b", original[2]],
[original[0], original[1], "b".repeat(64)],
["", "", ""],
]) {
const view = renderBeforeEffects(useObservatoryRecordedJobs, next, [snapshot]);
assert.deepEqual(view.jobs, []);
assert.equal(view.activeJob, null);
assert.equal(view.latestJob, null);
assert.equal(view.error, null);
assert.notEqual(view.state, "ready");
}
});
test("published, incompatible and previous-source profiles have no effective selection", () => {
const profile = {
setupId: "profile-a", origin: "portable-definition",
compatibility: { compatible: true },
runDefinition: { definitionSha256: "a".repeat(64) },
preflight: { outcome: "ready" },
};
function render(sourceId, setups) {
return renderBeforeEffects(useObservatoryLaboratorySetups, [sourceId], [
{ sourceSessionId: "source-a", setups }, "ready", null, "profile-a", 0, { kind: "idle" },
]);
}
assert.equal(render("source-a", [profile]).selectedSetupId, "profile-a");
for (const view of [
render("source-b", [profile]),
render("source-a", [{ ...profile, preflight: { outcome: "existing" } }]),
render("source-a", [{ ...profile, compatibility: { compatible: false } }]),
render("source-a", []),
]) {
assert.equal(view.selectedSetupId, "");
assert.equal(view.selectedSetup, null);
assert.deepEqual(view.selectableSetups, []);
}
});
test("historical failures are not presented as errors of a new operator action", () => {
const selection = ["source-a", "profile-a", "a".repeat(64)];
const snapshot = {
selectionKey: JSON.stringify(selection),
jobs: [{ jobId: "old-run", state: "failed", publication: { state: "not-required" } }],
state: "ready", error: null,
};
const view = renderBeforeEffects(useObservatoryRecordedJobs, selection, [snapshot]);
assert.equal(view.computationFailed, false);
assert.equal(view.error, null);
assert.equal(view.activeJob, null);
});
@@ -0,0 +1,591 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchObservatoryLaboratorySetups;
let fetchObservatoryPortableLaboratorySetups;
let preflightObservatoryLaboratorySetup;
let selectableObservatorySetups;
let ObservatoryLaboratorySetupContractError;
const authority = {
commands_enabled: false,
actuation_allowed: false,
navigation_or_safety_accepted: false,
production_accepted: false,
};
function definition() {
return {
schema_version: "missioncore.observatory-run-definition/v1",
definition_id: "m49-tgs-full-shadow",
version: 1,
work_id: "m49-tgs-full-shadow",
source: {
session_id: "source-a",
label: "RAVNOVES00",
required_modalities: ["point-cloud", "trajectory", "video"],
},
configuration: [{ role: "primary-profile", sha256: "a".repeat(64) }],
authority,
definition_sha256: "b".repeat(64),
};
}
function setup() {
return {
setup_id: "m49-tgs-full-shadow-v1",
display_name: "M4.9T5",
description: "Сохранённый полный source-paced shadow.",
origin: "archived-definition",
source: {
session_id: "source-a",
label: "RAVNOVES00",
required_modalities: ["point-cloud", "trajectory", "video"],
},
run_definition: definition(),
compatibility: { compatible: true, reasons: [] },
executor: {
contour_id: "worker-006",
state: "not-installed",
reason_code: "laboratory-runner-adapter-not-installed",
reason: "Адаптер не установлен.",
},
preserved_results: [{
result_id: `m49-tgs-full-shadow-${"c".repeat(64)}`,
result_kind: "recorded-source-paced-tgs-shadow",
relation: "primary-visual",
access: "legacy-lab",
created_at_utc: "2026-08-26T20:27:19Z",
observatory_projection_available: false,
}],
preflight: {
outcome: "blocked",
action: "open-legacy",
reason: "Точный результат сохранён в legacy LAB.",
submission_allowed: false,
existing_result_ids: [],
},
authority,
};
}
function portableSetup() {
return {
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
description: "Проверка записанной K1-сессии моделями EoMT и DDRNet; только наблюдение.",
origin: "portable-definition",
source_requirements: {
plugin_id: "nodedc.device.xgrids-lixelkity-k1",
archive_id: "xgrids-k1.viewer-live.evidence",
required_modalities: ["point-cloud", "trajectory", "video"],
camera_source_id: "sensor.camera.right",
camera_semantic_channel_id: "camera.video.recorded",
recorded_media_type: 'video/mp4; codecs="avc1.641028"',
recorded_media_init_sha256: "1".repeat(64),
camera_width: 800,
camera_height: 600,
calibration_slot: "camera_1",
calibration_identity_sha256: "2".repeat(64),
exactly_one_media_epoch: true,
seekable: true,
},
run_definition: {
definition_id: "lab-v1-eomt-ddrnet-portable",
version: 1,
definition_sha256: "3".repeat(64),
result_schema: "missioncore.recorded-eomt-ddrnet-review/v2",
result_kind: "recorded-perception-qualification",
models: [
{
name: "EoMT Cityscapes Large 1024",
release_id: "eomt-cityscapes-large-1024-v1",
model_id: "tue-mps/cityscapes_semantic_eomt_large_1024",
architecture: "EomtForUniversalSegmentation",
},
{
name: "DDRNet-39",
release_id: "lab-v1-ddrnet-39-goose-fine-64-v1",
model_id: "goose-ddrnet-class-512",
architecture: "ddrnet_39",
},
],
},
source_compatibility: {
outcome: "pass",
compatible: true,
reason_code: "source-compatible",
reason: "Запись соответствует требованиям EoMT + DDRNet.",
},
executor: {
contour_id: "worker-006",
state: "not-installed",
ready: false,
reason_code: "eomt-executor-release-unsealed",
reason: "Immutable executor release не установлен.",
},
existing_results: [],
preflight: {
outcome: "blocked",
action: "blocked",
reason: "Вычислительный контур LAB V1 пока недоступен.",
submission_allowed: false,
existing_result_ids: [],
},
authority,
};
}
function portableM49Setup() {
const profile = portableSetup();
profile.setup_id = "m49-tgs-portable-v2";
profile.display_name = "M4.9T5 · TRAVEL TGS · CPU-only, без ML";
profile.description = "Динамический TGS-разбор записанной K1-сессии без ML; только наблюдение.";
profile.run_definition = {
definition_id: "m49-tgs-portable",
version: 2,
definition_sha256: "4".repeat(64),
result_schema: "missioncore.recorded-travel-tgs-review/v2",
result_kind: "recorded-source-paced-tgs-shadow",
models: [],
};
profile.source_compatibility.reason = "Запись соответствует требованиям TRAVEL TGS.";
profile.executor.reason_code = "m49-executor-release-unsealed";
profile.executor.reason = "Immutable executor release не установлен.";
profile.preflight.reason = "Переносимый вычислительный контур M4.9T5 пока недоступен.";
return profile;
}
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchObservatoryLaboratorySetups,
fetchObservatoryPortableLaboratorySetups,
preflightObservatoryLaboratorySetup,
selectableObservatorySetups,
ObservatoryLaboratorySetupContractError,
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
});
after(async () => {
await server?.close();
});
test("Observatory setup catalog keeps definition identity separate from executor availability", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200, headers: { "Content-Type": "application/json" } });
};
const catalog = await fetchObservatoryLaboratorySetups("source-a", { fetcher });
assert.equal(catalog.setups[0].runDefinition.definitionSha256, "b".repeat(64));
assert.equal(catalog.setups[0].executor.state, "not-installed");
assert.equal(catalog.setups[0].preflight.submissionAllowed, false);
assert.equal(catalog.setups[0].preservedResults[0].access, "legacy-lab");
assert.equal(
calls[0].input,
"/api/v1/observatory/laboratory-setups?source_session_id=source-a",
);
assert.equal(calls[0].init.method, "GET");
});
test("portable profiles report compatible source separately from unavailable Worker", async () => {
const calls = [];
const selected = (await fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [portableSetup(), portableM49Setup()],
authority,
}), { status: 200 });
},
})).setups[0];
assert.equal(selected.origin, "portable-definition");
assert.equal(selected.compatibility.compatible, true);
assert.equal(selected.executor.state, "not-installed");
assert.equal(selected.preflight.submissionAllowed, false);
assert.deepEqual(selected.runDefinition.models.map((model) => model.name), [
"EoMT Cityscapes Large 1024",
"DDRNet-39",
]);
const m49 = (await fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [portableSetup(), portableM49Setup()],
authority,
}), { status: 200 }),
})).setups[1];
assert.equal(m49.displayName, "M4.9T5 · TRAVEL TGS · CPU-only, без ML");
assert.deepEqual(m49.runDefinition.models, []);
assert.equal(
calls[0].input,
"/api/v1/observatory/portable-laboratory-setups?source_session_id=source-a",
);
let preflightRequest;
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async (input, init) => {
preflightRequest = { input: String(input), init };
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "3".repeat(64),
check_sha256: null,
outcome: "blocked",
submission_allowed: false,
checks: [{
check_id: "executor",
outcome: "fail",
reason_code: "eomt-executor-release-unsealed",
message: "Immutable executor release не установлен.",
}],
existing_result_ids: [],
executor: portableSetup().executor,
authority,
}), { status: 200 });
},
});
assert.equal(preflightRequest.input, "/api/v1/observatory/run-preflights");
assert.equal(preflightRequest.init.method, "POST");
assert.equal(preflight.outcome, "blocked");
assert.equal(preflight.submissionAllowed, false);
assert.equal(preflight.checkSha256, null);
assert.equal(preflight.checks[0].outcome, "fail");
});
test("portable profile accepts a consistent ready projection", async () => {
const ready = portableSetup();
ready.executor = {
contour_id: "worker-006",
state: "ready",
ready: true,
reason_code: null,
reason: null,
};
ready.preflight = {
outcome: "ready",
action: "check",
reason: "Запись готова к постановке в очередь.",
submission_allowed: true,
existing_result_ids: [],
};
const catalog = await fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [ready],
authority,
}), { status: 200 }),
});
assert.equal(catalog.setups[0].executor.state, "ready");
assert.equal(catalog.setups[0].preflight.outcome, "ready");
assert.equal(catalog.setups[0].preflight.submissionAllowed, true);
});
test("portable LAB V1 rejects an unbound existing result projection", async () => {
const spoofed = portableSetup();
spoofed.existing_results = [{
result_id: `legacy-vegetation-${"4".repeat(64)}`,
result_kind: "recorded-perception-qualification",
created_at_utc: "2026-08-30T18:16:00Z",
}];
spoofed.preflight = {
outcome: "existing",
action: "open-existing",
reason: "Результат якобы готов.",
submission_allowed: false,
existing_result_ids: [spoofed.existing_results[0].result_id],
};
await assert.rejects(
fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [spoofed],
authority,
}), { status: 200 }),
}),
/значение изменилось|значение не поддерживается|обнаружены неизвестные поля/,
);
});
test("portable LAB V1 rejects heavyweight compatibility evidence", async () => {
const heavyweight = portableSetup();
heavyweight.source_compatibility.evidence = {
frame_count: 6830,
timeline_start_seconds: 0,
timeline_end_seconds: 819,
};
await assert.rejects(
fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [heavyweight],
authority,
}), { status: 200 }),
}),
/неизвестные поля/,
);
});
function cachedPortableSetup() {
const setup = portableSetup();
setup.existing_results = [{
result_id: "portable-result-001",
result_kind: setup.run_definition.result_kind,
relation: "exact-recorded-computation",
access: "observatory",
created_at_utc: "2026-09-03T00:00:00Z",
observatory_projection_available: true,
identity: {
job_id: "observatory-run-001",
source_session_id: "source-a",
source_catalog_sha256: "a".repeat(64),
source_bundle_sha256: "b".repeat(64),
source_capability_manifest_sha256: "c".repeat(64),
setup_id: setup.setup_id,
definition_sha256: setup.run_definition.definition_sha256,
package_sha256: "d".repeat(64),
artifact_manifest_id: "e".repeat(64),
},
}];
setup.preflight = {
outcome: "existing", action: "open-existing",
reason: "Точный результат проверен.", submission_allowed: false,
existing_result_ids: [setup.existing_results[0].result_id],
};
return setup;
}
function fetchCachedPortable(setup) {
return fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a", setups: [setup], authority,
}), { status: 200 }),
});
}
test("portable exact cached result is readable without an installed executor", async () => {
const catalog = await fetchCachedPortable(cachedPortableSetup());
const setup = catalog.setups[0];
assert.equal(setup.executor.state, "not-installed");
assert.equal(setup.preflight.outcome, "existing");
assert.equal(setup.preflight.submissionAllowed, false);
assert.deepEqual(setup.preflight.existingResultIds, ["portable-result-001"]);
assert.equal(setup.preservedResults[0].access, "observatory");
});
test("selector contains only compatible uncalculated portable definitions", async () => {
const uncached = (await fetchCachedPortable(portableSetup())).setups[0];
const cached = (await fetchCachedPortable(cachedPortableSetup())).setups[0];
const currentVersion = {
...uncached,
runDefinition: { ...uncached.runDefinition, version: 2, definitionSha256: "f".repeat(64) },
};
const catalog = (setups) => ({ sourceSessionId: "source-a", setups });
assert.deepEqual(selectableObservatorySetups(null), []);
assert.deepEqual(selectableObservatorySetups(catalog([])), []);
assert.deepEqual(selectableObservatorySetups(catalog([cached])), []);
// Same display label and setup ID, but no verified cache hit for the new definition.
assert.deepEqual(selectableObservatorySetups(catalog([currentVersion])), [currentVersion]);
assert.deepEqual(selectableObservatorySetups(catalog([
cached,
{ ...uncached, setupId: "remaining-profile" },
])).map((s) => s.setupId), ["remaining-profile"]);
assert.deepEqual(selectableObservatorySetups(catalog([
{ ...uncached, compatibility: { compatible: false, reasons: [] } },
{ ...uncached, origin: "archived-definition" },
{ ...uncached, origin: "existing-result" },
{ ...uncached, runDefinition: null },
])), []);
// Worker availability does not masquerade as completed computation.
assert.equal(uncached.executor.state, "not-installed");
assert.deepEqual(selectableObservatorySetups(catalog([uncached])), [uncached]);
// A missing/deleted/unpublished projection is not a completed result.
assert.deepEqual(selectableObservatorySetups(catalog([{
...uncached, preservedResults: cached.preservedResults,
}])).map((s) => s.setupId), [uncached.setupId]);
});
for (const [label, change] of [
["another source", (s) => { s.existing_results[0].identity.source_session_id = "source-b"; }],
["another setup", (s) => { s.existing_results[0].identity.setup_id = "another-profile"; }],
["another version", (s) => { s.existing_results[0].identity.definition_sha256 = "f".repeat(64); }],
["bad package digest", (s) => { s.existing_results[0].identity.package_sha256 = "not-a-digest"; }],
["unavailable artifact", (s) => { s.existing_results[0].observatory_projection_available = false; }],
["missing binding", (s) => { delete s.existing_results[0].identity; }],
["unrelated result ID", (s) => { s.preflight.existing_result_ids = ["different-result"]; }],
["duplicate result", (s) => {
s.existing_results.push(s.existing_results[0]);
s.preflight.existing_result_ids.push(s.preflight.existing_result_ids[0]);
}],
["contradictory action", (s) => { s.preflight.action = "check"; }],
["cached but queueable", (s) => { s.preflight.submission_allowed = true; }],
]) {
test(`portable cached result rejects ${label}`, async () => {
const setup = cachedPortableSetup();
change(setup);
await assert.rejects(fetchCachedPortable(setup));
});
}
test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200 }),
})).setups[0];
let request;
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "b".repeat(64),
check_sha256: null,
outcome: "blocked",
submission_allowed: false,
checks: [{
check_id: "executor",
outcome: "fail",
reason_code: "laboratory-runner-adapter-not-installed",
message: "Адаптер не установлен.",
}],
existing_result_ids: [],
executor: setup().executor,
authority,
}), { status: 200 });
},
});
assert.equal(request.input, "/api/v1/observatory/run-preflights");
assert.equal(request.init.method, "POST");
assert.deepEqual(JSON.parse(request.init.body), {
schema_version: "missioncore.observatory-run-preflight-request/v1",
source_session_id: "source-a",
setup_id: "m49-tgs-full-shadow-v1",
definition_sha256: "b".repeat(64),
});
assert.equal(preflight.outcome, "blocked");
assert.equal(preflight.submissionAllowed, false);
assert.equal(preflight.checkSha256, null);
});
test("Observatory dynamic preflight admits only an explicit queueable response", async () => {
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200 }),
})).setups[0];
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "b".repeat(64),
outcome: "queueable",
submission_allowed: true,
checks: [{
check_id: "durable-queue",
outcome: "pass",
reason_code: "exact-binding-ready",
message: "Точный источник и очередь готовы.",
}],
existing_result_ids: [],
executor: setup().executor,
authority,
}), { status: 200 }),
});
assert.equal(preflight.outcome, "queueable");
assert.equal(preflight.submissionAllowed, true);
assert.equal(preflight.checkSha256, null);
});
test("Observatory setup contract rejects authority escalation and response drift", async () => {
const escalated = setup();
escalated.authority = { ...authority, commands_enabled: true };
await assert.rejects(
fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [escalated],
authority,
}), { status: 200 }),
}),
ObservatoryLaboratorySetupContractError,
);
const drifted = setup();
drifted.unexpected = true;
await assert.rejects(
fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [drifted],
authority,
}), { status: 200 }),
}),
ObservatoryLaboratorySetupContractError,
);
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200 }),
})).setups[0];
await assert.rejects(
preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "c".repeat(64),
outcome: "blocked",
submission_allowed: false,
checks: [],
existing_result_ids: [],
executor: setup().executor,
authority,
}), { status: 200 }),
}),
ObservatoryLaboratorySetupContractError,
);
});
@@ -0,0 +1,277 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchObservatoryRecordedJobs;
let retryObservatoryRecordedJobPublication;
let submitObservatoryRecordedJob;
let ObservatoryRecordedJobContractError;
const authority = {
commands_enabled: false,
actuation_allowed: false,
navigation_or_safety_accepted: false,
production_accepted: false,
};
function job(state = "queued") {
return {
schema_version: "missioncore.observatory-recorded-job/v1",
job_id: "observatory-run-0123456789abcdef0123456789abcdef",
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
request_sha256: "1".repeat(64),
identity_sha256: "2".repeat(64),
submission_receipt_sha256: "3".repeat(64),
source: {
session_id: "source-a",
catalog_sha256: "4".repeat(64),
bundle_sha256: "5".repeat(64),
capability_manifest_sha256: "6".repeat(64),
adapter: {
adapter_id: "ravnoves00-m49-source-pack",
version: 1,
adapter_sha256: "7".repeat(64),
},
},
setup: {
setup_id: "m49-tgs",
definition_id: "m49-tgs",
definition_version: 1,
definition_sha256: "8".repeat(64),
},
executor: {
release_id: "m49-tgs-release",
release_sha256: "9".repeat(64),
image_sha256: "a".repeat(64),
model_release_ids: [],
learned_models: [],
model_manifest_sha256: "b".repeat(64),
resource_profile_id: "m49-tgs-cpu",
resource_profile_sha256: "c".repeat(64),
},
checkpoint_policy: {
mode: "non-checkpointable",
allowed_checkpoints: [],
last_checkpoint_id: null,
},
priority: { class: "recorded", rank: 100, server_owned: true },
state,
preemption_requested: state === "preemption-pending",
restart_from_zero: state === "paused",
preemption_receipt_sha256: null,
claim_generation: 0,
claim_lease: null,
result: state === "succeeded"
? { result_id: "m49-result", sha256: "d".repeat(64) }
: null,
publication: {
state: "not-required",
attempts: 0,
error: null,
published_at_utc: null,
},
terminal: state === "failed"
? { code: "worker-failed", message: "Worker завершил расчёт с ошибкой." }
: null,
created_at_utc: "2026-08-31T10:00:00Z",
updated_at_utc: "2026-08-31T10:00:01Z",
authority,
};
}
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchObservatoryRecordedJobs,
retryObservatoryRecordedJobPublication,
submitObservatoryRecordedJob,
ObservatoryRecordedJobContractError,
} = await server.ssrLoadModule("/src/core/observatory/recordedJobs.ts"));
});
after(async () => {
await server?.close();
});
test("Observatory reads the exact session/setup queue without polling", async () => {
const calls = [];
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [job("running")],
authority,
}), { status: 200 });
},
});
assert.equal(calls.length, 1);
assert.equal(
calls[0].input,
"/api/v1/observatory/runs?source_session_id=source-a&setup_id=m49-tgs&limit=20",
);
assert.equal(calls[0].init.method, "GET");
assert.equal(jobs[0].state, "running");
assert.equal(jobs[0].definitionSha256, "8".repeat(64));
});
test("Observatory submits only public identities and accepts every durable state", async () => {
const states = [
"accepted",
"queued",
"claimed",
"running",
"paused",
"preemption-pending",
"succeeded",
"failed",
"reconciliation-required",
];
for (const state of states) {
let request;
const result = await submitObservatoryRecordedJob(
"source-a",
"m49-tgs",
"observatory-ui:source-a:m49-tgs:request-a",
null,
{
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify(job(state)), { status: 200 });
},
},
);
assert.equal(result.state, state);
assert.equal(request.input, "/api/v1/observatory/runs");
assert.equal(request.init.method, "POST");
assert.deepEqual(JSON.parse(request.init.body), {
schema_version: "missioncore.observatory-recorded-run-submit/v1",
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
source_session_id: "source-a",
setup_id: "m49-tgs",
});
}
});
test("Observatory queue contract rejects authority escalation and response drift", async () => {
const escalated = job();
escalated.authority = { ...authority, commands_enabled: true };
await assert.rejects(
fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [escalated],
authority,
}), { status: 200 }),
}),
ObservatoryRecordedJobContractError,
);
const drifted = job();
drifted.worker_command = "forbidden";
await assert.rejects(
submitObservatoryRecordedJob(
"source-a",
"m49-tgs",
"observatory-ui:source-a:m49-tgs:request-a",
null,
{ fetcher: async () => new Response(JSON.stringify(drifted), { status: 200 }) },
),
ObservatoryRecordedJobContractError,
);
});
test("portable submission carries the exact definition/check fence", async () => {
let request;
await submitObservatoryRecordedJob(
"source-a",
"lab-v1-eomt-ddrnet-portable-v1",
"observatory-ui:source-a:lab-v1:request-a",
{
definitionSha256: "e".repeat(64),
checkSha256: "f".repeat(64),
},
{
fetcher: async (input, init) => {
request = { input: String(input), init };
const response = job("queued");
response.setup.setup_id = "lab-v1-eomt-ddrnet-portable-v1";
response.setup.definition_sha256 = "e".repeat(64);
response.source.session_id = "source-a";
return new Response(JSON.stringify(response), { status: 202 });
},
},
);
assert.deepEqual(JSON.parse(request.init.body), {
schema_version: "missioncore.observatory-recorded-run-submit/v1",
idempotency_key: "observatory-ui:source-a:lab-v1:request-a",
source_session_id: "source-a",
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
definition_sha256: "e".repeat(64),
check_sha256: "f".repeat(64),
});
});
test("publication retry never submits a second compute request", async () => {
let request;
const response = job("succeeded");
response.publication = {
state: "published",
attempts: 2,
error: null,
published_at_utc: "2026-09-01T12:00:00Z",
};
const retried = await retryObservatoryRecordedJobPublication(response.job_id, {
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify(response), { status: 200 });
},
});
assert.equal(
request.input,
`/api/v1/observatory/runs/${response.job_id}/publication/retry`,
);
assert.equal(request.init.method, "POST");
assert.equal(request.init.body, undefined);
assert.equal(retried.publication.state, "published");
});
test("queue query and response are fenced to the selected definition", async () => {
const oldVersion = job("succeeded");
oldVersion.setup.definition_sha256 = "f".repeat(64);
const otherSource = job("running");
otherSource.source.session_id = "source-b";
const otherSetup = job("running");
otherSetup.setup.setup_id = "other-setup";
let url;
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
definitionSha256: "8".repeat(64),
fetcher: async (input) => {
url = new URL(String(input), "http://localhost");
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [oldVersion, otherSource, otherSetup, job("queued")], authority,
}));
},
});
assert.equal(url.searchParams.get("definition_sha256"), "8".repeat(64));
assert.deepEqual(jobs.map((j) => j.state), ["queued"]);
});
test("portable submit rejects a successful response for an old definition", async () => {
await assert.rejects(submitObservatoryRecordedJob(
"source-a", "m49-tgs", "test-request",
{ definitionSha256: "f".repeat(64), checkSha256: "e".repeat(64) },
{ fetcher: async () => new Response(JSON.stringify(job("succeeded"))) },
), ObservatoryRecordedJobContractError);
});
@@ -0,0 +1,292 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let admitObservatoryRecordedRunReview;
let fetchObservatoryRecordedRunReview;
let observatoryRecordedRunBinding;
let ObservatoryRecordedRunContractError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
admitObservatoryRecordedRunReview,
fetchObservatoryRecordedRunReview,
observatoryRecordedRunBinding,
ObservatoryRecordedRunContractError,
} = await server.ssrLoadModule("/src/core/observatory/recordedRun.ts"));
});
after(async () => {
await server?.close();
});
const sourceSessionId = "20260828T130511Z_viewer_live";
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
function canonicalProvenance() {
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
return {
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
evidence_identity_sha256: identitySha256,
result_document_sha256: "a".repeat(64),
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewer_profile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commands_enabled: false,
},
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
actuation_accepted: false,
},
method: {
schema_version: "missioncore.laboratory-method/v1",
completeness: "legacy-partial",
execution_class: "ai-inference",
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
components: [{
kind: "source",
name: "sealed full-route LAB result",
version: "missioncore.lab-v1-vegetation-shadow/v1",
role: "immutable Session catalog projection",
identity_sha256: identitySha256,
}],
},
};
}
function lab(overrides = {}) {
return {
labId: "LAB V1",
sourceSessionId,
resultKind: "recorded-perception-qualification",
resultId,
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
configSha256: null,
runCreatedAtUtc: "2026-08-29T18:05:11.329061+00:00",
publishedAtUtc: "2026-08-30T12:00:00.000Z",
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
},
provenance: canonicalProvenance(),
...overrides,
};
}
function portableCapability() {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commandsEnabled: false,
};
}
function portableCapabilityDocument() {
return {
schema_version: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewer_profile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commands_enabled: false,
};
}
function portableLab(portableResultId, definitionSha256) {
return lab({
labId: "M4.9",
resultId: portableResultId,
resultKind: "recorded-route-analysis",
sourceResultId: null,
configSha256: definitionSha256,
replayCapability: portableCapability(),
provenance: {
schema_version: "missioncore.observatory-portable-result-publication/v1",
authority: {},
calculation_profile: {},
calculation_profile_sha256: "b".repeat(64),
job: {},
source: { session_id: sourceSessionId },
run_definition: { definition_sha256: definitionSha256 },
result_package: {
manifest_sha256: "c".repeat(64),
artifact_manifest_id: "d".repeat(64),
},
replay_capability: portableCapabilityDocument(),
storage: {},
method: {},
},
});
}
test("Observatory admits only the exact typed canonical recorded run", () => {
const binding = observatoryRecordedRunBinding(resultId, lab());
assert.deepEqual(binding, {
kind: "canonical-recorded-rerun",
evidenceSessionId: resultId,
sourceSessionId,
resultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
});
assert.equal(
observatoryRecordedRunBinding(resultId, lab({ replayCapability: null })),
null,
);
assert.throws(
() => observatoryRecordedRunBinding("different-session", lab()),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ sourceSessionId: "RAVNOVES004TREE" }),
),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ configSha256: "a".repeat(64) }),
),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ sourceResultId: "legacy-result" }),
),
ObservatoryRecordedRunContractError,
);
const invalidProvenance = canonicalProvenance();
invalidProvenance.method.components[0].identity_sha256 = "0".repeat(64);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ provenance: invalidProvenance }),
),
ObservatoryRecordedRunContractError,
);
});
test("Observatory rechecks the selected source and sealed review before mounting replay", () => {
const binding = observatoryRecordedRunBinding(resultId, lab());
const review = { sessionId: sourceSessionId, frameCount: 6830 };
assert.equal(
admitObservatoryRecordedRunReview(
binding,
sourceSessionId,
{ resultId, routeFullReview: review },
),
review,
);
assert.throws(
() => admitObservatoryRecordedRunReview(
binding,
"another-session",
{ resultId, routeFullReview: review },
),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => admitObservatoryRecordedRunReview(
binding,
sourceSessionId,
{ resultId, routeFullReview: { ...review, sessionId: "another-session" } },
),
ObservatoryRecordedRunContractError,
);
});
test("Observatory selects and strictly decodes a portable result viewer by capability", async () => {
const portableResultId = "portable-result-a";
const definitionSha256 = "e".repeat(64);
const binding = observatoryRecordedRunBinding(
portableResultId,
portableLab(portableResultId, definitionSha256),
);
assert.deepEqual(binding, {
kind: "portable-result-review",
evidenceSessionId: portableResultId,
sourceSessionId,
resultId: portableResultId,
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
definitionSha256,
});
let requestedUrl = null;
const review = await fetchObservatoryRecordedRunReview(binding, {
selectedSourceSessionId: sourceSessionId,
fetcher: async (url) => {
requestedUrl = url;
return {
ok: true,
status: 200,
json: async () => ({
schema_version: "missioncore.observatory-portable-result-view/v1",
result_id: portableResultId,
source_session_id: sourceSessionId,
result_kind: "recorded-route-analysis",
definition_sha256: definitionSha256,
viewer_capability: portableCapabilityDocument(),
calculation_profile: { name: "M4.9" },
artifact_manifest_id: "d".repeat(64),
artifacts: [{
role: "result-document",
media_type: "application/json",
sha256: "f".repeat(64),
byte_length: 42,
}],
result_document: { verdict: "reviewed" },
}),
};
},
});
assert.equal(
requestedUrl,
`/api/v1/observatory/portable-results/${portableResultId}`,
);
assert.equal(review.kind, "portable-result");
assert.deepEqual(review.resultDocument, { verdict: "reviewed" });
assert.deepEqual(review.artifacts, [{
role: "result-document",
mediaType: "application/json",
sha256: "f".repeat(64),
byteLength: 42,
}]);
});
test("Observatory run admission remains metadata-only until explicit UI activation", async () => {
const source = await readFile(
new URL("../src/core/observatory/recordedRun.ts", import.meta.url),
"utf8",
);
assert.match(source, /fetchVegetationShadowResultMetadata/);
assert.doesNotMatch(
source,
/fetchVegetationShadowResult\(|advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport|recording\.rrd/,
);
});
@@ -0,0 +1,294 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let productModel;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
productModel = await server.ssrLoadModule("/src/productModel.ts");
});
after(async () => {
await server?.close();
});
async function read(relativePath) {
return readFile(new URL(`../src/${relativePath}`, import.meta.url), "utf8");
}
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
);
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
});
test("Observatory mounts the one shared canonical replay only after explicit admission", async () => {
const [
app,
workspaceHub,
workspace,
hook,
recordedRun,
sharedReplay,
legacyReplayWrapper,
viewerProfiles,
styles,
] = await Promise.all([
read("App.tsx"),
read("workspaces/Workspaces.tsx"),
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
read("core/observatory/recordedRun.ts"),
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("core/observation/viewerProfile.ts"),
read("styles/observatory.css"),
]);
assert.match(app, /activeDefinition\.kind === "observatory"[\s\S]*Только наблюдение/);
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
assert.match(workspace, /useObservatoryCatalog/);
assert.match(workspace, /Связанных результатов нет/);
assert.match(workspace, /не является выводом о качестве/);
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
assert.match(workspace, /Полнота исторических/);
assert.match(workspace, /вне текущего загруженного среза/);
assert.match(workspace, /observatory-notice__copy/);
assert.match(workspace, /observatory-evidence-card/);
assert.match(workspace, /observatory-session-stack/);
assert.match(workspace, /observatory-session-summary__facts/);
assert.match(workspace, /observatory-evidence-card__copy/);
assert.match(
workspace,
/function evidenceResultSubtitle[\s\S]*calculationProfile\?\.displayName[\s\S]*`\$\{evidence\.label\} · \$\{profileName\}`[\s\S]*: evidence\.label/,
);
assert.match(
workspace,
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
);
assert.match(
workspace,
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
);
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
assert.match(workspace, /Открыть визуальный разбор/);
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
assert.match(workspace, /<summary>Документ результата<\/summary>/);
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/);
assert.match(workspace, /Закрыть разбор/);
assert.match(
workspace,
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
);
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
assert.match(workspace, /data-observatory-authority="observation-only"/);
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
assert.doesNotMatch(
`${workspace}\n${hook}`,
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.match(sharedReplay, /timelineStartSeconds/);
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
assert.match(
legacyReplayWrapper,
/export \{ CanonicalVegetationRerunReplay \} from "\.\.\/\.\.\/components\/laboratory\/CanonicalVegetationRerunReplay"/,
);
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
assert.doesNotMatch(recordedRun, /advanced-index|recording\.rrd|canonical-replay\.rrd/);
assert.doesNotMatch(workspace, /workspaces\/laboratory|LaboratoryRecordedClipPlayer|SimulationViewport/);
assert.match(viewerProfiles, /kind: "live-acquisition"/);
assert.match(viewerProfiles, /kind: "recorded-session"/);
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
assert.match(
styles,
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
);
assert.match(
styles,
/\.observatory-session-summary \{[\s\S]*grid-template-columns:[\s\S]*\.observatory-evidence-card \{[\s\S]*background: var\(--nodedc-glass-control-bg\);/,
);
assert.match(
styles,
/\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/,
);
assert.match(
styles,
/\.observatory-workspace \{[\s\S]*grid-auto-rows: max-content;[\s\S]*align-content: start;/,
);
assert.match(
styles,
/\.observatory-session-summary__facts dd \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
);
assert.match(
styles,
/\.observatory-catalog-bar h3,[\s\S]*\.observatory-session-summary h3,[\s\S]*\.observatory-evidence h3 \{[\s\S]*font-size: var\(--nodedc-font-size-lg\);/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 46rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 38rem\) \{[\s\S]*\.observatory-session-summary__facts \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
);
assert.doesNotMatch(styles, /\.observatory-evidence-card[\s\S]*background:\s*(?:#0{3,6}|black|rgb\(0[ ,])/i);
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
});
test("Observatory rename and delete use admitted projection mutations and canonical windows", async () => {
const [workspace, hook] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
]);
assert.match(
workspace,
/deleteObservatoryLabProjection,[\s\S]*renameObservatoryLabProjection,[\s\S]*from "\.\.\/\.\.\/core\/observatory\/catalogMutations"/,
);
assert.match(workspace, /<Window[\s\S]*title="Переименовать лабораторный результат"/);
assert.match(workspace, /<TextField[\s\S]*label="Название"[\s\S]*maxLength=\{160\}/);
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
assert.match(
workspace,
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
);
assert.match(
workspace,
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*evidence\?\.label === reconciliation\.displayName[\s\S]*setRenameTarget\(null\);/,
);
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const teardown = deleteFlow.indexOf("closeReplay();");
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
const refresh = deleteFlow.indexOf("void controller.refresh();");
assert.ok(teardown >= 0 && teardown < remove, "active replay must unmount before projection delete");
assert.ok(remove < tombstone, "local tombstone must follow the exact empty 204");
assert.ok(tombstone < refresh, "reconciliation refresh must follow the local tombstone");
assert.match(deleteFlow, /flushSync\(\(\) => \{[\s\S]*closeReplay\(\);[\s\S]*\}\);/);
assert.match(
deleteFlow,
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*observatoryCatalogConfirmsEvidenceDeletion\([\s\S]*setDeleteTarget\(null\);/,
);
assert.match(workspace, /mutationError[\s\S]*role="alert"/);
assert.match(hook, /activeRequestRef\.current\?\.controller\.abort\(\)/);
assert.match(hook, /requestSequenceRef\.current !== id/);
assert.match(hook, /const requestMutationRevision = mutationRevisionRef\.current/);
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
});
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("styles/observatory.css"),
read("core/observatory/useObservatoryLaboratorySetups.ts"),
read("core/observatory/useObservatoryRecordedJobs.ts"),
]);
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
assert.match(workspace, /setupController\.error \? \(/);
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
assert.match(workspace, /useObservatoryRecordedJobs/);
assert.match(
workspace,
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
);
assert.match(
workspace,
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
);
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
assert.match(workspace, /setupController\.selectableSetups\.map/);
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
assert.match(workspace, /aria-busy=\{calculationPending\}/);
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
assert.doesNotMatch(runBar, /StatusBadge/);
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
assert.match(
workspace,
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
);
assert.match(
jobsHook,
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
);
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
assert.match(jobsHook, /globalThis\.setTimeout/);
assert.doesNotMatch(jobsHook, /setInterval/);
assert.match(workspace, /job\.publication\.state === "published"/);
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
assert.match(
styles,
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
);
assert.match(
styles,
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
);
assert.match(
styles,
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
);
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
assert.match(setupHook, /preflightRequest\.current !== request/);
assert.match(
setupHook,
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
);
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets"); assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual( assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id), workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations"], ["lab-archive", "simulations", "observatory"],
); );
assert.equal( assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"), workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -27,6 +27,19 @@ test("top navigation has no Center and Park owns contour health first", () => {
); );
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health"); assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet"); assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
assert.deepEqual(
{
root: productModel.workspaceById("observatory")?.root,
kind: productModel.workspaceById("observatory")?.kind,
icon: productModel.workspaceById("observatory")?.icon,
},
{ root: "polygon", kind: "observatory", icon: "eye" },
);
}); });
test("every laboratory result uses the shared evidence template", async () => { test("every laboratory result uses the shared evidence template", async () => {
@@ -11,7 +11,12 @@ let recordedMediaSeekableCoverage;
let recordedMediaFragmentUrl; let recordedMediaFragmentUrl;
let recordedMediaDecodeStartSequence; let recordedMediaDecodeStartSequence;
let recordedMediaSegmentAppendOrder; let recordedMediaSegmentAppendOrder;
let recordedMediaSegmentSequenceAtTime;
let recordedMediaCanRollTarget; let recordedMediaCanRollTarget;
let recordedMediaTimestampStallRecoveryTarget;
let nextRecordedMediaRandomAccessSequence;
let recordedMediaRecoveryTargetSequence;
let selectRecordedMediaPreparationEpoch;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -26,7 +31,12 @@ before(async () => {
recordedMediaFragmentUrl, recordedMediaFragmentUrl,
recordedMediaDecodeStartSequence, recordedMediaDecodeStartSequence,
recordedMediaSegmentAppendOrder, recordedMediaSegmentAppendOrder,
recordedMediaSegmentSequenceAtTime,
recordedMediaCanRollTarget, recordedMediaCanRollTarget,
recordedMediaTimestampStallRecoveryTarget,
nextRecordedMediaRandomAccessSequence,
recordedMediaRecoveryTargetSequence,
selectRecordedMediaPreparationEpoch,
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx")); } = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
}); });
@@ -161,7 +171,7 @@ test("decoded duration and seekable range cover the complete declared epoch", ()
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false); assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
}); });
test("recorded player keeps full-archive range fallback and uses bounded generation-bound fragments", async () => { test("production replay derives bounded fragments and retains native range fallback", async () => {
const source = await readFile( const source = await readFile(
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
"utf8", "utf8",
@@ -176,6 +186,13 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
assert.match(source, /hasPresentedFrame/); assert.match(source, /hasPresentedFrame/);
assert.match(source, /retainForwardFrame/); assert.match(source, /retainForwardFrame/);
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/); assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
assert.match(source, /effectiveSegmentCount = segmentCount \?\? epoch\?\.segmentCount \?\? null/);
assert.match(source, /requestedSegmentSequence = segmentSequence \?\?/);
assert.match(source, /waitForRecordedVideoInitialFrame/);
assert.match(source, /setSegmentRecoveryGeneration/);
assert.match(source, /resumePlaybackIfRequested/);
assert.match(source, /!playbackPlayingRef\.current/);
assert.match(source, /await video\.play\(\)/);
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491); assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
assert.deepEqual( assert.deepEqual(
@@ -209,6 +226,44 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
); );
}); });
test("production replay derives its bounded fragment directly from the source clock", () => {
const ends = [0.101, 0.185, 0.286, 0.401];
const epochStart = 39.215263458;
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart), 1);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.101), 1);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.103), 2);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.4), 4);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 1), null);
});
test("a corrupt fragment advances recovery to the next random-access frame", () => {
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 1), 11);
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 20), 21);
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 49), null);
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 0), null);
});
test("paused seek displays the recovery keyframe until the source clock leaves the corrupt GOP", () => {
assert.equal(recordedMediaRecoveryTargetSequence(120, 120, 149), 149);
assert.equal(recordedMediaRecoveryTargetSequence(130, 120, 149), 149);
assert.equal(recordedMediaRecoveryTargetSequence(119, 120, 149), 119);
assert.equal(recordedMediaRecoveryTargetSequence(149, 120, 149), 149);
assert.equal(recordedMediaRecoveryTargetSequence(151, 120, 149), 151);
assert.equal(recordedMediaRecoveryTargetSequence(null, 120, 149), null);
assert.equal(recordedMediaRecoveryTargetSequence(120, null, null), 120);
});
test("camera admission selects one bounded epoch while the shared clock is outside video", () => {
const epochs = [
{ ordinal: 1, timelineStartSeconds: 39, timelineEndSeconds: 100 },
{ ordinal: 2, timelineStartSeconds: 120, timelineEndSeconds: 180 },
];
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 0).ordinal, 1);
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 70).ordinal, 1);
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 110).ordinal, 2);
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 200).ordinal, 2);
});
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => { test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true); assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true); assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
@@ -217,6 +272,12 @@ test("recorded player preserves forward rolling playback but seeks backward clip
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false); assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
}); });
test("recorded player skips only a proven buffered corrupt timestamp interval", () => {
assert.equal(recordedMediaTimestampStallRecoveryTarget(11.422, [[0, 16.287]]), 11.602);
assert.equal(recordedMediaTimestampStallRecoveryTarget(16.25, [[0, 16.287]]), null);
assert.equal(recordedMediaTimestampStallRecoveryTarget(20, [[0, 16.287]]), null);
});
test("loading and error overlays fully conceal recorded camera pixels", async () => { test("loading and error overlays fully conceal recorded camera pixels", async () => {
const css = await readFile( const css = await readFile(
new URL("../src/styles/observation.css", import.meta.url), new URL("../src/styles/observation.css", import.meta.url),
@@ -34,7 +34,7 @@ function camera(phase, byteLength = 1_024) {
return { phase, byteLength, message: null }; return { phase, byteLength, message: null };
} }
test("recorded session admits RRD and every declared camera as one atomic generation", () => { test("recorded session keeps camera and timeline atomic without hiding a ready spatial frame", () => {
const ids = ["camera.left", "camera.right"]; const ids = ["camera.left", "camera.right"];
const oneCamera = { const oneCamera = {
"camera.left": camera("ready"), "camera.left": camera("ready"),
@@ -42,7 +42,7 @@ test("recorded session admits RRD and every declared camera as one atomic genera
}; };
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera); const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
assert.equal(partialGate, "loading"); assert.equal(partialGate, "loading");
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading"); assert.equal(rerunPresentationStatus("ready", partialGate, true), "ready");
assert.equal( assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate), recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
"loading", "loading",
@@ -71,7 +71,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
...cameras, ...cameras,
"camera.right": camera("ready"), "camera.right": camera("ready"),
}), "error"); }), "error");
assert.equal(rerunPresentationStatus("ready", "error", true), "error"); assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
assert.equal( assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, "error"), recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
"error", "error",
@@ -13,7 +13,6 @@ let liveTimelineNeedsSynchronization;
let liveRerunReceiverBindingIdentity; let liveRerunReceiverBindingIdentity;
let recordedOpenWatchdogTimeoutMs; let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource; let rerunViewerInitialSource;
let rerunViewerOpenOptions;
let resolveRecordedViewerSourceUrl; let resolveRecordedViewerSourceUrl;
before(async () => { before(async () => {
@@ -31,7 +30,6 @@ before(async () => {
liveRerunReceiverBindingIdentity, liveRerunReceiverBindingIdentity,
recordedOpenWatchdogTimeoutMs, recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource, rerunViewerInitialSource,
rerunViewerOpenOptions,
resolveRecordedViewerSourceUrl, resolveRecordedViewerSourceUrl,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx")); } = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
}); });
@@ -71,9 +69,21 @@ test("only the canonical digest-bound generation URL reaches the native Rerun re
} }
}); });
test("only the live receiver opens on the native following edge", () => { test("one canonical LAB replay generation reaches the same native receiver", () => {
assert.deepEqual(rerunViewerOpenOptions(true), { follow_if_http: true }); const labSource =
assert.equal(rerunViewerOpenOptions(false), null); `/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"c".repeat(64)}`
+ "/canonical-replay.rrd";
const descriptor = {
sourceUrl: labSource,
viewerSourceUrl: `${labSource}?generation=${sha256}`,
byteLength: 300_000_000,
sha256,
};
assert.equal(
resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test"),
`http://mission-core.test${descriptor.viewerSourceUrl}`,
);
}); });
test("live presentation waits for the exact receiver to expose a usable range", () => { test("live presentation waits for the exact receiver to expose a usable range", () => {
@@ -216,7 +226,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.doesNotMatch(source, /missioncore\/recorded-recording/); assert.doesNotMatch(source, /missioncore\/recorded-recording/);
assert.doesNotMatch(source, /recordedChannel/); assert.doesNotMatch(source, /recordedChannel/);
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s); assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.match(source, /rerunViewerOpenOptions\(followLive\)/); assert.doesNotMatch(source, /rerunViewerOpenOptions/);
assert.match( assert.match(
source, source,
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/, /recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
@@ -257,6 +267,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
source, source,
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/, /if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
); );
assert.match(
source,
/reloadRecordedViewerAfterStaleModuleFailure\(\{[\s\S]*loadedUiBuildId: diagnosticLifecycle\.lineage\.uiBuildId/,
);
assert.match(
source,
/!recordedPerceptionUrl \|\| !recordedPerceptionLayers\.enabled/,
);
}); });
test("one live document owns one native Rerun receiver", async () => { test("one live document owns one native Rerun receiver", async () => {
@@ -287,8 +305,12 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
source, source,
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/, /const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
); );
assert.match(source, /followLive=\{liveRerunSource\}/); assert.match(
assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/); source,
/liveAcquisitionRerunProfile\(\{[\s\S]*liveActivitySequence: livePresentationActivitySequence/,
);
assert.match(source, /<RerunViewport[\s\S]*profile=\{rerunViewerProfile\}/);
assert.doesNotMatch(source, /followLive=\{liveRerunSource\}/);
assert.match( assert.match(
source, source,
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/, /sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
@@ -6,11 +6,13 @@ import { createServer } from "vite";
let server; let server;
let canPublishRecordedPlaybackController; let canPublishRecordedPlaybackController;
let createRecordedAutoplayGate; let createRecordedAutoplayGate;
let createRecordedInitialSeekGate;
let isRecordedPlaybackReady; let isRecordedPlaybackReady;
let isRecordedPlaybackPresentationReady; let isRecordedPlaybackPresentationReady;
let isUsableRecordedPlaybackRange; let isUsableRecordedPlaybackRange;
let recordedPlaybackBufferState; let recordedPlaybackBufferState;
let recordedPlaybackRangeWhenReady; let recordedPlaybackRangeWhenReady;
let rerunPresentationStatus;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -21,11 +23,13 @@ before(async () => {
({ ({
canPublishRecordedPlaybackController, canPublishRecordedPlaybackController,
createRecordedAutoplayGate, createRecordedAutoplayGate,
createRecordedInitialSeekGate,
isRecordedPlaybackReady, isRecordedPlaybackReady,
isRecordedPlaybackPresentationReady, isRecordedPlaybackPresentationReady,
isUsableRecordedPlaybackRange, isUsableRecordedPlaybackRange,
recordedPlaybackBufferState, recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady, recordedPlaybackRangeWhenReady,
rerunPresentationStatus,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx")); } = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
}); });
@@ -33,7 +37,7 @@ after(async () => {
await server?.close(); await server?.close();
}); });
test("a first frame reports buffer telemetry but is not ready for presentation", () => { test("a verified first frame is presentable while full-range controls stay closed", () => {
assert.equal(isUsableRecordedPlaybackRange(null), false); assert.equal(isUsableRecordedPlaybackRange(null), false);
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false); assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false); assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
@@ -47,8 +51,11 @@ test("a first frame reports buffer telemetry but is not ready for presentation",
bufferProgress: 0, bufferProgress: 0,
fullyBuffered: false, fullyBuffered: false,
}); });
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false); assert.equal(isRecordedPlaybackReady(true, true, firstFrame), true);
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null); assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
assert.equal(rerunPresentationStatus("ready", "loading", true), "ready");
assert.equal(rerunPresentationStatus("loading", "ready", true), "loading");
assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
}); });
test("host timeline remains unmounted until the verified recording is fully ready", () => { test("host timeline remains unmounted until the verified recording is fully ready", () => {
@@ -107,7 +114,7 @@ test("buffer progress grows independently and preserves the full-buffer toleranc
); );
assert.equal(missingDeclaredStart.bufferProgress, 1); assert.equal(missingDeclaredStart.bufferProgress, 1);
assert.equal(missingDeclaredStart.fullyBuffered, false); assert.equal(missingDeclaredStart.fullyBuffered, false);
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), false); assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), true);
}); });
test("a verified split boundary spill covers and clamps the declared LAB window", () => { test("a verified split boundary spill covers and clamps the declared LAB window", () => {
@@ -154,6 +161,31 @@ test("recorded autoplay waits for the full range and then runs exactly once", ()
assert.equal(gate.attempted(), true); assert.equal(gate.attempted(), true);
}); });
test("paused recorded replay seeks once to its first presentable frame", () => {
const gate = createRecordedInitialSeekGate();
const seeks = [];
const range = { min: 0, max: 535_717_620_042 };
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
39_215_263_458,
), true);
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
50_000_000_000,
), false);
assert.deepEqual(seeks, [39_215_263_458]);
assert.equal(gate.attempted(), true);
});
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => { test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
const gate = createRecordedAutoplayGate(); const gate = createRecordedAutoplayGate();
const seeks = []; const seeks = [];
@@ -1,81 +1,36 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import test from "node:test"; import test from "node:test";
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
const root = resolve(import.meta.dirname, ".."); const root = resolve(import.meta.dirname, "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer"); const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1"); const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
const sha256 = (path) => test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
createHash("sha256").update(readFileSync(path)).digest("hex"); const application = readJson(resolve(root, "package.json"));
const installed = readJson(resolve(packageRoot, "package.json"));
test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => { assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")); assert.equal(installed.version, "0.36.3");
assert.equal(manifest.version, "0.34.1"); assert.equal(application.scripts.postinstall, undefined);
const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
}); });
test("custom JavaScript glue references only exports present in its paired WASM", () => { test("the active application never imports or installs the archived vendor fork", () => {
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"); const application = readFileSync(resolve(root, "package.json"), "utf8");
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js"); const viewport = readFileSync(resolve(root, "src/components/RerunViewport.tsx"), "utf8");
const module = new WebAssembly.Module(readFileSync(wasmPath));
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
const imports = WebAssembly.Module.imports(module);
const glue = readFileSync(gluePath, "utf8");
const referencedExports = new Set(
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
);
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
assert.equal(imports.length, 927); assert.doesNotMatch(application, /patch-rerun-web-viewer/);
assert.equal(exports.size, 79); assert.doesNotMatch(application, /vendor\/rerun-web-viewer/);
assert.deepEqual(missingExports, []); assert.doesNotMatch(viewport, /vendor\/rerun-web-viewer/);
assert.match(glue, /export default function\(\)/); assert.doesNotMatch(viewport, /0\.34\.1/);
assert.match(glue, /if \(!wasm\) return;/);
}); });
test("custom Rerun WASM initializes and grows its externref table", () => { test("upstream WebViewer exposes one three-argument start contract", () => {
const runtime = makeRerunRuntime(); const declaration = readFileSync(resolve(packageRoot, "index.d.ts"), "utf8");
runtime.initSync({
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
});
assert.equal(typeof runtime.WebHandle, "function");
runtime.deinit();
});
test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => {
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
assert.match(patch, /fn pointer_ray_direction/);
assert.match(patch, /fn zoom_orbit_towards_pointer/);
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
assert.match(patch, /fn rotate_radians_around_anchor/);
assert.match(patch, /orbit_drag_anchor/);
assert.match(patch, /minimum_orbital_navigation_speed/);
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/);
assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/);
assert.match( assert.match(
patch, declaration,
/persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/, /start\(rrd: string \| string\[\] \| null, parent: HTMLElement \| null, options: WebViewerOptions \| null\): Promise<void>/,
); );
assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/);
assert.match(patch, /previous_picking_result/);
}); });
@@ -75,20 +75,29 @@ test("semantic point alignment follows the last qualified spatial increment", as
}); });
test("M4 keeps independent semantic controls in media and spatial panes", async () => { test("M4 keeps independent semantic controls in media and spatial panes", async () => {
const source = await readFile( const [source, canonical] = await Promise.all([
readFile(
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
"utf8", "utf8",
); ),
readFile(
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
"utf8",
),
]);
assert.match(source, /showMediaSemantic/); assert.match(source, /showMediaSemantic/);
assert.match(source, /showSpatialSemantic/); assert.match(source, /showSpatialSemantic/);
assert.match(source, /semantic && showMediaSemantic && frame/); assert.match(source, /activeSpatialSemantic = spatialSemantic \?\? activeSemantic/);
assert.match(source, /spatialSemanticClasses/);
assert.match(source, /spatialSemanticPalette/);
assert.match(source, /activeSemantic && evidenceDemand\.selectedSemanticMask && frame/);
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/); assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
assert.match(source, /\|\| !showSpatialSemantic/); assert.match(source, /\|\| !showSpatialSemantic/);
assert.match(source, /aria-label="Слои камеры и видео"/); assert.match(source, /aria-label="Слои камеры и видео"/);
assert.match(source, /aria-label="Слои 3D и плана"/); assert.match(source, /aria-label="Слои 3D и плана"/);
assert.match(source, /data-pane-mode="media"/); assert.match(canonical, /data-pane-mode="media"/);
assert.match(source, /data-pane-mode="spatial"/); assert.match(canonical, /data-pane-mode="spatial"/);
assert.match(source, /modeControlsVisible=\{!splitView\}/); assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/); assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
}); });
@@ -100,6 +100,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/); assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/);
assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/); assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/);
assert.match(runtime, /app\.autoRender = false/); assert.match(runtime, /app\.autoRender = false/);
assert.match(runtime, /app\.systems\.gsplat\?\.on\("frame:request", this\.requestGsplatFrame\)/);
assert.match(runtime, /app\.systems\.gsplat\?\.off\("frame:request", this\.requestGsplatFrame\)/);
assert.match(runtime, /app\.on\("update", this\.updateFrame\)/); assert.match(runtime, /app\.on\("update", this\.updateFrame\)/);
assert.match(runtime, /if \(!cameraChanged && this\.controlMode === "free"\) return/); assert.match(runtime, /if \(!cameraChanged && this\.controlMode === "free"\) return/);
assert.doesNotMatch(runtime, /app\.on\("frameupdate"/); assert.doesNotMatch(runtime, /app\.on\("frameupdate"/);
@@ -162,6 +164,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(ugv, /new this\.ammo\.btRaycastVehicle/); assert.match(ugv, /new this\.ammo\.btRaycastVehicle/);
assert.match(ugv, /type: "mesh"/); assert.match(ugv, /type: "mesh"/);
assert.match(ugv, /MeshoptSimplifier\.simplify/); assert.match(ugv, /MeshoptSimplifier\.simplify/);
assert.match(ugv, /MeshoptSimplifier\.simplifySloppy/);
assert.match(ugv, /Physics proxy превысил лимит/);
assert.match(ugv, /PHYSICS_PROXY_MAX_TRIANGLES = 240_000/); assert.match(ugv, /PHYSICS_PROXY_MAX_TRIANGLES = 240_000/);
assert.match(ugv, /createPhysicsProxyMesh/); assert.match(ugv, /createPhysicsProxyMesh/);
assert.match(ugv, /findSafeSpawnPosition/); assert.match(ugv, /findSafeSpawnPosition/);
@@ -179,8 +183,33 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(ugv, /desiredSpeed = forwardInput \* maxSpeed/); assert.match(ugv, /desiredSpeed = forwardInput \* maxSpeed/);
assert.match(ugv, /maximumAcceleration = clamp\(1\.4 \+ maxSpeed \* 0\.35, 1\.8, 4\.2\)/); assert.match(ugv, /maximumAcceleration = clamp\(1\.4 \+ maxSpeed \* 0\.35, 1\.8, 4\.2\)/);
assert.match(ugv, /SERVICE_BRAKE_DECELERATION_MPS2 = 1\.8/); assert.match(ugv, /SERVICE_BRAKE_DECELERATION_MPS2 = 1\.8/);
assert.match(ugv, /horizontalSpeed - deceleration \* Math\.max\(0, deltaSeconds\)/); assert.match(ugv, /PARKING_BRAKE_HOLD_DECELERATION_MPS2 = 6/);
assert.match(ugv, /this\.vehicle\.setBrake\(0, index\)/); assert.match(ugv, /PARKING_BRAKE_ENGAGE_SPEED_MPS = 0\.08/);
assert.match(ugv, /TYRE_FRICTION_SLIP = 8\.5/);
assert.match(ugv, /TYRE_STATIC_FRICTION_COEFFICIENT = 0\.95/);
assert.match(ugv, /TYRE_KINETIC_FRICTION_COEFFICIENT = 0\.78/);
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND = 10/);
assert.match(ugv, /GRAVITY_METERS_PER_SECOND_SQUARED = 9\.81/);
assert.match(ugv, /holding = !braking && forwardInput === 0 && turnInput === 0/);
assert.match(ugv, /longitudinalSpeedMetersPerSecond/);
assert.match(ugv, /parkingBrakeEngaged = holding/);
assert.match(ugv, /this\.settings\.massKg \* brakeDeceleration/);
assert.match(ugv, /this\.vehicle\.setBrake\(wheelBrakeForce, index\)/);
assert.match(ugv, /set_m_frictionSlip\(\s*parkingBrakeEngaged \? 0 : TYRE_FRICTION_SLIP/);
assert.match(ugv, /applyParkingTyreContact/);
assert.match(ugv, /wheel\.get_m_wheelsSuspensionForce\(\)/);
assert.doesNotMatch(ugv, /get_m_isInContact/);
assert.match(ugv, /normalForce < MIN_TYRE_NORMAL_FORCE_NEWTONS/);
assert.match(ugv, /lateralGravityAcceleration/);
assert.match(ugv, /longitudinalGravityAcceleration/);
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND \* lateralSlipSpeed/);
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND \* longitudinalSlipSpeed/);
assert.match(ugv, /Math\.hypot\(trialLateralForce, trialLongitudinalForce\)/);
assert.match(ugv, /body\.applyImpulse\(this\.tyreImpulseNative, this\.tyreRelativePositionNative\)/);
assert.doesNotMatch(ugv, /horizontalSpeed - deceleration \* Math\.max\(0, deltaSeconds\)/);
assert.match(ugv, /rollingFriction: 0\.12/);
assert.match(ugv, /angularDamping: 0\.6/);
assert.match(ugv, /wheel\.set_m_frictionSlip\(TYRE_FRICTION_SLIP\)/);
assert.doesNotMatch(ugv, /massKg \* 3/); assert.doesNotMatch(ugv, /massKg \* 3/);
assert.match(ugv, /pureTurn = !braking && forwardInput === 0 && turnInput !== 0/); assert.match(ugv, /pureTurn = !braking && forwardInput === 0 && turnInput !== 0/);
assert.match(ugv, /desiredYawRate = -turnInput \* maxTurnRate/); assert.match(ugv, /desiredYawRate = -turnInput \* maxTurnRate/);
@@ -124,6 +124,11 @@ test("Compute-contour telemetry remains a bounded system feature slice", async (
assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/); assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/);
assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/); assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/);
assert.match(telemetryPolling, /startSequentialPolling/); assert.match(telemetryPolling, /startSequentialPolling/);
assert.match(telemetryPolling, /catch \(reason: unknown\)[\s\S]*?setTelemetry\(null\)/);
assert.match(computeWorkspace, /sm_clock_mhz/);
assert.match(computeWorkspace, /memory_clock_mhz/);
assert.match(computeWorkspace, /Ожидает восстановления данных/);
assert.doesNotMatch(computeWorkspace, /Очередь свободна; модели остаются загруженными/);
assert.doesNotMatch(telemetryPolling, /if\s*\(\s*!enabled\s*\|\|\s*loading\s*\)/); assert.doesNotMatch(telemetryPolling, /if\s*\(\s*!enabled\s*\|\|\s*loading\s*\)/);
assert.match(pollingScheduler, /A transient failure must not stop polling/); assert.match(pollingScheduler, /A transient failure must not stop polling/);
assert.match(styles, /system-telemetry\.css/); assert.match(styles, /system-telemetry\.css/);
@@ -6,7 +6,10 @@ import { createServer } from "vite";
let server; let server;
let fetchVegetationBenchmarkResult; let fetchVegetationBenchmarkResult;
let fetchCanonicalRecordedLabSpatialFrame;
let fetchVegetationShadowResult; let fetchVegetationShadowResult;
let fetchVegetationRouteTgsAnchor;
let vegetationFullRouteMaskUrl;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -14,9 +17,17 @@ before(async () => {
logLevel: "silent", logLevel: "silent",
server: { middlewareMode: true }, server: { middlewareMode: true },
}); });
({ fetchVegetationBenchmarkResult, fetchVegetationShadowResult } = await server.ssrLoadModule( ({
fetchVegetationBenchmarkResult,
fetchVegetationShadowResult,
fetchVegetationRouteTgsAnchor,
vegetationFullRouteMaskUrl,
} = await server.ssrLoadModule(
"/src/core/laboratory/vegetationShadow.ts", "/src/core/laboratory/vegetationShadow.ts",
)); ));
({ fetchCanonicalRecordedLabSpatialFrame } = await server.ssrLoadModule(
"/src/core/laboratory/canonicalRecordedLabSpatial.ts",
));
}); });
after(async () => { after(async () => {
@@ -148,6 +159,70 @@ function coarseRouteVideo() {
}; };
} }
function fullRouteReview() {
const layer = (kind) => ({
name: kind === "city" ? "EoMT Cityscapes" : "ddrnet_39",
result_id: kind === "city"
? `result-${"2".repeat(64)}`
: `lab-v1-ravnoves-video-ddrnet-${"3".repeat(64)}`,
frame_count: 6830,
taxonomy: {
schema_version: kind === "city"
? "missioncore.recorded-eomt-taxonomy/v1"
: "missioncore.lab-v1-vegetation-taxonomy/v1",
classes: Array.from({ length: kind === "city" ? 16 : 64 }, (_, classId) => ({
class_id: classId,
label: classId === 0 ? "undefined" : `${kind}-${classId}`,
color_rgb: [classId, classId, classId],
disposition: classId === 0 ? "undefined" : "prediction",
})),
},
mask_archive: {
path: kind === "city"
? "video/eomt-semantic-masks.zip"
: "video/ddrnet-semantic-masks.zip",
sha256: "4".repeat(64),
byte_length: 4096,
},
inference_fps: 9.5,
latency_p95_ms: 101.2,
peak_reserved_vram_bytes: 3_000_000_000,
});
return {
source_id: "RAVNOVES004TREE",
session_id: "20260828T130511Z_viewer_live",
linked_route_review_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
source_job_id: "recorded-camera-eb2783c5480d56bda07c8af0",
source_job_input_sha256: "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
source_stream_sha256: "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
recorded_media_source_id: "recorded.camera.6a3945242828a038",
recorded_media_generation_sha256: "b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded",
frame_count: 6830,
width: 800,
height: 600,
timeline_start_seconds: 39.215263458,
timeline_end_seconds: 757.260263458,
timeline: {
path: "video/frame-source-times-ns.bin",
sha256: "5".repeat(64),
byte_length: 6830 * 8,
encoding: "uint64-le-nanoseconds",
frame_count: 6830,
},
ground_truth: false,
decode_repair: {
repaired_frame_count: 1,
sequence: 6092,
method: "duplicate-previous-decoded-frame",
proofs: {
eomt: { path: "proofs/decode_repair.json", sha256: "7".repeat(64) },
ddrnet: { path: "proofs/ddrnet_decode_repair.json", sha256: "8".repeat(64) },
},
},
layers: { city: layer("city"), vegetation: layer("vegetation") },
};
}
function labPayload(route = routeVideo()) { function labPayload(route = routeVideo()) {
return { return {
schema_version: "missioncore.lab-v1-vegetation-shadow/v1", schema_version: "missioncore.lab-v1-vegetation-shadow/v1",
@@ -233,6 +308,47 @@ test("vegetation LAB parses coarse material policy and sealed TGS binding", asyn
assert.equal(result.routeVideo.fusionMode, "synchronised-multilayer-review"); assert.equal(result.routeVideo.fusionMode, "synchronised-multilayer-review");
}); });
test("vegetation LAB parses the full 004 pass inside the existing result contract", async () => {
const payload = {
...labPayload(null),
catalogs: { goose: [], ravnoves: [] },
route_full_review: fullRouteReview(),
};
const timeline = new ArrayBuffer(6830 * 8);
const timelineView = new DataView(timeline);
for (let index = 0; index < 6830; index += 1) {
timelineView.setBigUint64(
index * 8,
BigInt(39_215_263_458 + index * 100_000_000),
true,
);
}
const result = await fetchVegetationShadowResult(resultId, {
fetcher: async (url) => String(url).endsWith("/route-timeline")
? new Response(timeline, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
ETag: `"${"5".repeat(64)}"`,
},
})
: new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
});
assert.equal(result.routeVideo, null);
assert.equal(result.routeFullReview.frameCount, 6830);
assert.equal(result.routeFullReview.city.taxonomy.length, 16);
assert.equal(result.routeFullReview.vegetation.taxonomy.length, 64);
assert.equal(result.routeFullReview.decodeRepair.sequence, 6092);
assert.equal(result.routeFullReview.frameSourceTimesNs.length, 6830);
assert.equal(
vegetationFullRouteMaskUrl(resultId, "vegetation", 6829),
`/api/v1/laboratory/vegetation-shadow/${resultId}/route-masks/vegetation/6829`,
);
});
test("vegetation GOOSE benchmark opens through its separate archival endpoint", async () => { test("vegetation GOOSE benchmark opens through its separate archival endpoint", async () => {
let requestedUrl = ""; let requestedUrl = "";
const result = await fetchVegetationBenchmarkResult(benchmarkResultId, { const result = await fetchVegetationBenchmarkResult(benchmarkResultId, {
@@ -255,8 +371,88 @@ test("vegetation GOOSE benchmark opens through its separate archival endpoint",
assert.equal(result.validationCases.length, 12); assert.equal(result.validationCases.length, 12);
}); });
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => { test("vegetation route TGS anchor keeps exact sealed metric shapes", async () => {
const [resultSource, benchmarkSource] = await Promise.all([ let requestedUrl = "";
const anchor = await fetchVegetationRouteTgsAnchor(resultId, 409, {
fetcher: async (url) => {
requestedUrl = String(url);
return new Response(JSON.stringify({
schema_version: "missioncore.lab-v1-route-tgs-anchor/v1",
source_sequence: 409,
slot: 1,
current_points_xyz_m: [[1, 2, 3], [4, 5, 6]],
costmap: {
cell_size_m: 0.45,
centers_xy_m: Array.from({ length: 2244 }, (_, index) => [index, -index]),
state_codes: Array.from({ length: 2244 }, (_, index) => index % 4),
z_bounds_m: Array.from({ length: 2244 }, () => [null, null]),
},
}), { status: 200, headers: { "Content-Type": "application/json" } });
},
});
assert.equal(
requestedUrl,
`/api/v1/laboratory/vegetation-shadow/${resultId}/route-tgs-anchor/409`,
);
assert.equal(anchor.sourceSequence, 409);
assert.equal(anchor.currentPointsXyzM.length, 2);
assert.equal(anchor.costmap.centersXyM.length, 2244);
assert.deepEqual(new Set(anchor.costmap.stateCodes), new Set([0, 1, 2, 3]));
});
test("canonical recorded LAB spatial frame keeps source, SLAM and body identity on one clock", async () => {
const generation = "e".repeat(64);
let requestedUrl = "";
const frame = await fetchCanonicalRecordedLabSpatialFrame("session-004", generation, 82_770_000_000, {
fetcher: async (url) => {
requestedUrl = String(url);
return new Response(JSON.stringify({
schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v3",
target_time_ns: 82_770_000_000,
source_time_ns: 82_769_535_708,
pose_time_ns: 82_769_535_708,
trajectory_time_ns: 82_700_000_000,
coordinate_frame: "body-ground",
sensor_height: {
meters: 0.32,
source: "local-source-cloud-ground-quantile-median",
sample_count: 20,
mad_m: 0.03,
authority: "visual-derived",
},
spatial_profile: {
profile_id: "source-paced-ground-v3",
local_slam_history_seconds: 5,
local_slam_radius_m: 30,
local_slam_voxel_size_m: 0.12,
local_slam_point_limit: 27000,
},
source_point_count: 2,
source_points_body_xyz_m: [[1, 2, 3], [4, 5, 6]],
local_slam_source_frame_count: 2,
local_slam_source_point_count: 4,
local_slam_point_count: 2,
local_slam_body_xyz_m: [[0, 0, 0], [1, 0, 0]],
body_frame: {
origin_map_xyz_m: [33, 4, 1],
sensor_origin_map_xyz_m: [33, 4, 1.32],
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
},
}), { status: 200, headers: { "Content-Type": "application/json" } });
},
});
assert.equal(
requestedUrl,
`/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000&profile=source-paced-ground-v3`,
);
assert.equal(frame.sourcePointCount, 2);
assert.equal(frame.localSlamBodyXyzM.length, 2);
assert.equal(frame.sensorHeight.meters, 0.32);
assert.deepEqual(frame.bodyFrame.originMapXyzM, [33, 4, 1]);
});
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([
readFile( readFile(
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url), new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
"utf8", "utf8",
@@ -265,14 +461,79 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
new URL("../src/workspaces/laboratory/VegetationBenchmarkResult.tsx", import.meta.url), new URL("../src/workspaces/laboratory/VegetationBenchmarkResult.tsx", import.meta.url),
"utf8", "utf8",
), ),
readFile(
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../src/styles/m4-replay-threat.css", import.meta.url),
"utf8",
),
]); ]);
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/); assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
assert.match(resultSource, /M4ReplayThreatVisual/);
assert.match(resultSource, /M49TgsFullShadowEvidence/); assert.match(resultSource, /M49TgsFullShadowEvidence/);
assert.match(resultSource, /semanticOverride/); assert.match(resultSource, /semanticOverride/);
assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/); assert.match(m49Source, /spatialSemantic=\{spatialSemantic\}/);
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 3); assert.match(m49Source, /controlLabel: "SEMANTICS"/);
assert.match(resultSource, /RAVNOVES004TREE mixed route review/); assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
assert.match(resultSource, /КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE/);
assert.match(resultSource, /<CanonicalVegetationRerunReplay/);
assert.doesNotMatch(resultSource, /RerunViewport/);
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
assert.match(rerunSource, /<RerunViewport/);
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
assert.match(rerunSource, /ЛОК\. SLAM/);
assert.match(rerunSource, /resolveCanonicalLabReplay/);
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
assert.match(rerunSource, /unifiedPerception: splitView/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
assert.match(rerunSource, /presentationReady && playback && playbackController/);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay:not\(\[data-presentation-state="ready"\]\)[\s\S]*laboratory-evidence-viewer__controls/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
);
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
assert.match(canonicalSource, /primary=\{mediaPane\}/);
assert.match(canonicalSource, /secondary=\{spatialPane/);
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/); assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/); assert.match(benchmarkSource, /M48MaskComparisonVisual/);
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/); assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
let liveAcquisitionRerunProfile;
let recordedSessionRerunProfile;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
liveAcquisitionRerunProfile,
recordedSessionRerunProfile,
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
});
after(async () => {
await server?.close();
});
test("live acquisition profile cannot acquire recorded playback policy", () => {
assert.deepEqual(liveAcquisitionRerunProfile({
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
liveActivitySequence: 12,
liveStreamId: "acquisition-1",
liveRecoveryAuthorityIdentity: "authority-1",
}), {
kind: "live-acquisition",
clock: "stream_time",
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
liveActivitySequence: 12,
liveStreamId: "acquisition-1",
liveRecoveryAuthorityIdentity: "authority-1",
});
});
test("recorded session profile owns progressive admission and on-demand layers", () => {
const artifact = {
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
viewerSourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd?generation=abc",
byteLength: 42,
sha256: "a".repeat(64),
};
const profile = recordedSessionRerunProfile({
sourceUrl: artifact.sourceUrl,
artifact,
autoplayWhenReady: true,
presentationGate: "loading",
expectedTimelineStartSeconds: 0,
expectedTimelineEndSeconds: 20,
initialPlaybackStartSeconds: 1,
view: "spatial",
viewResetGeneration: 0,
followTrajectory: false,
perceptionLayers: {
enabled: false,
detections2d: false,
segmentation: false,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
});
assert.equal(profile.kind, "recorded-session");
assert.equal(profile.clock, "session_time");
assert.equal(profile.artifact, artifact);
assert.equal(profile.perceptionLayers.enabled, false);
});
test("the old LAB transport is fenced as explicit legacy comparison only", () => {
assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, {
kind: "lab-recorded-evidence",
clock: "source-sequence",
cameraTransport: "generation-bound-fmp4",
spatialTransport: "bounded-sealed-artifacts",
loadPolicy: "explicit-legacy-comparison-only",
workerRequired: false,
});
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
});
+10
View File
@@ -0,0 +1,10 @@
# Archived viewer comparison source
`rerun-web-viewer-0.34.1/` is retained only as rollback-era comparison evidence
for ADR 0045. The active application neither imports it nor runs the historical
patch scripts during install or build. Canonical Control Station routes use the
unmodified `@rerun-io/web-viewer` dependency declared in `package.json`.
Do not update or reactivate this tree. Remove it with the remaining custom
fMP4/Three.js replay implementation after the canonical Rerun migration passes
operator visual acceptance.
+22
View File
@@ -0,0 +1,22 @@
{
"schema_version": "missioncore.equipment-model-registry/v1",
"models": [
{
"schema_version": "missioncore.equipment-model/v1",
"equipment_model_id": "xgrids.lixelkity-k1",
"equipment_model_version": 1,
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
"vendor": "XGRIDS",
"display_name": "XGRIDS LixelKity K1",
"category": "mobile-lidar-scanner",
"capability_ids": [
"recorded.camera.video",
"recorded.lidar.point-cloud",
"recorded.pose.trajectory"
],
"authority": {
"commands_enabled": false
}
}
]
}
+97
View File
@@ -0,0 +1,97 @@
{
"schema_version": "missioncore.observatory-laboratory-setup-registry/v1",
"setups": [
{
"setup_id": "m49-tgs-full-shadow-v1",
"display_name": "M4.9T5 · TRAVEL TGS · CPU-only, без ML",
"description": "Сохранённая конфигурация RAVNOVES00: TRAVEL TGS без ML-моделей, 4 489 кадров, causal rolling 1 s, полный визуальный разбор и отдельное доказательство integrated graph.",
"origin": "archived-definition",
"source": {
"session_id": "20260720T065719Z_viewer_live",
"label": "RAVNOVES00",
"required_modalities": [
"point-cloud",
"trajectory",
"video"
]
},
"run_definition": {
"definition_id": "m49-tgs-full-shadow",
"version": 1,
"work_id": "m49-tgs-full-shadow",
"configuration": [
{
"role": "primary-profile",
"path": "config/perception/m49-tgs-full-shadow-v1.json",
"sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"
}
]
},
"executor": {
"contour_id": "worker-006",
"state": "not-installed",
"reason_code": "laboratory-runner-adapter-not-installed",
"reason": "Повторный запуск этого сетапа ещё не подключён к общему контуру расчёта."
},
"preserved_results": [
{
"result_id": "m49-tgs-full-shadow-ef98de7db7596d48e8c8c0549ce68e6704ee03e87c8c4bcf1e3e748b7ccb032e",
"result_kind": "recorded-source-paced-tgs-shadow",
"relation": "primary-visual",
"access": "legacy-lab",
"created_at_utc": "2026-08-26T20:27:19.076865Z"
},
{
"result_id": "m49-tgs-integrated-graph-shadow-75e48fd8acf0246be16613e087fcf26fe909f7834718d217c74785a57f494b24",
"result_kind": "source-paced-integrated-graph-shadow",
"relation": "compute-successor",
"access": "evidence-only",
"created_at_utc": "2026-08-27T08:50:05.5622153+00:00"
}
],
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
},
{
"setup_id": "lab-v1-ravnoves004tree-final",
"display_name": "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
"description": "Точный RAVNOVES004TREE result: 6 830 кадров EoMT Cityscapes Large 1024 + DDRNet-39 и синхронный записанный replay; полный TGS и независимый YOLOX отсутствуют.",
"origin": "existing-result",
"source": {
"session_id": "20260828T130511Z_viewer_live",
"label": "RAVNOVES004TREE",
"required_modalities": [
"point-cloud",
"trajectory",
"video"
]
},
"run_definition": null,
"executor": {
"contour_id": "worker-006",
"state": "not-installed",
"reason_code": "durable-dispatch-definition-unavailable",
"reason": "Для готового результата не сохранён повторно запускаемый сетап; доступен записанный разбор."
},
"preserved_results": [
{
"result_id": "lab-v1-vegetation-shadow-8c8f387599955dd79a16ded2c2d7cfd7f9f308d704f52c42fc26bd39d6da2d60",
"result_kind": "recorded-perception-qualification",
"relation": "canonical-projection",
"access": "observatory",
"created_at_utc": "2026-08-29T18:05:11.329061Z"
}
],
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
]
}
@@ -0,0 +1,61 @@
{
"schema_version": "missioncore.observatory-m49-recorded-queue-binding/v1",
"binding_id": "m49-ravnoves00-recorded-queue-v1",
"source": {
"session_id": "20260720T065719Z_viewer_live",
"label": "RAVNOVES00",
"required_modalities": [
"point-cloud",
"trajectory",
"video"
],
"source_pack": {
"artifact_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
"sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
"byte_length": 72996000,
"media_type": "application/vnd.nodedc.lidar-source-pack+npz",
"expected_timeline_frames": 4489,
"expected_available_lidar_frames": 3928
}
},
"setup": {
"setup_id": "m49-tgs-full-shadow-v1",
"definition_id": "m49-tgs-full-shadow",
"definition_version": 1,
"definition_sha256": "836a66639e69f7ea00de2a9111c1c6c9c3c00f1abd726f1df596aeb4e3a88ae6"
},
"source_adapter": {
"adapter_id": "ravnoves00-m49-source-pack",
"version": 1
},
"executor": {
"release_id": "m49-tgs-full-shadow-worker-release",
"artifact_sha256": "5e0ea16c7a5cc760463836718b0cd8b0006ffc4b202e5f706a20a86ef2f912ab",
"code_revision": "40c850b167dda366d8aa45d828520168affaf9fd",
"service_installed": false,
"image_set": {
"schema_version": "missioncore.observatory-executor-image-set/v1",
"images": {
"travel": "7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f",
"parity": "ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0"
}
},
"learned_models": []
},
"resource_profile": {
"schema_version": "missioncore.observatory-recorded-resource-profile/v1",
"profile_id": "worker006-cpu-single-run-v1",
"contour_id": "worker-006",
"concurrency": 1,
"checkpoint_policy": "non-checkpointable",
"restart_from_zero": true,
"staging_discard_required": true,
"resource_release_receipt_required": true
},
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
{
"schema_version": "missioncore.recorded-capture-profile-registry/v1",
"profiles": [
{
"schema_version": "missioncore.recorded-capture-profile/v1",
"capture_profile_id": "xgrids-k1.viewer-live.fw-3.0.2.v1",
"capture_profile_version": 1,
"equipment_model_id": "xgrids.lixelkity-k1",
"equipment_model_sha256": "c95d7183ffd2e425892fd9ddfc4cd8dcbccc0231289127ddc14db6b52bbb92aa",
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
"archive_id": "xgrids-k1.viewer-live.evidence",
"firmware_compatibility_profile_id": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
"modalities": [
"point-cloud",
"trajectory",
"video"
],
"semantic_channels": [
"camera.video.recorded",
"spatial.point-cloud.recorded",
"spatial.pose.recorded"
],
"camera_media": {
"source_id": "sensor.camera.right",
"media_type": "video/mp4; codecs=\"avc1.641028\"",
"width": 800,
"height": 600,
"initialization_sha256": "e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38"
},
"calibration": {
"slot_id": "camera_1",
"sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
},
"media_epoch_policy": "exactly-one-complete-epoch",
"seekable": true,
"adapter": {
"adapter_id": "xgrids-k1-recorded-observatory-v2",
"version": 2,
"adapter_sha256": "4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,206 @@
{
"schema_version": "missioncore.perception-profile-candidate/v1",
"profile_id": "k1-perception-ddrnet39-rfdetr-tgs-prototype/v1",
"display_name": "K1 Perception — DDRNet-39 + RF-DETR + TGS",
"status": "stage-1-candidate-not-installed-not-qualified",
"experiment_policy": {
"bounded_lab_overload_allowed": true,
"retain_profiles_that_fail_current_hardware_realtime": true,
"realtime_failure_blocks_experimental_packaging": false,
"qualification_scope": ["profile-version", "effective-config", "hardware", "source", "transport"],
"preserve_original_source_clock": true,
"report_drops_and_staleness": true,
"overload_is_realtime_pass": false,
"future_onboard_placement_requires_new_measurement": true
},
"packaging": {
"image": "ndc-k1-perception-ddrnet39-rfdetr-tgs:prototype-v1",
"image_sha256": null,
"single_container": true,
"external_model_server_required": false,
"host_model_cache_required": false,
"runtime_model_download_allowed": false,
"model_environment_isolation": "supervised-processes-one-gpu-scheduler",
"common_interpreter_required": false
},
"hardware_baseline": {
"gpu": "NVIDIA GeForce RTX 4090",
"active_profiles_per_worker": 1,
"sources_per_worker": 1,
"gpu_inference_policy": "serialized",
"other_gpu_workloads_allowed": false
},
"cpu_execution_policy": {
"OPENBLAS_NUM_THREADS": 1,
"OMP_NUM_THREADS": 1,
"MKL_NUM_THREADS": 1,
"reason": "bounded-single-thread-numeric-libraries-before-whole-graph-qualification"
},
"operating_envelope": {
"status": "bounded-latency-reference-not-full-qualification",
"checker": "k1link.perception.worker_operating_envelope.operating_envelope_failures",
"reference_conditions": {
"envelope_id": "worker006-4090-610.47-stock-clock-reference/v1",
"gpu_name": "NVIDIA GeForce RTX 4090",
"driver_version": "610.47",
"cpu_limit_millicores": 8000,
"memory_limit_mib": 8192,
"minimum_sm_clock_mhz": 2610,
"minimum_memory_clock_mhz": 10251,
"maximum_snapshot_age_ms": 1000
},
"check_phase": "post-warmup-before-timed-window-plus-runtime-monitoring",
"container_may_set_host_clocks": false,
"persistent_host_clock_policy_installed": false,
"readiness_is_realtime_qualification": false,
"unknown_ownership_or_telemetry_is_pass": false,
"failed_envelope_allows_labelled_experiment": true
},
"output_freshness_contract": {
"schema_version": "missioncore.perception-scene-freshness/v1",
"required_layers": ["segmentation", "objects", "geometry", "motion", "costmap", "policy"],
"retain_original_layer_identity": true,
"propagate_required_input_age": true,
"recheck_at_publication_receipt_and_use": true,
"clock_uncertainty_adds_to_age": true,
"unavailable_or_stale_required_layer": "no-go-advisory-only",
"history_can_independently_authorize_terrain": false,
"transport_streamstart_and_lease_fencing_required_separately": true
},
"input_contract": {
"schema_version": "missioncore.perception-stream-start/v1",
"channels": ["camera", "point-cloud", "pose"],
"camera": {
"width": 800,
"height": 600,
"projection": "KB4",
"decoded_pixel_format": "bgr8",
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
"valid_fov_sha256": "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
},
"point_cloud": {
"representation": "vendor-registered-current-increment",
"coordinate_frame": "map",
"units": "metres",
"scalar_type": "float32-little-endian",
"raw_fields_retained": true
},
"pose": {"parent_frame": "map", "child_frame": "sensor"},
"clock_mapping_required": true,
"source_end_or_duration_required": false,
"full_source_transfer_required": false,
"full_source_hash_pass_required": false,
"full_decode_or_png_cache_required": false,
"previous_perception_artifacts_allowed": false
},
"stream_policy": {
"replay_speed": 1.0,
"release_clock": "original-source-timestamps",
"inference_stride": 1,
"sampling_status": "strict-every-frame-initial-baseline-no-silent-multirate",
"first_result_requires_eof": false,
"lookahead_allowed": false,
"reconnect": "new-epoch-drop-old-backlog-reacquire-codec-keyframe",
"overflow": "bounded-drop-with-ledger-degraded-and-failed-runtime-gate",
"archive_export_on_critical_path": false,
"transport_candidate": "grpc-bidirectional-protobuf-binary-payloads",
"transport_qualified": false
},
"components": {
"segmentation": {
"model": "ddrnet_39-goose-fine-64",
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
"precision": "existing-fp32-baseline",
"preprocessing": "center-600-square-to-512-rgb-tensor-0-1",
"outside_crop": "undefined-not-free",
"class_count": 64,
"runtime": "python-3.9-pytorch-1.13.1-cu117-super-gradients-3.2.0"
},
"objects": {
"model": "rf_detr_large_native_kb4",
"checkpoint_sha256": "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38",
"engine_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
"runtime": "tensorrt-11-fp16-native-uint8",
"minimum_score": 0.25,
"minimum_box_area_pixels": 64,
"maximum_box_area_fraction": 0.5,
"minimum_valid_fov_fraction": 0.5,
"require_center_inside_valid_fov": true,
"required_existing_classes": ["person", "cat", "dog"],
"fine_static_object_classifier_required": false,
"filter_quality_status": "existing-unchanged-near-large-and-small-object-risk-unqualified"
},
"geometry": {
"online_surface": "K1LocalSurfaceShadowEstimator",
"prepared_local_surface_allowed": false,
"generic_static_class": "static.unknown",
"detected_object_range": "median-camera-z-of-owned-current-points/v1",
"geometry_only_range": "nearest-euclidean-sensor-distance/v1",
"vehicle_clearance_claim": false,
"missing_support": "unavailable-not-zero-not-free"
},
"motion": {
"reuse": "BoundedSpatialTemporalProvider+ClassIndependentMotionEstimator",
"semantic_class_implies_motion": false
},
"costmap": {
"algorithm": "TRAVEL-TGS",
"revision": "95dc2fbd66a343efd9060c45a5711b6307a950a4",
"execution": "cpu",
"rolling_history_seconds": 1.0,
"states": ["UNOBSERVED", "GROUND_SUPPORT", "NONGROUND_OCCUPIED", "UNKNOWN_REJECTED"],
"policy_input": true
},
"policy": {
"scope": "rural-prototype-advisory-only",
"hard_surface": "ALLOW-candidate-subject-to-geometry-and-freshness",
"fine_road_sidewalk_bikeway_distinction_required": false,
"effective_material_rules_required": true,
"unknown_or_occupied_overrides_material_allowance": true,
"sensor_to_vehicle_mount": "simulation-only-until-measured"
}
},
"reference_files": [
{"path": "config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json", "sha256": "dbf4da5dbad6c3c22b1280b46ffcad81719bd183c81c263a4859847d829019b6"},
{"path": "config/perception/lab-v1-goose-vegetation-benchmark-v1.json", "sha256": "96a427a8baae387b827ec9c0bf7ca42e3fb9114b8fa9a8671bbc9d10877670b9"},
{"path": "config/perception/lab-v1-vegetation-provider-label-map-v1.json", "sha256": "f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"},
{"path": "config/perception/lab-v1-vegetation-mission-policy-v1.json", "sha256": "b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},
{"path": "config/perception/m49-tgs-full-shadow-v1.json", "sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"},
{"path": "config/perception/m4-geometry-association-v1.json", "sha256": "cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"}
],
"reference_usage": "reuse-model-parameters-and-algorithms-only-not-recording-ids-or-precomputed-results",
"realtime_contract": {
"schema_version": "missioncore.perception-realtime-contract/v1",
"budget_status": "engineering-targets-worker-pilot-measured-not-whole-path-qualified-not-safety-approved",
"budgets": {
"maximum_start_metadata_bytes": 65536,
"maximum_chunk_bytes": 1048576,
"maximum_inflight_bytes": 16777216,
"maximum_pending_camera_frames": 2,
"maximum_codec_preroll_ms": 500.0,
"maximum_pose_age_ms": 100.0,
"maximum_clock_uncertainty_ms": 5.0,
"maximum_release_lag_ms": 25.0,
"maximum_output_age_p95_ms": 125.0,
"maximum_output_age_p99_ms": 125.0,
"maximum_layer_age_ms": 250.0,
"maximum_first_result_ms": 1000.0,
"maximum_warmup_seconds": 120.0,
"maximum_stop_seconds": 5.0,
"maximum_vram_mib": 22000,
"maximum_rss_mib": 8192,
"maximum_backlog_growth_ms": 25.0
},
"budget_origin": "125ms-historical-envelope-retained-as-target-now-applied-to-whole-path; other-limits-explicit-initial-engineering-guards",
"capacity_drop_count_max": 0,
"unaccounted_input_count_max": 0,
"full_input_transfer_before_first_result_allowed": false,
"independent_quality_and_physical_live_status": "pending"
},
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
@@ -0,0 +1,162 @@
{
"schema_version": "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2",
"profile_id": "lab-v1-eomt-ddrnet-portable-v2",
"lab_id": "LAB-V1",
"worker_id": "worker-006",
"source_binding": {
"mode": "admitted-k1-recording",
"camera_source_id": "sensor.camera.right",
"camera_timeline": "dynamic",
"filesystem_paths": "executor-resolved",
"expected_width": 800,
"expected_height": 600,
"expected_frame_count": "source-derived",
"frame_indices": "source-derived-representative",
"crop_contract": "center-600-square-to-512; outside-crop-is-undefined"
},
"effective_config_contract": {
"schema_version": "missioncore.lab-v1-goose-vegetation-benchmark/v1",
"dynamic_field": "ravnoves",
"source_id": "sealed-source-derived",
"source_sha256": "sealed-camera-input-derived",
"base_m4_result_id": null
},
"runtime": {
"super_gradients_version": "3.2.0",
"super_gradients_revision": "54d062ecb1081944a672ce447cf3e96a36708ff9",
"python_version": "3.9",
"numpy_version": "1.23.0",
"cmake_version": "3.31.6",
"onnxsim_version": "0.4.36",
"opencv_python_version": "4.8.1.78",
"pytorch_version": "1.13.1",
"torchvision_version": "0.14.1",
"pytorch_cuda_version": "11.7",
"container_base": "nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04@sha256:ad6d59a3bbf3e82c1c849c9ac09cfc2a3e0bbb8655042fd899be6681b3fe2a85",
"miniconda_installer": "Miniconda3-py39_24.11.1-0-Linux-x86_64.sh",
"miniconda_installer_sha256": "3ea8373098d72140e08aac9217822b047ec094eb457e7f73945af7c6f68bf6f5"
},
"dataset": {
"dataset_id": "goose-2d-validation-visible-rgb",
"relative_root": "goose-2d/validation",
"mapping_relative_path": "goose_label_mapping.csv",
"mapping_sha256": "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f",
"image_glob": "images/val/**/*_windshield_vis.png",
"expected_pair_count": 962,
"input_size": [
512,
512
],
"preprocessing": [
"center-square-crop",
"nearest-neighbor-resize",
"rgb-to-tensor-0-1"
]
},
"candidates": {
"ddrnet": {
"candidate_id": "goose-ddrnet-class-512",
"model_names": [
"ddrnet_39"
],
"checkpoint_relative_path": "models/goose/ddrnet_class_512.pth",
"checkpoint_size_bytes": 259419077,
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
"published_validation_miou_percent": 46.53
}
},
"vegetation_class_names": [
"leaves",
"forest",
"bush",
"moss",
"tree_crown",
"tree_trunk",
"crops",
"low_grass",
"high_grass",
"scenery_vegetation",
"hedge",
"tree_root"
],
"visual_case_contract": {
"selection_basis": "ground-truth-class-support-only",
"case_count": 12,
"minimum_focus_pixels": 2048,
"strata": [
{
"class_name": "high_grass",
"count": 2
},
{
"class_name": "low_grass",
"count": 2
},
{
"class_name": "bush",
"count": 2
},
{
"class_name": "tree_trunk",
"count": 2
},
{
"class_name": "tree_crown",
"count": 1
},
{
"class_name": "hedge",
"count": 1
},
{
"class_name": "forest",
"count": 1
},
{
"class_name": "crops",
"count": 1
}
],
"error_overlay": {
"correct_material_rgba": [
34,
197,
94,
72
],
"missed_vegetation_rgba": [
239,
68,
68,
220
],
"false_vegetation_rgba": [
245,
158,
11,
220
],
"wrong_vegetation_material_rgba": [
168,
85,
247,
220
]
}
},
"policy_action_colors": {
"ALLOW": "#22c55e",
"HIGH_COST": "#f59e0b",
"NO_GO": "#ef4444"
},
"invariants": {
"one_heavy_candidate_at_a_time": true,
"raw_fisheye_is_immutable": true,
"outside_center_crop_is_free": false,
"missing_or_unknown_is_free": false,
"camera_semantics_can_clear_rigid_geometry": false,
"navigation_authority": false,
"actuation_authority": false,
"canonical_triton_mutation_allowed": false
}
}
@@ -0,0 +1,19 @@
{
"schema_version": "missioncore.lab-v1-ravnoves-source/v1",
"profile_id": "ravnoves004tree-full-video-source/v1",
"source": {
"source_id": "RAVNOVES004TREE/right-e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
"source_sha256": "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
"source_job_id": "recorded-camera-eb2783c5480d56bda07c8af0",
"source_job_input_sha256": "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
"session_id": "20260828T130511Z_viewer_live",
"base_m4_result_id": null,
"expected_width": 800,
"expected_height": 600,
"expected_frame_count": 6830,
"timeline_start_seconds": 39.215263458,
"timeline_end_seconds": 757.260263458,
"frame_indices": [],
"crop_contract": "center-600-square-to-512; outside-crop-is-undefined"
}
}
@@ -0,0 +1,60 @@
{
"schema_version": "missioncore.m49-tgs-portable-profile/v2",
"profile_id": "m49-tgs-portable-v2",
"source_binding": {
"mode": "admitted-k1-recording",
"camera_timeline": "dynamic",
"lidar_replay": "dynamic",
"trajectory": "dynamic",
"frame_counts": "source-derived",
"filesystem_paths": "executor-resolved"
},
"alignment": {
"timeline": "recorded-camera-host-arrival",
"lidar_selection": "latest-not-newer-than-camera-frame",
"maximum_lidar_age_seconds": 1.0,
"future_frames_allowed": false
},
"tgs": {
"max_range_m": 80.0,
"min_range_m": 1.0,
"resolution_m": 8.0,
"num_iterations": 3,
"num_lowest_representative_points": 5,
"minimum_points": 10,
"seed_threshold_m": 0.5,
"distance_threshold_m": 0.125,
"outlier_threshold_m": 0.3,
"normal_threshold": 0.94,
"weight_threshold": 200.0,
"lcc_normal_similarity": 0.03,
"lcc_planar_distance_m": 0.1,
"obstacle_height_m": 1.0,
"refine_mode": true
},
"rolling_profile": {
"history_seconds": 1.0,
"local_radius_m": 12.0,
"missing_lidar_policy": "all-cells-unobserved"
},
"costmap": {
"coordinate_frame": "map-gravity-local",
"cell_size_m": 0.45,
"radius_m": 12.0,
"state_priority": [
"NONGROUND_OCCUPIED",
"UNKNOWN_REJECTED",
"GROUND_SUPPORT",
"UNOBSERVED"
]
},
"invariants": {
"aos_allowed": false,
"gpu_allowed": false,
"missing_support_means_free": false,
"missing_lidar_means_unobserved": true,
"unobserved_cells_are_emitted": true,
"camera_projection_is_authoritative": false,
"navigation_or_actuation_allowed": false
}
}
+33 -2
View File
@@ -20,10 +20,23 @@ The normalizer exposes a read-only normalized telemetry adapter on
Timescale credentials, and Timescale is not published on a host port. Timescale credentials, and Timescale is not published on a host port.
The normalizer uses a separate `missioncore_normalizer` database role with only The normalizer uses a separate `missioncore_normalizer` database role with only
`SELECT`, `INSERT`, `UPDATE`, and bounded retention `DELETE` on the telemetry `SELECT`, `INSERT`, `UPDATE`, and bounded retention `DELETE` on the telemetry
hypertable. Raw telemetry older than 30 days is removed at most once per day by the hypertable. Raw telemetry older than 30 days is removed in batches of at most
normalizer. This stays compatible with the Apache-licensed Timescale image without 1000 rows per minute, on a separate connection/thread (2s statement / 250ms lock
deadline). Retention never runs inside the MQTT callback. This stays compatible
with the Apache-licensed Timescale image without
depending on the Timescale License retention scheduler. depending on the Timescale License retention scheduler.
The 1 GiB database container explicitly sets 128 MiB shared buffers and 64 MiB
maintenance memory. The HA image's automatic tuning sees the Docker VM instead
of this container limit; do not inherit its multi-GiB defaults.
For a memory-constrained local repair, `compose.source.yaml` mounts the reviewed
normalizer source read-only into the **existing** image. Set
`MISSIONCORE_NORMALIZER_SOURCE` to the absolute `normalizer.py` path, pass both
Compose files and the existing private `--env-file` / `--project-directory`, and
use `up -d --no-deps --no-build normalizer`. This is an explicit local source
override, not a rebuilt portable image. Normal restart/reboot retains the mount.
The stack is one deployment contour, not one multi-process container. Keeping broker, The stack is one deployment contour, not one multi-process container. Keeping broker,
normalizer and database in separate containers preserves independent health checks, normalizer and database in separate containers preserves independent health checks,
least-privilege boundaries and rollback while Compose provides one operator lifecycle: least-privilege boundaries and rollback while Compose provides one operator lifecycle:
@@ -136,6 +149,24 @@ globally unique because Mosquitto ACL ownership is username-based.
## Worker agent ## Worker agent
MQTT outputs use `startup_error_behavior="retry"`, with a 2000-metric buffer and
the configured flush interval. A missing broker at agent startup must not end
the service. See [Telegraf's startup policy](https://docs.influxdata.com/telegraf/v1/configuration/plugin-options/).
If Docker lost a published listener while the saved LAN address is unchanged,
the explicit broker Apply action reconciles that listener; a telemetry GET never
restarts infrastructure. Node connectivity does not prove a profile is ready.
The streaming profile can export one bounded (8 KiB) current observation using
`--telemetry-snapshot /telemetry/current.json`, with `/telemetry` mounted to
`C:\ProgramData\NDC\MissionCore\telemetry-agent\perception` on Worker. The existing
collector reads it with a 5s freshness limit and forwards it over the same MQTT
pipeline topic. It overrides the legacy service only when explicitly present;
an expired/broken file reports unavailable rather than relabeling a legacy model.
The publisher runs independently from heartbeat/inference and never renews a
lease, grants GPU ownership, or qualifies real-time. Queue memory includes active
input and decoder scratch. Stage timings are unavailable until actually measured.
No camera, point cloud, model result, MQTT secret, or motor command goes here.
Worker 006 uses the official Windows Telegraf distribution as the host service Worker 006 uses the official Windows Telegraf distribution as the host service
`NDC Mission Core Telemetry Agent`. Install or update it with: `NDC Mission Core Telemetry Agent`. Install or update it with:
@@ -0,0 +1,9 @@
# Local, read-only source override for repairing the existing normalizer without
# rebuilding an image on a memory-constrained operator machine. No extra service.
services:
normalizer:
volumes:
- type: bind
source: ${MISSIONCORE_NORMALIZER_SOURCE:?Set the absolute normalizer.py path}
target: /app/normalizer.py
read_only: true
+2
View File
@@ -49,6 +49,8 @@ services:
timescale: timescale:
image: timescale/timescaledb-ha:pg16.14-ts2.28.2-all-oss image: timescale/timescaledb-ha:pg16.14-ts2.28.2-all-oss
# The HA image auto-tunes for the Docker VM, not this 1 GiB container.
command: ["postgres", "-c", "shared_buffers=128MB", "-c", "work_mem=4MB", "-c", "maintenance_work_mem=64MB", "-c", "effective_cache_size=512MB", "-c", "max_parallel_workers_per_gather=0", "-c", "max_connections=32"]
container_name: ndc-mission-core-telemetry-timescaledb container_name: ndc-mission-core-telemetry-timescaledb
restart: unless-stopped restart: unless-stopped
security_opt: security_opt:

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