Compare commits
246
Commits
24b65926fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5d52521f | ||
|
|
c804d89b18 | ||
|
|
76dc9f19c9 | ||
|
|
e515ab1b8c | ||
|
|
1c7dd29d8a | ||
|
|
be58d589e2 | ||
|
|
5f75afb720 | ||
|
|
99f29218d7 | ||
|
|
db14ee5435 | ||
|
|
81dd44906d | ||
|
|
d45b329de8 | ||
|
|
f164606828 | ||
|
|
556e376a62 | ||
|
|
1a2de8df65 | ||
|
|
b536f0049a | ||
|
|
3b85299fd9 | ||
|
|
505844adc3 | ||
|
|
89b0eeca3f | ||
|
|
1488a46794 | ||
|
|
e4339f4e88 | ||
|
|
7e257275ab | ||
|
|
a3c15e11e9 | ||
|
|
54a85fdf50 | ||
|
|
14f8375713 | ||
|
|
6492ebcdee | ||
|
|
e57511f975 | ||
|
|
f0802d2713 | ||
|
|
d8afb61229 | ||
|
|
421a8c515a | ||
|
|
a23c5b2005 | ||
|
|
1832943558 | ||
|
|
cc90709919 | ||
|
|
ee5d760c30 | ||
|
|
0e9faa6824 | ||
|
|
c6693d6f44 | ||
|
|
ed9a77ba73 | ||
|
|
7dd2c1c836 | ||
|
|
762d77ef95 | ||
|
|
9c4d70b0e9 | ||
|
|
eed53dbc0b | ||
|
|
4f333fc11c | ||
|
|
35a517f921 | ||
|
|
9bba44f7c4 | ||
|
|
9b6534287a | ||
|
|
9e03404f23 | ||
|
|
fa8ac760a1 | ||
|
|
309bdf759e | ||
|
|
ff00851216 | ||
|
|
ad24518b71 | ||
|
|
7ce09eabcd | ||
|
|
92b60de945 | ||
|
|
67398fef10 | ||
|
|
a93f1f1b8b | ||
|
|
102e93796b | ||
|
|
4f715e41d2 | ||
|
|
8f17a7e282 | ||
|
|
7187fd86bd | ||
|
|
e6521e855a | ||
|
|
06d216ac4a | ||
|
|
d7986cb62f | ||
|
|
a0732386f1 | ||
|
|
5c4546a2a6 | ||
|
|
ef1c1f50de | ||
|
|
2f80507bc0 | ||
|
|
d2dbfc9c85 | ||
|
|
3dffe39c89 | ||
|
|
77acb377e2 | ||
|
|
2fdf1d3cc4 | ||
|
|
d8cc5367c4 | ||
|
|
111fe3dcfb | ||
|
|
63ea2bed67 | ||
|
|
020a878915 | ||
|
|
404285419f | ||
|
|
ba0c948ca1 | ||
|
|
429b4681fe | ||
|
|
22d69107ba | ||
|
|
a8647c4d87 | ||
|
|
b1aaa40508 | ||
|
|
3616acc648 | ||
|
|
e82d012907 | ||
|
|
fc545f8440 | ||
|
|
a98ef339de | ||
|
|
9062126913 | ||
|
|
59e14c5bc0 | ||
|
|
15bb793e5e | ||
|
|
79911eb316 | ||
|
|
d696842f5d | ||
|
|
57f2537af3 | ||
|
|
e56eb42507 | ||
|
|
b2a1b23131 | ||
|
|
cada687173 | ||
|
|
eff60e490a | ||
|
|
9db29bcdc6 | ||
|
|
27979854a7 | ||
|
|
3ea8d987a7 | ||
|
|
bd947b49f4 | ||
|
|
54c8d82884 | ||
|
|
bee8552003 | ||
|
|
4e0565eb4b | ||
|
|
44afadb021 | ||
|
|
e436fb56d4 | ||
|
|
c2710b55a7 | ||
|
|
d777f3198b | ||
|
|
1e4ddc2cff | ||
|
|
80fc0058cb | ||
|
|
43f1cdc862 | ||
|
|
0041e9fadb | ||
|
|
cabd3081c7 | ||
|
|
049ed3eed4 | ||
|
|
35c1249631 | ||
|
|
941183d624 | ||
|
|
6af4c208ce | ||
|
|
75a5320756 | ||
|
|
59a74a3677 | ||
|
|
35d28418f3 | ||
|
|
48835a0499 | ||
|
|
93ae1edbb4 | ||
|
|
093df8b38f | ||
|
|
35b6cd9e9e | ||
|
|
d80df61a7e | ||
|
|
d7b8989e43 | ||
|
|
0cc6b96306 | ||
|
|
6469505c41 | ||
|
|
191612282b | ||
|
|
0310592ee3 | ||
|
|
df0158e1ba | ||
|
|
2ef8c08bf2 | ||
|
|
c570f7d938 | ||
|
|
03e8e71bea | ||
|
|
61cbdb30a0 | ||
|
|
e91721fe6a | ||
|
|
70927ea585 | ||
|
|
03a7e730ae | ||
|
|
c31c46caa5 | ||
|
|
06a66a3da8 | ||
|
|
92625bf48b | ||
|
|
b8ba0cae1e | ||
|
|
f39f1ff0b6 | ||
|
|
380b6eaba7 | ||
|
|
f6d73d8bfb | ||
|
|
221e429c0a | ||
|
|
a85aed2e03 | ||
|
|
f102712f0b | ||
|
|
350366bae0 | ||
|
|
c23e86fd39 | ||
|
|
79ce55d197 | ||
|
|
a937400942 | ||
|
|
da60feff90 | ||
|
|
85349f42f9 | ||
|
|
ac69e3b444 | ||
|
|
6acf468b25 | ||
|
|
d264cbce04 | ||
|
|
1f8101e4a5 | ||
|
|
bcacb02a22 | ||
|
|
adcca7aa81 | ||
|
|
097e450a87 | ||
|
|
4e633a662c | ||
|
|
c6c42c29e8 | ||
|
|
ce09d30c1a | ||
|
|
34f13ba59e | ||
|
|
d9ea7c1129 | ||
|
|
a3c67e249c | ||
|
|
294585936f | ||
|
|
380a13688c | ||
|
|
fd8313899b | ||
|
|
05c99efb68 | ||
|
|
ffd6be5606 | ||
|
|
bfe6ef4c77 | ||
|
|
4e04d06191 | ||
|
|
5a78c997ac | ||
|
|
62d5520c7a | ||
|
|
a945d665dd | ||
|
|
d655d6998d | ||
|
|
7025e173a3 | ||
|
|
10fe727561 | ||
|
|
00ee5f163a | ||
|
|
cbcc09afd6 | ||
|
|
e958729e9d | ||
|
|
0fcd037e4f | ||
|
|
11a146011f | ||
|
|
44ebbe7ca2 | ||
|
|
a3299dcfc3 | ||
|
|
2989fba99c | ||
|
|
142481f0d8 | ||
|
|
b2ae29b8d8 | ||
|
|
8eced6c34d | ||
|
|
732cc139fd | ||
|
|
104e54f4dd | ||
|
|
7b021782d3 | ||
|
|
759830b054 | ||
|
|
19c0bd70d0 | ||
|
|
35d99e40d5 | ||
|
|
bfc1f1bbed | ||
|
|
f41428f907 | ||
|
|
3744d79ad3 | ||
|
|
b57c3e94da | ||
|
|
73a7d28065 | ||
|
|
f64fbb1fa6 | ||
|
|
1ff527b264 | ||
|
|
def8c71410 | ||
|
|
7e59176581 | ||
|
|
4cd94b5805 | ||
|
|
1766c0bdb2 | ||
|
|
56bfeaee7b | ||
|
|
892d0085e3 | ||
|
|
e2de8188ab | ||
|
|
9c69d81296 | ||
|
|
2f6e45bc96 | ||
|
|
8a1c578e0e | ||
|
|
9beb534108 | ||
|
|
d1b75efcea | ||
|
|
c9302c78c2 | ||
|
|
d67e86176e | ||
|
|
e72609120f | ||
|
|
177be8869f | ||
|
|
2a5763d3fb | ||
|
|
8d5aeb0533 | ||
|
|
be83283ee0 | ||
|
|
023151c186 | ||
|
|
e6a9846167 | ||
|
|
81fdf6904a | ||
|
|
9c5259dbc9 | ||
|
|
35daf73d5c | ||
|
|
07453142c2 | ||
|
|
f5ee42751d | ||
|
|
e9ffb829c9 | ||
|
|
757d368c86 | ||
|
|
f1cbe0061a | ||
|
|
74da6437e9 | ||
|
|
bd2892140f | ||
|
|
525ab74168 | ||
|
|
c30d77572e | ||
|
|
5179e93f4a | ||
|
|
c06b709fd7 | ||
|
|
856b61be99 | ||
|
|
76694cc869 | ||
|
|
e6df8cb264 | ||
|
|
4b487e367f | ||
|
|
d7d9485724 | ||
|
|
88e47fc39a | ||
|
|
0a0cde7b0e | ||
|
|
cd16ea28a6 | ||
|
|
9dec37a8a9 | ||
|
|
45019f8952 | ||
|
|
2e44e27967 | ||
|
|
2ccf172319 |
@@ -10,3 +10,4 @@
|
||||
*.las binary
|
||||
*.lcc binary
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -62,6 +62,40 @@ and the boundary between Mission Core and vendor-specific integration code.
|
||||
- Synthetic or explicitly redacted fixtures may be committed under
|
||||
`tests/fixtures/`.
|
||||
|
||||
## Insta360 and clean-host installation — owner requirement, 2026-09-08
|
||||
|
||||
- The current X4 starting point is USB enumeration only. SDK installation,
|
||||
camera initialization and live video have not been accepted.
|
||||
- Every Ubuntu change needed for Insta360 must execute through the shipped
|
||||
installer or the application's versioned device-preparation workflow from
|
||||
the first board experiment. Do not repair the board with an ad-hoc apt/pip
|
||||
install, copied library, chmod, environment override, service edit or root
|
||||
invocation and promise to package it later. Fix the product artifact first,
|
||||
then rerun that artifact. Read-only engineering inspection remains allowed.
|
||||
- Document every relevant action and its before/after evidence in
|
||||
`docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version,
|
||||
owning installer step, dependencies, idempotency, failure behavior and rollback.
|
||||
Keep private logs/identifiers/media out of Git and redact their public summary.
|
||||
- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency
|
||||
closure on the declared clean OS image and actual USB operation separately;
|
||||
installer, preparation and GUI acceptance must use the same shipped code.
|
||||
- X4 is operator live video and camera control (settings, photo, recording
|
||||
start/stop and file access), per the owner’s subsequent scope expansion.
|
||||
Preview and recording are separate operations. No stitching, AI, navigation
|
||||
or vehicle-control integration is admitted. Model support and physical device instances are separate: several
|
||||
cameras of the same model must retain independent identity and lifecycle.
|
||||
- Follow the scoped implementation plan in
|
||||
`docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan,
|
||||
not evidence that SDK compatibility, hardware capacity or installation passed.
|
||||
- Subsequent owner direction: the existing Ubuntu Mini is the available build
|
||||
and qualification machine; do not depend on Worker 006 or another host for
|
||||
this X4 work. Its existing GCC may compile through a versioned build artifact
|
||||
in bounded temporary staging. This is a build step, not runtime installation:
|
||||
no system changes, SDK execution or camera access during compilation. The
|
||||
final installer must carry the compiled payload so a clean operator Ubuntu
|
||||
does not inherit an undeclared compiler/build-cache prerequisite. All board
|
||||
staging/build/install/test actions remain documented and artifact-owned.
|
||||
|
||||
## Implementation order
|
||||
|
||||
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
||||
|
||||
Generated
+4
-4
@@ -15,7 +15,7 @@
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"meshoptimizer": "1.1.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
@@ -900,9 +900,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@rerun-io/web-viewer": {
|
||||
"version": "0.34.1",
|
||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
|
||||
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
|
||||
"version": "0.36.3",
|
||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
||||
"integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"postinstall": "node scripts/install-rerun-navigation.mjs",
|
||||
"prebuild": "node scripts/install-rerun-navigation.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test test/*.test.mjs",
|
||||
@@ -18,7 +19,7 @@
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"meshoptimizer": "1.1.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Синхронизированная запись</title>
|
||||
<style>
|
||||
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer"></div>
|
||||
<script type="module" src="/src/components/rerun/isolatedRerunEntry.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
|
||||
const manifest = JSON.parse(readFileSync(resolve(vendorRoot, "navigation-build.json"), "utf8"));
|
||||
const installed = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
if (installed.version !== "0.36.3" || manifest.upstreamVersion !== installed.version) {
|
||||
throw new Error("Rerun navigation requires the audited 0.36.3 package; rebase before upgrading");
|
||||
}
|
||||
if (sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))) !== manifest.patchSha256) {
|
||||
throw new Error("Rerun navigation source patch identity mismatch");
|
||||
}
|
||||
|
||||
const eyeType = "{ position: [number, number, number]; lookTarget: [number, number, number]; eyeUp: [number, number, number] } | null";
|
||||
const additions = {
|
||||
"index.js": {
|
||||
marker: " get_active_recording_id() {",
|
||||
code: ` get_camera_eye() {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.ts": {
|
||||
marker: " get_active_recording_id(): string | null {",
|
||||
code: ` get_camera_eye(): ${eyeType} {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.d.ts": {
|
||||
marker: " get_active_recording_id(): string | null;",
|
||||
code: ` get_camera_eye(): ${eyeType};\n`,
|
||||
},
|
||||
};
|
||||
|
||||
// Validate the complete set before changing any installed file. A package
|
||||
// upgrade, corrupt artifact or independently modified wrapper fails closed.
|
||||
const writes = [];
|
||||
for (const [name, identity] of Object.entries(manifest.files)) {
|
||||
const current = readFileSync(resolve(packageRoot, name));
|
||||
let output;
|
||||
if (additions[name]) {
|
||||
const { marker, code } = additions[name];
|
||||
const source = current.toString("utf8");
|
||||
const original = source.includes(code) ? source.replace(code, "") : source;
|
||||
if (sha(original) !== identity.upstreamSha256 || original.split(marker).length !== 2) {
|
||||
throw new Error(`Unexpected upstream wrapper: ${name}`);
|
||||
}
|
||||
output = Buffer.from(original.replace(marker, code + marker));
|
||||
} else {
|
||||
output = readFileSync(resolve(vendorRoot, identity.artifact));
|
||||
if (sha(output) !== identity.sha256) throw new Error(`Corrupt navigation artifact: ${name}`);
|
||||
if (![identity.upstreamSha256, identity.sha256].includes(sha(current))) {
|
||||
throw new Error(`Unexpected installed runtime: ${name}`);
|
||||
}
|
||||
}
|
||||
writes.push([resolve(packageRoot, name), output]);
|
||||
}
|
||||
for (const [path, output] of writes) writeFileSync(path, output);
|
||||
console.log("Installed NODE.DC Rerun 0.36.3 navigation/v1 (native camera, one renderer).");
|
||||
@@ -1,3 +1,5 @@
|
||||
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
|
||||
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
@@ -5,18 +7,14 @@ import {
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
@@ -30,6 +28,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel
|
||||
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
|
||||
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
|
||||
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
|
||||
import { useSessionOverviewMode } from "./core/observation/useSessionOverview";
|
||||
import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace";
|
||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
} from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
|
||||
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||
import {
|
||||
@@ -67,33 +68,18 @@ import {
|
||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
|
||||
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
|
||||
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
||||
import "./styles/scene-windows.css";
|
||||
type SceneToolWindowId = "sources" | "display" | "layers";
|
||||
const viewerSettingsQuietPeriodMs = 750;
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
|
||||
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
|
||||
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
|
||||
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
|
||||
];
|
||||
|
||||
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
|
||||
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
|
||||
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
|
||||
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
|
||||
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
|
||||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
||||
];
|
||||
|
||||
interface LivePerceptionLayers {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
@@ -170,7 +156,9 @@ export default function App() {
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
@@ -195,6 +183,7 @@ export default function App() {
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const closeDisplayRef = useRef<() => void>(() => {});
|
||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||
@@ -242,6 +231,13 @@ export default function App() {
|
||||
);
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayPresented;
|
||||
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
|
||||
sceneSettingsRef.current = settings;
|
||||
displayDraftRef.current = settings;
|
||||
confirmedSceneSettingsRef.current = settings;
|
||||
setSceneSettings(settings);
|
||||
setDisplayDraft(settings);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||
@@ -324,6 +320,7 @@ export default function App() {
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
if (
|
||||
!remote ||
|
||||
replayActiveRef.current ||
|
||||
workspaceLayoutProfile.profile ||
|
||||
sceneSettingsCommitterRef.current?.isBusy()
|
||||
) return;
|
||||
@@ -346,7 +343,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
if (!profile) return;
|
||||
if (!profile || selectedRecordedSessionIdRef.current) return;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
@@ -417,7 +414,10 @@ export default function App() {
|
||||
|
||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "display") {
|
||||
setDisplayWindowOpen(false);
|
||||
closeDisplayRef.current();
|
||||
}
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
@@ -449,9 +449,10 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
|
||||
const definition = workspaceById(viewId);
|
||||
if (!definition) return;
|
||||
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
|
||||
setActiveRoot(definition.root);
|
||||
workspace.openView(viewId);
|
||||
};
|
||||
@@ -477,14 +478,22 @@ export default function App() {
|
||||
const next = { ...displayDraftRef.current, ...patch };
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
sessionDisplayProfile.edited();
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
}
|
||||
// Preview and persistence are separate. An open inspector must not freeze
|
||||
// either its controls or the shared accumulation slider below the scene.
|
||||
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(next);
|
||||
return;
|
||||
}
|
||||
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, viewerSettingsQuietPeriodMs);
|
||||
}, []);
|
||||
}, [sessionDisplayProfile.edited]);
|
||||
|
||||
const flushDisplaySettings = useCallback(() => {
|
||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||
@@ -494,8 +503,15 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
sessionDisplayProfile.edited();
|
||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||
}, [commitDisplaySettings]);
|
||||
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
|
||||
|
||||
closeDisplayRef.current = () => {
|
||||
const settings = {...displayDraftRef.current};
|
||||
commitDisplaySettings(settings);
|
||||
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
|
||||
};
|
||||
|
||||
const openDisplay = () => {
|
||||
if (!sceneWorkspaceActive) return;
|
||||
@@ -629,7 +645,11 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
||||
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
onAddVehicle,
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
@@ -637,6 +657,7 @@ export default function App() {
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||
systemUtilityActions: computeContourSettings.utilityActions,
|
||||
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
|
||||
});
|
||||
|
||||
const header = (
|
||||
@@ -645,8 +666,6 @@ export default function App() {
|
||||
brandHref="/"
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
center={
|
||||
<>
|
||||
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
|
||||
<HeaderNavigation
|
||||
label="Архитектурные блоки пункта управления"
|
||||
value={activeRoot ?? undefined}
|
||||
@@ -656,7 +675,6 @@ export default function App() {
|
||||
}))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
@@ -802,10 +820,14 @@ export default function App() {
|
||||
settleRecordedReplaySwitch(outcome)}
|
||||
onDeleteBegin={releaseRecordedReplayForDelete}
|
||||
/>
|
||||
) : activeDefinition.kind === "datasets" ? (
|
||||
) : activeDefinition.kind === "missions" ? (
|
||||
<div ref={setWorkspaceHeaderToolsHost} />
|
||||
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
) : activeDefinition.kind === "observatory" ? (
|
||||
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||
) : activeDefinition.root === "system" ? (
|
||||
<SystemWorkspaceSelector
|
||||
value={activeDefinition.id}
|
||||
@@ -818,14 +840,28 @@ export default function App() {
|
||||
utilityActions={contentActions}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
<PlanningCaptureGuard
|
||||
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
||||
onResume={() => openView('local-device', 'planning')}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
launchProfile === 'planning' ? <PlanningConnectionWindow>
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
</PlanningConnectionWindow> : <DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
|
||||
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
launchProfile={launchProfile}
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
headerToolsHost={workspaceHeaderToolsHost}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
@@ -866,6 +902,7 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PlanningCaptureGuard>
|
||||
</ApplicationPanel>
|
||||
) : null}
|
||||
/>
|
||||
@@ -1027,133 +1064,12 @@ export default function App() {
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["points"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||
/>
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={paletteOptions}
|
||||
onChange={(palette) => stageDisplayPatch({ palette })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => stageDisplayPatch({ customColor })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
) : null}
|
||||
{replayPresented ? (
|
||||
<p className="scene-window-note">
|
||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
||||
/>
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scene",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
||||
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
</Window>
|
||||
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
|
||||
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
|
||||
onDismiss={sessionDisplayProfile.dismissError} />
|
||||
|
||||
<Window
|
||||
open={sceneWorkspaceActive && layerInspectorOpen}
|
||||
@@ -1178,64 +1094,7 @@ export default function App() {
|
||||
</span>
|
||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["geometry"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "geometry",
|
||||
label: "Геометрия",
|
||||
description: "Облако точек и путь",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
|
||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
|
||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "frames",
|
||||
label: "Координаты и камеры",
|
||||
description: "Преобразования и области обзора",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "perception",
|
||||
label: "Объекты и маски",
|
||||
description: "Ожидают внешние обработчики",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,92 +1 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EnvironmentBackground,
|
||||
EnvironmentMediaItem,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
||||
url: string;
|
||||
mediaKind: "image" | "video";
|
||||
};
|
||||
|
||||
function isReadyMediaItem(
|
||||
item: EnvironmentMediaItem,
|
||||
): item is ReadyEnvironmentMediaItem {
|
||||
return Boolean(item.url && item.mediaKind);
|
||||
}
|
||||
|
||||
export function EnvironmentBackgroundMedia({
|
||||
background,
|
||||
}: {
|
||||
background: EnvironmentBackground;
|
||||
}) {
|
||||
const items = useMemo(
|
||||
() => background.items.filter(isReadyMediaItem),
|
||||
[background.items],
|
||||
);
|
||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setFailedIds(new Set());
|
||||
}, [playlistIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!background.enabled
|
||||
|| !activeItem
|
||||
|| activeItem.mediaKind !== "image"
|
||||
|| playableItems.length < 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
||||
}, background.imageDurationSeconds * 1_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
activeItem,
|
||||
background.enabled,
|
||||
background.imageDurationSeconds,
|
||||
playableItems.length,
|
||||
]);
|
||||
|
||||
if (!background.enabled || !activeItem) return null;
|
||||
|
||||
return (
|
||||
<div className="landing-stage__media" aria-hidden="true">
|
||||
{activeItem.mediaKind === "video" ? (
|
||||
<video
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop={playableItems.length === 1}
|
||||
playsInline
|
||||
onEnded={() => setActiveIndex((current) => (
|
||||
(current + 1) % playableItems.length
|
||||
))}
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
alt=""
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { EnvironmentBackgroundMedia } from "@nodedc/ui-react";
|
||||
|
||||
@@ -1,248 +1 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
appendEnvironmentMediaItem,
|
||||
inferEnvironmentMediaKind,
|
||||
maxEnvironmentMediaItems,
|
||||
removeEnvironmentMediaItem,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaItem,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
interface EnvironmentMediaPlaylistEditorProps {
|
||||
surfaceId: EnvironmentSurfaceId;
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
].join(",");
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<EnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => (
|
||||
item.id === itemId ? { ...item, ...patch } : item
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
surfaceId,
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(
|
||||
() => [...background.items].reverse(),
|
||||
[background.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange(uploadingIds.size > 0);
|
||||
}, [onBusyChange, uploadingIds.size]);
|
||||
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
setItemError(
|
||||
itemId,
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить медиаконтент.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="environment-media-playlist">
|
||||
<div className="environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="environment-media-playlist__items"
|
||||
onReorder={(items) => onChange({
|
||||
...background,
|
||||
items: [...items].reverse(),
|
||||
})}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
return (
|
||||
<div className="environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url ?? ""}
|
||||
fileName={item.fileName}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={item.url}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
|
||||
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (
|
||||
playbackIndex === background.items.length - 1 ? error : null
|
||||
)}
|
||||
onSourceChange={(source) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="environment-media-playlist__empty">
|
||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="environment-media-playlist__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({
|
||||
...background,
|
||||
imageDurationSeconds,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
||||
перетаскивание меняет порядок.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { EnvironmentMediaPlaylistEditor } from "@nodedc/ui-react";
|
||||
|
||||
@@ -1,358 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
import { EnvironmentSettingsWindow as SharedEnvironmentSettingsWindow } from "@nodedc/ui-react";
|
||||
import { environmentSurfaceIds, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia } from "../core/environment/environmentSettings";
|
||||
import { roots, workspaces, workspacesForRoot } from "../productModel";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
|
||||
import {
|
||||
roots,
|
||||
workspaces,
|
||||
workspacesForRoot,
|
||||
} from "../productModel";
|
||||
|
||||
interface EnvironmentSettingsWindowProps {
|
||||
interface Props {
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
onUpload: (surfaceId: EnvironmentSurfaceId, itemId: string, file: File) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = useMemo(() => [
|
||||
{
|
||||
value: "home" as const,
|
||||
label: draft.pages.home.headerLabel,
|
||||
description: "Главная страница продукта",
|
||||
},
|
||||
...roots.map((root) => ({
|
||||
value: root.id,
|
||||
label: draft.pages[root.id].headerLabel,
|
||||
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
|
||||
})),
|
||||
], [draft.pages]);
|
||||
const quickActionWorkspaces = useMemo(
|
||||
() => surfaceId === "home"
|
||||
? workspaces.filter((workspace) => !workspace.internalOnly)
|
||||
: workspacesForRoot(surfaceId),
|
||||
[surfaceId],
|
||||
);
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "saving" || uploading;
|
||||
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedPage.primaryWorkspaceId
|
||||
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
|
||||
) {
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
||||
(page.background.enabled && !page.background.items.length)
|
||||
|| page.background.items.some((item) => {
|
||||
if (!item.url || !item.mediaKind) return true;
|
||||
if (item.source !== "url") return false;
|
||||
try {
|
||||
const parsed = new URL(item.url);
|
||||
return !["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
));
|
||||
if (invalidMedia) {
|
||||
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
|
||||
setLocalError(
|
||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Mission Core"
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: "Mission Core",
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: "MISSION CORE",
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.items.length) {
|
||||
setLocalError("Сначала добавьте медиаконтент.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings__editor">
|
||||
<div className="environment-settings__surface">
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__copy">
|
||||
<TextField
|
||||
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentMediaPlaylistEditor
|
||||
surfaceId={surfaceId}
|
||||
background={selectedBackground}
|
||||
disabled={busy}
|
||||
error={localError ?? error}
|
||||
onBusyChange={setUploading}
|
||||
onChange={(background) => {
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, background));
|
||||
}}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
const surfaces = environmentSurfaceIds.map(id => ({
|
||||
id, home: id === "home", description: id === "home" ? "Главная страница продукта" : `Стартовая страница раздела «${roots.find(root => root.id === id)?.label}»`,
|
||||
actions: id === "home" ? workspaces.filter(item => !item.internalOnly) : workspacesForRoot(id),
|
||||
}));
|
||||
export function EnvironmentSettingsWindow(props: Props) {
|
||||
return <SharedEnvironmentSettingsWindow {...props} productName="Mission Core" surfaces={surfaces} initialSurfaceId="fleet"
|
||||
onSave={draft => props.onSave({ ...draft, pages: Object.fromEntries(environmentSurfaceIds.map(id => [id, draft.pages[id]])) as EnvironmentSettings["pages"] })}
|
||||
onUpload={(id, itemId, file) => {
|
||||
if (!environmentSurfaceIds.includes(id as EnvironmentSurfaceId)) throw new Error("Страница окружения недоступна.");
|
||||
return props.onUpload(id as EnvironmentSurfaceId, itemId, file);
|
||||
}} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import type {RefObject} from "react";
|
||||
import {FloatingMediaWindow} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||
export {initialObservationWindowRect,shouldCaptureWorkspacePointer} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||
|
||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
@@ -10,60 +11,6 @@ import type {
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const WINDOW_WIDTH = 336;
|
||||
const WINDOW_HEIGHT = 210;
|
||||
const WINDOW_GAP = 16;
|
||||
const WINDOW_INSET = 18;
|
||||
const TIMELINE_CLEARANCE = 64;
|
||||
|
||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||
|
||||
export function shouldCaptureWorkspacePointer(
|
||||
button: number,
|
||||
target: ClosestTarget | null,
|
||||
): boolean {
|
||||
if (button !== 0 || !target) return false;
|
||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||
return !target.closest("button, input, select, textarea, a");
|
||||
}
|
||||
|
||||
export function initialObservationWindowRect(
|
||||
index: number,
|
||||
count = 1,
|
||||
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
||||
): ObservationWindowRect {
|
||||
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
||||
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
||||
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
||||
const maxColumns = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
||||
);
|
||||
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
||||
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
||||
const rowHeight = Math.max(
|
||||
1,
|
||||
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
||||
);
|
||||
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
||||
const row = Math.floor(index / columns);
|
||||
const column = index % columns;
|
||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||
|
||||
return {
|
||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||
y: Math.max(
|
||||
0,
|
||||
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function FloatingObservationWindow({
|
||||
source,
|
||||
index,
|
||||
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = boundsRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const measure = () => {
|
||||
const next = { width: element.clientWidth, height: element.clientHeight };
|
||||
setBounds((current) => (
|
||||
current?.width === next.width && current.height === next.height ? current : next
|
||||
));
|
||||
};
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [boundsRef]);
|
||||
|
||||
// Camera media admission must not wait for the viewport observer. A source
|
||||
// can arrive in the same commit as a workspace transition; mounting it with
|
||||
// the canonical fallback geometry keeps the browser transport alive until
|
||||
// the real bounds are measured immediately afterward.
|
||||
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
||||
const windowRect = rect ?? initialRect;
|
||||
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
boundsRef={boundsRef}
|
||||
rect={windowRect}
|
||||
onRectChange={onRectChange}
|
||||
maximized={maximized}
|
||||
onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
title={source.label}
|
||||
subtitle={source.description}
|
||||
status={(
|
||||
<span className="floating-observation-window__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
)}
|
||||
footer={(
|
||||
<span className="floating-observation-window__footer">
|
||||
<span>{source.endpointLabel || source.transport}</span>
|
||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||
</span>
|
||||
)}
|
||||
minWidth={Math.min(280, windowRect.width)}
|
||||
minHeight={Math.min(190, windowRect.height)}
|
||||
resizable={source.capabilities.resizable}
|
||||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture can fail if the browser ended the pointer between
|
||||
// dispatch and capture. The donor's window listeners remain fallback.
|
||||
}
|
||||
}}
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
return <FloatingMediaWindow title={source.label} subtitle={source.description}
|
||||
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
|
||||
active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
|
||||
status={<span className="floating-observation-window__status"><i data-availability={source.availability} aria-hidden="true"/>{observationSourceStatusLabel(source)}</span>}
|
||||
footer={<span className="floating-observation-window__footer"><span>{source.endpointLabel || source.transport}</span><span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span></span>}>
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
</FloatingMediaWindow>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import { LandingStage as SharedLandingStage, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { EnvironmentPage } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
|
||||
|
||||
export interface LandingStageProps {
|
||||
root: RootDefinition | null;
|
||||
@@ -25,40 +24,10 @@ export function LandingStage({
|
||||
quickActions,
|
||||
onOpenWorkspace,
|
||||
}: LandingStageProps) {
|
||||
const { background } = page;
|
||||
const hasMedia = background.enabled && background.items.some(
|
||||
(item) => item.url && item.mediaKind,
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="landing-stage"
|
||||
data-root={root?.id ?? "home"}
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackgroundMedia background={background} />
|
||||
<div className="landing-stage__shade" aria-hidden="true" />
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{quickActions.length ? (
|
||||
<div className="landing-stage__actions">
|
||||
{quickActions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
icon={<Icon name={workspace.icon} />}
|
||||
onClick={() => onOpenWorkspace(workspace.id)}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="landing-stage__status">
|
||||
<div>
|
||||
return <SharedLandingStage page={page} pageId={root?.id ?? "home"}
|
||||
actions={quickActions.map(workspace => ({ id: workspace.id, label: workspace.label, icon: workspace.icon, onSelect: () => onOpenWorkspace(workspace.id) }))}
|
||||
status={<>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</div>
|
||||
@@ -67,12 +36,9 @@ export function LandingStage({
|
||||
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
|
||||
</div>
|
||||
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
|
||||
</div>
|
||||
|
||||
<footer className="landing-stage__footer">
|
||||
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||
</>}
|
||||
footer={<>
|
||||
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
</>} />;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface CameraLeaseRetryBudget {
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
||||
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_BUFFERING_NOTICE_DELAY_MS = 1_000;
|
||||
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
||||
// main-thread stall. Keep one bounded append margin above that complete batch;
|
||||
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
||||
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
let onBufferError: (() => void) | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let bufferingNoticeTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
let playingReported = false;
|
||||
@@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({
|
||||
);
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
const clearBufferingNotice = () => {
|
||||
if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer);
|
||||
bufferingNoticeTimer = undefined;
|
||||
};
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
@@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
return;
|
||||
}
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
@@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
|
||||
const onPlaying = () => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
clearBufferingNotice();
|
||||
startupWatchdog?.markPlaying();
|
||||
transportHealthyRef.current = true;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
@@ -410,7 +419,21 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
};
|
||||
|
||||
const onWaiting = () => {
|
||||
if (!transportIsCurrent() || failed || !playingReported) return;
|
||||
// A short receipt gap must not flicker or restart the acquisition-owned
|
||||
// stream. A sustained decoder wait must not retain a playing presentation.
|
||||
if (bufferingNoticeTimer !== undefined) return;
|
||||
bufferingNoticeTimer = window.setTimeout(() => {
|
||||
bufferingNoticeTimer = undefined;
|
||||
if (!transportIsCurrent() || failed || video.readyState >= 3) return;
|
||||
setStatus("buffering");
|
||||
setMessage("Ожидание новых кадров.");
|
||||
}, CAMERA_BUFFERING_NOTICE_DELAY_MS);
|
||||
};
|
||||
video.addEventListener("playing", onPlaying);
|
||||
video.addEventListener("waiting", onWaiting);
|
||||
video.addEventListener("stalled", onWaiting);
|
||||
|
||||
const appendNext = () => {
|
||||
if (
|
||||
@@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({
|
||||
disposed = true;
|
||||
transportHealthyRef.current = false;
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Browser preview transport replaced or hidden");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
video.removeEventListener("waiting", onWaiting);
|
||||
video.removeEventListener("stalled", onWaiting);
|
||||
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
||||
if (sourceBuffer && onBufferUpdateEnd) {
|
||||
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
||||
@@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
{status !== "playing" ? (
|
||||
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
||||
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
||||
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
|
||||
<strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
|
||||
<span>{message}</span>
|
||||
{status === "error" ? (
|
||||
<button
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: "source",
|
||||
scope: "standalone",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
||||
import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
@@ -16,28 +16,6 @@ import type {
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
video: "video",
|
||||
image: "image",
|
||||
depth: "image",
|
||||
};
|
||||
|
||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||
unverified: "Не подтверждён",
|
||||
declared: "Канал объявлен",
|
||||
available: "Доступен",
|
||||
connecting: "Подключение",
|
||||
streaming: "Эфир",
|
||||
degraded: "Нестабильно",
|
||||
unavailable: "Недоступен",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
||||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({
|
||||
source,
|
||||
playback,
|
||||
@@ -159,105 +137,3 @@ export function ObservationMedia({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationSourcePicker({
|
||||
sources,
|
||||
visibleSourceIds,
|
||||
pendingSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
sources: readonly ObservationSourceDescriptor[];
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
pendingSourceIds?: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
||||
}) {
|
||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="scene-source-picker"
|
||||
placement="bottom-start"
|
||||
width={340}
|
||||
offset={10}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-source-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="scene-source-picker__trigger"
|
||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||
aria-label="Источники данных сцены"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={17} />
|
||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<header className="observation-source-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||
<strong>Источники данных</strong>
|
||||
</div>
|
||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||
</header>
|
||||
{sources.length ? (
|
||||
<div className="observation-source-menu__list">
|
||||
{sources.map((source) => {
|
||||
const selected = visibleSourceIds.has(source.id);
|
||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
||||
const canOpen = Boolean(
|
||||
selected ||
|
||||
source.modality === "point-cloud" ||
|
||||
source.previewUrl ||
|
||||
source.delivery ||
|
||||
source.activation?.controllable,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-source-option"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
disabled={pending || !canOpen}
|
||||
onClick={() => void onToggle(source.id)}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__check">
|
||||
{selected ? <Icon name="check" size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-source-menu__empty">
|
||||
<Icon name="database" size={18} />
|
||||
<strong>Каталог пока пуст</strong>
|
||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||
модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,197 +1 @@
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
export function ObservationTimeline({
|
||||
active,
|
||||
sourceCount,
|
||||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
rangeNs = null,
|
||||
currentNs = null,
|
||||
playing = false,
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
showJumpToEnd = true,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
sourceCount: number;
|
||||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
rangeNs?: { min: number; max: number } | null;
|
||||
currentNs?: number | null;
|
||||
playing?: boolean;
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
showJumpToEnd?: boolean;
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||
const buffered = Boolean(
|
||||
seekable &&
|
||||
(mode === "buffered" || mode === "recorded") &&
|
||||
playbackRange,
|
||||
);
|
||||
const elapsedSeconds = playbackRange
|
||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||
: 0;
|
||||
const durationSeconds = playbackRange
|
||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||
: 0;
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
const accumulationValue = accumulationSeconds === undefined
|
||||
? null
|
||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||
>
|
||||
{accumulationValue !== null ? (
|
||||
<div className="observation-timeline__accumulation">
|
||||
<span>Накопление</span>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||
onChange={(event) =>
|
||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||
onPointerUp={onAccumulationCommit}
|
||||
onTouchEnd={onAccumulationCommit}
|
||||
onKeyUp={onAccumulationCommit}
|
||||
onBlur={onAccumulationCommit}
|
||||
/>
|
||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="observation-timeline__playback">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Перейти к началу"
|
||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
className="observation-timeline__transport"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
/>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
value={String(playbackRate)}
|
||||
options={[
|
||||
{ value: "0.5", label: "0,5×" },
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="inline"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(durationSeconds, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Позиция воспроизведения"
|
||||
onChange={(event) => {
|
||||
if (!playbackRange) return;
|
||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>
|
||||
{buffered
|
||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||
: active ? "LIVE" : "—:—:—.———"}
|
||||
</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
{showJumpToEnd ? (
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
range: { min: number; max: number } | null | undefined,
|
||||
): { min: number; max: number } | null {
|
||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||
if (range.max <= range.min) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
export function timelineOffsetSeconds(
|
||||
range: { min: number; max: number },
|
||||
currentNs: number,
|
||||
): number {
|
||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||
}
|
||||
|
||||
export function formatTimelineDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||
const wholeMinutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - wholeMinutes * 60;
|
||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
|
||||
|
||||
@@ -29,7 +29,9 @@ export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "
|
||||
|
||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||
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_REQUIRED_AHEAD_SEGMENTS = 12;
|
||||
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
|
||||
@@ -72,6 +74,39 @@ export function recordedMediaDecodeStartSequence(
|
||||
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(
|
||||
appended: ReadonlySet<number>,
|
||||
decodeStartSequence: number,
|
||||
@@ -90,6 +125,32 @@ export function recordedMediaSegmentAppendOrder(
|
||||
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(
|
||||
previousSequence: number,
|
||||
nextSequence: number,
|
||||
@@ -121,6 +182,27 @@ function recordedMediaTimeRangesContain(
|
||||
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(
|
||||
state: "loading" | "ready" | "error",
|
||||
readyGeneration: string | null,
|
||||
@@ -151,6 +233,24 @@ export function selectRecordedMediaEpoch(
|
||||
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(
|
||||
durationSeconds: number,
|
||||
seekableEndSeconds: number,
|
||||
@@ -231,29 +331,14 @@ export async function fetchRecordedMediaArchive(
|
||||
return { manifest, byteLength: totalBytes };
|
||||
}
|
||||
|
||||
function videoHasSeekableArchive(
|
||||
function waitForRecordedVideoInitialFrame(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
): boolean {
|
||||
if (video.readyState < 1 || video.seekable.length < 1) return false;
|
||||
return recordedMediaSeekableCoverage(
|
||||
video.duration,
|
||||
video.seekable.end(video.seekable.length - 1),
|
||||
declaredDurationSeconds,
|
||||
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
||||
video.seekable.start(0),
|
||||
);
|
||||
}
|
||||
|
||||
function waitForSeekableArchive(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
|
||||
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;
|
||||
const armStallTimer = () => {
|
||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||
@@ -270,7 +355,7 @@ function waitForSeekableArchive(
|
||||
};
|
||||
const onProgress = () => {
|
||||
armStallTimer();
|
||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
||||
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
@@ -313,11 +398,7 @@ async function mountRecordedEpochStream(
|
||||
};
|
||||
video.load();
|
||||
try {
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
signal,
|
||||
);
|
||||
await waitForRecordedVideoInitialFrame(video, signal);
|
||||
return cleanup;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
@@ -336,6 +417,11 @@ interface RecordedSegmentTarget {
|
||||
resetAttempts: number;
|
||||
}
|
||||
|
||||
interface RecordedSegmentRecovery {
|
||||
readonly failedSequence: number;
|
||||
readonly recoverySequence: number;
|
||||
}
|
||||
|
||||
interface RecordedSegmentStreamRuntime {
|
||||
readonly generation: string;
|
||||
readonly mediaSource: MediaSource;
|
||||
@@ -716,6 +802,9 @@ export function RecordedFmp4Player({
|
||||
onAdmissionChange,
|
||||
onPlaybackChange,
|
||||
onPlayingRejected,
|
||||
playbackAuthority = "media",
|
||||
playbackTransport = "segmented",
|
||||
recoverTimestampStalls = false,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
@@ -728,6 +817,9 @@ export function RecordedFmp4Player({
|
||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
onPlayingRejected?: () => void;
|
||||
playbackAuthority?: "media" | "host";
|
||||
playbackTransport?: "segmented" | "epoch-stream";
|
||||
recoverTimestampStalls?: boolean;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||
@@ -768,12 +860,16 @@ export function RecordedFmp4Player({
|
||||
);
|
||||
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const [segmentRecoveryGeneration, setSegmentRecoveryGeneration] = useState(0);
|
||||
const [segmentRecovery, setSegmentRecovery] = useState<RecordedSegmentRecovery | null>(null);
|
||||
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
|
||||
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
|
||||
const targetRevisionRef = useRef(0);
|
||||
const targetReadyAbortRef = useRef<AbortController | null>(null);
|
||||
const lastSegmentRecoveryRef = useRef<string | null>(null);
|
||||
const playAttemptRevisionRef = useRef(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const playbackPlayingRef = useRef(Boolean(playback?.playing));
|
||||
@@ -781,25 +877,63 @@ export function RecordedFmp4Player({
|
||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||
? Math.min(4, Math.max(0.25, playback.rate))
|
||||
: 1;
|
||||
const epoch = useMemo(
|
||||
const playbackRateRef = useRef(playbackRate);
|
||||
playbackRateRef.current = playbackRate;
|
||||
const presentationEpoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(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(
|
||||
segmentCount !== null
|
||||
&& Number.isInteger(segmentCount)
|
||||
&& segmentCount >= 1
|
||||
playbackTransport === "segmented"
|
||||
&& requestedSegmentSequence !== null
|
||||
&& Number.isInteger(requestedSegmentSequence)
|
||||
&& requestedSegmentSequence >= 1
|
||||
&&
|
||||
effectiveSegmentCount !== null
|
||||
&& Number.isInteger(effectiveSegmentCount)
|
||||
&& effectiveSegmentCount >= 1
|
||||
&& typeof MediaSource !== "undefined"
|
||||
&& epoch
|
||||
&& epoch.segmentCount === segmentCount
|
||||
&& epoch.segmentCount === effectiveSegmentCount
|
||||
&& epoch.randomAccessSequences.length > 0
|
||||
&& epoch.segmentEndTimesSeconds.length === segmentCount
|
||||
&& epoch.segmentEndTimesSeconds.length === effectiveSegmentCount
|
||||
&& MediaSource.isTypeSupported(epoch.mediaType),
|
||||
);
|
||||
const directPlaybackSeconds = segmented ? null : currentSeconds;
|
||||
const waitingForEpoch = Boolean(archive && !epoch);
|
||||
const selectedGeneration = contract && epoch
|
||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
||||
const waitingForEpoch = Boolean(archive && !presentationEpoch);
|
||||
const selectedGeneration = contract && presentationEpoch
|
||||
? `${contract.manifestGenerationSha256}:${presentationEpoch.ordinal}:${presentationEpoch.timelineStartSeconds}:${presentationEpoch.timelineEndSeconds}`
|
||||
: null;
|
||||
const visualState = recordedMediaPresentationState(
|
||||
state,
|
||||
@@ -813,6 +947,7 @@ export function RecordedFmp4Player({
|
||||
if (!contract) {
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Некорректный descriptor записанной камеры.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -825,6 +960,9 @@ export function RecordedFmp4Player({
|
||||
const abort = new AbortController();
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setSegmentRecovery(null);
|
||||
lastSegmentRecoveryRef.current = null;
|
||||
setErrorMessage(null);
|
||||
setState("loading");
|
||||
reportAdmission({
|
||||
phase: "loading",
|
||||
@@ -842,6 +980,7 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Архив записанной камеры не прошёл проверку.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -853,43 +992,14 @@ export function RecordedFmp4Player({
|
||||
}, [admissionKey, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!archive || !contract || !prepare || segmented) return;
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
for (const candidate of archive.manifest.epochs) {
|
||||
const probe = document.createElement("video");
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
|
||||
cleanup();
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
}
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
byteLength: archive.byteLength,
|
||||
message: null,
|
||||
});
|
||||
})().catch((error: unknown) => {
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) return;
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive.byteLength,
|
||||
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
};
|
||||
}, [admissionKey, archive, contract, prepare, segmented]);
|
||||
if (!segmentRecovery || requestedSegmentSequence === null) return;
|
||||
if (
|
||||
requestedSegmentSequence >= segmentRecovery.failedSequence
|
||||
&& requestedSegmentSequence < segmentRecovery.recoverySequence
|
||||
) return;
|
||||
lastSegmentRecoveryRef.current = null;
|
||||
setSegmentRecovery(null);
|
||||
}, [requestedSegmentSequence, segmentRecovery]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -910,6 +1020,7 @@ export function RecordedFmp4Player({
|
||||
|
||||
setReadyGeneration(null);
|
||||
setSegmentedRuntimeGeneration(null);
|
||||
setErrorMessage(null);
|
||||
setState("loading");
|
||||
video.pause();
|
||||
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
|
||||
@@ -957,6 +1068,7 @@ export function RecordedFmp4Player({
|
||||
return;
|
||||
}
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Покадровый буфер записанной камеры не открылся.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -982,7 +1094,14 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [archive, contract, epoch, segmentCount, segmented]);
|
||||
}, [
|
||||
archive,
|
||||
contract,
|
||||
effectiveSegmentCount,
|
||||
epoch,
|
||||
segmented,
|
||||
segmentRecoveryGeneration,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = segmentedRuntimeRef.current;
|
||||
@@ -993,18 +1112,19 @@ export function RecordedFmp4Player({
|
||||
|| !archive
|
||||
|| !segmented
|
||||
|| segmentedRuntimeGeneration !== runtime.generation
|
||||
|| segmentSequence === null
|
||||
|| !Number.isInteger(segmentSequence)
|
||||
|| segmentSequence < 1
|
||||
|| segmentSequence > runtime.segmentCount
|
||||
|| effectiveSegmentSequence === null
|
||||
|| !Number.isInteger(effectiveSegmentSequence)
|
||||
|| effectiveSegmentSequence < 1
|
||||
|| effectiveSegmentSequence > runtime.segmentCount
|
||||
) return;
|
||||
const archiveByteLength = archive.byteLength;
|
||||
const decodeStart = recordedMediaDecodeStartSequence(
|
||||
runtime.randomAccessSequences,
|
||||
segmentSequence,
|
||||
effectiveSegmentSequence,
|
||||
);
|
||||
if (decodeStart === null) {
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Для кадра записанной камеры нет random-access фрагмента.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1015,10 +1135,11 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
const targetSeconds = recordedSegmentStartSeconds(
|
||||
runtime.segmentEndTimesSeconds,
|
||||
segmentSequence,
|
||||
effectiveSegmentSequence,
|
||||
);
|
||||
if (targetSeconds === null) {
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Для кадра записанной камеры нет точной media timestamp.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1030,15 +1151,15 @@ export function RecordedFmp4Player({
|
||||
const previousTarget = runtime.target;
|
||||
const readyEnd = Math.min(
|
||||
runtime.segmentCount,
|
||||
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
||||
effectiveSegmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
||||
);
|
||||
const desiredEnd = Math.min(
|
||||
runtime.segmentCount,
|
||||
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
||||
effectiveSegmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
||||
);
|
||||
const candidateTarget: RecordedSegmentTarget = {
|
||||
revision: previousTarget?.revision ?? 0,
|
||||
sequence: segmentSequence,
|
||||
sequence: effectiveSegmentSequence,
|
||||
decodeStart,
|
||||
readyEnd,
|
||||
desiredEnd,
|
||||
@@ -1046,6 +1167,24 @@ export function RecordedFmp4Player({
|
||||
forceReset: false,
|
||||
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(
|
||||
previousTarget.sequence,
|
||||
candidateTarget.sequence,
|
||||
@@ -1059,21 +1198,62 @@ export function RecordedFmp4Player({
|
||||
|| runtime.abort.signal.aborted
|
||||
|| (error instanceof DOMException && error.name === "AbortError")
|
||||
) 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);
|
||||
setErrorMessage(message);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
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) {
|
||||
runtime.target = {
|
||||
...candidateTarget,
|
||||
revision: previousTarget.revision,
|
||||
};
|
||||
runtime.onTargetBuffered = null;
|
||||
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
|
||||
void pumpRecordedSegmentWindow(runtime)
|
||||
.then(() => resumePlaybackIfRequested(previousTarget.revision))
|
||||
.catch(reportPumpError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1128,18 +1308,22 @@ export function RecordedFmp4Player({
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
runtime.hasPresentedFrame = true;
|
||||
setReadyGeneration(runtime.generation);
|
||||
setErrorMessage(null);
|
||||
setState("ready");
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
byteLength: archiveByteLength,
|
||||
message: null,
|
||||
});
|
||||
await resumePlaybackIfRequested(bufferedTarget.revision);
|
||||
} catch (error) {
|
||||
if (
|
||||
targetReadyAbort.signal.aborted
|
||||
|| (error instanceof DOMException && error.name === "AbortError")
|
||||
) return;
|
||||
if (recoverFromSegmentFailure(bufferedTarget.sequence)) return;
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Кадр записанной камеры не стал decoder-ready.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1160,7 +1344,13 @@ export function RecordedFmp4Player({
|
||||
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
|
||||
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
|
||||
};
|
||||
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]);
|
||||
}, [
|
||||
archive?.byteLength,
|
||||
effectiveSegmentSequence,
|
||||
playbackAuthority,
|
||||
segmented,
|
||||
segmentedRuntimeGeneration,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -1170,6 +1360,7 @@ export function RecordedFmp4Player({
|
||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||
: null;
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage(null);
|
||||
setState("loading");
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
@@ -1185,7 +1376,13 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
setReadyGeneration(generation);
|
||||
setErrorMessage(null);
|
||||
setState("ready");
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
disposed ||
|
||||
@@ -1195,11 +1392,12 @@ export function RecordedFmp4Player({
|
||||
return;
|
||||
}
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Записанная камера не открыла первый декодируемый кадр.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Записанная камера не стала seekable.",
|
||||
message: "Записанная камера не открыла первый декодируемый кадр.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1234,6 +1432,7 @@ export function RecordedFmp4Player({
|
||||
video.currentTime = target;
|
||||
} catch {
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Seek записанной камеры завершился ошибкой.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1244,11 +1443,13 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
}
|
||||
video.playbackRate = playbackRate;
|
||||
if (playback?.playing) {
|
||||
if (playback?.playing && !holdingForSegmentRecovery) {
|
||||
void video.play().catch(() => {
|
||||
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
|
||||
if (playbackAuthority === "host") return;
|
||||
onPlayingRejectedRef.current?.();
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Запуск записанной камеры отклонён браузером.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1264,12 +1465,95 @@ export function RecordedFmp4Player({
|
||||
bufferRevision,
|
||||
directPlaybackSeconds,
|
||||
epoch,
|
||||
holdingForSegmentRecovery,
|
||||
playback?.playing,
|
||||
playbackAuthority,
|
||||
playbackRate,
|
||||
segmented,
|
||||
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(() => {
|
||||
const video = videoRef.current;
|
||||
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
||||
@@ -1327,7 +1611,9 @@ export function RecordedFmp4Player({
|
||||
{visualState === "waiting"
|
||||
? "Камера на этой позиции ещё не записывалась"
|
||||
: visualState === "error"
|
||||
? "Записанное видео недоступно"
|
||||
? errorMessage ?? "Записанное видео недоступно"
|
||||
: errorMessage
|
||||
? errorMessage
|
||||
: archive
|
||||
? "Проверяем seek и codec записанного видео…"
|
||||
: "Читаем manifest записанного видео…"}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||
import { resolveAICompositionReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import type { AICompositionRun } from "../../core/observatory/aiComposition";
|
||||
|
||||
function resolveComposition(
|
||||
runId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal },
|
||||
) {
|
||||
return resolveAICompositionReplay(runId, launch, options);
|
||||
}
|
||||
|
||||
export function AICompositionReplay({ run }: { run: AICompositionRun }) {
|
||||
const semantics = run.moduleIds.includes("ddrnet") || run.moduleIds.includes("eomt");
|
||||
const detections = run.moduleIds.includes("rf-detr")
|
||||
|| run.moduleIds.includes("object-distance");
|
||||
const costmap = run.moduleIds.includes("tgs");
|
||||
return (
|
||||
<CanonicalResultRerunReplay
|
||||
key={run.runId}
|
||||
resultId={run.runId}
|
||||
sessionId={run.sourceSessionId}
|
||||
resolveReplay={resolveComposition}
|
||||
semantics={semantics}
|
||||
detections={detections}
|
||||
costmap={costmap}
|
||||
moduleIds={run.moduleIds}
|
||||
viewerLayers={run.presentation.viewerLayers}
|
||||
label={run.configurationLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { SegmentedControl, SplitPane, type SplitPaneOrientation } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "./LaboratoryEvidenceViewer";
|
||||
|
||||
export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
|
||||
"missioncore.canonical-recorded-lab-replay/v1";
|
||||
|
||||
export interface CanonicalRecordedLabMode<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interaction contract from the accepted recorded-LAB instrument.
|
||||
*
|
||||
* Keeping pane selection, collapse semantics, responsive split orientation,
|
||||
* expansion and splitter state here prevents individual experiments from
|
||||
* quietly growing their own replay behaviour. Experiments provide evidence
|
||||
* layers; they do not reimplement the laboratory shell.
|
||||
*/
|
||||
export function useCanonicalRecordedLabReplayState<
|
||||
TMedia extends string,
|
||||
TSpatial extends string,
|
||||
>({
|
||||
initialMediaMode,
|
||||
initialSpatialMode,
|
||||
}: {
|
||||
initialMediaMode: TMedia;
|
||||
initialSpatialMode: TSpatial | null;
|
||||
}) {
|
||||
const [mediaMode, setMediaMode] = useState<TMedia | null>(initialMediaMode);
|
||||
const [spatialMode, setSpatialMode] = useState<TSpatial | null>(initialSpatialMode);
|
||||
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
||||
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||
? "horizontal"
|
||||
: "vertical"
|
||||
));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(max-width: 900px)");
|
||||
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
const onMediaModeChange = useCallback((next: TMedia | "none") => {
|
||||
if (next === "none") return;
|
||||
setMediaMode((current) => current === next ? null : next);
|
||||
}, []);
|
||||
const onSpatialModeChange = useCallback((next: TSpatial | "none") => {
|
||||
if (next === "none") return;
|
||||
setSpatialMode((current) => current === next ? null : next);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
splitView: mediaMode !== null && spatialMode !== null,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
expanded,
|
||||
onMediaModeChange,
|
||||
onSpatialModeChange,
|
||||
onSplitPrimarySizeChange: setSplitPrimarySize,
|
||||
onExpandedChange: setExpanded,
|
||||
};
|
||||
}
|
||||
|
||||
export function CanonicalRecordedLabReplay<
|
||||
TMedia extends string,
|
||||
TSpatial extends string,
|
||||
>({
|
||||
label,
|
||||
mediaMode,
|
||||
mediaModes,
|
||||
spatialMode,
|
||||
spatialModes,
|
||||
expanded,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
mediaAriaLabel,
|
||||
spatialAriaLabel,
|
||||
mediaLayerControls,
|
||||
spatialLayerControls,
|
||||
spatialLeadingControl,
|
||||
spatialTrailingControl,
|
||||
mediaModeControlsVisible = true,
|
||||
paneToolbarsAlwaysVisible = false,
|
||||
mediaMultiLayer = false,
|
||||
mediaContent,
|
||||
spatialContent,
|
||||
unifiedContent,
|
||||
emptyMessage,
|
||||
deckOverlays,
|
||||
actions,
|
||||
overlay,
|
||||
transport,
|
||||
trailingActions,
|
||||
onMediaModeChange,
|
||||
onSpatialModeChange,
|
||||
onExpandedChange,
|
||||
onSplitPrimarySizeChange,
|
||||
}: {
|
||||
label: string;
|
||||
mediaMode: TMedia;
|
||||
mediaModes: readonly CanonicalRecordedLabMode<TMedia>[];
|
||||
spatialMode: TSpatial;
|
||||
spatialModes: readonly CanonicalRecordedLabMode<TSpatial>[];
|
||||
expanded: boolean;
|
||||
splitPrimarySize: number;
|
||||
splitOrientation: SplitPaneOrientation;
|
||||
mediaAriaLabel: string;
|
||||
spatialAriaLabel: string;
|
||||
mediaLayerControls?: ReactNode;
|
||||
spatialLayerControls?: ReactNode;
|
||||
spatialLeadingControl?: ReactNode;
|
||||
spatialTrailingControl?: ReactNode;
|
||||
mediaModeControlsVisible?: boolean;
|
||||
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
|
||||
paneToolbarsAlwaysVisible?: boolean;
|
||||
mediaMultiLayer?: boolean;
|
||||
mediaContent?: ReactNode;
|
||||
spatialContent?: ReactNode;
|
||||
/** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
|
||||
unifiedContent?: ReactNode;
|
||||
emptyMessage: string;
|
||||
deckOverlays?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
transport?: ReactNode;
|
||||
trailingActions?: ReactNode;
|
||||
onMediaModeChange: (mode: TMedia) => void;
|
||||
onSpatialModeChange: (mode: TSpatial) => void;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
onSplitPrimarySizeChange: (size: number) => void;
|
||||
}) {
|
||||
const splitView = mediaMode !== "none" && spatialMode !== "none";
|
||||
const mediaModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
||||
<SegmentedControl
|
||||
value={mediaMode}
|
||||
items={[...mediaModes]}
|
||||
label="Видео и камера"
|
||||
size="dense"
|
||||
onChange={onMediaModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const spatialModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
||||
<SegmentedControl
|
||||
value={spatialMode}
|
||||
items={[...spatialModes]}
|
||||
label="3D и план"
|
||||
size="dense"
|
||||
onChange={onSpatialModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const mediaPane = (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="media"
|
||||
aria-label={mediaAriaLabel}
|
||||
hidden={mediaMode === "none"}
|
||||
>
|
||||
{splitView || paneToolbarsAlwaysVisible ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="media"
|
||||
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
|
||||
>
|
||||
{mediaLayerControls}
|
||||
{mediaModeControlsVisible ? mediaModeControls : null}
|
||||
</div>
|
||||
) : null}
|
||||
{mediaContent}
|
||||
</section>
|
||||
);
|
||||
const spatialPane = spatialMode !== "none" ? (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="spatial"
|
||||
aria-label={spatialAriaLabel}
|
||||
>
|
||||
{splitView || paneToolbarsAlwaysVisible ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="spatial"
|
||||
>
|
||||
{spatialLeadingControl}
|
||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||
{spatialLayerControls}
|
||||
{spatialModeControls}
|
||||
{spatialTrailingControl}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{spatialContent}
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="l3-visual-audit m4-replay-threat-visual"
|
||||
data-contract={CANONICAL_RECORDED_LAB_REPLAY_CONTRACT}
|
||||
>
|
||||
<LaboratoryEvidenceViewer
|
||||
label={label}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mediaMode}
|
||||
modes={[...mediaModes]}
|
||||
secondaryMode={{
|
||||
value: spatialMode,
|
||||
modes: [...spatialModes],
|
||||
label: "3D и план",
|
||||
onChange: onSpatialModeChange,
|
||||
}}
|
||||
expanded={expanded}
|
||||
onModeChange={onMediaModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
trailingActions={trailingActions}
|
||||
>
|
||||
<div
|
||||
className="m4-replay-threat-visual__deck"
|
||||
data-split={splitView ? "true" : undefined}
|
||||
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
|
||||
data-native-rerun={unifiedContent ? "true" : undefined}
|
||||
>
|
||||
{unifiedContent ? (
|
||||
<div className="m4-replay-threat-visual__unified-content">
|
||||
{unifiedContent}
|
||||
</div>
|
||||
) : null}
|
||||
<SplitPane
|
||||
primary={mediaPane}
|
||||
secondary={spatialPane ?? <div />}
|
||||
primarySize={splitView ? splitPrimarySize : mediaMode !== "none" ? 100 : 0}
|
||||
onPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView}
|
||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||
/>
|
||||
{mediaMode === "none" && spatialMode === "none" ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{deckOverlays}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Checker,
|
||||
ControlRow,
|
||||
Icon,
|
||||
IconButton,
|
||||
Inspector,
|
||||
InspectorSelectField,
|
||||
RangeControl,
|
||||
ToastStack,
|
||||
Window,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunViewportStatus,
|
||||
} from "../RerunViewport";
|
||||
import {
|
||||
CanonicalRecordedLabReplay,
|
||||
useCanonicalRecordedLabReplayState,
|
||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
||||
import {
|
||||
fetchLabViewProfile,
|
||||
saveLabViewProfile,
|
||||
} from "../../core/observatory/labViewProfile";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "../../sceneSettings";
|
||||
|
||||
type MediaMode = "camera";
|
||||
type SpatialMode = "3d" | "plan";
|
||||
|
||||
interface CanonicalReplayLaunch {
|
||||
base: ObservationSessionReplayLaunch;
|
||||
replay: CanonicalLabReplayDescriptor;
|
||||
}
|
||||
|
||||
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||
const LAB_SCENE_SETTINGS_STORAGE_PREFIX = "missioncore.observatory.lab-scene-settings.v1:";
|
||||
const LAB_VIEW_PROFILE_ERROR_TOAST = "observatory-lab-view-profile-error";
|
||||
|
||||
const COLOR_OPTIONS: readonly { value: PointColorMode; label: string }[] = [
|
||||
{ value: "intensity", label: "Интенсивность" },
|
||||
{ value: "height", label: "Высота Z" },
|
||||
{ value: "distance", label: "Дистанция" },
|
||||
{ value: "rgb", label: "RGB" },
|
||||
{ value: "class", label: "Класс" },
|
||||
];
|
||||
|
||||
const PALETTE_OPTIONS: readonly { value: PointPalette; label: string }[] = [
|
||||
{ value: "turbo", label: "Turbo" },
|
||||
{ value: "viridis", label: "Viridis" },
|
||||
{ value: "plasma", label: "Plasma" },
|
||||
{ value: "grayscale", label: "Серый" },
|
||||
];
|
||||
|
||||
const LAB_SCENE_DEFAULTS: SceneSettings = {
|
||||
...defaultSceneSettings,
|
||||
pointSize: 3.8,
|
||||
accumulationSeconds: 5,
|
||||
};
|
||||
|
||||
export function normalizeLabSceneSettings(settings: SceneSettings): SceneSettings {
|
||||
const pointSize = Number.isFinite(settings.pointSize) ? settings.pointSize : LAB_SCENE_DEFAULTS.pointSize;
|
||||
const accumulationSeconds = Number.isFinite(settings.accumulationSeconds)
|
||||
? settings.accumulationSeconds : LAB_SCENE_DEFAULTS.accumulationSeconds;
|
||||
return {
|
||||
...settings,
|
||||
pointSize: Math.round(Math.max(0.1, pointSize) * 10) / 10,
|
||||
accumulationSeconds: Math.round(Math.max(0, accumulationSeconds)),
|
||||
};
|
||||
}
|
||||
|
||||
function loadLabSceneSettings(resultId: string): SceneSettings {
|
||||
if (typeof window === "undefined") return LAB_SCENE_DEFAULTS;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`);
|
||||
if (raw === null) return LAB_SCENE_DEFAULTS;
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return LAB_SCENE_DEFAULTS;
|
||||
const row = value as Record<string, unknown>;
|
||||
const pointSize = row.pointSize;
|
||||
const accumulationSeconds = row.accumulationSeconds;
|
||||
const colorMode = row.colorMode;
|
||||
const palette = row.palette;
|
||||
const showGrid = row.showGrid;
|
||||
const showLabels = row.showLabels;
|
||||
const showCameraFrustums = row.showCameraFrustums;
|
||||
if (
|
||||
typeof pointSize !== "number" || !Number.isFinite(pointSize) || pointSize < 0.1
|
||||
|| typeof accumulationSeconds !== "number" || !Number.isFinite(accumulationSeconds)
|
||||
|| accumulationSeconds < 0
|
||||
|| !COLOR_OPTIONS.some((option) => option.value === colorMode)
|
||||
|| !PALETTE_OPTIONS.some((option) => option.value === palette)
|
||||
|| typeof showGrid !== "boolean"
|
||||
|| typeof showLabels !== "boolean"
|
||||
|| typeof showCameraFrustums !== "boolean"
|
||||
) return LAB_SCENE_DEFAULTS;
|
||||
return {
|
||||
...LAB_SCENE_DEFAULTS,
|
||||
pointSize,
|
||||
accumulationSeconds,
|
||||
colorMode: colorMode as PointColorMode,
|
||||
palette: palette as PointPalette,
|
||||
showGrid,
|
||||
showLabels,
|
||||
showCameraFrustums,
|
||||
};
|
||||
} catch {
|
||||
return LAB_SCENE_DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
export function CanonicalResultRerunReplay({
|
||||
resultId,
|
||||
sessionId,
|
||||
initialPlaybackStartSeconds,
|
||||
resolveReplay,
|
||||
semantics = false,
|
||||
detections = false,
|
||||
costmap = false,
|
||||
moduleIds = [],
|
||||
viewerLayers = [],
|
||||
label = "Сохранённый результат",
|
||||
}: {
|
||||
resultId: string;
|
||||
sessionId: string;
|
||||
initialPlaybackStartSeconds?: number;
|
||||
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
|
||||
semantics?: boolean;
|
||||
detections?: boolean;
|
||||
costmap?: boolean;
|
||||
portable?: boolean;
|
||||
moduleIds?: readonly string[];
|
||||
viewerLayers?: readonly AIViewerLayer[];
|
||||
label?: string;
|
||||
}) {
|
||||
const {
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
expanded,
|
||||
onMediaModeChange,
|
||||
onSpatialModeChange,
|
||||
onSplitPrimarySizeChange,
|
||||
onExpandedChange,
|
||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "camera",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const layerIds = useMemo(
|
||||
() => new Set(viewerLayers.map((layer) => layer.layerId)),
|
||||
[viewerLayers],
|
||||
);
|
||||
const hasProjectedLayers = viewerLayers.length > 0;
|
||||
const hasDDRNet = layerIds.has("camera.ddrnet")
|
||||
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
|
||||
const hasEoMT = layerIds.has("camera.eomt")
|
||||
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
|
||||
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
|
||||
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
|
||||
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
|
||||
const legacyUnclassified = !hasProjectedLayers
|
||||
&& moduleIds.length === 0
|
||||
&& !semantics
|
||||
&& !detections
|
||||
&& !costmap;
|
||||
const hasCameraPane = layerIds.has("camera.source")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
|
||||
const hasSourcePoints = layerIds.has("spatial.source-points")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
|
||||
const hasLocalSlam = layerIds.has("spatial.local-slam")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
|
||||
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
|
||||
const splitView = hasCameraPane && hasSpatialPane;
|
||||
|
||||
const [showCamera, setShowCamera] = useState(true);
|
||||
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
|
||||
const [showEoMT, setShowEoMT] = useState(hasEoMT);
|
||||
const [showDetections, setShowDetections] = useState(hasDetections);
|
||||
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
|
||||
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
|
||||
const [showTgs, setShowTgs] = useState(hasTgs);
|
||||
const [followTarget, setFollowTarget] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
|
||||
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
|
||||
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
|
||||
);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
const sceneDraftRef = useRef(sceneDraft);
|
||||
const previousSplitViewRef = useRef(splitView);
|
||||
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
|
||||
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
}
|
||||
trackedLaunchSha256Ref.current = launchSha256;
|
||||
previousSplitViewRef.current = splitView;
|
||||
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) return;
|
||||
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [splitPrimarySize, splitView]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
|
||||
JSON.stringify({
|
||||
pointSize: sceneDraft.pointSize,
|
||||
accumulationSeconds: sceneDraft.accumulationSeconds,
|
||||
colorMode: sceneDraft.colorMode,
|
||||
palette: sceneDraft.palette,
|
||||
showGrid: sceneDraft.showGrid,
|
||||
showLabels: sceneDraft.showLabels,
|
||||
showCameraFrustums: sceneDraft.showCameraFrustums,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// The visual remains usable when the browser refuses local persistence.
|
||||
}
|
||||
}, [resultId, sceneDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
|
||||
if (!controller.signal.aborted && settings) {
|
||||
sceneDraftRef.current = settings;
|
||||
setSceneDraft(settings);
|
||||
}
|
||||
}).catch(() => {
|
||||
// The per-browser copy remains a usable fallback while the local service recovers.
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLaunch(null);
|
||||
setLaunchError(null);
|
||||
setPlayback(null);
|
||||
setPlaybackController(null);
|
||||
setViewerStatus("idle");
|
||||
void resolveObservationSessionReplay(sessionId, {
|
||||
signal: controller.signal,
|
||||
maximumWaitMs: 30 * 60 * 1000,
|
||||
onUpdate: () => undefined,
|
||||
}).then(async (value) => ({
|
||||
base: value,
|
||||
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
|
||||
})).then((value) => {
|
||||
if (!controller.signal.aborted) setLaunch(value);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId, sessionId, resolveReplay]);
|
||||
|
||||
const presentationReady = playbackController !== null
|
||||
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||
const presentationState = launchError || viewerStatus === "error"
|
||||
? "error"
|
||||
: presentationReady ? "ready" : "loading";
|
||||
const sceneSettings = useMemo<SceneSettings>(() => ({
|
||||
...sceneDraft,
|
||||
projection: spatialMode === "plan" ? "2d" : "3d",
|
||||
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
|
||||
showTrajectory: spatialMode !== null && showLocalSlam,
|
||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||
const semanticVisible = showDDRNet || showEoMT;
|
||||
const profile = launch ? laboratoryResultRerunProfile({
|
||||
resultId,
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
viewerSourceUrl: launch.replay.viewerSourceUrl,
|
||||
byteLength: launch.replay.byteLength,
|
||||
sha256: launch.replay.sha256,
|
||||
},
|
||||
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
|
||||
autoplayWhenReady: false,
|
||||
presentationGate: "ready",
|
||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
||||
initialPlaybackStartSeconds: initialPlaybackStartSeconds
|
||||
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
|
||||
+ (hasTgs ? 0.000001 : 0)),
|
||||
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
|
||||
viewResetGeneration,
|
||||
followTrajectory: followTarget,
|
||||
semanticLayer: showEoMT ? "city" : "vegetation",
|
||||
unifiedPerception: splitView,
|
||||
unifiedCameraShare: blueprintSplitPrimarySize / 100,
|
||||
planView: spatialMode === "plan",
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
cameraImage: showCamera,
|
||||
detections2d: hasDetections && showDetections,
|
||||
segmentation: semantics && semanticVisible,
|
||||
cuboids3d: false,
|
||||
costmap: hasTgs && showTgs,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: false,
|
||||
}) : null;
|
||||
|
||||
const toggle = (caption: string, active: boolean, onClick: () => void) => (
|
||||
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
|
||||
aria-pressed={active} onClick={onClick}>
|
||||
{caption}
|
||||
</Button>
|
||||
);
|
||||
const openSlamSettings = useCallback(() => {
|
||||
if (expanded) onExpandedChange(false);
|
||||
setSettingsOpen(true);
|
||||
}, [expanded, onExpandedChange]);
|
||||
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
|
||||
const next = { ...sceneDraftRef.current, ...patch };
|
||||
sceneDraftRef.current = next;
|
||||
setSceneDraft(next);
|
||||
}, []);
|
||||
const closeSlamSettings = useCallback(() => {
|
||||
const next = normalizeLabSceneSettings(sceneDraftRef.current);
|
||||
sceneDraftRef.current = next;
|
||||
setSceneDraft(next);
|
||||
setSettingsOpen(false);
|
||||
void saveLabViewProfile(resultId, next).then((settings) => {
|
||||
sceneDraftRef.current = settings;
|
||||
setSceneDraft(settings);
|
||||
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
|
||||
}).catch(() => {
|
||||
setToasts((current) => [
|
||||
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
|
||||
{
|
||||
id: LAB_VIEW_PROFILE_ERROR_TOAST,
|
||||
tone: "error",
|
||||
title: "Настройки LAB не сохранились",
|
||||
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [resultId]);
|
||||
const mediaLayerControls = (
|
||||
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
|
||||
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
|
||||
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
|
||||
{hasDDRNet || hasFallbackSemantics
|
||||
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
|
||||
{hasDetections
|
||||
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
|
||||
{hasSourcePoints
|
||||
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
|
||||
{hasLocalSlam ? (
|
||||
<div className="m4-replay-threat-visual__layer-with-settings">
|
||||
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
|
||||
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
|
||||
<Icon name="settings" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
|
||||
</div>
|
||||
);
|
||||
const followTargetControl = toggle(
|
||||
"СЛЕДОВАТЬ",
|
||||
followTarget,
|
||||
() => setFollowTarget((value) => !value),
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<IconButton label="Сбросить положение видов"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
|
||||
<Icon name="refresh" size={16} />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const transport = presentationReady && playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="shared-clock"
|
||||
rangeNs={playback.rangeNs}
|
||||
currentNs={playback.currentNs}
|
||||
playing={playback.playing}
|
||||
onSeek={playbackController.seek}
|
||||
onPlayingChange={playbackController.setPlaying}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}>
|
||||
<CanonicalRecordedLabReplay
|
||||
label={label}
|
||||
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
|
||||
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
|
||||
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
|
||||
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitView ? "vertical" : splitOrientation}
|
||||
mediaAriaLabel="Камера и рассчитанные слои"
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialTrailingControl={followTargetControl}
|
||||
mediaModeControlsVisible={false}
|
||||
paneToolbarsAlwaysVisible
|
||||
mediaMultiLayer
|
||||
actions={resetSpatialView}
|
||||
unifiedContent={profile ? (
|
||||
<div className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||
data-split-view={splitView ? "true" : undefined}>
|
||||
<RerunViewport profile={profile} sceneSettings={sceneSettings}
|
||||
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController} />
|
||||
</div>
|
||||
) : launchError ? (
|
||||
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
|
||||
) : <div aria-hidden="true" />}
|
||||
emptyMessage="Слои результата недоступны."
|
||||
deckOverlays={presentationState === "loading" ? (
|
||||
<div className="canonical-vegetation-rerun-replay__loading">
|
||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||
</div>
|
||||
) : undefined}
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
|
||||
placement="end" size="sm" onClose={closeSlamSettings}>
|
||||
<Inspector defaultOpen={["points"]} sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: <>
|
||||
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
|
||||
exactValueBounds={{ min: 0.1 }}
|
||||
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
|
||||
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
|
||||
options={[...COLOR_OPTIONS]}
|
||||
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
|
||||
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
|
||||
options={[...PALETTE_OPTIONS]}
|
||||
onChange={(palette) => patchSceneDraft({ palette })} />
|
||||
</>,
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
content: <RangeControl label="Накопление" min={0} max={500} step={1}
|
||||
exactValueBounds={{ min: 0 }}
|
||||
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
|
||||
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
|
||||
},
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: <>
|
||||
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
|
||||
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
|
||||
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
|
||||
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
|
||||
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
|
||||
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
|
||||
</>,
|
||||
},
|
||||
]} />
|
||||
</Window>
|
||||
<ToastStack items={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
|
||||
|
||||
export function CanonicalVegetationRerunReplay({ resultId, review }: {
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
return <CanonicalResultRerunReplay resultId={resultId} sessionId={review.sessionId}
|
||||
initialPlaybackStartSeconds={review.timelineStartSeconds} semantics
|
||||
resolveReplay={resolveCanonicalLabReplay} label="RAVNOVES004TREE · канонический повтор Rerun" />;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export const LABORATORY_REPORT_TEMPLATE_VERSION =
|
||||
|
||||
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
|
||||
deterministic: "Детерминированный",
|
||||
"ai-inference": "AI inference",
|
||||
"ai-inference": "AI Inference",
|
||||
hybrid: "Гибридный",
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,16 @@ export function laboratoryRecordedClipEndExclusiveNs(
|
||||
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({
|
||||
source,
|
||||
segmentCount,
|
||||
@@ -94,7 +104,8 @@ export function LaboratoryRecordedClipPlayer({
|
||||
}) {
|
||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||
const lastEmittedSequenceRef = useRef(sequence);
|
||||
lastEmittedSequenceRef.current = sequence;
|
||||
const lastObservedSequenceRef = useRef<number | null>(sequence);
|
||||
const pendingSequenceRef = useRef<number | null>(null);
|
||||
const frame = useMemo(
|
||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||
[frames, sequence],
|
||||
@@ -113,23 +124,42 @@ export function LaboratoryRecordedClipPlayer({
|
||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||
}, [continuousPlayback, onPlayingChange, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastObservedSequenceRef.current !== sequence) {
|
||||
pendingSequenceRef.current = sequence;
|
||||
}
|
||||
lastEmittedSequenceRef.current = sequence;
|
||||
}, [sequence]);
|
||||
|
||||
const emitSequence = useCallback((nextSequence: number) => {
|
||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||
lastEmittedSequenceRef.current = nextSequence;
|
||||
onSequenceChange(nextSequence);
|
||||
}, [onSequenceChange]);
|
||||
|
||||
const requestSequence = useCallback((nextSequence: number) => {
|
||||
pendingSequenceRef.current = nextSequence;
|
||||
emitSequence(nextSequence);
|
||||
}, [emitSequence]);
|
||||
|
||||
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
||||
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
||||
const first = frames[0];
|
||||
if (!first || endExclusiveNs === null) return;
|
||||
if (sourceTimeNs >= endExclusiveNs) {
|
||||
emitSequence(first.sequence);
|
||||
requestSequence(first.sequence);
|
||||
return;
|
||||
}
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}, [emitSequence, endExclusiveNs, frames]);
|
||||
if (!nearest) return;
|
||||
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 timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||
@@ -142,7 +172,7 @@ export function LaboratoryRecordedClipPlayer({
|
||||
className="laboratory-recorded-clip-player__spatial"
|
||||
aria-hidden={cameraPresentation === "primary"}
|
||||
>
|
||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
||||
{alternativeScene}
|
||||
</div>
|
||||
);
|
||||
const cameraPane = (
|
||||
@@ -202,7 +232,7 @@ export function LaboratoryRecordedClipPlayer({
|
||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||
onSeek={(timeNs) => {
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
if (nearest) requestSequence(nearest.sequence);
|
||||
}}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { GlassSurface } from "@nodedc/ui-react";
|
||||
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
|
||||
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
|
||||
}
|
||||
|
||||
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
|
||||
}
|
||||
|
||||
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
|
||||
}
|
||||
|
||||
export function PortableResultReplay({
|
||||
review,
|
||||
label,
|
||||
}: {
|
||||
review: ObservatoryPortableResultReview;
|
||||
label?: string;
|
||||
}) {
|
||||
const schema = review.resultDocument.schema_version;
|
||||
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
|
||||
const module = review.resultDocument.module;
|
||||
const moduleId = module && typeof module === "object" && "module_id" in module
|
||||
? module.module_id : undefined;
|
||||
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|
||||
|| (schema === "missioncore.recorded-ai-layer-review/v1"
|
||||
&& (moduleId === "ddrnet" || moduleId === "eomt"));
|
||||
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
|
||||
&& (moduleId === "rf-detr" || moduleId === "object-distance");
|
||||
if (!tgs && !semantics && !objects) return (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg">
|
||||
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
|
||||
</GlassSurface>
|
||||
);
|
||||
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
||||
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
|
||||
detections={objects}
|
||||
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
|
||||
label={label}
|
||||
resolveReplay={tgs ? resolvePortableTgsReplay
|
||||
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import type { RecordedCameraAdmissionState } from "../../core/observation/recordedSessionAdmission";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
@@ -25,6 +28,7 @@ export function RecordedEvidenceVideoScene({
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
overlaySeconds,
|
||||
semanticOverlay,
|
||||
pointCloudOverlay,
|
||||
ariaLabel,
|
||||
@@ -33,12 +37,17 @@ export function RecordedEvidenceVideoScene({
|
||||
segmentCount,
|
||||
onPlaybackChange,
|
||||
onPlayingRejected,
|
||||
onAdmissionChange,
|
||||
playbackAuthority = "media",
|
||||
playbackTransport = "segmented",
|
||||
recoverTimestampStalls = false,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback: RecordedObservationPlayback;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
overlaySeconds?: number;
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
||||
ariaLabel: string;
|
||||
@@ -47,7 +56,41 @@ export function RecordedEvidenceVideoScene({
|
||||
segmentCount?: number;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => 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 (
|
||||
<div className="recorded-evidence-video-scene">
|
||||
<RecordedFmp4Player
|
||||
@@ -57,29 +100,35 @@ export function RecordedEvidenceVideoScene({
|
||||
prepare
|
||||
segmentSequence={segmentSequence}
|
||||
segmentCount={segmentCount}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackChange={handlePlaybackChange}
|
||||
onPlayingRejected={onPlayingRejected}
|
||||
onAdmissionChange={handleAdmissionChange}
|
||||
playbackAuthority={playbackAuthority}
|
||||
playbackTransport={playbackTransport}
|
||||
recoverTimestampStalls={recoverTimestampStalls}
|
||||
/>
|
||||
{semanticOverlay ? (
|
||||
{overlaysPresented && semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
{pointCloudOverlay ? (
|
||||
{overlaysPresented && pointCloudOverlay ? (
|
||||
<RecordedEvidencePointCloudOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
overlay={pointCloudOverlay}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
{overlaysPresented ? (
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+116
@@ -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) => {
|
||||
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));
|
||||
}, [range]);
|
||||
}, [clock, range]);
|
||||
|
||||
return useMemo(() => ({
|
||||
playback,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {MapVisualSettings} from '../../core/map/mapView';
|
||||
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
|
||||
|
||||
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
|
||||
return {
|
||||
atmosphere_enabled: settings.atmosphereEnabled,
|
||||
atmosphere_hue: settings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
settings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
settings.atmosphereBrightness,
|
||||
fog_enabled: settings.fogEnabled,
|
||||
fog_density: settings.fogDensity,
|
||||
sun_enabled: settings.sunEnabled,
|
||||
sun_hour: settings.sunHour,
|
||||
sun_intensity: settings.sunIntensity,
|
||||
shadows_enabled: settings.shadowsEnabled,
|
||||
monochrome_enabled: settings.monochromeEnabled,
|
||||
monochrome_color: settings.monochromeColor,
|
||||
imagery_brightness: settings.imageryBrightness,
|
||||
imagery_contrast: settings.imageryContrast,
|
||||
imagery_saturation: settings.imagerySaturation,
|
||||
imagery_gamma: settings.imageryGamma,
|
||||
imagery_hue: settings.imageryHue,
|
||||
imagery_alpha: settings.imageryAlpha,
|
||||
globe_color: settings.globeColor,
|
||||
background_color: settings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
settings.terrainExaggeration,
|
||||
buildings_color: settings.buildingsColor,
|
||||
buildings_opacity: settings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
settings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: settings.gridLodEnabled,
|
||||
grid_height_meters: settings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
settings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
settings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
settings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
settings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
settings.gridLod3StepKm,
|
||||
grid_radius_km: settings.gridRadiusKm,
|
||||
grid_line_width: settings.gridLineWidth,
|
||||
grid_color: settings.gridColor,
|
||||
grid_opacity: settings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
settings.gridDotsEnabled,
|
||||
grid_dots_size: settings.gridDotsSize,
|
||||
grid_dots_color: settings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
settings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
settings.cameraAnimationEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
|
||||
import type { RouteCheck } from "../../core/missions/planner";
|
||||
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
|
||||
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
|
||||
}) {
|
||||
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
|
||||
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
|
||||
<LoadingRegion loading={busy} label="Проверка исходных данных">
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{report && <><dl>
|
||||
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
|
||||
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
|
||||
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
|
||||
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
|
||||
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
|
||||
{report.warnings.map(w => <p key={w}>{w}</p>)}
|
||||
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
|
||||
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
|
||||
</LoadingRegion>
|
||||
</div></Window>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { RangeControl } from "@nodedc/ui-react";
|
||||
import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner";
|
||||
/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */
|
||||
export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
useEffect(() => setCursor(0), [poses]);
|
||||
const projection = useMemo(() => {
|
||||
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
|
||||
for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); }
|
||||
const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin));
|
||||
const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale];
|
||||
const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" ");
|
||||
return { project, reference: line(source.poses), selected: line(poses), scale };
|
||||
}, [source, poses]);
|
||||
const point = poses[Math.min(cursor, poses.length - 1)];
|
||||
if (!point) return <div className="session-overview__empty">Выберите участок траектории.</div>;
|
||||
const [x, y] = projection.project(point);
|
||||
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
|
||||
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
|
||||
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
|
||||
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
|
||||
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
|
||||
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
|
||||
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
|
||||
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
|
||||
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {ReactNode} from "react";
|
||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
||||
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
|
||||
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
|
||||
const { data, error, retry } = useSessionOverview(sessionId);
|
||||
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
||||
const failure = error || (data?.state === "error" ? data.message : null);
|
||||
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
|
||||
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
|
||||
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {Button, StatusBadge} from '@nodedc/ui-react';
|
||||
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
|
||||
/** Composition-level handoff; never sends a device or acquisition command. */
|
||||
export function PlanningCaptureGuard({enabled, onResume, children}: {
|
||||
enabled: boolean;
|
||||
onResume: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const planning = usePlanningTest();
|
||||
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
|
||||
return <div className="mission-planner__check">
|
||||
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
|
||||
<p>{planning.test!.draft.name}</p>
|
||||
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
|
||||
{planning.error && <p role="alert">{planning.error}</p>}
|
||||
<div className="mission-planner__actions">
|
||||
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
|
||||
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useEffect,useState,type ReactNode} from 'react';
|
||||
import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
|
||||
/** Wrap the existing plugin connection flow, retaining its acquisition authority. */
|
||||
export function PlanningConnectionWindow({children}:{children:ReactNode}){
|
||||
const p=usePlanningTest(); const [open,setOpen]=useState(true);
|
||||
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
|
||||
useEffect(()=>{
|
||||
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
|
||||
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
|
||||
if(models.length===1)void selectModel(models[0].model.id);
|
||||
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
|
||||
if(!p.test)return <>{children}</>;
|
||||
const t=p.test, terminal=planningTestTerminal(t);
|
||||
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
|
||||
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
|
||||
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
|
||||
return <div className="mission-planner__check">
|
||||
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
|
||||
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
|
||||
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
|
||||
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
|
||||
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
|
||||
<div className="mission-planner__check">{details}
|
||||
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
|
||||
: terminal?<p>{t.message}</p>:children}
|
||||
</div>
|
||||
</Window>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react';
|
||||
import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost';
|
||||
import {plannerBase} from '../../core/missions/planner';
|
||||
import {startPlanningSceneStream} from '../../core/missions/planningSceneStream';
|
||||
import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry';
|
||||
|
||||
function nextAnimationFrame(limitMs=500):Promise<number|null>{
|
||||
return new Promise(resolve=>{
|
||||
let frame:number|undefined,settled=false;
|
||||
const finish=(value:number|null)=>{
|
||||
if(settled)return;
|
||||
settled=true;
|
||||
if(frame!==undefined&&value===null)cancelAnimationFrame(frame);
|
||||
clearTimeout(timeout);resolve(value);
|
||||
};
|
||||
const timeout=setTimeout(()=>finish(null),limitMs);
|
||||
frame=requestAnimationFrame(()=>finish(performance.now()));
|
||||
});
|
||||
}
|
||||
|
||||
/** One persistent viewer/channel. Updates replace entities without resetting the camera. */
|
||||
type HeightBounds={min:number;max:number};
|
||||
const CLIP_CEILING_M=80;
|
||||
export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record<string,string|number|boolean>;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){
|
||||
const optionsRef=useRef(options);optionsRef.current=options;
|
||||
const stateRef=useRef({active,revision});stateRef.current={active,revision};
|
||||
const ceilingRef=useRef<number|null>(null);
|
||||
const boundsRef=useRef<{min:number;max:number}|null>(null);
|
||||
const host=useRef<HTMLDivElement>(null);
|
||||
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
|
||||
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
|
||||
const [ceiling,setCeiling]=useState<number|null>(null);
|
||||
useEffect(()=>{
|
||||
ceilingRef.current=null;
|
||||
const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null;
|
||||
boundsRef.current=next;
|
||||
setBounds(next);setCeiling(null);
|
||||
},[runId,heightBounds?.min,heightBounds?.max]);
|
||||
useEffect(()=>{
|
||||
if(!host.current)return;
|
||||
let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined;
|
||||
const reporter=createPlanningPresentationReporter({
|
||||
url:`${plannerBase}/live-tests/${runId}/presentation-observations`,
|
||||
});
|
||||
const runtime=createIsolatedRerunHost(host.current);setState('loading');
|
||||
const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000);
|
||||
void runtime.ready.then(async({viewer,mount})=>{
|
||||
if(disposed)return;
|
||||
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false});
|
||||
if(disposed)return;
|
||||
const channel=viewer.open_channel('planning-'+runId);
|
||||
viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}});
|
||||
stopStream=startPlanningSceneStream({
|
||||
url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`,
|
||||
snapshot:()=>({...stateRef.current,options:{...optionsRef.current,
|
||||
...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}),
|
||||
apply:async(bytes,delivery)=>{
|
||||
if(disposed)return;
|
||||
if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){
|
||||
const next={min:delivery.heightMinM,max:delivery.heightMaxM};
|
||||
const previous=boundsRef.current;
|
||||
if(!previous||previous.min!==next.min||previous.max!==next.max){
|
||||
boundsRef.current=next;setBounds(next);
|
||||
if(ceilingRef.current!==null&&(ceilingRef.current<next.min||ceilingRef.current>next.max)){
|
||||
ceilingRef.current=null;setCeiling(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
sceneAvailable=true;
|
||||
const admissionStarted=performance.now();
|
||||
channel.send_rrd(bytes);
|
||||
const admissionEnded=performance.now();
|
||||
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
||||
if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');}
|
||||
// Rerun exposes no canvas-paint receipt. Two bounded browser frame
|
||||
// opportunities are retained as an explicit proxy, never as a GPU claim.
|
||||
const first=await nextAnimationFrame();
|
||||
const second=first===null?null:await nextAnimationFrame();
|
||||
if(!disposed)reporter.record(delivery,{
|
||||
rerunAdmissionMs:admissionEnded-admissionStarted,
|
||||
firstAnimationFrameMs:first===null?null:first-admissionEnded,
|
||||
secondAnimationFrameMs:second===null?null:second-admissionEnded,
|
||||
});
|
||||
},
|
||||
error:()=>{sceneAvailable=false;if(!disposed)setState('error');},
|
||||
});
|
||||
}).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}});
|
||||
return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();};
|
||||
},[runId,retry]);
|
||||
const sourceBounds=bounds??heightBounds;
|
||||
const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null;
|
||||
return <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
|
||||
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
|
||||
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
|
||||
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
|
||||
formatValue={value=>`${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')}
|
||||
onChange={value=>{
|
||||
const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value;
|
||||
ceilingRef.current=next;setCeiling(next);
|
||||
}}/>
|
||||
</div>}
|
||||
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
|
||||
</LoadingRegion>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react';
|
||||
import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects';
|
||||
|
||||
export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) {
|
||||
const r=p.result;
|
||||
return <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
|
||||
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
|
||||
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
|
||||
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
|
||||
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
|
||||
</div>},
|
||||
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
|
||||
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
|
||||
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
|
||||
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
|
||||
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
|
||||
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
|
||||
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
|
||||
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
|
||||
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
|
||||
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
|
||||
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
|
||||
</div>},
|
||||
]}/>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react';
|
||||
import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects';
|
||||
|
||||
export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: {
|
||||
items: PlanningProject[];
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string) => void;
|
||||
onRemove: (project: PlanningProject) => Promise<void>;
|
||||
}) {
|
||||
const [target, setTarget] = useState<PlanningProject | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
return <>
|
||||
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
|
||||
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
|
||||
value: item.key, label: item.name,
|
||||
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
|
||||
action: {
|
||||
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
|
||||
icon: <Icon name="trash" />,
|
||||
tone: 'danger' as const,
|
||||
disabled: !planningProjectDeletable(item),
|
||||
onAction: () => { setError(null); setTarget(item); },
|
||||
},
|
||||
}))]}
|
||||
onChange={key => { if (key) onChange(key); }} />
|
||||
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
|
||||
description={target ? <>
|
||||
<strong>{target.name}</strong>
|
||||
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
|
||||
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
|
||||
</> : null}
|
||||
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
|
||||
onClose={() => { if (!busy) setTarget(null); }}
|
||||
onConfirm={async () => {
|
||||
if (!target || busy) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await onRemove(target); setTarget(null); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
|
||||
finally { setBusy(false); }
|
||||
}} />
|
||||
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
|
||||
onDismiss={() => setError(null)} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
|
||||
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
|
||||
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||
|
||||
export function PlanningProjectSettings({p,onStart,starting}:{
|
||||
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
|
||||
}) {
|
||||
const disabled=p.busy||starting;
|
||||
const length=routeLength(p.poses);
|
||||
const routeReady=canStartPlanningRoute(length,'scanner');
|
||||
return <><Inspector variant="panel" defaultOpen={['project','zone']} sections={[
|
||||
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
|
||||
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
|
||||
</div>},
|
||||
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
|
||||
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
|
||||
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
|
||||
{p.source&&<>
|
||||
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
|
||||
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
|
||||
</>}
|
||||
</div>},
|
||||
]}/>
|
||||
<div className="inspector-control-stack">
|
||||
{p.source&&!routeReady&&<small>В записи недостаточно перемещения для привязки. Выберите другой эталон.</small>}
|
||||
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!routeReady} onClick={onStart}><Icon name="play"/>Начать новый проход</Button>
|
||||
{p.error&&<p role="alert">{p.error}</p>}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useState,type ReactNode,type RefObject} from 'react';
|
||||
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
|
||||
|
||||
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
|
||||
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
|
||||
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
|
||||
}){
|
||||
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
|
||||
const [maximized,setMaximized]=useState(false);
|
||||
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
|
||||
title={title} maximized={maximized} onMaximizedChange={setMaximized}
|
||||
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
|
||||
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
|
||||
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
|
||||
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
|
||||
}}>
|
||||
<div className="mission-planner__check">{children}</div>
|
||||
</WorkspaceWindow>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||
|
||||
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
|
||||
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
let disposed = false; setState("loading");
|
||||
const runtime = createIsolatedRerunHost(host.current);
|
||||
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||
if (disposed) return;
|
||||
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
|
||||
});
|
||||
if (disposed) return;
|
||||
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
|
||||
}, [sourceUrl, retry]);
|
||||
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
|
||||
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
|
||||
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
|
||||
</LoadingRegion>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { overviewChartPoints } from "../../core/observation/sessionOverview";
|
||||
|
||||
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
|
||||
const svg = useRef<SVGSVGElement>(null);
|
||||
const [size, setSize] = useState({ width: 900, height: 160 });
|
||||
useEffect(() => {
|
||||
if (!svg.current) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||||
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
}
|
||||
});
|
||||
observer.observe(svg.current);
|
||||
return () => observer.disconnect();
|
||||
}, [chart.length > 0]);
|
||||
const width = Math.max(1, size.width - 106);
|
||||
const height = Math.max(1, size.height - 38);
|
||||
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
|
||||
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
|
||||
return <div className="session-overview__chart">
|
||||
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
|
||||
<g transform="translate(64,10)">
|
||||
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
|
||||
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
|
||||
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
|
||||
</g>)}
|
||||
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
|
||||
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
|
||||
</g>
|
||||
</svg>
|
||||
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
|
||||
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
|
||||
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
|
||||
|
||||
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
|
||||
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
|
||||
const [ceiling, setCeiling] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<OverviewViewMode>("3d");
|
||||
const [viewError, setViewError] = useState<string | null>(null);
|
||||
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
|
||||
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const comparison = metadata?.comparison;
|
||||
const appliedMode = useRef<OverviewViewMode | null>(null);
|
||||
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
let disposed = false;
|
||||
const abort = new AbortController();
|
||||
appliedMode.current = null;
|
||||
setState("loading");
|
||||
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
|
||||
setRepresentation("original"); setAppliedRepresentation("original"); setUpdating(false);
|
||||
void fetchOverviewSpatial(sourceUrl, abort.signal).then(data => {
|
||||
if (disposed) return;
|
||||
setRepresentation(data.default_representation ?? "original"); setMetadata(data);
|
||||
}).catch(() => { if (!disposed) { setState("error"); setViewError("Версия облака недоступна."); } });
|
||||
const runtime = createIsolatedRerunHost(host.current);
|
||||
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||
if (disposed) return;
|
||||
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||
width: "100%", height: "100%", hide_welcome_screen: true,
|
||||
enable_history: false, allow_fullscreen: false,
|
||||
});
|
||||
if (disposed) return;
|
||||
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
|
||||
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
|
||||
}, [sourceUrl, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !metadata || !controller.current) return;
|
||||
const abort = new AbortController();
|
||||
setUpdating(true);
|
||||
const timer = setTimeout(() => {
|
||||
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
|
||||
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal,
|
||||
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
|
||||
if (abort.signal.aborted || !controller.current) return;
|
||||
controller.current.channel.send_rrd(result.bytes);
|
||||
appliedMode.current = mode;
|
||||
setVisiblePoints(result.visiblePoints); setViewError(null);
|
||||
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
|
||||
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
|
||||
}, 180);
|
||||
return () => { abort.abort(); clearTimeout(timer); };
|
||||
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
|
||||
|
||||
const low = comparison?.height_min_m ?? metadata?.height_min_m;
|
||||
const high = comparison?.height_max_m ?? metadata?.height_max_m;
|
||||
return <>
|
||||
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
|
||||
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
|
||||
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
|
||||
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
|
||||
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
|
||||
{toolbar}
|
||||
</div>
|
||||
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
|
||||
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
|
||||
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
|
||||
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
|
||||
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
|
||||
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
|
||||
</div>}
|
||||
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</LoadingRegion>
|
||||
<span className="session-overview__note" role="status" aria-busy={updating}>
|
||||
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
|
||||
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
|
||||
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
|
||||
</span>
|
||||
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
FieldFrame,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
aiModuleIdForSetup,
|
||||
fetchAIModuleCatalog,
|
||||
fetchAICompositionRuns,
|
||||
fetchAICompositionJobs,
|
||||
saveAIComposition,
|
||||
type AICompositionReceipt,
|
||||
type AICompositionRun,
|
||||
type AIGroupId,
|
||||
type AIModuleCatalog,
|
||||
} from "../../core/observatory/aiComposition";
|
||||
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
|
||||
|
||||
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
|
||||
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
|
||||
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
|
||||
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
|
||||
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
|
||||
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
|
||||
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
|
||||
];
|
||||
|
||||
export function AIConfigurationWindow({
|
||||
open, sourceSessionId, sourceLabel, onClose, onCalculated,
|
||||
}: {
|
||||
readonly open: boolean;
|
||||
readonly sourceSessionId: string;
|
||||
readonly sourceLabel: string;
|
||||
readonly onClose: () => void;
|
||||
readonly onCalculated: (receipt: AICompositionReceipt) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
|
||||
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
|
||||
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
|
||||
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
|
||||
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const request = new AbortController();
|
||||
setState("loading");
|
||||
setMessage(null);
|
||||
void Promise.all([
|
||||
fetchAIModuleCatalog(request.signal),
|
||||
fetchAICompositionJobs(sourceSessionId, request.signal),
|
||||
fetchAICompositionRuns(sourceSessionId, request.signal),
|
||||
]).then(([value, jobs, runs]) => {
|
||||
if (request.signal.aborted) return;
|
||||
setCatalog(value);
|
||||
setExistingJobs(jobs);
|
||||
setExistingRuns(runs);
|
||||
setSelected({});
|
||||
setState("ready");
|
||||
}).catch((error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
|
||||
setState("error");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [open, sourceSessionId]);
|
||||
|
||||
const existingByModule = useMemo(() => {
|
||||
const result = new Map<string, ObservatoryRecordedJob>();
|
||||
for (const job of existingJobs) {
|
||||
if (job.state === "failed" || job.publication.state === "failed") continue;
|
||||
const moduleId = aiModuleIdForSetup(job.setupId);
|
||||
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
|
||||
}
|
||||
return result;
|
||||
}, [existingJobs]);
|
||||
|
||||
const selections = useMemo(() => groups.flatMap(({ id }) => {
|
||||
const moduleId = selected[id];
|
||||
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
|
||||
(item) => item.moduleId === moduleId,
|
||||
);
|
||||
return module ? [{ group: id, module }] : [];
|
||||
}), [catalog, selected]);
|
||||
|
||||
const selectedModuleIds = useMemo(
|
||||
() => selections.map(({ module }) => module.moduleId).sort(),
|
||||
[selections],
|
||||
);
|
||||
const matchingRun = useMemo(() => existingRuns.find((run) => (
|
||||
run.state !== "failed"
|
||||
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
|
||||
)), [existingRuns, selectedModuleIds]);
|
||||
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
|
||||
const configurationIsRunning = matchingRun?.state === "running";
|
||||
|
||||
function selectModule(group: AIGroupId, moduleId: string): void {
|
||||
if (!catalog) return;
|
||||
setSelected((current) => {
|
||||
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
|
||||
const modules = catalog.groups.flatMap((item) => item.modules);
|
||||
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
|
||||
if (moduleId !== "none") {
|
||||
const module = modules.find((item) => item.moduleId === moduleId);
|
||||
for (const requirement of module?.requires ?? []) {
|
||||
if (requirement.startsWith("source.")) continue;
|
||||
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
|
||||
continue;
|
||||
}
|
||||
const providers = modules.filter((item) => item.provides.includes(requirement));
|
||||
if (providers.length === 1) {
|
||||
const providerGroup = catalog.groups.find((item) => item.modules.some(
|
||||
(candidate) => candidate.moduleId === providers[0]?.moduleId,
|
||||
))?.group;
|
||||
if (providerGroup) {
|
||||
next[providerGroup] = providers[0]?.moduleId;
|
||||
selectedIds.add(providers[0]?.moduleId ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
|
||||
.flatMap((item) => item.provides));
|
||||
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
|
||||
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
|
||||
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
|
||||
(module) => module.moduleId === item.moduleId,
|
||||
))?.group;
|
||||
if (dependentGroup) {
|
||||
next[dependentGroup] = undefined;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setMessage(null);
|
||||
if (state === "error") setState("ready");
|
||||
}
|
||||
|
||||
async function calculate(): Promise<void> {
|
||||
if (selections.length === 0 || state === "saving") return;
|
||||
if (configurationAlreadyExists) {
|
||||
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
|
||||
return;
|
||||
}
|
||||
setState("saving");
|
||||
setMessage(null);
|
||||
try {
|
||||
const receipt = await saveAIComposition(sourceSessionId, selections);
|
||||
onCalculated(receipt);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
return <Window
|
||||
open={open}
|
||||
className="observatory-ai-config-window"
|
||||
title="Сконфигурировать AI-слой"
|
||||
subtitle={sourceLabel}
|
||||
size="lg"
|
||||
closeOnBackdrop={state !== "saving"}
|
||||
closeOnEscape={state !== "saving"}
|
||||
onClose={onClose}
|
||||
footer={<WindowFooterActions>
|
||||
{configurationAlreadyExists ? (
|
||||
<span className="observatory-ai-config__duplicate" role="status">
|
||||
{configurationIsRunning
|
||||
? "Текущая конфигурация уже рассчитывается."
|
||||
: "Текущая конфигурация уже рассчитана."}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
|
||||
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
|
||||
aria-busy={state === "saving"}
|
||||
onClick={() => void calculate()}
|
||||
>
|
||||
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
|
||||
</Button>
|
||||
</WindowFooterActions>}
|
||||
>
|
||||
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
|
||||
<p className="observatory-ai-config__lead">
|
||||
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
|
||||
</p>
|
||||
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
|
||||
{catalog ? groups.map(({ id, label, description }) => {
|
||||
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
|
||||
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
|
||||
const selectedExisting = selectedModule
|
||||
? existingByModule.get(selectedModule.moduleId)
|
||||
: undefined;
|
||||
const allCalculated = modules.length > 0 && modules.every(
|
||||
(module) => existingByModule.has(module.moduleId),
|
||||
);
|
||||
return <section className="observatory-ai-module-group" key={id}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
|
||||
{selectedExisting
|
||||
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
|
||||
: selectedModule?.label ?? (allCalculated
|
||||
? "Результат доступен"
|
||||
: modules.length ? "Не используется" : "Не установлен")}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="observatory-ai-module-group__body">
|
||||
<FieldFrame
|
||||
label={`Модуль: ${label}`}
|
||||
description={modules.length
|
||||
? allCalculated
|
||||
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
|
||||
: id === "segmentation"
|
||||
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
|
||||
: id === "range"
|
||||
? "Дистанция использует рамки детектора и синхронное облако точек."
|
||||
: id === "geometry"
|
||||
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
|
||||
: "Связи с другими блоками определяются входами выбранного модуля."
|
||||
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
|
||||
>
|
||||
<Select
|
||||
label={`Выбрать модуль: ${label}`}
|
||||
value={selected[id] ?? "none"}
|
||||
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
|
||||
value: module.moduleId,
|
||||
label: module.label,
|
||||
description: existingByModule.has(module.moduleId)
|
||||
? "Готовый результат будет использован без повторного расчёта"
|
||||
: module.dockerName,
|
||||
}))]}
|
||||
disabled={modules.length === 0 || state === "saving"}
|
||||
onChange={(value) => selectModule(id, value)}
|
||||
/>
|
||||
</FieldFrame>
|
||||
</div>
|
||||
</section>;
|
||||
}) : null}
|
||||
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
|
||||
</div>
|
||||
</Window>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebViewer } from "@rerun-io/web-viewer";
|
||||
|
||||
import type { RerunFrameWindow } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunOwner } from "./recordedRerunOwner";
|
||||
|
||||
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
|
||||
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
|
||||
const mount = document.getElementById("viewer");
|
||||
if (!mount) throw new Error("Recorded viewer mount is missing");
|
||||
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
|
||||
import type { RerunFrameWindow } from "./recordedRerunProtocol";
|
||||
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
|
||||
|
||||
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
|
||||
export function createIsolatedRerunHost(host: HTMLElement) {
|
||||
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
|
||||
frame.title = "Синхронизированная запись";
|
||||
frame.className = "rerun-viewport__runtime";
|
||||
frame.src = "/rerun-runtime.html";
|
||||
let disposed = false;
|
||||
let releaseFacade: (() => void) | null = null;
|
||||
let releaseInput: (() => void) | null = null;
|
||||
let frameWindow: RerunFrameWindow | null = null;
|
||||
let rejectReady: ((reason: Error) => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
frame!.onload = null;
|
||||
frame!.onerror = null;
|
||||
releaseInput?.();
|
||||
releaseInput = null;
|
||||
try {
|
||||
releaseFacade?.();
|
||||
} catch {
|
||||
// Realm teardown is required even if a partial native start failed.
|
||||
} finally {
|
||||
releaseFacade = null;
|
||||
frameWindow = null;
|
||||
frame!.remove();
|
||||
frame = null;
|
||||
rejectReady?.(new Error("Recorded viewer was disposed"));
|
||||
rejectReady = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
|
||||
rejectReady = reject;
|
||||
frame!.onload = () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
|
||||
const api = frameWindow?.missionCoreRerun;
|
||||
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
|
||||
const owner = createRecordedRerunFacade((request, bytes) => {
|
||||
const current = frameWindow?.missionCoreRerun;
|
||||
if (!current) throw new Error("Recorded viewer was disposed");
|
||||
return current.invoke(request, bytes);
|
||||
});
|
||||
releaseFacade = owner.dispose;
|
||||
api.connect(owner.notify);
|
||||
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
|
||||
clearTimeout(timer);
|
||||
rejectReady = null;
|
||||
frame!.onload = null;
|
||||
frame!.onerror = null;
|
||||
resolve({ viewer: owner.facade, mount: host });
|
||||
} catch (error) {
|
||||
reject(new Error(String(error)));
|
||||
dispose();
|
||||
}
|
||||
};
|
||||
frame!.onerror = () => {
|
||||
reject(new Error("Recorded viewer host failed to load"));
|
||||
dispose();
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error("Recorded viewer host timed out"));
|
||||
dispose();
|
||||
}, 30_000);
|
||||
});
|
||||
host.append(frame);
|
||||
return { ready, dispose };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Relay Escape from the isolated viewer realm to the owning application. */
|
||||
export function relayIsolatedRerunInput(
|
||||
host: HTMLElement,
|
||||
frameWindow: Window | null,
|
||||
_frame: HTMLIFrameElement | null,
|
||||
) {
|
||||
const parent = host.ownerDocument.defaultView;
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
|
||||
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
};
|
||||
frameWindow?.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
frameWindow?.removeEventListener("keydown", escape);
|
||||
frameWindow = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
|
||||
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
|
||||
export type RecordedRerunCameraEye = {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly lookTarget: readonly [number, number, number];
|
||||
readonly eyeUp: readonly [number, number, number];
|
||||
};
|
||||
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
|
||||
};
|
||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
|
||||
};
|
||||
|
||||
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
|
||||
if (value == null) return null; // No 3D frame rendered yet.
|
||||
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
|
||||
const eye = value as Record<string, unknown>;
|
||||
const vector = (key: string): [number, number, number] => {
|
||||
const item = eye[key];
|
||||
if (!Array.isArray(item) || item.length !== 3 ||
|
||||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
|
||||
throw new Error("Invalid native Rerun camera vector");
|
||||
}
|
||||
return [item[0], item[1], item[2]];
|
||||
};
|
||||
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunInvoke } from "./recordedRerunProtocol";
|
||||
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||
|
||||
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
|
||||
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
|
||||
export type RecordedRerunViewer = Pick<WebViewer,
|
||||
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
|
||||
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
|
||||
"get_playing" | "get_time_range" | "set_active_timeline" |
|
||||
"set_current_time" | "set_playing"
|
||||
> & {
|
||||
open_channel: (name?: string) => Channel;
|
||||
get_camera_eye: () => RecordedRerunCameraEye | null;
|
||||
};
|
||||
|
||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
||||
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
|
||||
let nextId = 0;
|
||||
const callbacks = new Map<number, (...args: unknown[]) => void>();
|
||||
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
|
||||
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
|
||||
if (!invoke) throw new Error("Recorded viewer was disposed");
|
||||
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.result;
|
||||
};
|
||||
const dispose = () => {
|
||||
callbacks.clear();
|
||||
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
|
||||
starts.clear();
|
||||
try { if (invoke) command("stop"); } finally { invoke = null; }
|
||||
};
|
||||
const notify = (message: string) => {
|
||||
if (!invoke) return;
|
||||
const event = JSON.parse(message);
|
||||
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
|
||||
else {
|
||||
const pending = starts.get(event.id);
|
||||
starts.delete(event.id);
|
||||
if (event.type === "started") pending?.resolve();
|
||||
else pending?.reject(new Error(event.message));
|
||||
}
|
||||
};
|
||||
const facade: RecordedRerunViewer = {
|
||||
get ready() { return invoke ? command("ready") : false; },
|
||||
start(source, _parent, options) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
starts.set(id, { resolve, reject });
|
||||
try { command("start", [source, options], id); }
|
||||
catch (error) { starts.delete(id); reject(error); }
|
||||
});
|
||||
},
|
||||
stop: dispose,
|
||||
on: ((event: string, callback: (...args: unknown[]) => void) => {
|
||||
const id = ++nextId;
|
||||
callbacks.set(id, callback);
|
||||
try { command("on", [event], id); }
|
||||
catch (error) { callbacks.delete(id); throw error; }
|
||||
return () => {
|
||||
if (callbacks.delete(id) && invoke) command("off", [], id);
|
||||
};
|
||||
}) as WebViewer["on"],
|
||||
open_channel(name) {
|
||||
const id = ++nextId;
|
||||
command("channel-open", name === undefined ? [] : [name], id);
|
||||
let closed = false;
|
||||
return {
|
||||
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
|
||||
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (invoke) command("channel-close", [], id);
|
||||
},
|
||||
};
|
||||
},
|
||||
open: (...args) => command("open", args),
|
||||
close: (...args) => { if (invoke) command("close", args); },
|
||||
override_panel_state: (...args) => command("override_panel_state", args),
|
||||
get_active_recording_id: () => command("get_active_recording_id"),
|
||||
get_active_timeline: (...args) => command("get_active_timeline", args),
|
||||
get_current_time: (...args) => command("get_current_time", args),
|
||||
get_playing: (...args) => command("get_playing", args),
|
||||
get_time_range: (...args) => command("get_time_range", args),
|
||||
set_active_timeline: (...args) => command("set_active_timeline", args),
|
||||
set_current_time: (...args) => command("set_current_time", args),
|
||||
set_playing: (...args) => command("set_playing", args),
|
||||
get_camera_eye: () => command("get-camera-eye"),
|
||||
};
|
||||
return { facade, dispose, notify };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||
|
||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||
let native: WebViewer | null = null;
|
||||
let notify: RerunNotify | null = null;
|
||||
const subscriptions = new Map<number, () => void>();
|
||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
||||
const send = (message: object) => notify?.(JSON.stringify(message));
|
||||
const stop = () => {
|
||||
notify = null;
|
||||
for (const unsubscribe of subscriptions.values()) {
|
||||
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
|
||||
}
|
||||
subscriptions.clear();
|
||||
for (const channel of channels.values()) {
|
||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||
}
|
||||
channels.clear();
|
||||
const current = native;
|
||||
native = null;
|
||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||
};
|
||||
const required = () => {
|
||||
if (!native) throw new Error("Recorded viewer was disposed");
|
||||
return native;
|
||||
};
|
||||
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
|
||||
const starting = required();
|
||||
try {
|
||||
await starting.start(args[0], mount, args[1]);
|
||||
if (native === starting) send({ type: "started", id });
|
||||
} catch (error) {
|
||||
if (native === starting) send({ type: "start-failed", id, message: String(error) });
|
||||
} finally {
|
||||
if (native !== starting) {
|
||||
try { starting.stop(); } catch { /* Late completion after disposal. */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
version: 2,
|
||||
connect(callback) {
|
||||
if (native) throw new Error("Recorded viewer already has an owner");
|
||||
native = create();
|
||||
notify = callback;
|
||||
},
|
||||
invoke(request, bytes) {
|
||||
try {
|
||||
const { method, args = [], id } = JSON.parse(request);
|
||||
let result: unknown;
|
||||
switch (method) {
|
||||
case "stop": stop(); break;
|
||||
case "start": required(); void start(id, args); break;
|
||||
case "ready": result = native?.ready ?? false; break;
|
||||
case "on": {
|
||||
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
|
||||
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
|
||||
break;
|
||||
}
|
||||
case "off":
|
||||
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
|
||||
break;
|
||||
case "channel-open": {
|
||||
const name = args[0] as string | undefined;
|
||||
channels.set(id, required().open_channel(name));
|
||||
break;
|
||||
}
|
||||
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
|
||||
case "channel-send": if (bytes) {
|
||||
channels.get(id)?.send_rrd(bytes);
|
||||
} break;
|
||||
case "channel-close":
|
||||
try { channels.get(id)?.close(); } finally {
|
||||
channels.delete(id);
|
||||
}
|
||||
break;
|
||||
case "get-camera-eye": result = readNativeRerunCameraEye(
|
||||
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
|
||||
); break;
|
||||
case "open": required().open(args[0]); break;
|
||||
case "close": required().close(args[0]); break;
|
||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
|
||||
case "get_active_recording_id": result = required().get_active_recording_id(); break;
|
||||
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
|
||||
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
|
||||
case "get_playing": result = required().get_playing(args[0]); break;
|
||||
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
|
||||
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
|
||||
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
|
||||
case "set_playing": required().set_playing(args[0], args[1]); break;
|
||||
default: throw new Error("Unsupported recorded viewer command");
|
||||
}
|
||||
return JSON.stringify({ result });
|
||||
} catch (error) {
|
||||
// A native Error also has a foreign prototype. Return text, never throw
|
||||
// that object into a parent Promise/callback retained after disposal.
|
||||
return JSON.stringify({ error: String(error) });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Only parent-owned inputs and primitive strings cross the recorded realm.
|
||||
* An SDK instance, Promise, DOM node or native callback never becomes a value
|
||||
* retained by React. This is not a second playback clock.
|
||||
*/
|
||||
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
|
||||
export type RerunNotify = (message: string) => void;
|
||||
export interface RerunFrameApi {
|
||||
version: 2;
|
||||
connect: (notify: RerunNotify) => void;
|
||||
invoke: RerunInvoke;
|
||||
}
|
||||
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
|
||||
import type {RecordedPointColorLoadState} from '../RerunViewport';
|
||||
|
||||
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
|
||||
|
||||
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
|
||||
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
|
||||
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
|
||||
sourceGeneration?: string;
|
||||
}) {
|
||||
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
|
||||
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
|
||||
const loadRef = useRef(onLoad); loadRef.current = onLoad;
|
||||
const failed = useRef(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const key = settings ? pointDisplayKey(settings) : '';
|
||||
const percent = settings?.pointDecimationPercent ?? 0;
|
||||
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
|
||||
const matched = sameSource && applied?.key === key;
|
||||
useEffect(() => {
|
||||
// A new settings interaction retries a failed request, but accumulation or
|
||||
// size edits must not cancel an already-running preparation of the same key.
|
||||
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
|
||||
}, [settings]);
|
||||
useEffect(() => {
|
||||
if (!endpoint || !settings || !ready) return;
|
||||
if (percent === 0 || percent === 100) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
|
||||
return;
|
||||
}
|
||||
if (matched) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: 'Прореженное облако готово.'});
|
||||
return;
|
||||
}
|
||||
const owner = ownerRef.current();
|
||||
if (!owner) return;
|
||||
const abort = new AbortController();
|
||||
failed.current = false;
|
||||
// Never append another generation at the same temporal entity path: an
|
||||
// accumulated range would otherwise draw older samples as well.
|
||||
const bank = crypto.randomUUID().replaceAll('-', '');
|
||||
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
|
||||
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
|
||||
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
|
||||
if (!abort.signal.aborted) owner.send(chunk);
|
||||
}, undefined, sourceGeneration).then(bytes => {
|
||||
if (abort.signal.aborted) return;
|
||||
setApplied({endpoint, revision, key, bank, sourceGeneration});
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
|
||||
message: 'Прореженное облако готово.'});
|
||||
}).catch(() => {
|
||||
if (!abort.signal.aborted) {
|
||||
failed.current = true;
|
||||
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
|
||||
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
|
||||
}
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
|
||||
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
|
||||
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
|
||||
hidePoints: percent === 100};
|
||||
}
|
||||
@@ -118,6 +118,9 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
private renderIntervalMs = 1000 / 60;
|
||||
private renderAccumulatorMs = 0;
|
||||
private disposed = false;
|
||||
private requestGsplatFrame = (): void => {
|
||||
this.requestRender();
|
||||
};
|
||||
private updateFrame = (deltaSeconds: number): void => {
|
||||
if (!this.app || !this.camera || this.disposed) return;
|
||||
this.ugvController?.update(deltaSeconds);
|
||||
@@ -158,6 +161,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.app = app;
|
||||
app.autoRender = false;
|
||||
app.on("update", this.updateFrame);
|
||||
app.systems.gsplat?.on("frame:request", this.requestGsplatFrame);
|
||||
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||
@@ -379,6 +383,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
if (this.app) {
|
||||
this.app.autoRender = false;
|
||||
this.app.renderNextFrame = false;
|
||||
this.app.systems.gsplat?.off("frame:request", this.requestGsplatFrame);
|
||||
this.app.off("update", this.updateFrame);
|
||||
this.app.destroy();
|
||||
}
|
||||
|
||||
@@ -232,7 +232,6 @@ export class SimulationUgvController {
|
||||
private active = false;
|
||||
private vehicleEntity: Entity | null = null;
|
||||
private vehicle: NativeRaycastVehicle | null = null;
|
||||
private vehicleTuning: NativeObject | null = null;
|
||||
private vehicleRaycaster: NativeObject | null = null;
|
||||
private tyreImpulseNative: NativeVector3 | null = null;
|
||||
private tyreRelativePositionNative: NativeVector3 | null = null;
|
||||
@@ -599,7 +598,12 @@ export class SimulationUgvController {
|
||||
this.app,
|
||||
meshInstance.mesh,
|
||||
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;
|
||||
const entity = new Entity(`UGV physics proxy ${index + 1}`);
|
||||
@@ -745,7 +749,6 @@ export class SimulationUgvController {
|
||||
|
||||
dynamicsWorld.addAction(nativeVehicle);
|
||||
this.vehicleEntity = vehicle;
|
||||
this.vehicleTuning = tuning;
|
||||
this.vehicleRaycaster = raycaster;
|
||||
this.vehicle = nativeVehicle;
|
||||
this.dynamicsWorld = dynamicsWorld;
|
||||
@@ -861,12 +864,10 @@ export class SimulationUgvController {
|
||||
}
|
||||
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.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.vehicleRaycaster = null;
|
||||
this.vehicleTuning = null;
|
||||
this.tyreImpulseNative = null;
|
||||
this.tyreRelativePositionNative = null;
|
||||
this.dynamicsWorld = null;
|
||||
@@ -1005,7 +1006,7 @@ function createPhysicsProxyMesh(
|
||||
app: Application,
|
||||
source: Mesh,
|
||||
localTransform: Mat4,
|
||||
targetRatio: number,
|
||||
targetTriangleCount: number,
|
||||
): Mesh | null {
|
||||
const primitive = source.primitive[0];
|
||||
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
|
||||
@@ -1039,8 +1040,11 @@ function createPhysicsProxyMesh(
|
||||
if (triangleIndexCount < 3) return null;
|
||||
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
|
||||
|
||||
if (targetRatio < 1) {
|
||||
const targetIndexCount = Math.max(3, Math.floor(indices.length * targetRatio / 3) * 3);
|
||||
const targetIndexCount = Math.max(
|
||||
3,
|
||||
Math.min(indices.length, Math.floor(targetTriangleCount) * 3),
|
||||
);
|
||||
if (indices.length > targetIndexCount) {
|
||||
const [simplifiedIndices] = MeshoptSimplifier.simplify(
|
||||
indices,
|
||||
transformed,
|
||||
@@ -1049,6 +1053,26 @@ function createPhysicsProxyMesh(
|
||||
PHYSICS_PROXY_ERROR,
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -6,19 +6,18 @@ interface TelemetrySeriesProps {
|
||||
ceiling?: number;
|
||||
}
|
||||
|
||||
function linePoints(values: Array<number | null>, ceiling?: number): string {
|
||||
const finite = values.filter((value): value is number =>
|
||||
value !== null && Number.isFinite(value),
|
||||
);
|
||||
if (finite.length === 0) return "";
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1);
|
||||
const denominator = Math.max(1, values.length - 1);
|
||||
return values.map((value, index) => {
|
||||
const x = index / denominator * 100;
|
||||
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value));
|
||||
const y = 36 - normalized / maximum * 34;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(" ");
|
||||
export function lineSegments(values: Array<number | null>, ceiling?: number): string[] {
|
||||
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
if (!finite.length) return [];
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1), denominator = Math.max(1, values.length - 1);
|
||||
const minimum = Math.min(0, ...finite), span = maximum - minimum;
|
||||
const segments:string[]=[];let segment:string[]=[];
|
||||
values.forEach((value,index)=>{
|
||||
if(value===null||!Number.isFinite(value)){if(segment.length)segments.push(segment.join(' '));segment=[];return;}
|
||||
segment.push(`${(index/denominator*100).toFixed(2)},${(36-(Math.min(maximum,value)-minimum)/span*34).toFixed(2)}`);
|
||||
});
|
||||
if(segment.length)segments.push(segment.join(' '));
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function TelemetrySeries({
|
||||
@@ -28,7 +27,7 @@ export function TelemetrySeries({
|
||||
resource,
|
||||
ceiling,
|
||||
}: TelemetrySeriesProps) {
|
||||
const points = linePoints(values, ceiling);
|
||||
const segments = lineSegments(values, ceiling);
|
||||
return (
|
||||
<div className="system-telemetry-series">
|
||||
<div>
|
||||
@@ -45,7 +44,7 @@ export function TelemetrySeries({
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
<path d="M0 37 H100" />
|
||||
{points ? <polyline points={points} /> : null}
|
||||
{segments.map((points,index)=><polyline key={index} points={points} />)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
onAddVehicle?: () => void;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
saveWorkspaceLayout: () => Promise<void>;
|
||||
workspaceLayoutSaving: boolean;
|
||||
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
|
||||
}
|
||||
|
||||
export function deviceRuntimeUtilityAction({
|
||||
@@ -43,15 +45,18 @@ export function deviceRuntimeUtilityAction({
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving,
|
||||
systemUtilityActions,
|
||||
sessionOverview,
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
|
||||
if (definition?.kind === "device") {
|
||||
actions.push(deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
@@ -68,14 +73,21 @@ export function useApplicationPanelActions({
|
||||
});
|
||||
}
|
||||
if (definition?.root === "system") actions.push(...systemUtilityActions);
|
||||
if (definition?.kind === "recordings" && sessionOverview) actions.push({
|
||||
label: sessionOverview.open ? "Закрыть информацию о записи" : "Информация о записи",
|
||||
icon: "info", pressed: sessionOverview.open, disabled: !sessionOverview.available,
|
||||
onClick: sessionOverview.toggle,
|
||||
});
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
systemUtilityActions,
|
||||
workspaceLayoutSaving,
|
||||
sessionOverview,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
|
||||
// Composition root: this is the only place where Mission Core chooses which
|
||||
// statically reviewed device plugins are shipped in the current build.
|
||||
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||
xgridsK1Plugin,
|
||||
]);
|
||||
|
||||
// Node-executed camera controls use the existing sensor contribution contract;
|
||||
// they do not register a desktop capture/AI runtime or a new product workspace.
|
||||
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
||||
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
||||
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
||||
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||
const NO_NODE_SENSORS: readonly SensorUiContribution[] = Object.freeze([]);
|
||||
|
||||
interface DevicePluginHostValue {
|
||||
registry: DevicePluginRegistry;
|
||||
@@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId(
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
nodeSensorContributions = NO_NODE_SENSORS,
|
||||
children,
|
||||
}: {
|
||||
plugins: readonly DeviceUiPlugin[];
|
||||
nodeSensorContributions?: readonly SensorUiContribution[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]);
|
||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
|
||||
@@ -97,12 +97,24 @@ export interface DevicePluginHostActions {
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
/** Session-bound, presentation-only extension of an active spatial workflow. */
|
||||
export interface SpatialActivityPresentation {
|
||||
sessionId: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
model: DeviceModelDefinition;
|
||||
host: DevicePluginHostActions;
|
||||
spatialActivity?: SpatialActivityPresentation;
|
||||
}
|
||||
|
||||
export interface DeviceUiPlugin {
|
||||
sensorUi?: {
|
||||
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
|
||||
};
|
||||
manifest: DevicePluginManifest;
|
||||
RuntimeProvider: ComponentType<{
|
||||
activeModel: DeviceModelDefinition | null;
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
type DeviceUiPlugin,
|
||||
type RegisteredDeviceModel,
|
||||
} from "./contracts";
|
||||
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||
|
||||
export interface DevicePluginRegistry {
|
||||
readonly sensorContributions: readonly SensorUiContribution[];
|
||||
readonly plugins: readonly DeviceUiPlugin[];
|
||||
readonly models: readonly RegisteredDeviceModel[];
|
||||
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
||||
@@ -15,6 +17,7 @@ export interface DevicePluginRegistry {
|
||||
|
||||
export function createDevicePluginRegistry(
|
||||
installedPlugins: readonly DeviceUiPlugin[],
|
||||
nodeSensorContributions: readonly SensorUiContribution[] = [],
|
||||
): DevicePluginRegistry {
|
||||
const pluginIds = new Set<string>();
|
||||
const modelIds = new Set<string>();
|
||||
@@ -133,7 +136,15 @@ export function createDevicePluginRegistry(
|
||||
}
|
||||
|
||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||
const sensorContributions = [
|
||||
...installedPlugins.flatMap(plugin => plugin.sensorUi?.contributions ?? []),
|
||||
...nodeSensorContributions,
|
||||
];
|
||||
if (new Set(sensorContributions.map(value => value.kind)).size !== sensorContributions.length) {
|
||||
throw new Error('Плагины повторяют модель устройства БК.');
|
||||
}
|
||||
return {
|
||||
sensorContributions: Object.freeze(sensorContributions),
|
||||
plugins: Object.freeze([...installedPlugins]),
|
||||
models: Object.freeze(models),
|
||||
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
import { roots, type RootId } from "../../productModel";
|
||||
|
||||
export type EnvironmentSurfaceId = "home" | RootId;
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentMediaItem {
|
||||
id: string;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: EnvironmentMediaItem[];
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
import { defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems, type EnvironmentBackground, type EnvironmentMediaItem, type EnvironmentMediaKind, type EnvironmentMediaSource, type EnvironmentPage } from "@nodedc/ui-core";
|
||||
export { createEnvironmentMediaItem, appendEnvironmentMediaItem, removeEnvironmentMediaItem, inferEnvironmentMediaKind, defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems } from "@nodedc/ui-core";
|
||||
export type { EnvironmentBackground, EnvironmentMediaItem, EnvironmentMediaKind, EnvironmentMediaSource, EnvironmentPage } from "@nodedc/ui-core";
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
revision: number;
|
||||
@@ -54,72 +31,19 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
||||
"polygon",
|
||||
];
|
||||
|
||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
||||
export const maxEnvironmentMediaItems = 24;
|
||||
|
||||
function emptyBackground(): EnvironmentBackground {
|
||||
return {
|
||||
enabled: false,
|
||||
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
||||
return {
|
||||
id: `media-${crypto.randomUUID()}`,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
||||
): EnvironmentBackground {
|
||||
if (
|
||||
background.items.length >= maxEnvironmentMediaItems
|
||||
|| background.items.some((candidate) => candidate.id === item.id)
|
||||
) {
|
||||
return background;
|
||||
}
|
||||
return {
|
||||
...background,
|
||||
enabled: background.items.length === 0 ? true : background.enabled,
|
||||
items: [...background.items, item],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
): EnvironmentBackground {
|
||||
const items = background.items.filter((item) => item.id !== itemId);
|
||||
if (items.length === background.items.length) return background;
|
||||
return {
|
||||
...background,
|
||||
enabled: items.length > 0 && background.enabled,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
function emptyBackground(): EnvironmentBackground { return { enabled: false, imageDurationSeconds: defaultEnvironmentImageDurationSeconds, items: [] }; }
|
||||
|
||||
const defaultQuickActions: Record<
|
||||
EnvironmentSurfaceId,
|
||||
readonly [string | null, string | null]
|
||||
> = {
|
||||
home: ["spatial-scene", "local-device"],
|
||||
fleet: ["contour-health", "local-device"],
|
||||
observation: ["spatial-scene", "cameras"],
|
||||
missions: ["mission-planner", null],
|
||||
home: ["spatial-scene", "vehicles"],
|
||||
fleet: ["vehicles", "contour-health"],
|
||||
observation: ["cameras", "world-map"],
|
||||
missions: ["routes", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", null],
|
||||
polygon: ["lab-archive", "local-device"],
|
||||
};
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
|
||||
export interface MetricDefinition {label:string;group:string;resource:string;unit:string;reason:string|null}
|
||||
export interface BoardMonitor {
|
||||
available:boolean;fresh:boolean;storage:string;source_id:string;
|
||||
latest:{seq:number;at:number;boot_id:string;values:Record<string,number|null>}|null;
|
||||
definitions:Record<string,MetricDefinition>;received_at:number;backlog:number;
|
||||
database_bytes:number|null;database_budget_bytes:number;archive_since:number|null;
|
||||
start:number;end:number;bucket_seconds:number;
|
||||
series:{at:number;min:number|null;max:number|null;mean:number|null;count:number}[];
|
||||
events:{at:number;code:string;kind:string;locations:string[]}[];
|
||||
}
|
||||
export function useBoardMonitor(vehicle:string,metric:string,window:number,end:number|null){
|
||||
const [value,setValue]=useState<BoardMonitor|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{
|
||||
let active=true;let pending:AbortController|undefined;let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
setValue(null);setError('');
|
||||
const read=async()=>{
|
||||
const controller=new AbortController();pending=controller;
|
||||
const deadline=setTimeout(()=>controller.abort(),12000);
|
||||
try {
|
||||
const query=new URLSearchParams({metric,window:String(window),...(end===null?{}:{end:String(end)})});
|
||||
const response=await fetch(`/api/v1/fleet/${encodeURIComponent(vehicle)}/monitor?${query}`,{credentials:'same-origin',cache:'no-store',signal:controller.signal});
|
||||
if(!response.ok)throw new Error('Мониторинг БК недоступен.');
|
||||
const next=await response.json() as BoardMonitor;
|
||||
if(active){setValue(next);setError('');}
|
||||
}catch {if(active)setError('Не удалось обновить мониторинг БК.');}
|
||||
finally {clearTimeout(deadline);if(active)timer=setTimeout(()=>void read(),5000);}
|
||||
};
|
||||
void read();return()=>{active=false;pending?.abort();clearTimeout(timer);};
|
||||
},[vehicle,metric,window,end]);
|
||||
return {value,error,dismissError:()=>setError('')};
|
||||
}
|
||||
export function monitorSeries(value:BoardMonitor,mode:'min'|'max'|'mean') {
|
||||
const count=Math.ceil((value.end-value.start)/value.bucket_seconds)+1;
|
||||
const values:Array<number|null>=Array(Math.min(count,302)).fill(null);
|
||||
const first=Math.floor(value.start/value.bucket_seconds)*value.bucket_seconds;
|
||||
for(const point of value.series){const index=Math.round((point.at-first)/value.bucket_seconds);if(index>=0&&index<values.length)values[index]=point[mode];}
|
||||
return values;
|
||||
}
|
||||
export function monitorValue(value:number|null|undefined,unit=''){
|
||||
if(value==null||!Number.isFinite(value))return 'Нет измерения';
|
||||
if(unit==='bytes'||unit==='bytes/s'){
|
||||
const labels=unit==='bytes'?['Б','КиБ','МиБ','ГиБ']:['Б/с','КиБ/с','МиБ/с','ГиБ/с'];let index=0;
|
||||
while(value>=1024&&index<3){value/=1024;index++;}
|
||||
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})} ${labels[index]}`;
|
||||
}
|
||||
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})}${unit?' '+unit:''}`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface ObservationLayout {
|
||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
}
|
||||
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
||||
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
||||
export function decodeObservationLayout(value:unknown):ObservationLayout {
|
||||
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
|
||||
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
|
||||
result.order=ids(source.order);result.hidden=ids(source.hidden);
|
||||
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
|
||||
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
|
||||
if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement;
|
||||
return result;
|
||||
}
|
||||
export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{
|
||||
const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))];
|
||||
}
|
||||
export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout {
|
||||
const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to);
|
||||
if(source<0||target<0||source===target)return layout;
|
||||
order.splice(source,1);order.splice(target,0,from);return {...layout,order};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from './useFleet';
|
||||
|
||||
export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||
const path=`/${encodeURIComponent(vehicleID)}/devices`;
|
||||
const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{
|
||||
const value=fleet.items.find(v=>v.id===vehicleID);
|
||||
if(!value)throw new Error('Аппарат не найден.');
|
||||
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
|
||||
};
|
||||
return {
|
||||
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
|
||||
inventory:async()=>inventory(await fleetRequest()),
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`${path}/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface BoardHost {
|
||||
hostname: string; os: string; architecture: string; cpus: number;
|
||||
memory_kib: number | null; collected_at: string;
|
||||
networks: { name: string; up: boolean; addresses: string[] }[];
|
||||
usb: { port: string; product: string }[];
|
||||
}
|
||||
export interface Vehicle {
|
||||
id: string; node_id: string; name: string; platform: string;
|
||||
enrollment: "pending" | "paired" | "revoked" | "failed";
|
||||
connectivity: "online" | "offline"; last_seen: number | null;
|
||||
host: BoardHost | null; notice: string; revision: number;
|
||||
}
|
||||
export interface FleetPreview {
|
||||
preview_id: string; node_id: string; name: string; host: BoardHost;
|
||||
endpoint: string; expires_at: number;
|
||||
}
|
||||
export async function fleetRequest<T>(path = "", method = "GET", body?: unknown): Promise<T> {
|
||||
const response = await fetch(`/api/v1/fleet${path}`, {
|
||||
method, credentials: "same-origin", cache: "no-store",
|
||||
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(typeof data.detail === "string" ? data.detail : "Не удалось выполнить действие с аппаратом.");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
export function useFleet() {
|
||||
const [items, setItems] = useState<Vehicle[] | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const refresh = useCallback(async () => {
|
||||
const value = await fleetRequest<{ items: Vehicle[] }>();
|
||||
setItems(value.items); setError(""); return value.items;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let fallback: ReturnType<typeof setInterval> | undefined;
|
||||
const read = async () => {
|
||||
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
|
||||
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
|
||||
};
|
||||
void read();
|
||||
const events = new EventSource("/api/v1/fleet/events");
|
||||
events.onmessage = event => {
|
||||
if (!active) return;
|
||||
try { const value = JSON.parse(event.data); setItems(value.items); setError(""); if (fallback) { clearInterval(fallback); fallback = undefined; } }
|
||||
catch { unavailable(); }
|
||||
};
|
||||
const unavailable = () => {
|
||||
if (!active) return;
|
||||
setError("Связь обновляется. Показаны последние полученные сведения.");
|
||||
if (!fallback) fallback = setInterval(() => void read(), 5000);
|
||||
};
|
||||
events.onerror = unavailable;
|
||||
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
|
||||
}, []);
|
||||
return { items, error, refresh };
|
||||
}
|
||||
@@ -29,7 +29,6 @@ export interface E31LaboratoryResult {
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E32LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
@@ -53,7 +52,6 @@ export interface E32LaboratoryResult {
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E33LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ObservationSessionReplayLaunch } from "../observation/sessionArchive";
|
||||
import type { RecordedRrdArtifactDescriptor } from "../observation/viewerProfile";
|
||||
|
||||
const SAFE_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||
const SAFE_SESSION_SOURCE = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
||||
const MAX_CANONICAL_REPLAY_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
export interface CanonicalLabReplayDescriptor extends RecordedRrdArtifactDescriptor {
|
||||
blueprintSourceUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the one immutable RRD used by the canonical recorded LAB.
|
||||
*
|
||||
* The server caches the merge of the sealed spatial recording and the LAB AI
|
||||
* evidence. Rerun therefore opens one source and cannot present the base store
|
||||
* before a second receiver has finished decoding the semantic layer.
|
||||
*/
|
||||
export async function resolveCanonicalLabReplay(
|
||||
resultId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
{
|
||||
origin = window.location.origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
sourceKind = "legacy-vegetation",
|
||||
}: {
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!(sourceKind === "portable-tgs"
|
||||
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
|
||||
: sourceKind === "portable-semantic"
|
||||
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
|
||||
: sourceKind === "portable-objects"
|
||||
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
|
||||
: SAFE_RESULT_ID.test(resultId))
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl = sourceKind !== "legacy-vegetation"
|
||||
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
|
||||
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
if (sourceKind === "legacy-vegetation") descriptorUrl.searchParams.set("base_generation", launch.sha256);
|
||||
if (descriptorUrl.origin !== base.origin) {
|
||||
throw new Error("Канонический replay LAB должен быть same-origin.");
|
||||
}
|
||||
const response = await fetcher(descriptorUrl.href, {
|
||||
method: "HEAD",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/vnd.rerun.rrd" },
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const byteLength = Number(response.headers.get("Content-Length"));
|
||||
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||
if (
|
||||
response.status !== 200
|
||||
|| contentType !== "application/vnd.rerun.rrd"
|
||||
|| response.headers.get("X-Rerun-Format") !== "RRF2"
|
||||
|| !sha256
|
||||
|| !Number.isSafeInteger(byteLength)
|
||||
|| byteLength < 4
|
||||
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
|
||||
) {
|
||||
throw new Error("Единый replay LAB не прошёл проверку.");
|
||||
}
|
||||
return {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
|
||||
byteLength,
|
||||
sha256,
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAICompositionReplay(
|
||||
runId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
{
|
||||
origin = window.location.origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
const response = await fetcher(descriptorUrl.href, {
|
||||
method: "HEAD",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/vnd.rerun.rrd" },
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const byteLength = Number(response.headers.get("Content-Length"));
|
||||
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||
if (
|
||||
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|
||||
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|
||||
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|
||||
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
|
||||
) {
|
||||
throw new Error("Составной replay LAB не прошёл проверку.");
|
||||
}
|
||||
return {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
|
||||
byteLength,
|
||||
sha256,
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
export const CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS = 1;
|
||||
export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v3";
|
||||
|
||||
export interface CanonicalRecordedLabPackedCellEvidence {
|
||||
centersBodyXyM: Float32Array;
|
||||
zBoundsM: Float32Array;
|
||||
stateCodes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CanonicalRecordedLabBodyGroundFrame {
|
||||
originMapXyzM: readonly [number, number, number];
|
||||
sensorOriginMapXyzM: readonly [number, number, number];
|
||||
basisMapFromBody: readonly [
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
];
|
||||
}
|
||||
|
||||
export interface CanonicalRecordedLabTgsCostmap {
|
||||
centersXyM: readonly (readonly [number, number])[];
|
||||
stateCodes: readonly number[];
|
||||
zBoundsM: readonly (readonly [number | null, number | null])[];
|
||||
}
|
||||
|
||||
export function canonicalRecordedLabTgsIsCurrent(
|
||||
currentTimeNs: number,
|
||||
anchorTimeNs: number,
|
||||
historySeconds = CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS,
|
||||
): boolean {
|
||||
if (
|
||||
!Number.isSafeInteger(currentTimeNs)
|
||||
|| !Number.isSafeInteger(anchorTimeNs)
|
||||
|| !Number.isFinite(historySeconds)
|
||||
|| historySeconds <= 0
|
||||
) return false;
|
||||
const ageNs = currentTimeNs - anchorTimeNs;
|
||||
return ageNs >= 0 && ageNs <= Math.round(historySeconds * 1_000_000_000);
|
||||
}
|
||||
|
||||
export function canonicalMapGravityLocalPointToBodyGround(
|
||||
point: readonly [number, number, number],
|
||||
anchor: CanonicalRecordedLabBodyGroundFrame,
|
||||
current: CanonicalRecordedLabBodyGroundFrame,
|
||||
): readonly [number, number, number] {
|
||||
// TGS is translation-only map-gravity-local: its axes are map axes and its
|
||||
// origin is the LiDAR at the source frame. It is not an anchor body frame.
|
||||
const map: readonly [number, number, number] = [
|
||||
anchor.sensorOriginMapXyzM[0] + point[0],
|
||||
anchor.sensorOriginMapXyzM[1] + point[1],
|
||||
anchor.sensorOriginMapXyzM[2] + point[2],
|
||||
];
|
||||
const delta: readonly [number, number, number] = [
|
||||
map[0] - current.originMapXyzM[0],
|
||||
map[1] - current.originMapXyzM[1],
|
||||
map[2] - current.originMapXyzM[2],
|
||||
];
|
||||
return [
|
||||
current.basisMapFromBody[0][0] * delta[0]
|
||||
+ current.basisMapFromBody[1][0] * delta[1]
|
||||
+ current.basisMapFromBody[2][0] * delta[2],
|
||||
current.basisMapFromBody[0][1] * delta[0]
|
||||
+ current.basisMapFromBody[1][1] * delta[1]
|
||||
+ current.basisMapFromBody[2][1] * delta[2],
|
||||
current.basisMapFromBody[0][2] * delta[0]
|
||||
+ current.basisMapFromBody[1][2] * delta[1]
|
||||
+ current.basisMapFromBody[2][2] * delta[2],
|
||||
];
|
||||
}
|
||||
|
||||
export function canonicalRecordedLabPackedTgsCells(
|
||||
costmap: CanonicalRecordedLabTgsCostmap,
|
||||
anchor: CanonicalRecordedLabBodyGroundFrame,
|
||||
current: CanonicalRecordedLabBodyGroundFrame,
|
||||
): CanonicalRecordedLabPackedCellEvidence {
|
||||
if (
|
||||
costmap.centersXyM.length !== costmap.stateCodes.length
|
||||
|| costmap.centersXyM.length !== costmap.zBoundsM.length
|
||||
) throw new Error("Canonical recorded LAB TGS accounting changed");
|
||||
const centers: number[] = [];
|
||||
const zBounds: number[] = [];
|
||||
costmap.centersXyM.forEach(([x, y], index) => {
|
||||
const bounds = costmap.zBoundsM[index] ?? [null, null];
|
||||
const center = canonicalMapGravityLocalPointToBodyGround([x, y, 0], anchor, current);
|
||||
centers.push(center[0], center[1]);
|
||||
if (bounds[0] === null || bounds[1] === null) {
|
||||
zBounds.push(Number.NaN, Number.NaN);
|
||||
return;
|
||||
}
|
||||
const bottom = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[0]], anchor, current);
|
||||
const top = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[1]], anchor, current);
|
||||
zBounds.push(Math.min(bottom[2], top[2]), Math.max(bottom[2], top[2]));
|
||||
});
|
||||
return {
|
||||
centersBodyXyM: Float32Array.from(centers),
|
||||
zBoundsM: Float32Array.from(zBounds),
|
||||
stateCodes: Uint8Array.from(costmap.stateCodes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { LaboratoryFetch } from "./advancedResults";
|
||||
import { CANONICAL_RECORDED_LAB_SPATIAL_PROFILE } from "./canonicalRecordedLab";
|
||||
|
||||
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export class CanonicalRecordedLabSpatialContractError extends Error {}
|
||||
|
||||
export interface CanonicalRecordedLabSpatialFrame {
|
||||
targetTimeNs: number;
|
||||
sourceTimeNs: number;
|
||||
poseTimeNs: number;
|
||||
trajectoryTimeNs: number;
|
||||
sourcePointCount: number;
|
||||
coordinateFrame: "body-ground";
|
||||
sensorHeight: {
|
||||
meters: number;
|
||||
source: "local-source-cloud-ground-quantile-median" | "session-source-cloud-fallback";
|
||||
sampleCount: number;
|
||||
madM: number;
|
||||
authority: "visual-derived";
|
||||
};
|
||||
spatialProfile: {
|
||||
profileId: typeof CANONICAL_RECORDED_LAB_SPATIAL_PROFILE;
|
||||
localSlamHistorySeconds: number;
|
||||
localSlamRadiusM: number;
|
||||
localSlamVoxelSizeM: number;
|
||||
localSlamPointLimit: number;
|
||||
};
|
||||
bodyFrame: {
|
||||
originMapXyzM: readonly [number, number, number];
|
||||
sensorOriginMapXyzM: readonly [number, number, number];
|
||||
basisMapFromBody: readonly [
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
];
|
||||
};
|
||||
sourcePointsBodyXyzM: readonly (readonly [number, number, number])[];
|
||||
localSlamSourceFrameCount: number;
|
||||
localSlamSourcePointCount: number;
|
||||
localSlamBodyXyzM: readonly (readonly [number, number, number])[];
|
||||
}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact(value: unknown, expected: unknown, label: string): void {
|
||||
if (value !== expected) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(`${label}: контракт изменён.`);
|
||||
}
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось целое значение.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pointList(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): readonly (readonly [number, number, number])[] {
|
||||
return arrayValue(value, label).map((entry, index) => {
|
||||
const point = arrayValue(entry, `${label}[${index}]`).map(
|
||||
(channel, channelIndex) => numberValue(channel, `${label}[${index}][${channelIndex}]`),
|
||||
);
|
||||
if (point.length !== 3) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(`${label}[${index}]: размер изменён.`);
|
||||
}
|
||||
return [point[0]!, point[1]!, point[2]!] as const;
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCanonicalRecordedLabSpatialFrame(
|
||||
sessionId: string,
|
||||
generationSha256: string,
|
||||
targetTimeNs: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<CanonicalRecordedLabSpatialFrame> {
|
||||
if (
|
||||
!SAFE_SESSION_ID.test(sessionId)
|
||||
|| !SHA256.test(generationSha256)
|
||||
|| !Number.isSafeInteger(targetTimeNs)
|
||||
|| targetTimeNs < 0
|
||||
) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(
|
||||
"Canonical LAB spatial identity недопустима.",
|
||||
);
|
||||
}
|
||||
const query = new URLSearchParams({
|
||||
generation: generationSha256,
|
||||
time_ns: String(targetTimeNs),
|
||||
profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||
});
|
||||
const response = await fetcher(
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`
|
||||
+ `/canonical-lab/spatial-frame?${query.toString()}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(
|
||||
`Canonical LAB spatial frame недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||
"canonical_lab.spatial_frame.schema_version",
|
||||
);
|
||||
exact(payload.coordinate_frame, "body-ground", "canonical_lab.spatial_frame.coordinate_frame");
|
||||
exact(payload.target_time_ns, targetTimeNs, "canonical_lab.spatial_frame.target_time_ns");
|
||||
const sourcePoints = pointList(
|
||||
payload.source_points_body_xyz_m,
|
||||
"canonical_lab.spatial_frame.source_points",
|
||||
);
|
||||
const localSlam = pointList(
|
||||
payload.local_slam_body_xyz_m,
|
||||
"canonical_lab.spatial_frame.local_slam",
|
||||
);
|
||||
const sourcePointCount = integerValue(
|
||||
payload.source_point_count,
|
||||
"canonical_lab.spatial_frame.source_point_count",
|
||||
);
|
||||
const localSlamPointCount = integerValue(
|
||||
payload.local_slam_point_count,
|
||||
"canonical_lab.spatial_frame.local_slam_point_count",
|
||||
);
|
||||
if (
|
||||
sourcePointCount !== sourcePoints.length
|
||||
|| sourcePointCount > 100_000
|
||||
|| localSlamPointCount !== localSlam.length
|
||||
|| localSlam.length > 27_000
|
||||
) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(
|
||||
"Canonical LAB spatial accounting изменён.",
|
||||
);
|
||||
}
|
||||
const bodyFrame = objectValue(payload.body_frame, "canonical_lab.spatial_frame.body_frame");
|
||||
const origin = pointList(
|
||||
[bodyFrame.origin_map_xyz_m],
|
||||
"canonical_lab.spatial_frame.body_frame.origin",
|
||||
)[0]!;
|
||||
const sensorOrigin = pointList(
|
||||
[bodyFrame.sensor_origin_map_xyz_m],
|
||||
"canonical_lab.spatial_frame.body_frame.sensor_origin",
|
||||
)[0]!;
|
||||
const basisRows = pointList(
|
||||
bodyFrame.basis_map_from_body,
|
||||
"canonical_lab.spatial_frame.body_frame.basis",
|
||||
);
|
||||
if (basisRows.length !== 3) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(
|
||||
"Canonical LAB spatial basis изменён.",
|
||||
);
|
||||
}
|
||||
const sensorHeight = objectValue(payload.sensor_height, "canonical_lab.spatial_frame.sensor_height");
|
||||
if (
|
||||
sensorHeight.source !== "local-source-cloud-ground-quantile-median"
|
||||
&& sensorHeight.source !== "session-source-cloud-fallback"
|
||||
) {
|
||||
throw new CanonicalRecordedLabSpatialContractError(
|
||||
"canonical_lab.spatial_frame.sensor_height.source: контракт изменён.",
|
||||
);
|
||||
}
|
||||
exact(
|
||||
sensorHeight.authority,
|
||||
"visual-derived",
|
||||
"canonical_lab.spatial_frame.sensor_height.authority",
|
||||
);
|
||||
const spatialProfile = objectValue(
|
||||
payload.spatial_profile,
|
||||
"canonical_lab.spatial_frame.spatial_profile",
|
||||
);
|
||||
exact(
|
||||
spatialProfile.profile_id,
|
||||
CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||
"canonical_lab.spatial_frame.spatial_profile.profile_id",
|
||||
);
|
||||
return {
|
||||
targetTimeNs,
|
||||
sourceTimeNs: integerValue(payload.source_time_ns, "canonical_lab.spatial_frame.source_time_ns"),
|
||||
poseTimeNs: integerValue(payload.pose_time_ns, "canonical_lab.spatial_frame.pose_time_ns"),
|
||||
trajectoryTimeNs: integerValue(
|
||||
payload.trajectory_time_ns,
|
||||
"canonical_lab.spatial_frame.trajectory_time_ns",
|
||||
),
|
||||
sourcePointCount,
|
||||
coordinateFrame: "body-ground",
|
||||
sensorHeight: {
|
||||
meters: numberValue(sensorHeight.meters, "canonical_lab.spatial_frame.sensor_height.meters"),
|
||||
source: sensorHeight.source,
|
||||
sampleCount: integerValue(
|
||||
sensorHeight.sample_count,
|
||||
"canonical_lab.spatial_frame.sensor_height.sample_count",
|
||||
),
|
||||
madM: numberValue(sensorHeight.mad_m, "canonical_lab.spatial_frame.sensor_height.mad_m"),
|
||||
authority: "visual-derived",
|
||||
},
|
||||
spatialProfile: {
|
||||
profileId: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||
localSlamHistorySeconds: numberValue(
|
||||
spatialProfile.local_slam_history_seconds,
|
||||
"canonical_lab.spatial_frame.spatial_profile.history",
|
||||
),
|
||||
localSlamRadiusM: numberValue(
|
||||
spatialProfile.local_slam_radius_m,
|
||||
"canonical_lab.spatial_frame.spatial_profile.radius",
|
||||
),
|
||||
localSlamVoxelSizeM: numberValue(
|
||||
spatialProfile.local_slam_voxel_size_m,
|
||||
"canonical_lab.spatial_frame.spatial_profile.voxel",
|
||||
),
|
||||
localSlamPointLimit: integerValue(
|
||||
spatialProfile.local_slam_point_limit,
|
||||
"canonical_lab.spatial_frame.spatial_profile.limit",
|
||||
),
|
||||
},
|
||||
bodyFrame: {
|
||||
originMapXyzM: origin,
|
||||
sensorOriginMapXyzM: sensorOrigin,
|
||||
basisMapFromBody: [basisRows[0]!, basisRows[1]!, basisRows[2]!],
|
||||
},
|
||||
sourcePointsBodyXyzM: sourcePoints,
|
||||
localSlamSourceFrameCount: integerValue(
|
||||
payload.local_slam_source_frame_count,
|
||||
"canonical_lab.spatial_frame.local_slam_source_frames",
|
||||
),
|
||||
localSlamSourcePointCount: integerValue(
|
||||
payload.local_slam_source_point_count,
|
||||
"canonical_lab.spatial_frame.local_slam_source_points",
|
||||
),
|
||||
localSlamBodyXyzM: localSlam,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +154,9 @@ export interface M4ThreatTimelineFrame {
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
pointCloudLayer: "current-increment";
|
||||
localSlamBodyXyzM?: readonly M4Point3[];
|
||||
localSlamSourceFrameCount?: number;
|
||||
localSlamSourcePointCount?: number;
|
||||
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
|
||||
cameraProjectedSourceCount: number;
|
||||
cameraProjectedPointCount: number;
|
||||
@@ -168,10 +171,11 @@ export interface M4ThreatTimelineFrame {
|
||||
|
||||
export interface M4ThreatTimeline {
|
||||
resultId: string;
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
||||
recordedSourceSessionId: string;
|
||||
recordedSourceId: string;
|
||||
imageWidth: 800;
|
||||
imageHeight: 600;
|
||||
frameCount: 4489;
|
||||
frameCount: number;
|
||||
frameTimesNs: readonly number[];
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
@@ -234,7 +238,7 @@ export interface M4ThreatPlaybackProgress {
|
||||
|
||||
export interface M4ThreatPlaybackPointPack {
|
||||
resultId: string;
|
||||
frameCount: 4489;
|
||||
frameCount: number;
|
||||
pointCount: number;
|
||||
pointOffsets: Uint32Array;
|
||||
pointsMapXyzM: Float32Array;
|
||||
@@ -257,7 +261,7 @@ export interface M4ThreatPlaybackChunkDescriptor {
|
||||
|
||||
export interface M4ThreatPlaybackManifest {
|
||||
resultId: string;
|
||||
frameCount: 4489;
|
||||
frameCount: number;
|
||||
pointCount: number;
|
||||
pointOffsets: Uint32Array;
|
||||
chunkFrameCount: 24;
|
||||
@@ -337,7 +341,7 @@ const motion = (value: unknown): M4ThreatMotion => {
|
||||
};
|
||||
const resultId = (value: unknown): string => {
|
||||
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: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
@@ -704,12 +708,8 @@ export async function fetchM4ThreatTimeline(
|
||||
exact(payload.result_id, result, "M4.6 timeline result");
|
||||
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||
exact(
|
||||
recorded.session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"M4.6 recorded session",
|
||||
);
|
||||
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
||||
const recordedSessionId = text(recorded.session_id, "M4.6 recorded session");
|
||||
const recordedSourceId = text(recorded.source_id, "M4.6 recorded source id");
|
||||
exact(
|
||||
recorded.representation_id,
|
||||
"registered-map-increment-v1",
|
||||
@@ -720,7 +720,10 @@ export async function fetchM4ThreatTimeline(
|
||||
"host-arrival-best-effort",
|
||||
"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(
|
||||
(value) => integer(value, "M4.6 timeline time"),
|
||||
);
|
||||
@@ -738,7 +741,8 @@ export async function fetchM4ThreatTimeline(
|
||||
);
|
||||
return {
|
||||
resultId: result,
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
||||
recordedSourceSessionId: recordedSessionId,
|
||||
recordedSourceId,
|
||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||
frameCount,
|
||||
@@ -849,12 +853,14 @@ export async function fetchM4ThreatTimelineChunk(
|
||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||
cameraObstacleProjectionDelivery = null,
|
||||
playbackPointPack,
|
||||
includePoints = true,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
endpointRoot?: string;
|
||||
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
||||
playbackPointPack?: M4ThreatPlaybackPointPack;
|
||||
includePoints?: boolean;
|
||||
} = {},
|
||||
): Promise<M4ThreatTimelineChunk> {
|
||||
const params = new URLSearchParams({
|
||||
@@ -864,7 +870,7 @@ export async function fetchM4ThreatTimelineChunk(
|
||||
if (cameraObstacleProjectionDelivery !== null) {
|
||||
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
||||
}
|
||||
if (playbackPointPack) params.set("include_points", "false");
|
||||
if (playbackPointPack || !includePoints) params.set("include_points", "false");
|
||||
const response = await fetcher(
|
||||
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
@@ -995,7 +1001,10 @@ export async function fetchM4ThreatPlaybackManifest(
|
||||
exact(manifest.result_id, result, "M4.6 playback result");
|
||||
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
||||
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 offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
||||
if (offsetsRaw.length !== frameCount + 1) {
|
||||
@@ -1332,6 +1341,17 @@ function parseTimelineFrame(
|
||||
"current-increment",
|
||||
"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
|
||||
? []
|
||||
: 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[];
|
||||
}
|
||||
|
||||
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 {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
@@ -116,6 +166,7 @@ export interface VegetationShadowResult {
|
||||
validationCases: readonly VegetationVisualCase[];
|
||||
routeVideo: VegetationRouteVideo | null;
|
||||
routeReview: VegetationMixedRouteReview | null;
|
||||
routeFullReview: VegetationFullRouteReview | null;
|
||||
limitations: readonly string[];
|
||||
visualShadowReady: 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(
|
||||
value: unknown,
|
||||
resultId: string,
|
||||
@@ -589,9 +899,10 @@ function parseResult(
|
||||
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
||||
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
|
||||
const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot);
|
||||
const routeFullReview = fullRouteReviewValue(payload.route_full_review);
|
||||
if (
|
||||
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.");
|
||||
}
|
||||
@@ -605,6 +916,7 @@ function parseResult(
|
||||
validationCases,
|
||||
routeVideo: routeVideoValue(payload.route_video),
|
||||
routeReview,
|
||||
routeFullReview,
|
||||
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
||||
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
||||
visualShadowReady: true,
|
||||
@@ -625,7 +937,96 @@ export function vegetationVideoMaskUrl(resultId: string, sequence: number): stri
|
||||
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,
|
||||
{
|
||||
fetcher = fetch,
|
||||
@@ -642,11 +1043,60 @@ export async function fetchVegetationShadowResult(
|
||||
if (!response.ok) {
|
||||
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseResult(
|
||||
const result = parseResult(
|
||||
await response.json(),
|
||||
resultId,
|
||||
"/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(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { plannerBase, plannerRequest, type Draft } from './planner';
|
||||
|
||||
export interface PlanningLiveTest {
|
||||
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
|
||||
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
|
||||
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
|
||||
tracking_state?: 'acquiring'|'tracking'|'lost';
|
||||
tracking_established?: boolean;
|
||||
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
|
||||
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
|
||||
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
|
||||
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
|
||||
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
|
||||
route_relocalization_policy?: {version?:string;query_radius_m?:number};
|
||||
}
|
||||
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
|
||||
const endpoint = plannerBase+'/live-tests';
|
||||
const terminal = new Set(['completed','cancelled','error','interrupted']);
|
||||
const Context = createContext<{
|
||||
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
|
||||
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
|
||||
}|null>(null);
|
||||
|
||||
/** Server owns the run and its frozen reference, never the workspace launch profile. */
|
||||
export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||
const [test,setTest]=useState<PlanningLiveTest|null>(null);
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
|
||||
const [pollError,setPollError]=useState<string|null>(null);
|
||||
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
|
||||
const generation=useRef(0);
|
||||
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
|
||||
const select=useCallback(async(id:string)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
let disposed=false, timer:ReturnType<typeof setTimeout>;
|
||||
const poll=async()=>{
|
||||
const expected=generation.current;
|
||||
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
|
||||
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
|
||||
if(!disposed)timer=setTimeout(()=>void poll(),1500);
|
||||
};void poll();return()=>{disposed=true;clearTimeout(timer);};
|
||||
},[]);
|
||||
const begin=useCallback(async(draft:Draft)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
const finish=useCallback(async()=>{
|
||||
if(!test)return;generation.current+=1;setBusy(true);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[test]);
|
||||
const retryInitialization=useCallback(async()=>{
|
||||
if(!test)return;generation.current+=1;setBusy(true);setError(null);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[test]);
|
||||
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
|
||||
}
|
||||
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
|
||||
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
|
||||
|
||||
/** A prepared consumer owns the next capture until explicitly finished or used. */
|
||||
export function planningAwaitsCapture(test:PlanningLiveTest|null){
|
||||
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
|
||||
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
|
||||
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
|
||||
export type Direction = "forward" | "reverse";
|
||||
export interface Draft {
|
||||
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
|
||||
zone: { session_id: string; generation: string; label: string };
|
||||
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
|
||||
}
|
||||
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
|
||||
export const plannerBase = "/api/v1/mission-planner";
|
||||
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
|
||||
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
|
||||
}
|
||||
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
|
||||
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
|
||||
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
|
||||
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
|
||||
const poses = source.poses.slice(start, end + 1);
|
||||
return direction === "reverse" ? poses.reverse() : poses;
|
||||
}
|
||||
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
|
||||
export function endAtDistance(source: PlanningSource, start: number, length: number) {
|
||||
const target = source.poses[start].distance_m + length;
|
||||
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
|
||||
return index < 0 ? source.poses.length - 1 : index;
|
||||
}
|
||||
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
|
||||
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|
||||
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|
||||
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
|
||||
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
|
||||
let low = min, high = max;
|
||||
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
|
||||
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
|
||||
|
||||
export interface PlanningMatchState {
|
||||
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
|
||||
result:{status:string}|null;
|
||||
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
|
||||
presentation_state?:'live'|'historical'|'unlocalized';
|
||||
}
|
||||
/** Current geometry is insufficient: the temporal gate must also be tracking. */
|
||||
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
|
||||
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
|
||||
&&(t.presentation_state===undefined||t.presentation_state==='live')
|
||||
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
|
||||
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
|
||||
&&t.result?.status==='candidate';
|
||||
}
|
||||
|
||||
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
||||
type PlanningPresentation=PlanningMatchState & {message:string};
|
||||
const phaseLabels:Record<string,string>={
|
||||
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
|
||||
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
|
||||
};
|
||||
|
||||
/** Terminal failures and data freshness take precedence over successful old fits. */
|
||||
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
|
||||
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
|
||||
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
|
||||
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
|
||||
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
|
||||
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
|
||||
if(t.planning_phase==='lost'&&!t.tracking_established)
|
||||
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
|
||||
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
|
||||
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
|
||||
if(t.planning_phase&&phaseLabels[t.planning_phase])
|
||||
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
|
||||
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
|
||||
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
|
||||
}
|
||||
|
||||
/** A plugin may show this only for its matching, authoritative acquiring session. */
|
||||
export function planningActivity(
|
||||
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
|
||||
):SpatialActivityPresentation|undefined{
|
||||
if(!t.query_session_id||!['running','error'].includes(t.state)
|
||||
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
|
||||
const status=planningStatus(t,error);
|
||||
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
|
||||
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PlanningSceneDelivery } from './planningSceneStream';
|
||||
|
||||
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
|
||||
'missioncore.planning-browser-presentation/v1';
|
||||
|
||||
type BrowserPresentationSample = {
|
||||
cloud_revision:number;
|
||||
cloud_sequence:number;
|
||||
display_epoch:string;
|
||||
request_ms:number;
|
||||
rerun_admission_ms:number;
|
||||
first_animation_frame_ms:number|null;
|
||||
second_animation_frame_ms:number|null;
|
||||
frame_timeout:boolean;
|
||||
source_to_second_animation_frame_upper_bound_ms:number|null;
|
||||
};
|
||||
|
||||
type ReporterDependencies = {
|
||||
url:string;
|
||||
fetcher?:typeof fetch;
|
||||
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||
};
|
||||
|
||||
const batchSize=8;
|
||||
const flushDelayMs=250;
|
||||
|
||||
/**
|
||||
* Bounded, best-effort browser observation. These samples never drive scene
|
||||
* admission, registration, navigation, or device control.
|
||||
*/
|
||||
export function createPlanningPresentationReporter(deps:ReporterDependencies){
|
||||
const fetcher=deps.fetcher??fetch;
|
||||
const schedule=deps.schedule??setTimeout;
|
||||
const cancel=deps.cancel??clearTimeout;
|
||||
let pending:BrowserPresentationSample[]=[];
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
let sending=false,stopped=false;
|
||||
|
||||
const flush=async()=>{
|
||||
if(sending||stopped||!pending.length)return;
|
||||
if(timer!==undefined){cancel(timer);timer=undefined;}
|
||||
sending=true;
|
||||
const samples=pending.splice(0,batchSize);
|
||||
try{
|
||||
// Deliberately best-effort: the presentation observer must not make the
|
||||
// live Rerun channel wait for an unrelated report request.
|
||||
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
|
||||
});
|
||||
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
|
||||
}catch{
|
||||
// A lost browser report is observable as fewer samples, never retried into
|
||||
// an unbounded queue and never promoted into a capture/fit failure.
|
||||
}finally{
|
||||
sending=false;
|
||||
if(!stopped&&pending.length)void flush();
|
||||
}
|
||||
};
|
||||
|
||||
const record=(delivery:PlanningSceneDelivery,timing:{
|
||||
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
|
||||
secondAnimationFrameMs:number|null;
|
||||
})=>{
|
||||
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
|
||||
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
|
||||
const second=timing.secondAnimationFrameMs;
|
||||
pending.push({
|
||||
cloud_revision:delivery.cloudRevision,
|
||||
cloud_sequence:delivery.cloudSequence,
|
||||
display_epoch:delivery.displayEpoch,
|
||||
request_ms:delivery.requestMs,
|
||||
rerun_admission_ms:timing.rerunAdmissionMs,
|
||||
first_animation_frame_ms:timing.firstAnimationFrameMs,
|
||||
second_animation_frame_ms:second,
|
||||
frame_timeout:second===null,
|
||||
source_to_second_animation_frame_upper_bound_ms:second===null?null:
|
||||
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
|
||||
});
|
||||
if(pending.length>=batchSize)void flush();
|
||||
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
|
||||
};
|
||||
|
||||
return {
|
||||
record,
|
||||
dispose:()=>{
|
||||
stopped=true;
|
||||
if(timer!==undefined)cancel(timer);
|
||||
pending=[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Draft } from './planner';
|
||||
import type { RegistrationReport } from './useRegistrationTest';
|
||||
import { plannerBase, plannerRequest } from './planner';
|
||||
|
||||
export interface PlanningProject {
|
||||
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
|
||||
state: string; created_at_utc: string; result_status: string|null;
|
||||
reference_label: string; query_label: string|null; draft_id: string; revision: number;
|
||||
}
|
||||
export interface PlanningProjectDetail extends PlanningProject {
|
||||
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
|
||||
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
|
||||
scene_note?:string|null;
|
||||
}
|
||||
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
|
||||
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|
||||
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
|
||||
export async function deletePlanningProject(project: PlanningProject) {
|
||||
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
|
||||
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
|
||||
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
|
||||
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
|
||||
);
|
||||
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
|
||||
}
|
||||
export function planningProjectStatus(p: PlanningProject) {
|
||||
if (p.kind === 'draft') return 'Подготовка';
|
||||
if (planningProjectPending(p)) return 'Выполняется';
|
||||
if (p.result_status === 'candidate') return 'Кандидат совмещения';
|
||||
if (p.result_status === 'rejected') return 'Совмещение отклонено';
|
||||
return 'Без результата совмещения';
|
||||
}
|
||||
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
|
||||
return items.find(p => p.key === last)?.key
|
||||
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
|
||||
?? items[0]?.key ?? '';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
|
||||
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
|
||||
export type PlanningSceneDelivery = {
|
||||
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
|
||||
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
|
||||
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
|
||||
};
|
||||
type Dependencies = {
|
||||
url:string; snapshot:()=>Snapshot;
|
||||
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
|
||||
fetcher?:typeof fetch; now?:()=>number;
|
||||
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||
};
|
||||
|
||||
export function startPlanningSceneStream(deps:Dependencies){
|
||||
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
|
||||
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
|
||||
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
let request:AbortController|undefined;
|
||||
const poll=async()=>{
|
||||
if(stopped)return;
|
||||
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
|
||||
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
|
||||
timer=schedule(()=>void poll(),500);return;
|
||||
}
|
||||
request=new AbortController();
|
||||
const deadline=schedule(()=>request?.abort(),2500);
|
||||
let failed=false;
|
||||
try{
|
||||
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
|
||||
params.set('base',String(needsBase||!cursor||key!==nextKey));
|
||||
if(cursor)params.set('cursor',cursor);
|
||||
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
|
||||
if(!response.ok)throw new Error('scene unavailable');
|
||||
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
|
||||
if(stopped)return;
|
||||
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
|
||||
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
|
||||
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
|
||||
const elapsed=now()-started;
|
||||
if(presentation==='live'){
|
||||
// Include the entire request duration: a conservative upper bound, without
|
||||
// comparing clocks on different hosts. Late bytes must not revive green.
|
||||
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
|
||||
throw new Error('scene expired during delivery');
|
||||
}
|
||||
const latest=deps.snapshot();
|
||||
// Never apply a response from the previous display mode or live/ended state.
|
||||
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
|
||||
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
|
||||
if(!nextCursor)throw new Error('scene cursor missing');
|
||||
if(bytes)await deps.apply(bytes,{
|
||||
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
|
||||
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
|
||||
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
|
||||
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
|
||||
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
|
||||
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
|
||||
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
|
||||
}); // Native admission only; the caller may record a separate presentation proxy.
|
||||
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
|
||||
}catch{
|
||||
// Keep the last admitted cursor (including camera intent). A full geometry
|
||||
// repair after a missed/expired response is not permission to reset the eye.
|
||||
if(!stopped){failed=true;needsBase=true;deps.error();}
|
||||
}finally{
|
||||
cancel(deadline);request=undefined;
|
||||
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
|
||||
}
|
||||
|
||||
function parseNonnegativeInteger(value:string|null){
|
||||
if(value===null||!/^\d+$/.test(value))return null;
|
||||
const parsed=Number(value);
|
||||
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value:string|null){
|
||||
if(value===null||value.trim()==='')return null;
|
||||
const parsed=Number(value);
|
||||
return Number.isFinite(parsed)?parsed:null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
|
||||
|
||||
export function useMissionPlanner() {
|
||||
const [sessions, setSessions] = useState<SessionOption[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [saved, setSaved] = useState<Draft | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||
const [direction, setDirection] = useState<Direction>("forward");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sourceError, setSourceError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [catalogBusy, setCatalogBusy] = useState(true);
|
||||
const [loadVersion, setLoadVersion] = useState(0);
|
||||
const [sourceVersion, setSourceVersion] = useState(0);
|
||||
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
|
||||
const [check, setCheck] = useState<RouteCheck | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setCatalogBusy(true); setError(null);
|
||||
void Promise.all([
|
||||
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1", { signal: abort.signal }),
|
||||
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
|
||||
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
|
||||
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
|
||||
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
|
||||
return () => abort.abort();
|
||||
}, [loadVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setSourceError(null);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
|
||||
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
|
||||
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
|
||||
return () => abort.abort();
|
||||
}, [sessionId, sourceVersion, pinnedGeneration]);
|
||||
|
||||
const currentSource = source?.session_id === sessionId ? source : null;
|
||||
// New executions always own the complete reference. No independent interval
|
||||
// state can survive a source change or collapse on same-source reselection.
|
||||
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
|
||||
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
|
||||
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
|
||||
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|
||||
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
|
||||
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
|
||||
const chooseSource = (id: string) => { setPinnedGeneration(null); setSessionId(id); setSourceVersion(n => n + 1); setCheck(null); };
|
||||
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
|
||||
const openDraft = async (id: string) => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
|
||||
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
|
||||
setDirection(next.route.direction); setCheck(null);
|
||||
setSourceVersion(n => n + 1);
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const save = async () => {
|
||||
if (!ready || !currentSource) return;
|
||||
setBusy(true); setError(null); setCheck(null);
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
|
||||
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
|
||||
generation: currentSource.generation, whole_recording: true, direction,
|
||||
}) });
|
||||
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
|
||||
return next;
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const runCheck = async () => {
|
||||
if (!saved || dirty || !ready) return;
|
||||
setBusy(true); setError(null); setCheck(null);
|
||||
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
|
||||
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const loadMore = async () => {
|
||||
if (!cursor) return;
|
||||
setCatalogBusy(true);
|
||||
try {
|
||||
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
||||
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
|
||||
};
|
||||
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
|
||||
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
|
||||
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
|
||||
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
|
||||
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { plannerBase, plannerRequest } from './planner';
|
||||
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
|
||||
|
||||
const storageKey = 'missioncore.planning-project.selected.v1';
|
||||
export function usePlanningProjects() {
|
||||
const [items, setItems] = useState<PlanningProject[]>([]);
|
||||
const [key, setKey] = useState('');
|
||||
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
|
||||
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
|
||||
const [version, setVersion] = useState(0);
|
||||
const initialized = useRef(false);
|
||||
const selectedKey = useRef('');
|
||||
const removedKeys = useRef(new Set<string>());
|
||||
const removing = useRef(false);
|
||||
const select = useCallback((value: string) => {
|
||||
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
|
||||
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
|
||||
}, []);
|
||||
const refresh = useCallback(() => setVersion(n=>n+1), []);
|
||||
const remove = useCallback(async (project: PlanningProject) => {
|
||||
if (removing.current) throw new Error('Удаление уже выполняется.');
|
||||
removing.current = true;
|
||||
try {
|
||||
await deletePlanningProject(project);
|
||||
removedKeys.current.add(project.key);
|
||||
setItems(items => items.filter(item => item.key !== project.key));
|
||||
if (selectedKey.current === project.key) select('');
|
||||
} finally { removing.current = false; }
|
||||
}, [select]);
|
||||
useEffect(()=>{
|
||||
const abort = new AbortController(); setLoading(true); setError(null);
|
||||
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
|
||||
if (abort.signal.aborted) return;
|
||||
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
|
||||
setItems(visible);
|
||||
if (!initialized.current) {
|
||||
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
|
||||
select(defaultPlanningProject(visible,last));
|
||||
}
|
||||
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
|
||||
return ()=>abort.abort();
|
||||
},[version,select]);
|
||||
useEffect(()=>{
|
||||
if (!key) return;
|
||||
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
|
||||
setDetail(null); setError(null);
|
||||
const [kind,id]=key.split(':');
|
||||
const poll=async()=>{
|
||||
try {
|
||||
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
|
||||
if(abort.signal.aborted || removedKeys.current.has(key))return;
|
||||
if(next.key!==key)throw new Error('Получен результат другого исследования.');
|
||||
setDetail(next);setError(null);
|
||||
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
|
||||
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
|
||||
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
|
||||
};
|
||||
void poll(); return()=>{abort.abort();clearTimeout(timer);};
|
||||
},[key,version]);
|
||||
return {items,key,detail,loading,error,select,refresh,remove};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
|
||||
|
||||
export interface RegistrationReport {
|
||||
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
|
||||
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
|
||||
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
|
||||
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
|
||||
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
|
||||
}
|
||||
|
||||
export function useRegistrationTest(draft: Draft | null) {
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
|
||||
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
|
||||
const [catalogBusy,setCatalogBusy]=useState(false);
|
||||
// Repeat recordings belong to the planner, including its own captured passes;
|
||||
// they are not restricted to the independent-reference catalog.
|
||||
useEffect(()=>{
|
||||
const abort=new AbortController();setCatalogBusy(true);
|
||||
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
|
||||
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
|
||||
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
|
||||
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
|
||||
return()=>abort.abort();
|
||||
},[]);
|
||||
const loadMore=async()=>{
|
||||
if(!cursor||catalogBusy)return;setCatalogBusy(true);
|
||||
try{
|
||||
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
||||
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
|
||||
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
|
||||
};
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
|
||||
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
|
||||
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
|
||||
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||
return () => abort.abort();
|
||||
}, [sessionId]);
|
||||
const busy = submitting;
|
||||
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
|
||||
const run = async (savedDraft = draft) => {
|
||||
if (!source || busy || !savedDraft) return;
|
||||
setSubmitting(true); setError(null);
|
||||
try {
|
||||
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
|
||||
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
|
||||
});
|
||||
return data;
|
||||
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
|
||||
};
|
||||
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export const OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA =
|
||||
"missioncore.observatory-calculation-profile/v1" as const;
|
||||
|
||||
export type ObservationLabCalculationProfileOrigin =
|
||||
| "archived-definition"
|
||||
| "existing-result";
|
||||
|
||||
export interface ObservationLabCalculationProfile {
|
||||
readonly schemaVersion: typeof OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA;
|
||||
readonly setupId: string;
|
||||
readonly displayName: string;
|
||||
readonly origin: ObservationLabCalculationProfileOrigin;
|
||||
readonly definitionId: string | null;
|
||||
readonly definitionVersion: number | null;
|
||||
readonly definitionSha256: string | null;
|
||||
}
|
||||
|
||||
export class ObservationLabCalculationProfileContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservationLabCalculationProfileContractError";
|
||||
}
|
||||
}
|
||||
|
||||
const PROFILE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"setup_id",
|
||||
"display_name",
|
||||
"origin",
|
||||
"definition_id",
|
||||
"definition_version",
|
||||
"definition_sha256",
|
||||
]);
|
||||
const IDENTIFIER = /^[a-z][a-z0-9-]{2,95}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireText(value: unknown, field: string, maximum: number): string {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length === 0
|
||||
|| value.length > maximum
|
||||
|| value !== value.trim()
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Поле calculation_profile.${field} должно быть непустой строкой.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireIdentifier(value: unknown, field: string): string {
|
||||
const identifier = requireText(value, field, 96);
|
||||
if (!IDENTIFIER.test(identifier)) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Поле calculation_profile.${field} содержит некорректный идентификатор.`,
|
||||
);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
export function decodeObservationLabCalculationProfile(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
): ObservationLabCalculationProfile | null {
|
||||
if (value === null) return null;
|
||||
if (!isRecord(value) || Object.keys(value).some((key) => !PROFILE_KEYS.has(key))) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} нарушил контракт полей.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(value).length !== PROFILE_KEYS.size
|
||||
|| value.schema_version !== OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестную версию.`,
|
||||
);
|
||||
}
|
||||
|
||||
const origin = value.origin;
|
||||
if (origin !== "archived-definition" && origin !== "existing-result") {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестное происхождение.`,
|
||||
);
|
||||
}
|
||||
const definitionId = value.definition_id === null
|
||||
? null
|
||||
: requireIdentifier(value.definition_id, "definition_id");
|
||||
const definitionVersion = value.definition_version === null
|
||||
? null
|
||||
: value.definition_version;
|
||||
if (
|
||||
definitionVersion !== null
|
||||
&& (
|
||||
typeof definitionVersion !== "number"
|
||||
|| !Number.isInteger(definitionVersion)
|
||||
|| definitionVersion < 1
|
||||
)
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} содержит некорректную версию определения.`,
|
||||
);
|
||||
}
|
||||
const definitionSha256 = value.definition_sha256 === null
|
||||
? null
|
||||
: requireText(value.definition_sha256, "definition_sha256", 64);
|
||||
if (definitionSha256 !== null && !SHA256.test(definitionSha256)) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} не содержит SHA-256 определения.`,
|
||||
);
|
||||
}
|
||||
|
||||
const definitionFieldCount = [
|
||||
definitionId,
|
||||
definitionVersion,
|
||||
definitionSha256,
|
||||
].filter((entry) => entry !== null).length;
|
||||
if (
|
||||
(origin === "existing-result" && definitionFieldCount !== 0)
|
||||
|| (origin === "archived-definition" && definitionFieldCount !== 3)
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} содержит неполную идентичность определения.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA,
|
||||
setupId: requireIdentifier(value.setup_id, "setup_id"),
|
||||
displayName: requireText(value.display_name, "display_name", 256),
|
||||
origin,
|
||||
definitionId,
|
||||
definitionVersion,
|
||||
definitionSha256,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
export interface ObservationCanonicalLabReplayCapability {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1";
|
||||
kind: "canonical-recorded-rerun";
|
||||
viewerProfile: "recorded-session";
|
||||
timeline: "session_time";
|
||||
activation: "explicit";
|
||||
commandsEnabled: false;
|
||||
}
|
||||
|
||||
export interface ObservationPortableLabReplayCapability {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v2";
|
||||
kind: "portable-result-review";
|
||||
viewerProfile: "portable-result";
|
||||
timeline: "result-defined";
|
||||
activation: "explicit";
|
||||
commandsEnabled: false;
|
||||
}
|
||||
|
||||
export type ObservationLabReplayCapability =
|
||||
| ObservationCanonicalLabReplayCapability
|
||||
| ObservationPortableLabReplayCapability;
|
||||
|
||||
export class ObservationLabReplayCapabilityContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservationLabReplayCapabilityContractError";
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const CAPABILITY_KEYS = new Set([
|
||||
"schema_version",
|
||||
"kind",
|
||||
"viewer_profile",
|
||||
"timeline",
|
||||
"activation",
|
||||
"commands_enabled",
|
||||
]);
|
||||
const CANONICAL_PROVENANCE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"evidence_identity_sha256",
|
||||
"result_document_sha256",
|
||||
"replay_capability",
|
||||
"authority",
|
||||
"method",
|
||||
]);
|
||||
const PORTABLE_PROVENANCE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"authority",
|
||||
"calculation_profile",
|
||||
"calculation_profile_sha256",
|
||||
"job",
|
||||
"source",
|
||||
"run_definition",
|
||||
"result_package",
|
||||
"replay_capability",
|
||||
"storage",
|
||||
"method",
|
||||
]);
|
||||
const AUTHORITY_KEYS = new Set([
|
||||
"commands_enabled",
|
||||
"navigation_or_safety_accepted",
|
||||
"actuation_accepted",
|
||||
]);
|
||||
const METHOD_KEYS = new Set([
|
||||
"schema_version",
|
||||
"completeness",
|
||||
"execution_class",
|
||||
"pipeline_id",
|
||||
"components",
|
||||
]);
|
||||
const METHOD_COMPONENT_KEYS = new Set([
|
||||
"kind",
|
||||
"name",
|
||||
"version",
|
||||
"role",
|
||||
"identity_sha256",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`${label} содержит неизвестные или пропущенные поля.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeLabReplayCapability(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
): ObservationLabReplayCapability | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Replay-возможность LAB-сессии ${sessionId} должна быть объектом или null.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
value.activation !== "explicit"
|
||||
|| value.commands_enabled !== false
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.schema_version === "missioncore.observation-lab-replay-capability/v1"
|
||||
&& value.kind === "canonical-recorded-rerun"
|
||||
&& value.viewer_profile === "recorded-session"
|
||||
&& value.timeline === "session_time"
|
||||
) {
|
||||
return {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
value.schema_version === "missioncore.observation-lab-replay-capability/v2"
|
||||
&& value.kind === "portable-result-review"
|
||||
&& value.viewer_profile === "portable-result"
|
||||
&& value.timeline === "result-defined"
|
||||
) {
|
||||
return {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review",
|
||||
viewerProfile: "portable-result",
|
||||
timeline: "result-defined",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
};
|
||||
}
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Replay-возможность LAB-сессии ${sessionId} содержит неизвестный viewer capability.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeCanonicalLabReplayCapabilityProvenance(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
): ObservationCanonicalLabReplayCapability {
|
||||
assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1"
|
||||
|| typeof provenance.evidence_identity_sha256 !== "string"
|
||||
|| !SHA256.test(provenance.evidence_identity_sha256)
|
||||
|| resultId !== `lab-v1-vegetation-shadow-${provenance.evidence_identity_sha256}`
|
||||
|| typeof provenance.result_document_sha256 !== "string"
|
||||
|| !SHA256.test(provenance.result_document_sha256)
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(provenance.authority)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} не содержит authority.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(provenance.authority, AUTHORITY_KEYS, `Canonical authority LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
provenance.authority.commands_enabled !== false
|
||||
|| provenance.authority.navigation_or_safety_accepted !== false
|
||||
|| provenance.authority.actuation_accepted !== false
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} нарушает observation-only authority.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(provenance.method)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} не содержит method manifest.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(provenance.method, METHOD_KEYS, `Canonical method LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
provenance.method.schema_version !== "missioncore.laboratory-method/v1"
|
||||
|| provenance.method.completeness !== "legacy-partial"
|
||||
|| provenance.method.execution_class !== "ai-inference"
|
||||
|| provenance.method.pipeline_id
|
||||
!== "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|
||||
|| !Array.isArray(provenance.method.components)
|
||||
|| provenance.method.components.length !== 1
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical method LAB-сессии ${sessionId} не соответствует записанному прогону.`,
|
||||
);
|
||||
}
|
||||
const component = provenance.method.components[0];
|
||||
if (!isRecord(component)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical method LAB-сессии ${sessionId} не содержит source component.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(
|
||||
component,
|
||||
METHOD_COMPONENT_KEYS,
|
||||
`Canonical source component LAB-сессии ${sessionId}`,
|
||||
);
|
||||
if (
|
||||
component.kind !== "source"
|
||||
|| component.name !== "sealed full-route LAB result"
|
||||
|| component.version !== "missioncore.lab-v1-vegetation-shadow/v1"
|
||||
|| component.role !== "immutable Session catalog projection"
|
||||
|| component.identity_sha256 !== provenance.evidence_identity_sha256
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical source component LAB-сессии ${sessionId} потерял immutable identity.`,
|
||||
);
|
||||
}
|
||||
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
|
||||
if (capability === null || capability.kind !== "canonical-recorded-rerun") {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`,
|
||||
);
|
||||
}
|
||||
return capability;
|
||||
}
|
||||
|
||||
export function decodePortableLabReplayCapabilityProvenance(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
sourceSessionId: string,
|
||||
definitionSha256: string,
|
||||
): ObservationPortableLabReplayCapability {
|
||||
assertExactKeys(
|
||||
provenance,
|
||||
PORTABLE_PROVENANCE_KEYS,
|
||||
`Portable provenance LAB-сессии ${sessionId}`,
|
||||
);
|
||||
if (
|
||||
provenance.schema_version !== "missioncore.observatory-portable-result-publication/v1"
|
||||
|| !isRecord(provenance.source)
|
||||
|| provenance.source.session_id !== sourceSessionId
|
||||
|| !isRecord(provenance.run_definition)
|
||||
|| provenance.run_definition.definition_sha256 !== definitionSha256
|
||||
|| !isRecord(provenance.result_package)
|
||||
|| typeof provenance.result_package.manifest_sha256 !== "string"
|
||||
|| !SHA256.test(provenance.result_package.manifest_sha256)
|
||||
|| typeof provenance.result_package.artifact_manifest_id !== "string"
|
||||
|| !SHA256.test(provenance.result_package.artifact_manifest_id)
|
||||
|| sessionId !== resultId
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Portable provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
|
||||
);
|
||||
}
|
||||
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
|
||||
if (capability === null || capability.kind !== "portable-result-review") {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Portable provenance LAB-сессии ${sessionId} не содержит viewer capability.`,
|
||||
);
|
||||
}
|
||||
return capability;
|
||||
}
|
||||
|
||||
/** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */
|
||||
export function decodeRollingCanonicalLabReplayCapability(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
): ObservationLabReplayCapability | null {
|
||||
if (!Object.prototype.hasOwnProperty.call(provenance, "replay_capability")) return null;
|
||||
return decodeCanonicalLabReplayCapabilityProvenance(provenance, sessionId, resultId);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { isUsableRecordedPlaybackRange } from "./recordedRerunLifecycle";
|
||||
|
||||
export function createReentrantViewerDisposer(
|
||||
cleanupOnce: () => void,
|
||||
releaseNativeViewer: () => void,
|
||||
): () => void {
|
||||
let cleanupComplete = false;
|
||||
return () => {
|
||||
try {
|
||||
if (!cleanupComplete) {
|
||||
cleanupComplete = true;
|
||||
cleanupOnce();
|
||||
}
|
||||
} finally {
|
||||
// A deferred native start can resolve after a pre-ready stop. Reapply
|
||||
// release at every boundary so it cannot reopen after React unmount.
|
||||
releaseNativeViewer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface ActiveLiveViewerOwner {
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
|
||||
|
||||
/** Own exactly one native live receiver per application document. */
|
||||
export function claimExclusiveLiveViewer(release: () => void): () => void {
|
||||
const owner = { release };
|
||||
const previous = activeLiveViewerOwner;
|
||||
activeLiveViewerOwner = owner;
|
||||
previous?.release();
|
||||
return () => {
|
||||
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function isLiveRerunPresentationReady(
|
||||
viewerStarted: boolean,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
backendActivitySequence: number | null,
|
||||
): boolean {
|
||||
return viewerStarted
|
||||
&& Number.isSafeInteger(backendActivitySequence)
|
||||
&& (backendActivitySequence ?? 0) > 0
|
||||
&& isUsableRecordedPlaybackRange(rangeNs);
|
||||
}
|
||||
|
||||
export function liveTimelineNeedsSynchronization(
|
||||
followLive: boolean,
|
||||
activeTimeline: string | null | undefined,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
): boolean {
|
||||
return followLive
|
||||
&& isUsableRecordedPlaybackRange(rangeNs)
|
||||
&& activeTimeline !== "stream_time";
|
||||
}
|
||||
|
||||
export function liveRerunReceiverBindingIdentity(
|
||||
sourceUrl: string,
|
||||
liveStreamId: string | null,
|
||||
followLive: boolean,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
followLive ? "live" : "recorded",
|
||||
sourceUrl.trim(),
|
||||
followLive ? liveStreamId?.trim() ?? "" : "",
|
||||
]);
|
||||
}
|
||||
@@ -327,6 +327,53 @@ export function verifyLiveViewerClientBuild(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a recorded viewer whose lazy module disappeared during a frontend
|
||||
* deployment. Live acquisition deliberately never reloads automatically, but
|
||||
* a saved recording has no device-side authority to preserve and can safely
|
||||
* move to the current immutable application build.
|
||||
*/
|
||||
export async function reloadRecordedViewerAfterStaleModuleFailure({
|
||||
loadedUiBuildId,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
reload = () => window.location.reload(),
|
||||
}: {
|
||||
loadedUiBuildId: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
reload?: () => void;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
signal?.aborted ||
|
||||
loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID ||
|
||||
!HASHED_UI_BUILD_ID.test(loadedUiBuildId)
|
||||
) return false;
|
||||
try {
|
||||
const response = await fetcher("/api/v1/viewer/client-contract", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-MissionCore-UI-Build": loadedUiBuildId,
|
||||
},
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
if (signal?.aborted || !response.ok) return false;
|
||||
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
|
||||
if (
|
||||
!expectedUiBuildId ||
|
||||
expectedUiBuildId === loadedUiBuildId ||
|
||||
!HASHED_UI_BUILD_ID.test(expectedUiBuildId)
|
||||
) return false;
|
||||
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
|
||||
reload();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function startBuildMonitor(): void {
|
||||
if (buildMonitorAbort || typeof window === "undefined") return;
|
||||
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Small lease for the server's ephemeral blueprint, never for the recording. */
|
||||
export function keepRecordedBlueprintSession({
|
||||
endpointUrl,
|
||||
origin,
|
||||
applicationId,
|
||||
recordingId,
|
||||
ownerId,
|
||||
fetcher = fetch,
|
||||
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
|
||||
cancel = (handle: number) => window.clearInterval(handle),
|
||||
}: {
|
||||
endpointUrl: string;
|
||||
origin: string;
|
||||
applicationId: string;
|
||||
recordingId: string;
|
||||
ownerId: string;
|
||||
fetcher?: typeof fetch;
|
||||
schedule?: (callback: () => void) => number;
|
||||
cancel?: (handle: number) => void;
|
||||
}): () => void {
|
||||
const endpoint = new URL(endpointUrl, origin);
|
||||
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
|
||||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
|
||||
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
|
||||
}
|
||||
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
|
||||
const identity = { application_id: applicationId, recording_id: recordingId,
|
||||
blueprint_session_id: ownerId };
|
||||
let closed = false;
|
||||
let pending: AbortController | null = null;
|
||||
const renew = () => {
|
||||
if (closed) return;
|
||||
pending?.abort();
|
||||
pending = new AbortController();
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
|
||||
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
|
||||
};
|
||||
const timer = schedule(renew);
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
cancel(timer);
|
||||
pending?.abort();
|
||||
pending = null;
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "release" }),
|
||||
keepalive: true, signal: AbortSignal.timeout(5_000),
|
||||
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {SceneSettings} from '../../sceneSettings';
|
||||
|
||||
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
|
||||
export function pointDisplayKey(settings: PointDisplaySettings): string {
|
||||
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
|
||||
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
|
||||
}
|
||||
|
||||
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
|
||||
export async function streamRecordedPointDisplay(
|
||||
blueprintUrl: string, settings: PointDisplaySettings,
|
||||
identity: {applicationId: string; recordingId: string}, displayBank: string,
|
||||
signal: AbortSignal, send: (chunk: Uint8Array) => void,
|
||||
fetcher: typeof fetch = fetch,
|
||||
sourceGeneration?: string,
|
||||
): Promise<number> {
|
||||
const endpoint = new URL(blueprintUrl, window.location.origin);
|
||||
const percent = settings.pointDecimationPercent ?? 0;
|
||||
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|
||||
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|
||||
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|
||||
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
|
||||
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
|
||||
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
|
||||
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
|
||||
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
|
||||
point_decimation_percent: percent, display_bank: displayBank,
|
||||
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
|
||||
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
|
||||
throw new Error('Не удалось подготовить прореженное облако');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
let bytes = 0;
|
||||
let pending = new Uint8Array(0);
|
||||
let admitted = false;
|
||||
let complete = false;
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
const combined = new Uint8Array(pending.length + value.length);
|
||||
combined.set(pending); combined.set(value, pending.length); pending = combined;
|
||||
if (!admitted) {
|
||||
if (pending.length < 4) continue;
|
||||
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
|
||||
pending = pending.slice(4); admitted = true;
|
||||
}
|
||||
while (pending.length >= 4 && !complete) {
|
||||
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
|
||||
if (length === 0) { pending = pending.slice(4); complete = true; break; }
|
||||
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
|
||||
if (pending.length < length + 4) break;
|
||||
const payload = pending.slice(4, length + 4);
|
||||
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
|
||||
send(payload); pending = pending.slice(length + 4);
|
||||
}
|
||||
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
|
||||
}
|
||||
if (!complete || pending.length) throw new Error('Поток облака не завершён');
|
||||
return bytes;
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { SceneSettings } from "../../sceneSettings";
|
||||
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
|
||||
import type { RerunPlaybackState, RerunViewportStatus } from "./viewerProfile";
|
||||
|
||||
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
||||
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
|
||||
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
|
||||
const RECORDED_OPEN_GRACE_MS = 30_000;
|
||||
const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024;
|
||||
|
||||
export const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
|
||||
|
||||
export interface RecordedPlaybackBufferState {
|
||||
bufferedEndNs: number | null;
|
||||
expectedStartNs: number | null;
|
||||
expectedEndNs: number | null;
|
||||
bufferProgress: number | null;
|
||||
fullyBuffered: boolean;
|
||||
}
|
||||
|
||||
export function recordedPointColorKey(
|
||||
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
|
||||
): string {
|
||||
const custom = settings.palette === "custom" || settings.colorMode === "class"
|
||||
? settings.customColor.toLowerCase()
|
||||
: "-";
|
||||
return `${settings.colorMode}|${settings.palette}|${custom}`;
|
||||
}
|
||||
|
||||
export function recordedOpenWatchdogTimeoutMs(byteLength: number): number {
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength < 4) {
|
||||
throw new Error("Unsafe recorded RRD byte length");
|
||||
}
|
||||
const transferBudgetMs = Math.ceil(
|
||||
(byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000,
|
||||
);
|
||||
return Math.min(
|
||||
RECORDED_OPEN_MAX_TIMEOUT_MS,
|
||||
Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export function createRecordedOpenWatchdog<T>({
|
||||
byteLength,
|
||||
schedule,
|
||||
cancel,
|
||||
onTimeout,
|
||||
}: {
|
||||
byteLength: number;
|
||||
schedule: (callback: () => void, timeoutMs: number) => T;
|
||||
cancel: (handle: T) => void;
|
||||
onTimeout: () => void;
|
||||
}): { arm: () => void; clear: () => void; pending: () => boolean } {
|
||||
const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength);
|
||||
let handle: T | null = null;
|
||||
return {
|
||||
arm() {
|
||||
if (handle !== null) return;
|
||||
handle = schedule(() => {
|
||||
handle = null;
|
||||
onTimeout();
|
||||
}, timeoutMs);
|
||||
},
|
||||
clear() {
|
||||
if (handle === null) return;
|
||||
cancel(handle);
|
||||
handle = null;
|
||||
},
|
||||
pending: () => handle !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isUsableRecordedPlaybackRange(
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
): rangeNs is { min: number; max: number } {
|
||||
return Boolean(
|
||||
rangeNs
|
||||
&& Number.isFinite(rangeNs.min)
|
||||
&& Number.isFinite(rangeNs.max)
|
||||
&& rangeNs.max >= rangeNs.min,
|
||||
);
|
||||
}
|
||||
|
||||
export function recordedPlaybackBufferState(
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
expectedTimelineEndSeconds?: number,
|
||||
expectedTimelineStartSeconds?: number,
|
||||
): RecordedPlaybackBufferState {
|
||||
const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null;
|
||||
const bufferedEndNs = usableRange?.max ?? null;
|
||||
if (expectedTimelineEndSeconds === undefined) {
|
||||
return {
|
||||
bufferedEndNs,
|
||||
expectedStartNs: null,
|
||||
expectedEndNs: null,
|
||||
bufferProgress: null,
|
||||
fullyBuffered: usableRange !== null,
|
||||
};
|
||||
}
|
||||
if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) {
|
||||
return {
|
||||
bufferedEndNs,
|
||||
expectedStartNs: null,
|
||||
expectedEndNs: null,
|
||||
bufferProgress: null,
|
||||
fullyBuffered: false,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000;
|
||||
const expectedStartNs = expectedTimelineStartSeconds === undefined
|
||||
? 0
|
||||
: expectedTimelineStartSeconds * 1_000_000_000;
|
||||
if (
|
||||
!Number.isFinite(expectedEndNs)
|
||||
|| !Number.isFinite(expectedStartNs)
|
||||
|| expectedStartNs < 0
|
||||
|| expectedEndNs < expectedStartNs
|
||||
) {
|
||||
return {
|
||||
bufferedEndNs,
|
||||
expectedStartNs: null,
|
||||
expectedEndNs: null,
|
||||
bufferProgress: null,
|
||||
fullyBuffered: false,
|
||||
};
|
||||
}
|
||||
const fullyBuffered = usableRange !== null
|
||||
&& usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS
|
||||
&& usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS;
|
||||
const expectedDurationNs = expectedEndNs - expectedStartNs;
|
||||
const bufferProgress = bufferedEndNs === null
|
||||
? 0
|
||||
: expectedDurationNs <= 0
|
||||
? (fullyBuffered ? 1 : 0)
|
||||
: Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs));
|
||||
return {
|
||||
bufferedEndNs,
|
||||
expectedStartNs,
|
||||
expectedEndNs,
|
||||
bufferProgress,
|
||||
fullyBuffered,
|
||||
};
|
||||
}
|
||||
|
||||
export function isRecordedPlaybackReady(
|
||||
viewerStarted: boolean,
|
||||
artifactVerified: boolean,
|
||||
buffer: RecordedPlaybackBufferState,
|
||||
): boolean {
|
||||
return viewerStarted && artifactVerified && buffer.bufferedEndNs !== null;
|
||||
}
|
||||
|
||||
export function recordedPlaybackRangeWhenReady(
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
buffer: RecordedPlaybackBufferState,
|
||||
artifactVerified: boolean,
|
||||
): { min: number; max: number } | null {
|
||||
if (
|
||||
!artifactVerified
|
||||
|| !buffer.fullyBuffered
|
||||
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||
) return null;
|
||||
return {
|
||||
min: buffer.expectedStartNs === null
|
||||
? rangeNs.min
|
||||
: Math.max(rangeNs.min, buffer.expectedStartNs),
|
||||
max: buffer.expectedEndNs === null
|
||||
? rangeNs.max
|
||||
: Math.min(rangeNs.max, buffer.expectedEndNs),
|
||||
};
|
||||
}
|
||||
|
||||
export function isRecordedPlaybackPresentationReady(
|
||||
status: RerunViewportStatus,
|
||||
playback: RerunPlaybackState | null,
|
||||
): boolean {
|
||||
return status === "ready"
|
||||
&& playback?.fullyBuffered === true
|
||||
&& isUsableRecordedPlaybackRange(playback.rangeNs);
|
||||
}
|
||||
|
||||
export function rerunPresentationStatus(
|
||||
status: RerunViewportStatus,
|
||||
_gate: RecordedAdmissionPhase,
|
||||
recorded: boolean,
|
||||
): RerunViewportStatus {
|
||||
if (!recorded) return status;
|
||||
return status === "error" ? "error" : status;
|
||||
}
|
||||
|
||||
export function isRecordedPlaybackFullyBuffered(
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
expectedTimelineEndSeconds?: number,
|
||||
): boolean {
|
||||
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
|
||||
}
|
||||
|
||||
export function attemptRecordedAutoplay(
|
||||
seekToStart: () => void,
|
||||
startPlaying: () => void,
|
||||
): boolean {
|
||||
try {
|
||||
seekToStart();
|
||||
startPlaying();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRecordedInitialSeekGate(): {
|
||||
attempt: (
|
||||
viewerStarted: boolean,
|
||||
fullyBuffered: boolean,
|
||||
presentationReady: boolean,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
seekToStart: (startNs: number) => void,
|
||||
preferredStartNs?: number,
|
||||
) => boolean;
|
||||
attempted: () => boolean;
|
||||
} {
|
||||
let consumed = false;
|
||||
return {
|
||||
attempt(
|
||||
viewerStarted,
|
||||
fullyBuffered,
|
||||
presentationReady,
|
||||
rangeNs,
|
||||
seekToStart,
|
||||
preferredStartNs,
|
||||
) {
|
||||
if (
|
||||
consumed
|
||||
|| !viewerStarted
|
||||
|| !fullyBuffered
|
||||
|| !presentationReady
|
||||
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||
) return false;
|
||||
consumed = true;
|
||||
const startNs = Number.isFinite(preferredStartNs)
|
||||
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
|
||||
: rangeNs.min;
|
||||
try {
|
||||
seekToStart(startNs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
attempted: () => consumed,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRecordedAutoplayGate(): {
|
||||
attempt: (
|
||||
viewerStarted: boolean,
|
||||
fullyBuffered: boolean,
|
||||
presentationReady: boolean,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
seekToStart: (startNs: number) => void,
|
||||
startPlaying: () => void,
|
||||
preferredStartNs?: number,
|
||||
) => boolean;
|
||||
attempted: () => boolean;
|
||||
} {
|
||||
let consumed = false;
|
||||
return {
|
||||
attempt(
|
||||
viewerStarted,
|
||||
fullyBuffered,
|
||||
presentationReady,
|
||||
rangeNs,
|
||||
seekToStart,
|
||||
startPlaying,
|
||||
preferredStartNs,
|
||||
) {
|
||||
if (
|
||||
consumed
|
||||
|| !viewerStarted
|
||||
|| !fullyBuffered
|
||||
|| !presentationReady
|
||||
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||
) return false;
|
||||
consumed = true;
|
||||
const startNs = Number.isFinite(preferredStartNs)
|
||||
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
|
||||
: rangeNs.min;
|
||||
return attemptRecordedAutoplay(
|
||||
() => seekToStart(startNs),
|
||||
startPlaying,
|
||||
);
|
||||
},
|
||||
attempted: () => consumed,
|
||||
};
|
||||
}
|
||||
|
||||
export function canPublishRecordedPlaybackController(
|
||||
readyToRender: boolean,
|
||||
presentationGate: RecordedAdmissionPhase,
|
||||
): boolean {
|
||||
return readyToRender && presentationGate === "ready";
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface ObservationReplayAttempt {
|
||||
readonly signal: AbortSignal;
|
||||
isCurrent: () => boolean;
|
||||
finish: () => boolean;
|
||||
}
|
||||
|
||||
export interface ObservationReplayCoordinator {
|
||||
begin: () => ObservationReplayAttempt;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/** Latest explicit admission owns the viewer, even if an older promise settles later. */
|
||||
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
||||
let sequence = 0;
|
||||
let active: AbortController | null = null;
|
||||
|
||||
return {
|
||||
begin() {
|
||||
active?.abort();
|
||||
const controller = new AbortController();
|
||||
const attemptSequence = ++sequence;
|
||||
active = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => (
|
||||
!controller.signal.aborted
|
||||
&& active === controller
|
||||
&& sequence === attemptSequence
|
||||
),
|
||||
finish: () => {
|
||||
if (active !== controller || sequence !== attemptSequence) return false;
|
||||
active = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
active?.abort();
|
||||
// Keep settlement ownership until finish(). isCurrent() already fails,
|
||||
// while begin() can replace this attempt immediately.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
} 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 =
|
||||
| "recording"
|
||||
@@ -9,7 +23,7 @@ export type ObservationSessionStatus =
|
||||
| "interrupted"
|
||||
| "failed";
|
||||
|
||||
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
||||
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
|
||||
|
||||
export interface ObservationLabInstance {
|
||||
labId: string;
|
||||
@@ -20,6 +34,8 @@ export interface ObservationLabInstance {
|
||||
configSha256: string | null;
|
||||
runCreatedAtUtc: string;
|
||||
publishedAtUtc: string;
|
||||
replayCapability: ObservationLabReplayCapability | null;
|
||||
calculationProfile: ObservationLabCalculationProfile | null;
|
||||
provenance: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
@@ -32,10 +48,21 @@ export interface ObservationSessionSummary {
|
||||
modalities: readonly string[];
|
||||
durationSeconds: number;
|
||||
replayable: boolean;
|
||||
captureAttestation: ObservationCaptureAttestation | null;
|
||||
preparation: ObservationSessionCatalogPreparation | 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 {
|
||||
items: readonly ObservationSessionSummary[];
|
||||
}
|
||||
@@ -52,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
|
||||
seekable: true;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
mapGeneration?: string;
|
||||
playback: {
|
||||
speed: number;
|
||||
loop: boolean;
|
||||
@@ -152,10 +180,11 @@ const ITEM_KEYS = new Set([
|
||||
"modalities",
|
||||
"duration_seconds",
|
||||
"replayable",
|
||||
"capture_attestation",
|
||||
"preparation",
|
||||
"lab",
|
||||
]);
|
||||
const LAB_KEYS = new Set([
|
||||
const LEGACY_LAB_KEYS = new Set([
|
||||
"lab_id",
|
||||
"source_session_id",
|
||||
"result_kind",
|
||||
@@ -166,6 +195,27 @@ const LAB_KEYS = new Set([
|
||||
"published_at_utc",
|
||||
"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([
|
||||
"preparation_id",
|
||||
"state",
|
||||
@@ -188,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
|
||||
"seekable",
|
||||
"byte_length",
|
||||
"sha256",
|
||||
"map_generation",
|
||||
"playback",
|
||||
"media_sources",
|
||||
]);
|
||||
@@ -379,11 +430,103 @@ function decodeItem(value: unknown, index: number): ObservationSessionSummary {
|
||||
modalities: requireModalities(value.modalities),
|
||||
durationSeconds: value.duration_seconds,
|
||||
replayable: value.replayable,
|
||||
captureAttestation: decodeCaptureAttestation(value.capture_attestation, id),
|
||||
preparation: decodeCatalogPreparation(value.preparation, 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(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
@@ -394,7 +537,23 @@ function decodeLabInstance(
|
||||
`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);
|
||||
if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) {
|
||||
throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер.");
|
||||
@@ -430,6 +589,28 @@ function decodeLabInstance(
|
||||
if (!isRecord(value.provenance)) {
|
||||
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 {
|
||||
labId,
|
||||
sourceSessionId,
|
||||
@@ -445,6 +626,8 @@ function decodeLabInstance(
|
||||
value.published_at_utc,
|
||||
`lab(${sessionId}).published_at_utc`,
|
||||
),
|
||||
replayCapability,
|
||||
calculationProfile,
|
||||
provenance: value.provenance,
|
||||
};
|
||||
}
|
||||
@@ -606,6 +789,9 @@ export function decodeObservationSessionReplay(
|
||||
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
||||
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
|
||||
}
|
||||
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
|
||||
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
|
||||
}
|
||||
const viewerSourceUrl = requireString(
|
||||
launch.viewer_source_url,
|
||||
"launch.viewer_source_url",
|
||||
@@ -664,6 +850,7 @@ export function decodeObservationSessionReplay(
|
||||
integer: true,
|
||||
}),
|
||||
sha256: launch.sha256,
|
||||
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
|
||||
playback: { speed, loop: launch.playback.loop },
|
||||
mediaSources,
|
||||
};
|
||||
@@ -1181,6 +1368,7 @@ export async function fetchObservationSessionCatalog({
|
||||
queryParameters.set("limit", String(Number(limit)));
|
||||
}
|
||||
if (scope !== "all") queryParameters.set("scope", scope);
|
||||
if (scope === "laboratory") queryParameters.set("lab_contract", "v3");
|
||||
const serializedQuery = queryParameters.toString();
|
||||
const query = serializedQuery ? `?${serializedQuery}` : "";
|
||||
let response: Response;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
decodeObservationSessionCatalog,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
type ObservationSessionCatalog,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionScope,
|
||||
} from "./sessionArchive";
|
||||
|
||||
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
|
||||
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
|
||||
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
|
||||
readonly nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
|
||||
export async function fetchObservationSessionCatalogPage({
|
||||
scope,
|
||||
limit = 100,
|
||||
cursor = null,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
scope: ObservationSessionScope;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
}): Promise<ObservationSessionCatalogPage> {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|
||||
|| (cursor !== null && !CURSOR.test(cursor))) {
|
||||
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const query = new URLSearchParams({ limit: String(limit), scope });
|
||||
if (scope === "laboratory") query.set("lab_contract", "v3");
|
||||
query.set("pagination", "cursor-v1");
|
||||
if (cursor !== null) query.set("cursor", cursor);
|
||||
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
|
||||
method: "GET", headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ObservationSessionApiError(
|
||||
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
|
||||
}
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
|
||||
}
|
||||
const page = body as Record<string, unknown>;
|
||||
if (Object.keys(page).length !== 3
|
||||
|| page.schema_version !== PAGE_SCHEMA
|
||||
|| !Object.hasOwn(page, "items")
|
||||
|| !(page.next_cursor === null
|
||||
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
|
||||
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
|
||||
}
|
||||
const catalog = decodeObservationSessionCatalog({ items: page.items });
|
||||
if (catalog.items.length > limit) {
|
||||
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { ...catalog, nextCursor: page.next_cursor };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
|
||||
|
||||
const schema = 'missioncore.session-display-profile/v1';
|
||||
const fields = {
|
||||
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
|
||||
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
|
||||
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
|
||||
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
|
||||
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
|
||||
} as const;
|
||||
|
||||
function endpoint(sessionId: string) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
|
||||
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
|
||||
}
|
||||
|
||||
export function encodeSessionDisplay(settings: SceneSettings) {
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
|
||||
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
|
||||
}
|
||||
|
||||
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
|
||||
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
|
||||
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
|
||||
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
|
||||
}
|
||||
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
|
||||
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
|
||||
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
|
||||
}
|
||||
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|
||||
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|
||||
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|
||||
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|
||||
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
|
||||
throw new Error('Некорректные настройки отображения');
|
||||
}
|
||||
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
|
||||
}
|
||||
|
||||
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
|
||||
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
|
||||
const document = await response.json();
|
||||
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
|
||||
: decodeSessionDisplay(document, sessionId);
|
||||
}
|
||||
|
||||
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
|
||||
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
|
||||
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
|
||||
decodeSessionDisplay(await response.json(), sessionId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface SessionOverviewMetrics {
|
||||
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
|
||||
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
|
||||
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
|
||||
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
|
||||
interval_statistics_complete: boolean; gaps_over_second: number | null;
|
||||
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
|
||||
spatial_available: boolean;
|
||||
}
|
||||
export interface SessionOverview {
|
||||
schema_version: "missioncore.session-overview/v1";
|
||||
state: "queued" | "preparing" | "ready" | "error";
|
||||
session: { session_id: string; display_name: string; duration_seconds: number | null;
|
||||
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
|
||||
metrics?: SessionOverviewMetrics | null;
|
||||
scene_url?: string | null;
|
||||
message?: string;
|
||||
messages_processed?: number;
|
||||
}
|
||||
|
||||
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
|
||||
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
|
||||
signal, method: retry ? "POST" : "GET", cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Обзор записи недоступен.");
|
||||
const data = await response.json() as SessionOverview;
|
||||
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|
||||
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
|
||||
if (data.scene_url) {
|
||||
const url = new URL(data.scene_url, window.location.origin);
|
||||
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
|
||||
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
|
||||
const xmax = Math.max(1, ...valid.map(p => p[0]));
|
||||
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
|
||||
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type OverviewViewMode = "3d" | "top";
|
||||
export type OverviewRepresentation = "original" | "corrected";
|
||||
export interface OverviewComparison {
|
||||
generation: string; map_generation: string; sample_points: number; source_points: number;
|
||||
original_path_m: number; corrected_path_m: number; height_min_m: number; height_max_m: number;
|
||||
}
|
||||
export interface OverviewSpatialMetadata {
|
||||
default_representation?: OverviewRepresentation;
|
||||
height_min_m: number | null; height_max_m: number | null; sample_points: number;
|
||||
comparison?: OverviewComparison;
|
||||
}
|
||||
|
||||
function endpoint(source: string) {
|
||||
const url = new URL(source, window.location.origin);
|
||||
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
|
||||
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
|
||||
return url;
|
||||
}
|
||||
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
|
||||
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Параметры среза недоступны.");
|
||||
return response.json();
|
||||
}
|
||||
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal,
|
||||
comparisonGeneration: string | null = null, representation: OverviewRepresentation = "original") {
|
||||
if (representation === "corrected" && !comparisonGeneration) throw new Error("Исправленная версия недоступна.");
|
||||
const url = endpoint(source);
|
||||
const generation = url.searchParams.get("generation");
|
||||
url.search = "";
|
||||
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
|
||||
comparison_generation: comparisonGeneration, representation }) });
|
||||
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
|
||||
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
|
||||
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
|
||||
}
|
||||
@@ -12,6 +12,16 @@ import {
|
||||
type ObservationSessionScope,
|
||||
type ObservationSessionSummary,
|
||||
} 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 ObservationReplayOutcome = "accepted" | "error" | "cancelled";
|
||||
@@ -41,17 +51,6 @@ export interface ObservationSessionsController {
|
||||
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(
|
||||
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 {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
|
||||
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
|
||||
|
||||
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
|
||||
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const applyRef = useRef(apply); applyRef.current = apply;
|
||||
const current = useRef(sessionId); current.current = sessionId;
|
||||
const writeTail = useRef(Promise.resolve());
|
||||
const revision = useRef(0);
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
if (!sessionId) return;
|
||||
const abort = new AbortController();
|
||||
const readRevision = revision.current;
|
||||
applyRef.current({...defaultSceneSettings});
|
||||
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
|
||||
if (abort.signal.aborted || current.current !== sessionId) return;
|
||||
if (revision.current === readRevision) applyRef.current(settings);
|
||||
}).catch(reason => {
|
||||
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [sessionId]);
|
||||
const edited = useCallback(() => { revision.current += 1; }, []);
|
||||
const save = useCallback((settings: SceneSettings) => {
|
||||
const id = current.current;
|
||||
if (!id) return;
|
||||
const snapshot = {...settings};
|
||||
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
|
||||
if (current.current === id) setError(null);
|
||||
}).catch(reason => {
|
||||
if (current.current === id) setError(String(reason.message ?? reason));
|
||||
});
|
||||
}, []);
|
||||
return {save, edited, error, dismissError: () => setError(null)};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
|
||||
|
||||
export function useSessionOverview(sessionId: string) {
|
||||
const [data, setData] = useState<SessionOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
setData(null); setError(null);
|
||||
const load = async (retry = false) => {
|
||||
try {
|
||||
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
|
||||
if (abort.signal.aborted) return;
|
||||
setData(next);
|
||||
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
|
||||
} catch (reason) {
|
||||
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
|
||||
}
|
||||
};
|
||||
void load(retryGeneration > 0);
|
||||
return () => { abort.abort(); clearTimeout(timer); };
|
||||
}, [sessionId, retryGeneration]);
|
||||
return { data: data?.session.session_id === sessionId ? data : null, error,
|
||||
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
|
||||
}
|
||||
|
||||
export function useSessionOverviewMode(enabled: boolean) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
|
||||
};
|
||||
window.addEventListener("keydown", close, true);
|
||||
return () => window.removeEventListener("keydown", close, true);
|
||||
}, [open]);
|
||||
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
|
||||
|
||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
|
||||
|
||||
export interface RerunPlaybackState {
|
||||
recordingId: string;
|
||||
timeline: string;
|
||||
rangeNs: { min: number; max: number } | null;
|
||||
currentNs: number;
|
||||
playing: boolean;
|
||||
bufferedEndNs: number | null;
|
||||
expectedStartNs: number | null;
|
||||
expectedEndNs: number | null;
|
||||
bufferProgress: number | null;
|
||||
fullyBuffered: boolean;
|
||||
}
|
||||
|
||||
export interface RerunPlaybackController {
|
||||
seek: (timeNs: number) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
jumpToEnd: () => void;
|
||||
}
|
||||
|
||||
export interface RecordedPerceptionLayers {
|
||||
enabled: boolean;
|
||||
cameraImage?: boolean;
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
costmap?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordedRrdArtifactDescriptor {
|
||||
sourceUrl: string;
|
||||
viewerSourceUrl: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The native live receiver owns one acquisition lineage and never exposes
|
||||
* recorded transport or autoplay policy.
|
||||
*/
|
||||
export interface LiveAcquisitionRerunProfile {
|
||||
kind: "live-acquisition";
|
||||
clock: "stream_time";
|
||||
sourceUrl: string;
|
||||
liveActivitySequence: number | null;
|
||||
liveStreamId: string | null;
|
||||
liveRecoveryAuthorityIdentity: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sealed session is progressively presentable, while its shared playback
|
||||
* controls remain fenced by the aggregate session admission gate.
|
||||
*/
|
||||
export interface RecordedSessionRerunProfile {
|
||||
kind: "recorded-session";
|
||||
clock: "session_time";
|
||||
sourceUrl: string;
|
||||
artifact: RecordedRrdArtifactDescriptor | null;
|
||||
autoplayWhenReady: boolean;
|
||||
presentationGate: RecordedAdmissionPhase;
|
||||
expectedTimelineStartSeconds?: number;
|
||||
expectedTimelineEndSeconds?: number;
|
||||
initialPlaybackStartSeconds?: number;
|
||||
view: RecordedRerunView;
|
||||
viewResetGeneration: 0 | 1;
|
||||
followTrajectory: boolean;
|
||||
perceptionLayers: RecordedPerceptionLayers;
|
||||
/** Explicit small blueprint endpoint when the viewer source is a merged LAB RRD. */
|
||||
blueprintSourceUrl?: string;
|
||||
/** Optional immutable RRD sidecar for LAB/model evidence on the same recording clock. */
|
||||
perceptionSourceUrl?: string;
|
||||
/** Selects one semantic entity without changing the sealed sidecar. */
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||
unifiedPerception?: boolean;
|
||||
/** Controlled camera-column share for the native horizontal Rerun container. */
|
||||
unifiedCameraShare?: number;
|
||||
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||
planView?: boolean;
|
||||
perceptionRetryGeneration: number;
|
||||
lockPerceptionCameraInteraction: boolean;
|
||||
}
|
||||
|
||||
/** A LAB owns its result presentation; it does not inherit session-view settings. */
|
||||
export interface LaboratoryResultRerunProfile
|
||||
extends Omit<RecordedSessionRerunProfile, "kind"> {
|
||||
kind: "laboratory-result";
|
||||
resultId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||
* canonical LAB route or start work in the background.
|
||||
*/
|
||||
export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||
kind: "lab-recorded-evidence";
|
||||
clock: "source-sequence";
|
||||
cameraTransport: "generation-bound-fmp4";
|
||||
spatialTransport: "bounded-sealed-artifacts";
|
||||
loadPolicy: "explicit-legacy-comparison-only";
|
||||
workerRequired: false;
|
||||
}
|
||||
|
||||
export type RerunViewerProfile =
|
||||
| LiveAcquisitionRerunProfile
|
||||
| RecordedSessionRerunProfile
|
||||
| LaboratoryResultRerunProfile;
|
||||
|
||||
export type ObservationViewerProfile =
|
||||
| RerunViewerProfile
|
||||
| LaboratoryRecordedEvidenceViewerProfile;
|
||||
|
||||
export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||
kind: "lab-recorded-evidence",
|
||||
clock: "source-sequence",
|
||||
cameraTransport: "generation-bound-fmp4",
|
||||
spatialTransport: "bounded-sealed-artifacts",
|
||||
loadPolicy: "explicit-legacy-comparison-only",
|
||||
workerRequired: false,
|
||||
} satisfies LaboratoryRecordedEvidenceViewerProfile);
|
||||
|
||||
export function liveAcquisitionRerunProfile(
|
||||
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||
): LiveAcquisitionRerunProfile {
|
||||
return { ...input, kind: "live-acquisition", clock: "stream_time" };
|
||||
}
|
||||
|
||||
export function recordedSessionRerunProfile(
|
||||
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||
): RecordedSessionRerunProfile {
|
||||
return { ...input, kind: "recorded-session", clock: "session_time" };
|
||||
}
|
||||
|
||||
export function laboratoryResultRerunProfile(
|
||||
input: Omit<LaboratoryResultRerunProfile, "kind" | "clock">,
|
||||
): LaboratoryResultRerunProfile {
|
||||
if (!input.resultId.trim()) throw new Error("LAB result identity is required");
|
||||
return { ...input, kind: "laboratory-result", clock: "session_time" };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Entry intent belongs to application navigation, not a retained server job. */
|
||||
export type WorkspaceLaunchProfile = 'direct' | 'planning';
|
||||
|
||||
export function workspaceLaunchProfile(
|
||||
current: WorkspaceLaunchProfile,
|
||||
kind: string,
|
||||
requested: WorkspaceLaunchProfile = 'direct',
|
||||
): WorkspaceLaunchProfile {
|
||||
return kind === 'device' || kind === 'spatial' ? requested : current;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
decodeObservatoryRecordedJob,
|
||||
type ObservatoryRecordedJob,
|
||||
} from "./recordedJobs";
|
||||
|
||||
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
|
||||
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
|
||||
|
||||
export interface AIModule {
|
||||
readonly moduleId: string;
|
||||
readonly moduleSha256: string;
|
||||
readonly label: string;
|
||||
readonly dockerName: string;
|
||||
readonly requires: readonly string[];
|
||||
readonly provides: readonly string[];
|
||||
readonly defaults: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface AIModuleGroup {
|
||||
readonly group: AIGroupId;
|
||||
readonly modules: readonly AIModule[];
|
||||
}
|
||||
|
||||
export interface AIModuleCatalog {
|
||||
readonly groups: readonly AIModuleGroup[];
|
||||
}
|
||||
|
||||
export interface AICompositionReceipt {
|
||||
readonly compositionSha256: string;
|
||||
readonly created: boolean;
|
||||
readonly dispatchReady: boolean;
|
||||
readonly dispatchReason: string;
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly run: AICompositionRun;
|
||||
}
|
||||
|
||||
export interface AIViewerLayer {
|
||||
readonly layerId: string;
|
||||
readonly paneId: "camera" | "spatial";
|
||||
readonly label: string;
|
||||
readonly control: "toggle" | "toggle-with-settings";
|
||||
readonly order: number;
|
||||
}
|
||||
|
||||
export interface AICompositionPresentation {
|
||||
readonly configurationLabel: string;
|
||||
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
|
||||
readonly viewerLayers: readonly AIViewerLayer[];
|
||||
}
|
||||
|
||||
export interface AICompositionRun {
|
||||
readonly runId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly compositionSha256: string;
|
||||
readonly moduleIds: readonly string[];
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobIds: readonly string[];
|
||||
readonly resultIds: readonly string[];
|
||||
readonly createdAtUtc: string;
|
||||
readonly state: "running" | "ready" | "failed";
|
||||
readonly configurationLabel: string;
|
||||
readonly displayName: string | null;
|
||||
readonly presentation: AICompositionPresentation;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
}
|
||||
|
||||
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
ddrnet: "ai-segmentation-ddrnet-v1",
|
||||
eomt: "ai-segmentation-eomt-v1",
|
||||
tgs: "m49-tgs-portable-v2",
|
||||
"rf-detr": "ai-detection-rf-detr-v1",
|
||||
"object-distance": "ai-range-object-distance-v1",
|
||||
});
|
||||
|
||||
export function aiModuleIdForSetup(setupId: string): string | null {
|
||||
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
|
||||
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "каталог AI-модулей");
|
||||
exactKeys(root, ["authority", "groups", "schema_version"]);
|
||||
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
|
||||
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
|
||||
const groups = array(root.groups).map((value): AIModuleGroup => {
|
||||
const group = record(value, "группа AI-модулей");
|
||||
exactKeys(group, ["group", "modules"]);
|
||||
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
|
||||
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
|
||||
}
|
||||
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
|
||||
});
|
||||
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
|
||||
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
|
||||
}
|
||||
return { groups };
|
||||
}
|
||||
|
||||
export async function saveAIComposition(
|
||||
sourceSessionId: string,
|
||||
selections: readonly { group: AIGroupId; module: AIModule }[],
|
||||
): Promise<AICompositionReceipt> {
|
||||
const response = await fetch("/api/v1/observatory/ai-compositions", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: COMPOSITION_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
selections: selections.map(({ group, module }) => ({
|
||||
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
|
||||
parameters: module.defaults,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "конфигурация AI-слоя");
|
||||
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
|
||||
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
|
||||
throw new Error("Сервер вернул конфигурацию для другой записи.");
|
||||
}
|
||||
const digest = text(root.composition_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
|
||||
const dispatch = record(root.dispatch, "готовность композиции");
|
||||
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
|
||||
if (dispatch.ready !== true) {
|
||||
throw new Error(text(dispatch.reason));
|
||||
}
|
||||
const setupIds = array(dispatch.setup_ids).map(text);
|
||||
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
|
||||
if (!jobs.length || jobs.length !== setupIds.length
|
||||
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
|
||||
throw new Error("Сервер вернул неполную очередь композиции.");
|
||||
}
|
||||
const run = compositionRunOf(root.run);
|
||||
return {
|
||||
compositionSha256: digest,
|
||||
created: boolean(dispatch && root.created),
|
||||
dispatchReady: boolean(dispatch.ready),
|
||||
dispatchReason: text(dispatch.reason),
|
||||
setupIds,
|
||||
jobs,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAICompositionRuns(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly AICompositionRun[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "запуски композиций");
|
||||
exactKeys(root, ["items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
|
||||
throw new Error("Версия запусков AI-композиций изменилась.");
|
||||
}
|
||||
return array(root.items).map(compositionRunOf);
|
||||
}
|
||||
|
||||
export async function renameAICompositionRunProjection(
|
||||
runId: string,
|
||||
displayName: string,
|
||||
): Promise<string> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI Inference.");
|
||||
}
|
||||
const normalized = displayName.trim();
|
||||
if (!normalized || normalized.length > 160) {
|
||||
throw new Error("Название результата должно содержать от 1 до 160 символов.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
|
||||
display_name: normalized,
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const row = record(body, "результат AI Inference");
|
||||
exactKeys(row, ["display_name", "run_id", "schema_version"]);
|
||||
if (
|
||||
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|
||||
|| row.run_id !== runId
|
||||
|| row.display_name !== normalized
|
||||
) throw new Error("Сервер не подтвердил переименование результата AI Inference.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI Inference.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(await bodyOf(response), response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function compositionRunOf(value: unknown): AICompositionRun {
|
||||
const row = record(value, "запуск AI-композиции");
|
||||
const expected = [
|
||||
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
|
||||
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
|
||||
"source_session_id", "state",
|
||||
];
|
||||
// The receipt embeds the immutable run before its live state projection.
|
||||
const receiptShape = [
|
||||
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
|
||||
"run_id", "schema_version", "setup_ids", "source_session_id",
|
||||
];
|
||||
const keys = Object.keys(row);
|
||||
const projected = keys.length === expected.length;
|
||||
exactKeys(row, projected ? expected : receiptShape);
|
||||
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
|
||||
throw new Error("Версия запуска AI-композиции изменилась.");
|
||||
}
|
||||
const sourceSessionId = text(row.source_session_id);
|
||||
const compositionSha256 = text(row.composition_sha256);
|
||||
const runId = text(row.run_id);
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
|
||||
throw new Error("Некорректная идентичность запуска AI-композиции.");
|
||||
}
|
||||
const presentation = presentationOf(row.presentation);
|
||||
const state = projected ? text(row.state) : "running";
|
||||
if (!new Set(["running", "ready", "failed"]).has(state)) {
|
||||
throw new Error("Неизвестное состояние запуска AI-композиции.");
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
sourceSessionId,
|
||||
compositionSha256,
|
||||
moduleIds: array(row.module_ids).map(text),
|
||||
setupIds: array(row.setup_ids).map(text),
|
||||
jobIds: array(row.job_ids).map(text),
|
||||
resultIds: projected ? array(row.result_ids).map(text) : [],
|
||||
createdAtUtc: text(row.created_at_utc),
|
||||
state: state as AICompositionRun["state"],
|
||||
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
|
||||
displayName: projected
|
||||
? row.display_name === null ? null : text(row.display_name)
|
||||
: null,
|
||||
presentation,
|
||||
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function presentationOf(value: unknown): AICompositionPresentation {
|
||||
const row = record(value, "проекция AI-композиции");
|
||||
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
|
||||
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
|
||||
throw new Error("Версия проекции AI-композиции изменилась.");
|
||||
}
|
||||
const modules = array(row.modules).map((value) => {
|
||||
const module = record(value, "модуль проекции");
|
||||
exactKeys(module, ["label", "module_id"]);
|
||||
return { moduleId: text(module.module_id), label: text(module.label) };
|
||||
});
|
||||
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
|
||||
const layer = record(value, "слой viewer");
|
||||
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
|
||||
const paneId = text(layer.pane_id);
|
||||
const control = text(layer.control);
|
||||
if (!new Set(["camera", "spatial"]).has(paneId)
|
||||
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|
||||
|| typeof layer.order !== "number") {
|
||||
throw new Error("Некорректная проекция слоя viewer.");
|
||||
}
|
||||
return {
|
||||
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
|
||||
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
|
||||
};
|
||||
});
|
||||
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
|
||||
}
|
||||
|
||||
export async function fetchAICompositionJobs(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "очередь AI-слоёв");
|
||||
exactKeys(root, ["authority", "items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
|
||||
throw new Error("Версия очереди AI-слоёв изменилась.");
|
||||
}
|
||||
return array(root.items).map(decodeObservatoryRecordedJob);
|
||||
}
|
||||
|
||||
function moduleOf(value: unknown): AIModule {
|
||||
const row = record(value, "AI-модуль");
|
||||
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
|
||||
const digest = text(row.module_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
|
||||
return {
|
||||
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
|
||||
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
|
||||
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
|
||||
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
|
||||
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
|
||||
}
|
||||
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
|
||||
function apiError(body: unknown, status: number): Error {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
|
||||
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const entropy = typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `ai-layer:${entropy}`;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
type ObservationLabInstance,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../observation/sessionArchive";
|
||||
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
|
||||
import {
|
||||
observatoryRecordedRunBinding,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
} from "./recordedRun";
|
||||
|
||||
export interface ObservatoryEvidence {
|
||||
readonly sessionId: string;
|
||||
readonly label: string;
|
||||
readonly status: ObservationSessionSummary["status"];
|
||||
readonly publishedAtUtc: string;
|
||||
readonly lab: ObservationLabInstance;
|
||||
readonly recordedRun: ObservatoryRecordedRunBinding | null;
|
||||
}
|
||||
|
||||
export interface ObservatorySession {
|
||||
readonly source: ObservationSessionSummary;
|
||||
readonly evidence: readonly ObservatoryEvidence[];
|
||||
}
|
||||
|
||||
export interface ObservatoryCatalog {
|
||||
readonly items: readonly ObservatorySession[];
|
||||
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
|
||||
readonly window: {
|
||||
readonly limit: number;
|
||||
readonly sourceCount: number;
|
||||
readonly laboratoryCount: number;
|
||||
readonly sourceLimitReached: boolean;
|
||||
readonly laboratoryLimitReached: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ObservatoryCatalogMutation =
|
||||
| {
|
||||
readonly kind: "rename";
|
||||
readonly displayName: string;
|
||||
readonly revision: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "delete";
|
||||
readonly revision: number;
|
||||
};
|
||||
|
||||
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
|
||||
string,
|
||||
ObservatoryCatalogMutation
|
||||
>;
|
||||
|
||||
export interface ObservatoryCatalogReconciliation {
|
||||
readonly catalog: ObservatoryCatalog;
|
||||
readonly overlay: ObservatoryCatalogMutationOverlay;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryCatalogContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export function findObservatoryEvidence(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): ObservatoryEvidence | null {
|
||||
for (const item of catalog.items) {
|
||||
const evidence = item.evidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
);
|
||||
if (evidence) return evidence;
|
||||
}
|
||||
return catalog.unresolvedEvidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function observatoryCatalogConfirmsEvidenceDeletion(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return !catalog.window.laboratoryLimitReached
|
||||
&& findObservatoryEvidence(catalog, sessionId) === null;
|
||||
}
|
||||
|
||||
export function applyObservatoryCatalogMutationOverlay(
|
||||
catalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
): ObservatoryCatalog {
|
||||
if (overlay.size === 0) return catalog;
|
||||
|
||||
const removedSessionIds = new Set<string>();
|
||||
const projectEvidence = (
|
||||
evidence: ObservatoryEvidence,
|
||||
): ObservatoryEvidence | null => {
|
||||
const mutation = overlay.get(evidence.sessionId);
|
||||
if (!mutation) return evidence;
|
||||
if (mutation.kind === "delete") {
|
||||
removedSessionIds.add(evidence.sessionId);
|
||||
return null;
|
||||
}
|
||||
return evidence.label === mutation.displayName
|
||||
? evidence
|
||||
: { ...evidence, label: mutation.displayName };
|
||||
};
|
||||
const projectEvidenceList = (
|
||||
evidence: readonly ObservatoryEvidence[],
|
||||
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
|
||||
const projected = projectEvidence(candidate);
|
||||
return projected ? [projected] : [];
|
||||
});
|
||||
|
||||
const items = catalog.items.map((item) => ({
|
||||
...item,
|
||||
evidence: projectEvidenceList(item.evidence),
|
||||
}));
|
||||
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
|
||||
return {
|
||||
...catalog,
|
||||
items,
|
||||
unresolvedEvidence,
|
||||
window: {
|
||||
...catalog.window,
|
||||
laboratoryCount: Math.max(
|
||||
0,
|
||||
catalog.window.laboratoryCount - removedSessionIds.size,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
requestMutationRevision: number,
|
||||
): ObservatoryCatalogReconciliation {
|
||||
const remaining = new Map(overlay);
|
||||
for (const [sessionId, mutation] of overlay) {
|
||||
if (mutation.revision > requestMutationRevision) continue;
|
||||
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
|
||||
const confirmed = mutation.kind === "rename"
|
||||
? evidence?.label === mutation.displayName
|
||||
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
|
||||
if (confirmed) remaining.delete(sessionId);
|
||||
}
|
||||
return {
|
||||
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
|
||||
overlay: remaining,
|
||||
};
|
||||
}
|
||||
|
||||
function newestFirst(left: string, right: string): number {
|
||||
return Date.parse(right) - Date.parse(left);
|
||||
}
|
||||
|
||||
function boundedLimit(limit: number): number {
|
||||
return Number.isFinite(limit)
|
||||
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||
: 100;
|
||||
}
|
||||
|
||||
function evidenceFromSession(
|
||||
session: ObservationSessionSummary,
|
||||
): ObservatoryEvidence | null {
|
||||
if (!session.lab) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
||||
);
|
||||
}
|
||||
// Historical LABs retain their archive and replay adapters. Observatory is
|
||||
// the portable-profile product; a familiar LAB label is not admission.
|
||||
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
|
||||
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
|
||||
return {
|
||||
sessionId: session.id,
|
||||
label: session.label,
|
||||
status: session.status,
|
||||
publishedAtUtc: session.lab.publishedAtUtc,
|
||||
lab: session.lab,
|
||||
recordedRun,
|
||||
};
|
||||
}
|
||||
|
||||
function sortEvidence(
|
||||
evidence: readonly ObservatoryEvidence[],
|
||||
): ObservatoryEvidence[] {
|
||||
return [...evidence].sort((left, right) =>
|
||||
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|
||||
|| left.sessionId.localeCompare(right.sessionId));
|
||||
}
|
||||
|
||||
export function buildObservatoryCatalog(
|
||||
sourceSessions: readonly ObservationSessionSummary[],
|
||||
laboratorySessions: readonly ObservationSessionSummary[],
|
||||
limit = 100,
|
||||
): ObservatoryCatalog {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const sources = new Map<string, ObservationSessionSummary>();
|
||||
for (const source of sourceSessions) {
|
||||
if (source.lab !== null) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`Каталог источников вернул LAB-сессию ${source.id}.`,
|
||||
);
|
||||
}
|
||||
sources.set(source.id, source);
|
||||
}
|
||||
|
||||
const linked = new Map<string, ObservatoryEvidence[]>();
|
||||
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||
for (const laboratorySession of laboratorySessions) {
|
||||
const evidence = evidenceFromSession(laboratorySession);
|
||||
if (!evidence) continue;
|
||||
const sourceId = evidence.lab.sourceSessionId;
|
||||
if (!sources.has(sourceId)) {
|
||||
// A bounded traversal may stop before this source. Absence alone is
|
||||
// not proof of a broken relationship or permission to remove data.
|
||||
unresolvedEvidence.push(evidence);
|
||||
continue;
|
||||
}
|
||||
const current = linked.get(sourceId) ?? [];
|
||||
current.push(evidence);
|
||||
linked.set(sourceId, current);
|
||||
}
|
||||
|
||||
const items = [...sources.values()]
|
||||
.sort((left, right) =>
|
||||
newestFirst(left.startedAtUtc, right.startedAtUtc)
|
||||
|| left.id.localeCompare(right.id))
|
||||
.map((source) => ({
|
||||
source,
|
||||
evidence: sortEvidence(linked.get(source.id) ?? []),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
unresolvedEvidence: sortEvidence(unresolvedEvidence),
|
||||
window: {
|
||||
limit: safeLimit,
|
||||
sourceCount: sourceSessions.length,
|
||||
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
|
||||
+ unresolvedEvidence.length,
|
||||
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchObservatoryCatalog({
|
||||
signal,
|
||||
limit = 100,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
limit?: number;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
} = {}): Promise<ObservatoryCatalog> {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const request = new AbortController();
|
||||
const abort = () => request.abort(signal?.reason);
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
const [sources, laboratories] = await Promise.all([
|
||||
readCatalogPages("source", safeLimit, fetcher, request.signal),
|
||||
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
|
||||
]);
|
||||
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
|
||||
return {
|
||||
...catalog,
|
||||
window: {
|
||||
...catalog.window,
|
||||
sourceLimitReached: sources.hasMore,
|
||||
laboratoryLimitReached: laboratories.hasMore,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
request.abort();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata only, never recordings or result artifacts. The cap is an explicit
|
||||
// partial window, not silent completeness or an unbounded background crawler.
|
||||
const MAX_CATALOG_PAGES = 256;
|
||||
|
||||
async function readCatalogPages(
|
||||
scope: "source" | "laboratory",
|
||||
limit: number,
|
||||
fetcher: ObservationSessionFetch,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
|
||||
const items: ObservationSessionSummary[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const cursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
|
||||
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
|
||||
for (const item of page.items) {
|
||||
if (seenIds.has(item.id)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
|
||||
}
|
||||
seenIds.add(item.id);
|
||||
// Do not accumulate legacy provenance while walking the shared catalog.
|
||||
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
|
||||
items.push(item);
|
||||
} else if (!item.lab) {
|
||||
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
|
||||
}
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (cursor === null) return { items, hasMore: false };
|
||||
if (cursors.has(cursor)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
|
||||
}
|
||||
cursors.add(cursor);
|
||||
}
|
||||
return { items, hasMore: true };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user