Compare commits
176
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0a0cde7b0e | ||
|
|
9dec37a8a9 | ||
|
|
24b65926fe | ||
|
|
57dacc5a04 | ||
|
|
d9ec8c9cef | ||
|
|
af1e530162 | ||
|
|
e10b96b546 | ||
|
|
a2c3385062 |
Generated
+4
-5
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
@@ -15,7 +14,7 @@
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/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 +899,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,7 +4,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
@@ -18,7 +17,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>
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ControlRow,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
@@ -629,7 +628,10 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
onAddVehicle,
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
@@ -645,8 +647,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 +656,6 @@ export default function App() {
|
||||
}))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
@@ -802,10 +801,12 @@ export default function App() {
|
||||
settleRecordedReplaySwitch(outcome)}
|
||||
onDeleteBegin={releaseRecordedReplayForDelete}
|
||||
/>
|
||||
) : activeDefinition.kind === "datasets" ? (
|
||||
) : 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}
|
||||
@@ -826,6 +827,7 @@ export default function App() {
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
|
||||
@@ -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,524 @@
|
||||
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 { recordedSessionRerunProfile } 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 ? recordedSessionRerunProfile({
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
viewerSourceUrl: launch.replay.viewerSourceUrl,
|
||||
byteLength: launch.replay.byteLength,
|
||||
sha256: launch.replay.sha256,
|
||||
},
|
||||
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
|
||||
autoplayWhenReady: false,
|
||||
presentationGate: "ready",
|
||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
||||
initialPlaybackStartSeconds: 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,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,244 @@
|
||||
export type RecordedRerunCameraEye = {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly lookTarget: readonly [number, number, number];
|
||||
readonly eyeUp: readonly [number, number, number];
|
||||
};
|
||||
|
||||
type Vector3 = [number, number, number];
|
||||
type DragMode = "rotate" | "pan" | "roll";
|
||||
|
||||
const ROTATION_RADIANS_PER_POINT = 0.004;
|
||||
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
|
||||
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
|
||||
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
|
||||
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
|
||||
const DOM_WHEEL_DELTA_LINE = 1;
|
||||
const DOM_WHEEL_DELTA_PAGE = 2;
|
||||
|
||||
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],
|
||||
};
|
||||
|
||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
const length = (value: Vector3) => Math.hypot(...value);
|
||||
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
|
||||
const magnitude = length(value);
|
||||
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
|
||||
};
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
|
||||
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
|
||||
const axis = normalize(rawAxis, [0, 0, 1]);
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
return add(
|
||||
add(scale(value, cosine), scale(cross(axis, value), sine)),
|
||||
scale(axis, dot(axis, value) * (1 - cosine)),
|
||||
);
|
||||
};
|
||||
|
||||
function cloneEye(value: RecordedRerunCameraEye): {
|
||||
position: Vector3;
|
||||
lookTarget: Vector3;
|
||||
eyeUp: Vector3;
|
||||
} {
|
||||
return {
|
||||
position: vector(value.position),
|
||||
lookTarget: vector(value.lookTarget),
|
||||
eyeUp: vector(value.eyeUp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
|
||||
* renderer. Rerun writes operator navigation into its active blueprint clone;
|
||||
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
|
||||
* here lets a later layer blueprint activate with the exact operator pose.
|
||||
*/
|
||||
export function createRecordedRerunCameraJournal(
|
||||
canvas: HTMLCanvasElement,
|
||||
scope: Window & typeof globalThis,
|
||||
) {
|
||||
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
|
||||
let spatialViewportStart = 0;
|
||||
let pointerId: number | null = null;
|
||||
let dragMode: DragMode | null = null;
|
||||
let previousPointer: readonly [number, number] | null = null;
|
||||
let latestPointer: readonly [number, number] | null = null;
|
||||
let maxOrbitalRadius = 1.0e17;
|
||||
|
||||
const isSpatialPoint = (event: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
|
||||
};
|
||||
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
|
||||
const usableUp = () => {
|
||||
const fwd = forward();
|
||||
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
|
||||
? [1, 0, 0]
|
||||
: cross(fwd, [0, 0, 1]);
|
||||
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
|
||||
const candidate = normalize(eye.eyeUp, fallback);
|
||||
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
|
||||
};
|
||||
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
|
||||
|
||||
const rotate = (deltaX: number, deltaY: number) => {
|
||||
const radius = orbitRadius();
|
||||
const up = usableUp();
|
||||
let fwd = forward();
|
||||
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
|
||||
fwd = normalize(
|
||||
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
|
||||
fwd,
|
||||
);
|
||||
const right = normalize(cross(fwd, up), [1, 0, 0]);
|
||||
const maxPitch = 0.99 * Math.PI / 2;
|
||||
const nextPitch = clamp(
|
||||
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
|
||||
-maxPitch,
|
||||
maxPitch,
|
||||
);
|
||||
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
|
||||
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
|
||||
};
|
||||
|
||||
const pan = (deltaX: number, deltaY: number) => {
|
||||
const fwd = forward();
|
||||
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
|
||||
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
|
||||
const speed = 0.001 * orbitRadius();
|
||||
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
|
||||
eye.position = add(eye.position, translation);
|
||||
eye.lookTarget = add(eye.lookTarget, translation);
|
||||
};
|
||||
|
||||
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const left = rect.left + rect.width * spatialViewportStart;
|
||||
const centerX = left + (rect.right - left) / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const relativeX = event.clientX - centerX;
|
||||
const relativeY = event.clientY - centerY;
|
||||
const divisor = relativeX * relativeX + relativeY * relativeY;
|
||||
if (divisor <= Number.EPSILON) return;
|
||||
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
|
||||
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
|
||||
};
|
||||
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (!isSpatialPoint(event)) return;
|
||||
pointerId = event.pointerId;
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
|
||||
const deltaX = event.clientX - previousPointer[0];
|
||||
const deltaY = event.clientY - previousPointer[1];
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
if (dragMode === "rotate") rotate(deltaX, deltaY);
|
||||
else if (dragMode === "pan") pan(deltaX, deltaY);
|
||||
else roll(event, deltaX, deltaY);
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerMove(event);
|
||||
pointerId = null;
|
||||
previousPointer = null;
|
||||
dragMode = null;
|
||||
};
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
|
||||
? event.clientX
|
||||
: latestPointer?.[0];
|
||||
if (wheelX === undefined || rect.width <= 0) return;
|
||||
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
|
||||
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
|
||||
? RERUN_WEB_LINE_SCROLL_POINTS
|
||||
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
|
||||
? rect.height
|
||||
: 1;
|
||||
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
|
||||
// eframe negates the DOM delta before Rerun applies
|
||||
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
|
||||
// exponent products collapse to the raw accumulated DOM delta below.
|
||||
const divisor = event.ctrlKey
|
||||
? RERUN_WEB_PINCH_SCROLL_DIVISOR
|
||||
: RERUN_ORBITAL_SCROLL_DIVISOR;
|
||||
const radiusFactor = Math.exp(scrollPoints / divisor);
|
||||
const offset = subtract(eye.position, eye.lookTarget);
|
||||
const radius = length(offset);
|
||||
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
|
||||
// the scene-derived cap is allowed to stay there and is never snapped in.
|
||||
const nextRadius = clamp(
|
||||
radius * radiusFactor,
|
||||
RERUN_MIN_ORBIT_DISTANCE,
|
||||
Math.max(radius, maxOrbitalRadius),
|
||||
);
|
||||
eye.position = add(
|
||||
eye.lookTarget,
|
||||
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", pointerDown, true);
|
||||
scope.addEventListener("pointermove", pointerMove, true);
|
||||
scope.addEventListener("pointerup", pointerEnd, true);
|
||||
scope.addEventListener("pointercancel", pointerEnd, true);
|
||||
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
|
||||
|
||||
return {
|
||||
current(): RecordedRerunCameraEye {
|
||||
return {
|
||||
position: [...eye.position],
|
||||
lookTarget: [...eye.lookTarget],
|
||||
eyeUp: [...eye.eyeUp],
|
||||
};
|
||||
},
|
||||
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
|
||||
eye = cloneEye(nextEye);
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
pointerId = null;
|
||||
dragMode = null;
|
||||
previousPointer = null;
|
||||
},
|
||||
setSpatialViewportStart(nextSpatialViewportStart: number) {
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
},
|
||||
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
|
||||
if (
|
||||
Number.isFinite(nextMaxOrbitalRadius) &&
|
||||
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
|
||||
) {
|
||||
maxOrbitalRadius = nextMaxOrbitalRadius;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointerdown", pointerDown, true);
|
||||
scope.removeEventListener("pointermove", pointerMove, true);
|
||||
scope.removeEventListener("pointerup", pointerEnd, true);
|
||||
scope.removeEventListener("pointercancel", pointerEnd, true);
|
||||
scope.removeEventListener("wheel", wheel, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye;
|
||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
||||
};
|
||||
|
||||
/** 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),
|
||||
configure_camera_journal: (eye, spatialViewportStart) => command(
|
||||
"configure-camera-journal", [eye, spatialViewportStart],
|
||||
),
|
||||
get_camera_eye: () => command("get-camera-eye"),
|
||||
set_camera_viewport_start: (spatialViewportStart) => command(
|
||||
"set-camera-viewport-start", [spatialViewportStart],
|
||||
),
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
|
||||
"set-camera-max-orbital-radius", [maxOrbitalRadius],
|
||||
),
|
||||
};
|
||||
return { facade, dispose, notify };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunCameraJournal } 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"]>>();
|
||||
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
|
||||
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();
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = null;
|
||||
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 && starting.canvas) {
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = createRecordedRerunCameraJournal(
|
||||
starting.canvas,
|
||||
window as Window & typeof globalThis,
|
||||
);
|
||||
}
|
||||
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 "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); 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 };
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@ const PHYSICS_PROXY_ERROR = 0.0003;
|
||||
const PHYSICS_PROXY_MAX_TRIANGLES = 240_000;
|
||||
const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8;
|
||||
const COAST_DECELERATION_MPS2 = 0.18;
|
||||
const BRAKE_ATTITUDE_DAMPING = 6;
|
||||
const PARKING_BRAKE_HOLD_DECELERATION_MPS2 = 6;
|
||||
const PARKING_BRAKE_ENGAGE_SPEED_MPS = 0.08;
|
||||
const TYRE_FRICTION_SLIP = 8.5;
|
||||
const TYRE_STATIC_FRICTION_COEFFICIENT = 0.95;
|
||||
const TYRE_KINETIC_FRICTION_COEFFICIENT = 0.78;
|
||||
const TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND = 10;
|
||||
const GRAVITY_METERS_PER_SECOND_SQUARED = 9.81;
|
||||
const MIN_TYRE_NORMAL_FORCE_NEWTONS = 1;
|
||||
const DEFAULT_ORBIT_PITCH = 0.48;
|
||||
const CAMERA_RETURN_DELAY_SECONDS = 1.2;
|
||||
const CAMERA_RETURN_DURATION_SECONDS = 2;
|
||||
@@ -79,12 +86,20 @@ interface NativeTransform extends NativeObject {
|
||||
getRotation(): NativeQuaternion;
|
||||
}
|
||||
|
||||
interface NativeRaycastInfo extends NativeObject {
|
||||
get_m_contactNormalWS(): NativeVector3;
|
||||
get_m_contactPointWS(): NativeVector3;
|
||||
get_m_wheelAxleWS(): NativeVector3;
|
||||
}
|
||||
|
||||
interface NativeWheelInfo extends NativeObject {
|
||||
set_m_suspensionStiffness(value: number): void;
|
||||
set_m_wheelsDampingRelaxation(value: number): void;
|
||||
set_m_wheelsDampingCompression(value: number): void;
|
||||
set_m_frictionSlip(value: number): void;
|
||||
set_m_rollInfluence(value: number): void;
|
||||
get_m_wheelsSuspensionForce(): number;
|
||||
get_m_raycastInfo(): NativeRaycastInfo;
|
||||
}
|
||||
|
||||
interface NativeRaycastVehicle extends NativeObject {
|
||||
@@ -102,6 +117,7 @@ interface NativeRaycastVehicle extends NativeObject {
|
||||
setBrake(force: number, wheel: number): void;
|
||||
setSteeringValue(value: number, wheel: number): void;
|
||||
getNumWheels(): number;
|
||||
getWheelInfo(wheel: number): NativeWheelInfo;
|
||||
updateWheelTransform(wheel: number, interpolated: boolean): void;
|
||||
getWheelTransformWS(wheel: number): NativeTransform;
|
||||
getForwardVector(): NativeVector3;
|
||||
@@ -126,6 +142,11 @@ interface NativeDynamicsWorld extends NativeObject {
|
||||
removeAction(action: NativeObject): void;
|
||||
}
|
||||
|
||||
interface NativeRigidBody extends NativeObject {
|
||||
applyImpulse(impulse: NativeVector3, relativePosition: NativeVector3): void;
|
||||
setActivationState(state: number): void;
|
||||
}
|
||||
|
||||
interface PhysicsSystemAccess {
|
||||
systems: {
|
||||
rigidbody: {
|
||||
@@ -137,7 +158,7 @@ interface PhysicsSystemAccess {
|
||||
|
||||
interface NativeRigidBodyAccess {
|
||||
rigidbody?: {
|
||||
body: NativeObject | null;
|
||||
body: NativeRigidBody | null;
|
||||
linearVelocity: Vec3;
|
||||
angularVelocity: Vec3;
|
||||
teleport(position: Vec3, rotation?: Vec3 | Quat): void;
|
||||
@@ -192,6 +213,13 @@ export class SimulationUgvController {
|
||||
private readonly smoothedCamera = new Vec3();
|
||||
private readonly limitedLinearVelocity = new Vec3();
|
||||
private readonly limitedAngularVelocity = new Vec3();
|
||||
private readonly tyreContactNormal = new Vec3();
|
||||
private readonly tyreLateralDirection = new Vec3();
|
||||
private readonly tyreLongitudinalDirection = new Vec3();
|
||||
private readonly tyreContactPoint = new Vec3();
|
||||
private readonly tyreRelativePosition = new Vec3();
|
||||
private readonly tyreAngularContactVelocity = new Vec3();
|
||||
private readonly tyreContactVelocity = new Vec3();
|
||||
private readonly spawnPosition = UGV_SPAWN_POSITION.clone();
|
||||
private orbitPointerId: number | null = null;
|
||||
private orbitPointerX = 0;
|
||||
@@ -204,8 +232,9 @@ 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;
|
||||
private dynamicsWorld: NativeDynamicsWorld | null = null;
|
||||
private chassisMaterial: StandardMaterial | null = null;
|
||||
private wheelMaterial: StandardMaterial | null = null;
|
||||
@@ -308,9 +337,27 @@ export class SimulationUgvController {
|
||||
const braking = this.pressed.has("Space");
|
||||
const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody;
|
||||
const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6;
|
||||
const nativeForward = this.vehicle.getForwardVector();
|
||||
const nativeForwardLength = Math.hypot(
|
||||
nativeForward.x(),
|
||||
nativeForward.y(),
|
||||
nativeForward.z(),
|
||||
);
|
||||
const longitudinalSpeedMetersPerSecond = rigidbody && nativeForwardLength > 0.001
|
||||
? Math.abs(
|
||||
(
|
||||
rigidbody.linearVelocity.x * nativeForward.x()
|
||||
+ rigidbody.linearVelocity.y * nativeForward.y()
|
||||
+ rigidbody.linearVelocity.z * nativeForward.z()
|
||||
) / nativeForwardLength,
|
||||
)
|
||||
: Math.abs(speedMetersPerSecond);
|
||||
const maxSpeed = this.settings.maxSpeedMetersPerSecond;
|
||||
const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180;
|
||||
const pureTurn = !braking && forwardInput === 0 && turnInput !== 0;
|
||||
const holding = !braking && forwardInput === 0 && turnInput === 0;
|
||||
const parkingBrakeEngaged = holding
|
||||
&& longitudinalSpeedMetersPerSecond <= PARKING_BRAKE_ENGAGE_SPEED_MPS;
|
||||
const desiredSpeed = forwardInput * maxSpeed;
|
||||
const speedError = desiredSpeed - speedMetersPerSecond;
|
||||
const speedResponseRange = Math.max(0.35, maxSpeed * 0.2);
|
||||
@@ -325,13 +372,27 @@ export class SimulationUgvController {
|
||||
const leftCommand = clamp(forwardCommand - turnCommand, -1, 1);
|
||||
const rightCommand = clamp(forwardCommand + turnCommand, -1, 1);
|
||||
const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel;
|
||||
const brakeDeceleration = braking
|
||||
? SERVICE_BRAKE_DECELERATION_MPS2
|
||||
: holding
|
||||
? parkingBrakeEngaged
|
||||
? PARKING_BRAKE_HOLD_DECELERATION_MPS2
|
||||
: COAST_DECELERATION_MPS2
|
||||
: 0;
|
||||
const wheelBrakeForce = this.settings.massKg * brakeDeceleration
|
||||
/ Math.max(1, this.wheelDefinitions.length);
|
||||
|
||||
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
||||
const definition = this.wheelDefinitions[index];
|
||||
const command = definition.left ? leftCommand : rightCommand;
|
||||
// Parking contact is solved below with one 2D Coulomb limit; disable the
|
||||
// raycast vehicle's parallel friction impulse so grip is not counted twice.
|
||||
this.vehicle.getWheelInfo(index).set_m_frictionSlip(
|
||||
parkingBrakeEngaged ? 0 : TYRE_FRICTION_SLIP,
|
||||
);
|
||||
this.vehicle.setSteeringValue(0, index);
|
||||
this.vehicle.applyEngineForce(command * engineForce, index);
|
||||
this.vehicle.setBrake(0, index);
|
||||
this.vehicle.setBrake(wheelBrakeForce, index);
|
||||
this.vehicle.updateWheelTransform(index, true);
|
||||
const transform = this.vehicle.getWheelTransformWS(index);
|
||||
const position = transform.getOrigin();
|
||||
@@ -340,22 +401,21 @@ export class SimulationUgvController {
|
||||
definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w());
|
||||
}
|
||||
|
||||
const body = rigidbody?.body ?? null;
|
||||
if (rigidbody && body) {
|
||||
this.applyParkingTyreContact(
|
||||
deltaSeconds,
|
||||
rigidbody,
|
||||
body,
|
||||
parkingBrakeEngaged,
|
||||
);
|
||||
}
|
||||
|
||||
if (rigidbody) {
|
||||
const linearVelocity = rigidbody.linearVelocity;
|
||||
let nextLinearX = linearVelocity.x;
|
||||
let nextLinearZ = linearVelocity.z;
|
||||
let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ);
|
||||
const coasting = !braking && forwardInput === 0 && turnInput === 0;
|
||||
if ((braking || coasting) && horizontalSpeed > 0.001) {
|
||||
const deceleration = braking
|
||||
? SERVICE_BRAKE_DECELERATION_MPS2
|
||||
: COAST_DECELERATION_MPS2;
|
||||
const nextSpeed = Math.max(0, horizontalSpeed - deceleration * Math.max(0, deltaSeconds));
|
||||
const scale = nextSpeed / horizontalSpeed;
|
||||
nextLinearX *= scale;
|
||||
nextLinearZ *= scale;
|
||||
horizontalSpeed = nextSpeed;
|
||||
}
|
||||
if (pureTurn && horizontalSpeed > 0.001) {
|
||||
const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8);
|
||||
nextLinearX *= pivotDamping;
|
||||
@@ -372,9 +432,6 @@ export class SimulationUgvController {
|
||||
rigidbody.linearVelocity = this.limitedLinearVelocity;
|
||||
}
|
||||
const angularVelocity = rigidbody.angularVelocity;
|
||||
const attitudeDamping = braking
|
||||
? Math.exp(-Math.max(0, deltaSeconds) * BRAKE_ATTITUDE_DAMPING)
|
||||
: 1;
|
||||
let nextAngularY = angularVelocity.y;
|
||||
if (pureTurn) {
|
||||
const desiredYawRate = -turnInput * maxTurnRate;
|
||||
@@ -387,24 +444,137 @@ export class SimulationUgvController {
|
||||
} else if (Math.abs(angularVelocity.y) > maxTurnRate) {
|
||||
nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate;
|
||||
}
|
||||
if (attitudeDamping !== 1 || nextAngularY !== angularVelocity.y) {
|
||||
if (nextAngularY !== angularVelocity.y) {
|
||||
this.limitedAngularVelocity.set(
|
||||
angularVelocity.x * attitudeDamping,
|
||||
angularVelocity.x,
|
||||
nextAngularY,
|
||||
angularVelocity.z * attitudeDamping,
|
||||
angularVelocity.z,
|
||||
);
|
||||
rigidbody.angularVelocity = this.limitedAngularVelocity;
|
||||
}
|
||||
}
|
||||
|
||||
const body = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody?.body as {
|
||||
setActivationState?: (state: number) => void;
|
||||
} | null;
|
||||
body?.setActivationState?.(DISABLE_DEACTIVATION);
|
||||
body?.setActivationState(DISABLE_DEACTIVATION);
|
||||
if (this.vehicleEntity.getPosition().y < -8) this.reset();
|
||||
this.updateCamera(deltaSeconds);
|
||||
}
|
||||
|
||||
private applyParkingTyreContact(
|
||||
deltaSeconds: number,
|
||||
rigidbody: NonNullable<NativeRigidBodyAccess["rigidbody"]>,
|
||||
body: NativeRigidBody,
|
||||
parkingBrakeEngaged: boolean,
|
||||
): void {
|
||||
if (!this.vehicle || !this.vehicleEntity || !this.tyreImpulseNative
|
||||
|| !this.tyreRelativePositionNative) return;
|
||||
|
||||
if (!parkingBrakeEngaged) return;
|
||||
|
||||
const timeStep = Math.min(Math.max(0, deltaSeconds), 1 / 30);
|
||||
if (timeStep === 0) return;
|
||||
const wheelEffectiveMass = this.settings.massKg
|
||||
/ Math.max(1, this.wheelDefinitions.length);
|
||||
const chassisPosition = this.vehicleEntity.getPosition();
|
||||
|
||||
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
||||
const wheel = this.vehicle.getWheelInfo(index);
|
||||
const raycast = wheel.get_m_raycastInfo();
|
||||
const normalForce = wheel.get_m_wheelsSuspensionForce();
|
||||
if (!Number.isFinite(normalForce) || normalForce < MIN_TYRE_NORMAL_FORCE_NEWTONS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nativeNormal = raycast.get_m_contactNormalWS();
|
||||
this.tyreContactNormal.set(nativeNormal.x(), nativeNormal.y(), nativeNormal.z());
|
||||
if (this.tyreContactNormal.lengthSq() < 0.001) continue;
|
||||
this.tyreContactNormal.normalize();
|
||||
|
||||
const nativeAxle = raycast.get_m_wheelAxleWS();
|
||||
this.tyreLateralDirection.set(nativeAxle.x(), nativeAxle.y(), nativeAxle.z());
|
||||
this.tyreLateralDirection.addScaled(
|
||||
this.tyreContactNormal,
|
||||
-this.tyreLateralDirection.dot(this.tyreContactNormal),
|
||||
);
|
||||
if (this.tyreLateralDirection.lengthSq() < 0.001) continue;
|
||||
this.tyreLateralDirection.normalize();
|
||||
this.tyreLongitudinalDirection.cross(
|
||||
this.tyreContactNormal,
|
||||
this.tyreLateralDirection,
|
||||
).normalize();
|
||||
|
||||
const nativeContactPoint = raycast.get_m_contactPointWS();
|
||||
this.tyreContactPoint.set(
|
||||
nativeContactPoint.x(),
|
||||
nativeContactPoint.y(),
|
||||
nativeContactPoint.z(),
|
||||
);
|
||||
this.tyreRelativePosition.sub2(this.tyreContactPoint, chassisPosition);
|
||||
this.tyreAngularContactVelocity.cross(
|
||||
rigidbody.angularVelocity,
|
||||
this.tyreRelativePosition,
|
||||
);
|
||||
this.tyreContactVelocity.add2(
|
||||
rigidbody.linearVelocity,
|
||||
this.tyreAngularContactVelocity,
|
||||
);
|
||||
|
||||
const lateralSlipSpeed = this.tyreContactVelocity.dot(this.tyreLateralDirection);
|
||||
const longitudinalSlipSpeed = this.tyreContactVelocity.dot(
|
||||
this.tyreLongitudinalDirection,
|
||||
);
|
||||
// Static tyre friction is a contact constraint: it balances the component
|
||||
// of gravity along the surface and damps slip at the contact patch. The
|
||||
// force still passes through a Coulomb circle and is applied at the wheel,
|
||||
// so the chassis remains a fully dynamic rigid body.
|
||||
const lateralGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
|
||||
* this.tyreLateralDirection.y;
|
||||
const longitudinalGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
|
||||
* this.tyreLongitudinalDirection.y;
|
||||
const trialLateralForce = -wheelEffectiveMass * (
|
||||
lateralGravityAcceleration
|
||||
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * lateralSlipSpeed
|
||||
);
|
||||
const trialLongitudinalForce = -wheelEffectiveMass * (
|
||||
longitudinalGravityAcceleration
|
||||
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * longitudinalSlipSpeed
|
||||
);
|
||||
const staticFrictionLimit = TYRE_STATIC_FRICTION_COEFFICIENT * normalForce;
|
||||
let lateralForce = trialLateralForce;
|
||||
let longitudinalForce = trialLongitudinalForce;
|
||||
|
||||
if (Math.hypot(trialLateralForce, trialLongitudinalForce) > staticFrictionLimit) {
|
||||
const slipSpeed = Math.hypot(lateralSlipSpeed, longitudinalSlipSpeed);
|
||||
const kineticFrictionLimit = TYRE_KINETIC_FRICTION_COEFFICIENT * normalForce;
|
||||
if (slipSpeed > 0.0001) {
|
||||
lateralForce = -(lateralSlipSpeed / slipSpeed) * kineticFrictionLimit;
|
||||
longitudinalForce = -(longitudinalSlipSpeed / slipSpeed) * kineticFrictionLimit;
|
||||
} else {
|
||||
const forceScale = staticFrictionLimit
|
||||
/ Math.hypot(trialLateralForce, trialLongitudinalForce);
|
||||
lateralForce = trialLateralForce * forceScale;
|
||||
longitudinalForce = trialLongitudinalForce * forceScale;
|
||||
}
|
||||
}
|
||||
|
||||
const lateralImpulse = lateralForce * timeStep;
|
||||
const longitudinalImpulse = longitudinalForce * timeStep;
|
||||
this.tyreImpulseNative.setValue(
|
||||
this.tyreLateralDirection.x * lateralImpulse
|
||||
+ this.tyreLongitudinalDirection.x * longitudinalImpulse,
|
||||
this.tyreLateralDirection.y * lateralImpulse
|
||||
+ this.tyreLongitudinalDirection.y * longitudinalImpulse,
|
||||
this.tyreLateralDirection.z * lateralImpulse
|
||||
+ this.tyreLongitudinalDirection.z * longitudinalImpulse,
|
||||
);
|
||||
this.tyreRelativePositionNative.setValue(
|
||||
this.tyreRelativePosition.x,
|
||||
this.tyreRelativePosition.y,
|
||||
this.tyreRelativePosition.z,
|
||||
);
|
||||
body.applyImpulse(this.tyreImpulseNative, this.tyreRelativePositionNative);
|
||||
}
|
||||
}
|
||||
|
||||
private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> {
|
||||
const models = collisionWorld.findComponents("model") as ModelComponent[];
|
||||
if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV.");
|
||||
@@ -428,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}`);
|
||||
@@ -464,8 +639,9 @@ export class SimulationUgvController {
|
||||
type: "dynamic",
|
||||
mass: this.settings.massKg,
|
||||
friction: 0.85,
|
||||
linearDamping: 0.08,
|
||||
angularDamping: 0.45,
|
||||
rollingFriction: 0.12,
|
||||
linearDamping: 0.12,
|
||||
angularDamping: 0.6,
|
||||
});
|
||||
|
||||
this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05));
|
||||
@@ -525,7 +701,10 @@ export class SimulationUgvController {
|
||||
applyMaterial(wheelMesh, this.wheelMaterial);
|
||||
anchor.addChild(wheelMesh);
|
||||
vehicle.addChild(anchor);
|
||||
this.wheelDefinitions.push({ ...definition, anchor });
|
||||
this.wheelDefinitions.push({
|
||||
...definition,
|
||||
anchor,
|
||||
});
|
||||
}
|
||||
|
||||
vehicle.setLocalPosition(this.spawnPosition);
|
||||
@@ -559,16 +738,17 @@ export class SimulationUgvController {
|
||||
wheel.set_m_suspensionStiffness(24);
|
||||
wheel.set_m_wheelsDampingRelaxation(3.2);
|
||||
wheel.set_m_wheelsDampingCompression(4.8);
|
||||
wheel.set_m_frictionSlip(5.5);
|
||||
wheel.set_m_frictionSlip(TYRE_FRICTION_SLIP);
|
||||
wheel.set_m_rollInfluence(0.08);
|
||||
}
|
||||
this.ammo.destroy(axle);
|
||||
this.ammo.destroy(direction);
|
||||
this.ammo.destroy(connection);
|
||||
this.tyreImpulseNative = new this.ammo.btVector3(0, 0, 0);
|
||||
this.tyreRelativePositionNative = new this.ammo.btVector3(0, 0, 0);
|
||||
|
||||
dynamicsWorld.addAction(nativeVehicle);
|
||||
this.vehicleEntity = vehicle;
|
||||
this.vehicleTuning = tuning;
|
||||
this.vehicleRaycaster = raycaster;
|
||||
this.vehicle = nativeVehicle;
|
||||
this.dynamicsWorld = dynamicsWorld;
|
||||
@@ -684,10 +864,12 @@ export class SimulationUgvController {
|
||||
}
|
||||
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
|
||||
if (this.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;
|
||||
|
||||
if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy());
|
||||
@@ -824,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;
|
||||
@@ -858,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,
|
||||
@@ -868,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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
onAddVehicle?: () => void;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
@@ -43,6 +44,7 @@ export function deviceRuntimeUtilityAction({
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
@@ -52,6 +54,7 @@ export function useApplicationPanelActions({
|
||||
}: 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,
|
||||
@@ -71,6 +74,7 @@ export function useApplicationPanelActions({
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -48,9 +48,13 @@ import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
||||
import { fetchM48TRiskQualityResult } from "./m48tRiskQuality";
|
||||
import { fetchM49TgsFailClosedResult } from "./m49TgsFailClosed";
|
||||
import { fetchM49TgsFullShadowResult } from "./m49TgsFullShadow";
|
||||
import { fetchVegetationShadowResult } from "./vegetationShadow";
|
||||
import {
|
||||
fetchVegetationBenchmarkResult,
|
||||
fetchVegetationShadowResult,
|
||||
} from "./vegetationShadow";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "lab-v1-vegetation-benchmark"
|
||||
| "lab-v1-vegetation-shadow"
|
||||
| "m48-object-centric-quality"
|
||||
| "m48-small-static-passage-regression"
|
||||
@@ -102,6 +106,7 @@ export interface AdvancedLaboratoryIndexItem {
|
||||
}
|
||||
|
||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"lab-v1-vegetation-benchmark",
|
||||
"lab-v1-vegetation-shadow",
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
@@ -148,6 +153,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
];
|
||||
|
||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"lab-v1-vegetation-benchmark": "lab-v1-vegetation-benchmark",
|
||||
"lab-v1-vegetation-shadow": "lab-v1-vegetation-shadow",
|
||||
"m48-object-centric-quality": "m48-object-quality-(?:pack|result)",
|
||||
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
|
||||
@@ -201,6 +207,7 @@ export function isAdvancedLaboratoryWorkId(
|
||||
|
||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
return {
|
||||
vegetationBenchmark: null,
|
||||
vegetationShadow: null,
|
||||
m47Graph: null,
|
||||
m48: null,
|
||||
@@ -335,7 +342,8 @@ export function advancedLaboratoryResultAvailable(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
): boolean {
|
||||
return workId === "lab-v1-vegetation-shadow" ? results.vegetationShadow !== null
|
||||
return workId === "lab-v1-vegetation-benchmark" ? results.vegetationBenchmark !== null
|
||||
: workId === "lab-v1-vegetation-shadow" ? results.vegetationShadow !== null
|
||||
: workId === "m48-object-centric-quality" ? results.m48 !== null
|
||||
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
|
||||
: workId === "m48-static-occupancy-qualification" ? results.m48StaticOccupancy !== null
|
||||
@@ -393,7 +401,10 @@ export async function fetchAdvancedLaboratoryResult(
|
||||
} = {},
|
||||
): Promise<AdvancedLaboratoryResults> {
|
||||
const results = emptyAdvancedLaboratoryResults();
|
||||
if (workId === "lab-v1-vegetation-shadow") {
|
||||
if (workId === "lab-v1-vegetation-benchmark") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("Vegetation benchmark identity не выбрана.");
|
||||
results.vegetationBenchmark = await fetchVegetationBenchmarkResult(resultId, { fetcher, signal });
|
||||
} else if (workId === "lab-v1-vegetation-shadow") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("Vegetation LAB identity не выбрана.");
|
||||
results.vegetationShadow = await fetchVegetationShadowResult(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48-object-centric-quality") {
|
||||
|
||||
@@ -45,6 +45,7 @@ import type { M49TgsFullShadowResult } from "./m49TgsFullShadow";
|
||||
import type { VegetationShadowResult } from "./vegetationShadow";
|
||||
|
||||
export interface AdvancedLaboratoryResults {
|
||||
vegetationBenchmark: VegetationShadowResult | null;
|
||||
vegetationShadow: VegetationShadowResult | null;
|
||||
m47Graph: M47ReferenceGraphLabResult | null;
|
||||
m48: M48AdvancedResult | null;
|
||||
|
||||
@@ -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;
|
||||
@@ -967,6 +965,7 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
const e39 = settledCatalogValue(settled[7]);
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
vegetationBenchmark: null,
|
||||
vegetationShadow: null,
|
||||
m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null,
|
||||
m48r3StaticOccupancy: 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",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LaboratoryFetch } from "./advancedResults";
|
||||
|
||||
const RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||
const BENCHMARK_RESULT_ID = /^lab-v1-vegetation-benchmark-[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const CANDIDATES = ["ddrnet", "ppliteseg"] as const;
|
||||
const ROUTE_MODES = ["source", "ddrnet", "ppliteseg", "urban", "rural", "offroad"] as const;
|
||||
@@ -75,6 +76,84 @@ export interface VegetationRouteVideo {
|
||||
aggregatePredictionPixels: readonly number[];
|
||||
policyPresets: Readonly<Record<string, Readonly<Record<string, string>>>> | null;
|
||||
fusionMode: "synchronised-multilayer-review" | null;
|
||||
validFovMaskSha256: string | null;
|
||||
}
|
||||
|
||||
export interface VegetationMixedRouteCase {
|
||||
caseId: string;
|
||||
phase: "rural" | "transition" | "urban";
|
||||
sourceSequence: number;
|
||||
sessionSeconds: number;
|
||||
assets: Readonly<Record<"source" | "city" | "vegetation" | "tgs", string>>;
|
||||
tgs: {
|
||||
groundCells: number;
|
||||
occupiedCells: number;
|
||||
rejectedCells: number;
|
||||
unobservedCells: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VegetationMixedRouteReview {
|
||||
sourceId: "RAVNOVES004TREE";
|
||||
sessionId: string;
|
||||
packId: string;
|
||||
frameCount: 10;
|
||||
models: {
|
||||
city: { name: string; inferenceFps: number; endToEndP95Ms: number };
|
||||
vegetation: { name: string; latencyP95Ms: number };
|
||||
tgs: { name: string; latencyP95Ms: number; cellSizeM: number; radiusM: number };
|
||||
};
|
||||
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 {
|
||||
@@ -86,6 +165,8 @@ export interface VegetationShadowResult {
|
||||
routeCases: readonly VegetationVisualCase[];
|
||||
validationCases: readonly VegetationVisualCase[];
|
||||
routeVideo: VegetationRouteVideo | null;
|
||||
routeReview: VegetationMixedRouteReview | null;
|
||||
routeFullReview: VegetationFullRouteReview | null;
|
||||
limitations: readonly string[];
|
||||
visualShadowReady: true;
|
||||
missionPolicyReadyForConfiguration: true;
|
||||
@@ -192,6 +273,7 @@ function visualCaseValue(
|
||||
value: unknown,
|
||||
resultId: string,
|
||||
expectedKind: "goose" | "ravnoves",
|
||||
endpointRoot: string,
|
||||
): VegetationVisualCase {
|
||||
const row = objectValue(value, `vegetation.${expectedKind}.case`);
|
||||
exact(row.source_kind, expectedKind, "vegetation.case.source_kind");
|
||||
@@ -210,7 +292,7 @@ function visualCaseValue(
|
||||
if (!SHA256.test(sha256) || !path.startsWith(`visual/${expectedKind}/${caseId}/`)) {
|
||||
throw new VegetationShadowContractError(`vegetation.case.assets.${key}: proof invalid.`);
|
||||
}
|
||||
projected[key] = `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/assets/${path
|
||||
projected[key] = `${endpointRoot}/${encodeURIComponent(resultId)}/assets/${path
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/")}`;
|
||||
@@ -342,10 +424,16 @@ function routeVideoValue(value: unknown): VegetationRouteVideo | null {
|
||||
evidenceState,
|
||||
};
|
||||
});
|
||||
const expectedClassCount = viewKind === "coarse-material-policy-review" ? 9 : 64;
|
||||
const expectedClassCount = viewKind === "coarse-material-policy-review" ? 10 : 64;
|
||||
if (classes.length !== expectedClassCount) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: taxonomy size changed.");
|
||||
}
|
||||
if (
|
||||
viewKind === "coarse-material-policy-review"
|
||||
&& (classes[9]?.disposition !== "undefined" || classes[9]?.evidenceState !== "UNOBSERVED")
|
||||
) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: valid-FOV class changed.");
|
||||
}
|
||||
const aggregatePredictionPixels = arrayValue(
|
||||
row.aggregate_prediction_pixels,
|
||||
"vegetation.route_video.aggregate_prediction_pixels",
|
||||
@@ -364,7 +452,18 @@ function routeVideoValue(value: unknown): VegetationRouteVideo | null {
|
||||
integerValue(maskArchive.byte_length, "vegetation.route_video.mask_archive.byte_length");
|
||||
let policyPresets: VegetationRouteVideo["policyPresets"] = null;
|
||||
let fusionMode: VegetationRouteVideo["fusionMode"] = null;
|
||||
let validFovMaskSha256: string | null = null;
|
||||
if (viewKind === "coarse-material-policy-review") {
|
||||
const validFov = objectValue(row.valid_fov, "vegetation.route_video.valid_fov");
|
||||
exact(validFov.mask_path, "video/valid-fov-mask.png", "vegetation.route_video.valid_fov.path");
|
||||
validFovMaskSha256 = textValue(
|
||||
validFov.mask_sha256,
|
||||
"vegetation.route_video.valid_fov.sha256",
|
||||
);
|
||||
if (!SHA256.test(validFovMaskSha256)) {
|
||||
throw new VegetationShadowContractError("vegetation.route_video: valid-FOV digest invalid.");
|
||||
}
|
||||
exact(validFov.outside_valid_fov_class_id, 9, "vegetation.route_video.valid_fov.class_id");
|
||||
const policy = objectValue(row.policy, "vegetation.route_video.policy");
|
||||
const presets = objectValue(policy.presets, "vegetation.route_video.policy.presets");
|
||||
policyPresets = Object.fromEntries(Object.entries(presets).map(([presetId, rawRules]) => {
|
||||
@@ -399,10 +498,368 @@ function routeVideoValue(value: unknown): VegetationRouteVideo | null {
|
||||
aggregatePredictionPixels,
|
||||
policyPresets,
|
||||
fusionMode,
|
||||
validFovMaskSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown, resultId: string): VegetationShadowResult {
|
||||
function mixedRouteReviewValue(
|
||||
value: unknown,
|
||||
resultId: string,
|
||||
endpointRoot: string,
|
||||
): VegetationMixedRouteReview | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const row = objectValue(value, "vegetation.route_review");
|
||||
exact(row.source_id, "RAVNOVES004TREE", "vegetation.route_review.source_id");
|
||||
exact(row.frame_count, 10, "vegetation.route_review.frame_count");
|
||||
exact(row.ground_truth, false, "vegetation.route_review.ground_truth");
|
||||
exact(
|
||||
row.selection_policy,
|
||||
"same-scene-camera-lidar-aligned-review-islands/v1",
|
||||
"vegetation.route_review.selection_policy",
|
||||
);
|
||||
const packId = textValue(row.pack_id, "vegetation.route_review.pack_id");
|
||||
if (!/^mixed-route-review-pack-[a-f0-9]{64}$/.test(packId)) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.pack_id: identity invalid.");
|
||||
}
|
||||
const models = objectValue(row.models, "vegetation.route_review.models");
|
||||
const city = objectValue(models.city, "vegetation.route_review.models.city");
|
||||
const vegetation = objectValue(models.vegetation, "vegetation.route_review.models.vegetation");
|
||||
const tgsModel = objectValue(models.tgs, "vegetation.route_review.models.tgs");
|
||||
exact(city.frames, 10, "vegetation.route_review.models.city.frames");
|
||||
exact(vegetation.frames, 10, "vegetation.route_review.models.vegetation.frames");
|
||||
exact(tgsModel.frames, 10, "vegetation.route_review.models.tgs.frames");
|
||||
const cases = arrayValue(row.cases, "vegetation.route_review.cases").map((raw, index) => {
|
||||
const item = objectValue(raw, `vegetation.route_review.cases[${index}]`);
|
||||
const caseId = textValue(item.case_id, `vegetation.route_review.cases[${index}].case_id`);
|
||||
if (caseId !== `route-${String(index + 1).padStart(2, "0")}`) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.case order changed.");
|
||||
}
|
||||
const phaseValue = item.phase;
|
||||
if (phaseValue !== "rural" && phaseValue !== "transition" && phaseValue !== "urban") {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.phase changed.");
|
||||
}
|
||||
const phase: VegetationMixedRouteCase["phase"] = phaseValue;
|
||||
const assets = objectValue(item.assets, `vegetation.route_review.cases[${index}].assets`);
|
||||
const projected = Object.fromEntries(["source", "city", "vegetation", "tgs"].map((key) => {
|
||||
const descriptor = objectValue(assets[key], `vegetation.route_review.assets.${key}`);
|
||||
const path = textValue(descriptor.path, `vegetation.route_review.assets.${key}.path`);
|
||||
const digest = textValue(descriptor.sha256, `vegetation.route_review.assets.${key}.sha256`);
|
||||
if (!SHA256.test(digest) || !path.startsWith(`route-review/${caseId}/`)) {
|
||||
throw new VegetationShadowContractError(`vegetation.route_review.assets.${key}: proof invalid.`);
|
||||
}
|
||||
return [key, `${endpointRoot}/${encodeURIComponent(resultId)}/assets/${path
|
||||
.split("/").map(encodeURIComponent).join("/")}`];
|
||||
})) as Record<"source" | "city" | "vegetation" | "tgs", string>;
|
||||
const tgs = objectValue(item.tgs, `vegetation.route_review.cases[${index}].tgs`);
|
||||
const groundCells = integerValue(tgs.ground_cells, "vegetation.route_review.tgs.ground");
|
||||
const occupiedCells = integerValue(tgs.occupied_cells, "vegetation.route_review.tgs.occupied");
|
||||
const rejectedCells = integerValue(tgs.rejected_cells, "vegetation.route_review.tgs.rejected");
|
||||
const unobservedCells = integerValue(tgs.unobserved_cells, "vegetation.route_review.tgs.unobserved");
|
||||
if (groundCells + occupiedCells + rejectedCells + unobservedCells !== 2244) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.tgs cell accounting changed.");
|
||||
}
|
||||
return {
|
||||
caseId,
|
||||
phase,
|
||||
sourceSequence: integerValue(item.source_sequence, "vegetation.route_review.source_sequence"),
|
||||
sessionSeconds: numberValue(item.session_seconds, "vegetation.route_review.session_seconds"),
|
||||
assets: projected,
|
||||
tgs: { groundCells, occupiedCells, rejectedCells, unobservedCells },
|
||||
};
|
||||
});
|
||||
if (cases.length !== 10) {
|
||||
throw new VegetationShadowContractError("vegetation.route_review.cases: expected 10 aligned islands.");
|
||||
}
|
||||
return {
|
||||
sourceId: "RAVNOVES004TREE",
|
||||
sessionId: textValue(row.session_id, "vegetation.route_review.session_id"),
|
||||
packId,
|
||||
frameCount: 10,
|
||||
models: {
|
||||
city: {
|
||||
name: textValue(city.name, "vegetation.route_review.models.city.name"),
|
||||
inferenceFps: numberValue(city.inference_fps, "vegetation.route_review.models.city.fps"),
|
||||
endToEndP95Ms: numberValue(city.end_to_end_p95_ms, "vegetation.route_review.models.city.p95"),
|
||||
},
|
||||
vegetation: {
|
||||
name: textValue(vegetation.name, "vegetation.route_review.models.vegetation.name"),
|
||||
latencyP95Ms: numberValue(vegetation.latency_p95_ms, "vegetation.route_review.models.vegetation.p95"),
|
||||
},
|
||||
tgs: {
|
||||
name: textValue(tgsModel.name, "vegetation.route_review.models.tgs.name"),
|
||||
latencyP95Ms: numberValue(tgsModel.latency_p95_ms, "vegetation.route_review.models.tgs.p95"),
|
||||
cellSizeM: numberValue(tgsModel.cell_size_m, "vegetation.route_review.models.tgs.cell"),
|
||||
radiusM: numberValue(tgsModel.radius_m, "vegetation.route_review.models.tgs.radius"),
|
||||
},
|
||||
},
|
||||
cases,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
endpointRoot: string,
|
||||
): VegetationShadowResult {
|
||||
const payload = objectValue(value, "Vegetation LAB");
|
||||
exact(payload.schema_version, "missioncore.lab-v1-vegetation-shadow/v1", "vegetation.schema");
|
||||
exact(payload.result_id, resultId, "vegetation.result_id");
|
||||
@@ -438,10 +895,15 @@ function parseResult(value: unknown, resultId: string): VegetationShadowResult {
|
||||
"vegetation.authority.camera_semantics_can_clear_rigid_geometry",
|
||||
);
|
||||
const routeCases = arrayValue(catalogs.ravnoves, "vegetation.catalogs.ravnoves")
|
||||
.map((item) => visualCaseValue(item, resultId, "ravnoves"));
|
||||
.map((item) => visualCaseValue(item, resultId, "ravnoves", endpointRoot));
|
||||
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
||||
.map((item) => visualCaseValue(item, resultId, "goose"));
|
||||
if (routeCases.length !== 0 || validationCases.length !== 12) {
|
||||
.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 || routeFullReview ? validationCases.length !== 0 : validationCases.length !== 12)
|
||||
) {
|
||||
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
|
||||
}
|
||||
return {
|
||||
@@ -453,6 +915,8 @@ function parseResult(value: unknown, resultId: string): VegetationShadowResult {
|
||||
routeCases,
|
||||
validationCases,
|
||||
routeVideo: routeVideoValue(payload.route_video),
|
||||
routeReview,
|
||||
routeFullReview,
|
||||
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
||||
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
||||
visualShadowReady: true,
|
||||
@@ -473,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,
|
||||
@@ -490,5 +1043,85 @@ export async function fetchVegetationShadowResult(
|
||||
if (!response.ok) {
|
||||
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseResult(await response.json(), resultId);
|
||||
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(
|
||||
resultId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<VegetationShadowResult> {
|
||||
if (!BENCHMARK_RESULT_ID.test(resultId)) {
|
||||
throw new VegetationShadowContractError("Vegetation benchmark identity недопустима.");
|
||||
}
|
||||
const endpointRoot = "/api/v1/laboratory/vegetation-benchmark";
|
||||
const response = await fetcher(
|
||||
`${endpointRoot}/${encodeURIComponent(resultId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new VegetationShadowContractError(
|
||||
`Vegetation benchmark недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const result = parseResult(await response.json(), resultId, endpointRoot);
|
||||
if (result.routeVideo) {
|
||||
throw new VegetationShadowContractError("Vegetation benchmark содержит route video.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -152,10 +179,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 +194,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",
|
||||
@@ -379,11 +428,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 +535,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 +587,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 +624,8 @@ function decodeLabInstance(
|
||||
value.published_at_utc,
|
||||
`lab(${sessionId}).published_at_utc`,
|
||||
),
|
||||
replayCapability,
|
||||
calculationProfile,
|
||||
provenance: value.provenance,
|
||||
};
|
||||
}
|
||||
@@ -1181,6 +1362,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 };
|
||||
}
|
||||
@@ -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,129 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||
* canonical LAB route or start work in the background.
|
||||
*/
|
||||
export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||
kind: "lab-recorded-evidence";
|
||||
clock: "source-sequence";
|
||||
cameraTransport: "generation-bound-fmp4";
|
||||
spatialTransport: "bounded-sealed-artifacts";
|
||||
loadPolicy: "explicit-legacy-comparison-only";
|
||||
workerRequired: false;
|
||||
}
|
||||
|
||||
export type RerunViewerProfile =
|
||||
| LiveAcquisitionRerunProfile
|
||||
| RecordedSessionRerunProfile;
|
||||
|
||||
export type ObservationViewerProfile =
|
||||
| RerunViewerProfile
|
||||
| LaboratoryRecordedEvidenceViewerProfile;
|
||||
|
||||
export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||
kind: "lab-recorded-evidence",
|
||||
clock: "source-sequence",
|
||||
cameraTransport: "generation-bound-fmp4",
|
||||
spatialTransport: "bounded-sealed-artifacts",
|
||||
loadPolicy: "explicit-legacy-comparison-only",
|
||||
workerRequired: false,
|
||||
} satisfies LaboratoryRecordedEvidenceViewerProfile);
|
||||
|
||||
export function liveAcquisitionRerunProfile(
|
||||
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||
): LiveAcquisitionRerunProfile {
|
||||
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
||||
}
|
||||
|
||||
export function recordedSessionRerunProfile(
|
||||
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||
): RecordedSessionRerunProfile {
|
||||
return { kind: "recorded-session", clock: "session_time", ...input };
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
|
||||
|
||||
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
|
||||
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
|
||||
|
||||
export type ObservatoryCatalogMutationFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface ObservatoryLabProjectionMutationResult {
|
||||
readonly schemaVersion: typeof PROJECTION_SCHEMA;
|
||||
readonly sessionId: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogMutationError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryCatalogMutationError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
displayName: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLabProjectionMutationResult> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const normalizedName = displayName.trim();
|
||||
if (normalizedName.length < 1 || normalizedName.length > 160) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Название лабораторного результата должно содержать от 1 до 160 символов.",
|
||||
);
|
||||
}
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schema_version: RENAME_SCHEMA,
|
||||
display_name: normalizedName,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
"Не удалось переименовать лабораторный результат.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const decoded = decodeProjection(body);
|
||||
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер не подтвердил точное переименование выбранного результата.",
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export async function deleteObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
"Не удалось удалить лабораторный результат из AI Inference.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|
||||
const sessionId = binding.evidenceSessionId;
|
||||
const admittedViewer = (
|
||||
binding.kind === "canonical-recorded-rerun"
|
||||
&& binding.viewerProfile === "recorded-session"
|
||||
&& binding.timeline === "session_time"
|
||||
) || (
|
||||
binding.kind === "portable-result-review"
|
||||
&& binding.viewerProfile === "portable-result"
|
||||
&& binding.timeline === "result-defined"
|
||||
);
|
||||
if (
|
||||
!admittedViewer
|
||||
|| binding.activation !== "explicit"
|
||||
|| binding.resultId !== sessionId
|
||||
|| binding.sourceSessionId === sessionId
|
||||
|| !SAFE_SESSION_ID.test(sessionId)
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Выбранный результат не допущен к изменению каталога AI Inference.",
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function request(
|
||||
fetcher: ObservatoryCatalogMutationFetch,
|
||||
input: string,
|
||||
init: RequestInit,
|
||||
fallback: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryCatalogMutationError(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(body: unknown, fallback: string, status: number): Error {
|
||||
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
|
||||
}
|
||||
if (typeof body === "string" && body.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.trim(), status);
|
||||
}
|
||||
return new ObservatoryCatalogMutationError(fallback, status);
|
||||
}
|
||||
|
||||
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул некорректное подтверждение лабораторного результата.",
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const expected = ["display_name", "schema_version", "session_id"];
|
||||
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Подтверждение лабораторного результата содержит неизвестные поля.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.schema_version !== PROJECTION_SCHEMA
|
||||
|| typeof value.session_id !== "string"
|
||||
|| !SAFE_SESSION_ID.test(value.session_id)
|
||||
|| typeof value.display_name !== "string"
|
||||
|| value.display_name.trim() !== value.display_name
|
||||
|| value.display_name.length < 1
|
||||
|| value.display_name.length > 160
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
schemaVersion: PROJECTION_SCHEMA,
|
||||
sessionId: value.session_id,
|
||||
displayName: value.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
const PROFILE_SCHEMA = "missioncore.observatory-lab-view-profile/v1";
|
||||
|
||||
export async function fetchLabViewProfile(
|
||||
resultId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings | null> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Профиль отображения LAB не загрузился.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
export async function saveLabViewProfile(
|
||||
resultId: string,
|
||||
settings: SceneSettings,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
method: "PUT",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PROFILE_SCHEMA,
|
||||
result_id: resultId,
|
||||
scene_settings: {
|
||||
point_size: settings.pointSize,
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
show_grid: settings.showGrid,
|
||||
show_labels: settings.showLabels,
|
||||
show_camera_frustums: settings.showCameraFrustums,
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Настройки LAB не сохранились. Повторите закрытие окна.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
function decodeProfile(value: unknown, expectedResultId: string): SceneSettings {
|
||||
const row = record(value);
|
||||
exactKeys(row, ["result_id", "scene_settings", "schema_version", "updated_at_utc"]);
|
||||
if (row.schema_version !== PROFILE_SCHEMA || row.result_id !== expectedResultId) {
|
||||
throw new Error("Сервер вернул профиль другой LAB.");
|
||||
}
|
||||
const settings = record(row.scene_settings);
|
||||
exactKeys(settings, [
|
||||
"accumulation_seconds", "color_mode", "palette", "point_size",
|
||||
"show_camera_frustums", "show_grid", "show_labels",
|
||||
]);
|
||||
const pointSize = finite(settings.point_size);
|
||||
const accumulationSeconds = finite(settings.accumulation_seconds);
|
||||
const colorMode = settings.color_mode;
|
||||
const palette = settings.palette;
|
||||
if (
|
||||
pointSize < 0.1
|
||||
|| accumulationSeconds < 0
|
||||
|| !new Set(["intensity", "height", "distance", "rgb", "class"]).has(String(colorMode))
|
||||
|| !new Set(["turbo", "viridis", "plasma", "grayscale"]).has(String(palette))
|
||||
|| typeof settings.show_grid !== "boolean"
|
||||
|| typeof settings.show_labels !== "boolean"
|
||||
|| typeof settings.show_camera_frustums !== "boolean"
|
||||
) throw new Error("Сервер вернул повреждённый профиль LAB.");
|
||||
return {
|
||||
projection: "3d",
|
||||
pointSize,
|
||||
colorMode: colorMode as SceneSettings["colorMode"],
|
||||
palette: palette as SceneSettings["palette"],
|
||||
customColor: "#35d7c1",
|
||||
accumulationSeconds,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showGrid: settings.show_grid,
|
||||
showLabels: settings.show_labels,
|
||||
showCameraFrustums: settings.show_camera_frustums,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Некорректный профиль отображения LAB.");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error("Некорректное числовое значение профиля LAB.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) {
|
||||
throw new Error("Контракт профиля отображения LAB изменился.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
export { fetchObservatoryPortableLaboratorySetups } from "./portableLaboratorySetups";
|
||||
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
|
||||
const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1";
|
||||
const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ObservatoryLaboratorySetupOrigin =
|
||||
| "archived-definition"
|
||||
| "existing-result"
|
||||
| "portable-definition";
|
||||
export type ObservatoryLaboratorySetupAction =
|
||||
| "open-existing"
|
||||
| "open-legacy"
|
||||
| "check"
|
||||
| "blocked";
|
||||
|
||||
export interface ObservatoryLaboratoryRunDefinition {
|
||||
readonly definitionId: string;
|
||||
readonly version: number;
|
||||
readonly workId: string | null;
|
||||
readonly definitionSha256: string;
|
||||
readonly resultSchema: string | null;
|
||||
readonly resultKind: string | null;
|
||||
readonly models: readonly {
|
||||
readonly name: string;
|
||||
readonly releaseId: string;
|
||||
readonly modelId: string;
|
||||
readonly architecture: string;
|
||||
}[];
|
||||
readonly configuration: readonly {
|
||||
readonly role: string;
|
||||
readonly sha256: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryPreservedResult {
|
||||
readonly resultId: string;
|
||||
readonly resultKind: string;
|
||||
readonly relation: string;
|
||||
readonly access: "legacy-lab" | "observatory" | "evidence-only";
|
||||
readonly createdAtUtc: string;
|
||||
readonly observatoryProjectionAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetup {
|
||||
readonly setupId: string;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly origin: ObservatoryLaboratorySetupOrigin;
|
||||
readonly runDefinition: ObservatoryLaboratoryRunDefinition | null;
|
||||
readonly compatibility: {
|
||||
readonly compatible: boolean;
|
||||
readonly reasons: readonly { readonly code: string; readonly message: string }[];
|
||||
};
|
||||
readonly executor: {
|
||||
readonly contourId: string;
|
||||
readonly state: "not-installed" | "ready";
|
||||
readonly reasonCode: string;
|
||||
readonly reason: string;
|
||||
};
|
||||
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
|
||||
readonly preflight: {
|
||||
readonly outcome: "existing" | "ready" | "blocked";
|
||||
readonly action: ObservatoryLaboratorySetupAction;
|
||||
readonly reason: string;
|
||||
readonly submissionAllowed: boolean;
|
||||
readonly existingResultIds: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetupCatalog {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setups: readonly ObservatoryLaboratorySetup[];
|
||||
}
|
||||
|
||||
// Cache identity is verified by the portable catalog decoder/server, never by a LAB label.
|
||||
export function selectableObservatorySetups(catalog: ObservatoryLaboratorySetupCatalog | null) {
|
||||
return catalog?.setups.filter((setup) => (
|
||||
setup.origin === "portable-definition"
|
||||
&& setup.runDefinition !== null
|
||||
&& setup.compatibility.compatible
|
||||
&& setup.preflight.outcome !== "existing"
|
||||
)) ?? [];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryRunPreflight {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setupId: string;
|
||||
readonly definitionSha256: string | null;
|
||||
readonly checkSha256: string | null;
|
||||
readonly outcome: "existing" | "queueable" | "blocked";
|
||||
readonly submissionAllowed: boolean;
|
||||
readonly checks: readonly {
|
||||
readonly checkId: string;
|
||||
readonly outcome: "pass" | "fail" | "not-applicable";
|
||||
readonly reasonCode: string;
|
||||
readonly message: string;
|
||||
}[];
|
||||
readonly existingResultIds: readonly string[];
|
||||
}
|
||||
|
||||
export type ObservatoryLaboratorySetupFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class ObservatoryLaboratorySetupContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryLaboratorySetupContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryLaboratorySetups(
|
||||
sourceSessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const catalog = decodeCatalog(body);
|
||||
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Каталог сетапов относится к другой исходной сессии.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId: string,
|
||||
setup: ObservatoryLaboratorySetup,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratoryRunPreflight> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
"/api/v1/observatory/run-preflights",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PREFLIGHT_REQUEST_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setup.setupId,
|
||||
definition_sha256: setup.runDefinition?.definitionSha256 ?? null,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const preflight = decodePreflight(body);
|
||||
if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другому источнику или сетапу.",
|
||||
);
|
||||
}
|
||||
if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другой версии сетапа.",
|
||||
);
|
||||
}
|
||||
return preflight;
|
||||
}
|
||||
|
||||
function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||
const row = record(value, "каталог сетапов");
|
||||
exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов");
|
||||
exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов");
|
||||
observationAuthority(row.authority);
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "source_session_id"),
|
||||
setups: array(row.setups, "setups").map(decodeSetup),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSetup(value: unknown): ObservatoryLaboratorySetup {
|
||||
const row = record(value, "сетап");
|
||||
exactKeys(row, [
|
||||
"authority", "compatibility", "description", "display_name", "executor", "origin",
|
||||
"preflight", "preserved_results", "run_definition", "setup_id", "source",
|
||||
], "сетап");
|
||||
observationAuthority(row.authority);
|
||||
const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin");
|
||||
const compatibility = record(row.compatibility, "compatibility");
|
||||
const executor = record(row.executor, "executor");
|
||||
const preflight = record(row.preflight, "preflight");
|
||||
const source = record(row.source, "source");
|
||||
const runDefinition = row.run_definition === null
|
||||
? null
|
||||
: decodeRunDefinition(row.run_definition);
|
||||
if ((origin === "archived-definition") !== (runDefinition !== null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Происхождение сетапа не совпадает с RunDefinition.",
|
||||
);
|
||||
}
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "source");
|
||||
text(source.session_id, "source.session_id");
|
||||
text(source.label, "source.label");
|
||||
array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality"));
|
||||
exactKeys(compatibility, ["compatible", "reasons"], "compatibility");
|
||||
exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor");
|
||||
exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight");
|
||||
return {
|
||||
setupId: text(row.setup_id, "setup_id"),
|
||||
displayName: text(row.display_name, "display_name"),
|
||||
description: text(row.description, "description"),
|
||||
origin,
|
||||
runDefinition,
|
||||
compatibility: {
|
||||
compatible: boolean(row.compatibility && compatibility.compatible, "compatible"),
|
||||
reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => {
|
||||
const reason = record(item, "compatibility reason");
|
||||
exactKeys(reason, ["code", "message"], "compatibility reason");
|
||||
return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") };
|
||||
}),
|
||||
},
|
||||
executor: {
|
||||
contourId: text(executor.contour_id, "executor.contour_id"),
|
||||
state: exact(executor.state, "not-installed", "executor.state"),
|
||||
reasonCode: text(executor.reason_code, "executor.reason_code"),
|
||||
reason: text(executor.reason, "executor.reason"),
|
||||
},
|
||||
preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult),
|
||||
preflight: {
|
||||
outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"),
|
||||
action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"),
|
||||
reason: text(preflight.reason, "preflight.reason"),
|
||||
submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"),
|
||||
existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||
const row = record(value, "RunDefinition");
|
||||
exactKeys(row, [
|
||||
"authority", "configuration", "definition_id", "definition_sha256", "schema_version",
|
||||
"source", "version", "work_id",
|
||||
], "RunDefinition");
|
||||
exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema");
|
||||
observationAuthority(row.authority);
|
||||
const source = record(row.source, "RunDefinition source");
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source");
|
||||
text(source.session_id, "RunDefinition source.session_id");
|
||||
text(source.label, "RunDefinition source.label");
|
||||
array(source.required_modalities, "RunDefinition source.required_modalities")
|
||||
.forEach((item) => text(item, "RunDefinition source modality"));
|
||||
const digest = text(row.definition_sha256, "definition_sha256");
|
||||
if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition.");
|
||||
return {
|
||||
definitionId: text(row.definition_id, "definition_id"),
|
||||
version: positiveInteger(row.version, "definition version"),
|
||||
workId: text(row.work_id, "work_id"),
|
||||
definitionSha256: digest,
|
||||
resultSchema: null,
|
||||
resultKind: null,
|
||||
models: [],
|
||||
configuration: array(row.configuration, "configuration").map((item) => {
|
||||
const reference = record(item, "configuration reference");
|
||||
exactKeys(reference, ["role", "sha256"], "configuration reference");
|
||||
const sha256 = text(reference.sha256, "configuration sha256");
|
||||
if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации.");
|
||||
return { role: text(reference.role, "configuration role"), sha256 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult {
|
||||
const row = record(value, "preserved result");
|
||||
exactKeys(row, [
|
||||
"access", "created_at_utc", "observatory_projection_available", "relation",
|
||||
"result_id", "result_kind",
|
||||
], "preserved result");
|
||||
return {
|
||||
resultId: text(row.result_id, "result_id"),
|
||||
resultKind: text(row.result_kind, "result_kind"),
|
||||
relation: text(row.relation, "relation"),
|
||||
access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"),
|
||||
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||
observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
|
||||
const row = record(value, "preflight");
|
||||
const baseKeys = [
|
||||
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
|
||||
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
|
||||
] as const;
|
||||
const hasPortableCheck = Object.hasOwn(row, "check_sha256");
|
||||
exactKeys(
|
||||
row,
|
||||
hasPortableCheck ? [...baseKeys, "check_sha256"] : baseKeys,
|
||||
"preflight",
|
||||
);
|
||||
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
|
||||
observationAuthority(row.authority);
|
||||
const digest = row.definition_sha256;
|
||||
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
|
||||
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
|
||||
}
|
||||
const checkDigest = hasPortableCheck ? row.check_sha256 : null;
|
||||
if (
|
||||
checkDigest !== null
|
||||
&& (typeof checkDigest !== "string" || !SHA256.test(checkDigest))
|
||||
) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Некорректный check digest preflight.",
|
||||
);
|
||||
}
|
||||
const outcome = oneOf(
|
||||
row.outcome,
|
||||
["existing", "queueable", "blocked"] as const,
|
||||
"preflight outcome",
|
||||
);
|
||||
const submissionAllowed = boolean(
|
||||
row.submission_allowed,
|
||||
"preflight submission_allowed",
|
||||
);
|
||||
if (
|
||||
submissionAllowed !== (outcome === "queueable")
|
||||
|| (hasPortableCheck && outcome === "queueable" && checkDigest === null)
|
||||
) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight содержит противоречивое разрешение постановки в очередь.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
|
||||
setupId: text(row.setup_id, "preflight setup_id"),
|
||||
definitionSha256: digest,
|
||||
checkSha256: checkDigest,
|
||||
outcome,
|
||||
submissionAllowed,
|
||||
checks: array(row.checks, "preflight checks").map((item) => {
|
||||
const check = record(item, "preflight check");
|
||||
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
|
||||
return {
|
||||
checkId: text(check.check_id, "check_id"),
|
||||
outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"),
|
||||
reasonCode: text(check.reason_code, "check reason_code"),
|
||||
message: text(check.message, "check message"),
|
||||
};
|
||||
}),
|
||||
existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
|
||||
exactKeys(row, keys, "authority");
|
||||
for (const key of keys) {
|
||||
exact(row[key], false, `authority.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(fetcher: ObservatoryLaboratorySetupFetch, input: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен.");
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const textBody = await response.text();
|
||||
if (!textBody) return undefined;
|
||||
try { return JSON.parse(textBody) as unknown; } catch { return textBody; }
|
||||
}
|
||||
|
||||
function apiError(body: unknown, status: number): ObservatoryLaboratorySetupContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryLaboratorySetupContractError(
|
||||
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${label}: значение изменилось.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: значение не поддерживается.`);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import type {
|
||||
ObservatoryLaboratoryPreservedResult,
|
||||
ObservatoryLaboratoryRunDefinition,
|
||||
ObservatoryLaboratorySetup,
|
||||
ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
const PORTABLE_CATALOG_SCHEMA = "missioncore.observatory-portable-setup-catalog/v2";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export class ObservatoryPortableSetupDecodeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryPortableSetupDecodeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePortableCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||
const row = record(value, "portable-каталог сетапов");
|
||||
exactKeys(
|
||||
row,
|
||||
["authority", "schema_version", "setups", "source_session_id"],
|
||||
"portable-каталог сетапов",
|
||||
);
|
||||
exact(row.schema_version, PORTABLE_CATALOG_SCHEMA, "schema_version portable-каталога");
|
||||
observationAuthority(row.authority);
|
||||
const sourceSessionId = text(row.source_session_id, "portable source_session_id");
|
||||
return {
|
||||
sourceSessionId,
|
||||
setups: array(row.setups, "portable setups").map((item) => decodePortableSetup(item, sourceSessionId)),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableSetup(value: unknown, sourceSessionId: string): ObservatoryLaboratorySetup {
|
||||
const row = record(value, "portable-сетап");
|
||||
exactKeys(row, [
|
||||
"authority", "description", "display_name", "executor", "existing_results",
|
||||
"origin", "preflight", "run_definition", "setup_id", "source_compatibility",
|
||||
"source_requirements",
|
||||
], "portable-сетап");
|
||||
exact(row.origin, "portable-definition", "portable origin");
|
||||
observationAuthority(row.authority);
|
||||
decodePortableSourceRequirements(row.source_requirements);
|
||||
const setupId = text(row.setup_id, "portable setup_id");
|
||||
const runDefinition = decodePortableRunDefinition(row.run_definition);
|
||||
|
||||
const compatibility = record(row.source_compatibility, "portable source_compatibility");
|
||||
exactKeys(
|
||||
compatibility,
|
||||
["compatible", "outcome", "reason", "reason_code"],
|
||||
"portable source_compatibility",
|
||||
);
|
||||
const compatible = boolean(compatibility.compatible, "portable compatible");
|
||||
exact(
|
||||
compatibility.outcome,
|
||||
compatible ? "pass" : "blocked",
|
||||
"portable compatibility outcome",
|
||||
);
|
||||
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
|
||||
const compatibilityReasonCode = text(
|
||||
compatibility.reason_code,
|
||||
"portable compatibility reason_code",
|
||||
);
|
||||
|
||||
const executor = record(row.executor, "portable executor");
|
||||
exactKeys(
|
||||
executor,
|
||||
["contour_id", "ready", "reason", "reason_code", "state"],
|
||||
"portable executor",
|
||||
);
|
||||
const executorState = oneOf(
|
||||
executor.state,
|
||||
["not-installed", "ready"] as const,
|
||||
"portable executor state",
|
||||
);
|
||||
const executorReady = boolean(executor.ready, "portable executor ready");
|
||||
if (executorReady !== (executorState === "ready")) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable executor: состояние готовности противоречиво.",
|
||||
);
|
||||
}
|
||||
const executorReason = executor.reason === null
|
||||
? "Исполнитель установлен."
|
||||
: text(executor.reason, "portable executor reason");
|
||||
const executorReasonCode = executor.reason_code === null
|
||||
? "portable-executor-ready"
|
||||
: text(executor.reason_code, "portable executor reason_code");
|
||||
if (
|
||||
(executorReady && (executor.reason !== null || executor.reason_code !== null))
|
||||
|| (!executorReady && (executor.reason === null || executor.reason_code === null))
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable executor: причина недоступности противоречит состоянию.",
|
||||
);
|
||||
}
|
||||
|
||||
const preflight = record(row.preflight, "portable preflight");
|
||||
exactKeys(preflight, [
|
||||
"action", "existing_result_ids", "outcome", "reason", "submission_allowed",
|
||||
], "portable preflight");
|
||||
const preflightOutcome = oneOf(
|
||||
preflight.outcome,
|
||||
["existing", "ready", "blocked"] as const,
|
||||
"portable preflight outcome",
|
||||
);
|
||||
const preflightAction = oneOf(
|
||||
preflight.action,
|
||||
["open-existing", "check", "blocked"] as const,
|
||||
"portable preflight action",
|
||||
);
|
||||
const submissionAllowed = boolean(
|
||||
preflight.submission_allowed,
|
||||
"portable preflight submission_allowed",
|
||||
);
|
||||
if (
|
||||
submissionAllowed !== (preflightOutcome === "ready")
|
||||
|| (preflightOutcome === "ready" && preflightAction !== "check")
|
||||
|| (preflightOutcome === "blocked" && preflightAction !== "blocked")
|
||||
|| (preflightOutcome === "existing" && preflightAction !== "open-existing")
|
||||
|| (submissionAllowed && (!compatible || !executorReady))
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable preflight: состояние запуска противоречиво.",
|
||||
);
|
||||
}
|
||||
const existingResults = array(row.existing_results, "portable existing_results").map(
|
||||
(item) => decodePortableResult(item, sourceSessionId, setupId, runDefinition),
|
||||
);
|
||||
const existingResultIds = array(
|
||||
preflight.existing_result_ids,
|
||||
"portable existing_result_ids",
|
||||
).map((item) => text(item, "portable existing result id"));
|
||||
if (
|
||||
(existingResults.length > 0) !== (preflightOutcome === "existing")
|
||||
|| existingResultIds.length !== existingResults.length
|
||||
|| new Set(existingResultIds).size !== existingResultIds.length
|
||||
|| existingResults.some((result, index) => result.resultId !== existingResultIds[index])
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable result: готовность не соответствует проверенным результатам.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
setupId,
|
||||
displayName: text(row.display_name, "portable display_name"),
|
||||
description: text(row.description, "portable description"),
|
||||
origin: "portable-definition",
|
||||
runDefinition,
|
||||
compatibility: {
|
||||
compatible,
|
||||
reasons: compatible
|
||||
? []
|
||||
: [{ code: compatibilityReasonCode, message: compatibilityReason }],
|
||||
},
|
||||
executor: {
|
||||
contourId: text(executor.contour_id, "portable executor contour_id"),
|
||||
state: executorState,
|
||||
reasonCode: executorReasonCode,
|
||||
reason: executorReason,
|
||||
},
|
||||
preservedResults: existingResults,
|
||||
preflight: {
|
||||
outcome: preflightOutcome,
|
||||
action: preflightAction,
|
||||
reason: text(preflight.reason, "portable preflight reason"),
|
||||
submissionAllowed,
|
||||
existingResultIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableResult(
|
||||
value: unknown, sourceSessionId: string, setupId: string,
|
||||
definition: ObservatoryLaboratoryRunDefinition,
|
||||
): ObservatoryLaboratoryPreservedResult {
|
||||
const row = record(value, "portable result");
|
||||
exactKeys(row, [
|
||||
"result_id", "result_kind", "relation", "access", "created_at_utc",
|
||||
"observatory_projection_available", "identity",
|
||||
], "portable result");
|
||||
exact(row.result_kind, definition.resultKind, "portable result kind");
|
||||
exact(row.relation, "exact-recorded-computation", "portable result relation");
|
||||
exact(row.access, "observatory", "portable result access");
|
||||
exact(row.observatory_projection_available, true, "portable result availability");
|
||||
const identity = record(row.identity, "portable result identity");
|
||||
exactKeys(identity, [
|
||||
"job_id", "source_session_id", "source_catalog_sha256", "source_bundle_sha256",
|
||||
"source_capability_manifest_sha256", "setup_id", "definition_sha256",
|
||||
"package_sha256", "artifact_manifest_id",
|
||||
], "portable result identity");
|
||||
exact(identity.source_session_id, sourceSessionId, "portable result source");
|
||||
exact(identity.setup_id, setupId, "portable result setup");
|
||||
exact(identity.definition_sha256, definition.definitionSha256, "portable result definition");
|
||||
text(identity.job_id, "portable result job");
|
||||
for (const key of [
|
||||
"source_catalog_sha256", "source_bundle_sha256", "source_capability_manifest_sha256",
|
||||
"definition_sha256", "package_sha256", "artifact_manifest_id",
|
||||
]) {
|
||||
if (!SHA256.test(text(identity[key], `portable result ${key}`))) {
|
||||
throw new ObservatoryPortableSetupDecodeError(`Portable result: некорректный ${key}.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
resultId: text(row.result_id, "portable result id"),
|
||||
resultKind: text(row.result_kind, "portable result kind"),
|
||||
relation: "exact-recorded-computation",
|
||||
access: "observatory",
|
||||
createdAtUtc: text(row.created_at_utc, "portable result created_at_utc"),
|
||||
observatoryProjectionAvailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableSourceRequirements(value: unknown): void {
|
||||
const row = record(value, "portable source_requirements");
|
||||
exactKeys(row, [
|
||||
"archive_id", "calibration_identity_sha256", "calibration_slot", "camera_height",
|
||||
"camera_semantic_channel_id", "camera_source_id", "camera_width",
|
||||
"exactly_one_media_epoch", "plugin_id", "recorded_media_init_sha256",
|
||||
"recorded_media_type", "required_modalities", "seekable",
|
||||
], "portable source_requirements");
|
||||
for (const key of [
|
||||
"archive_id", "calibration_slot", "camera_semantic_channel_id", "camera_source_id",
|
||||
"plugin_id", "recorded_media_type",
|
||||
]) text(row[key], `portable source_requirements.${key}`);
|
||||
for (const key of ["calibration_identity_sha256", "recorded_media_init_sha256"]) {
|
||||
const digest = text(row[key], `portable source_requirements.${key}`);
|
||||
if (!SHA256.test(digest)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`portable source_requirements.${key}: некорректный digest.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
positiveInteger(row.camera_width, "portable camera_width");
|
||||
positiveInteger(row.camera_height, "portable camera_height");
|
||||
exact(row.exactly_one_media_epoch, true, "portable exactly_one_media_epoch");
|
||||
exact(row.seekable, true, "portable seekable");
|
||||
array(row.required_modalities, "portable required_modalities")
|
||||
.forEach((item) => text(item, "portable required modality"));
|
||||
}
|
||||
|
||||
function decodePortableRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||
const row = record(value, "portable RunDefinition");
|
||||
exactKeys(row, [
|
||||
"definition_id", "definition_sha256", "models", "result_kind", "result_schema",
|
||||
"version",
|
||||
], "portable RunDefinition");
|
||||
const digest = text(row.definition_sha256, "portable definition_sha256");
|
||||
if (!SHA256.test(digest)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable RunDefinition: некорректный digest.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
definitionId: text(row.definition_id, "portable definition_id"),
|
||||
version: positiveInteger(row.version, "portable definition version"),
|
||||
workId: null,
|
||||
definitionSha256: digest,
|
||||
resultSchema: text(row.result_schema, "portable result_schema"),
|
||||
resultKind: text(row.result_kind, "portable result_kind"),
|
||||
models: array(row.models, "portable models").map((item) => {
|
||||
const model = record(item, "portable model");
|
||||
exactKeys(
|
||||
model,
|
||||
["architecture", "model_id", "name", "release_id"],
|
||||
"portable model",
|
||||
);
|
||||
return {
|
||||
name: text(model.name, "portable model name"),
|
||||
releaseId: text(model.release_id, "portable model release_id"),
|
||||
modelId: text(model.model_id, "portable model model_id"),
|
||||
architecture: text(model.architecture, "portable model architecture"),
|
||||
};
|
||||
}),
|
||||
configuration: [],
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = [
|
||||
"commands_enabled",
|
||||
"actuation_allowed",
|
||||
"navigation_or_safety_accepted",
|
||||
"production_accepted",
|
||||
];
|
||||
exactKeys(row, keys, "authority");
|
||||
for (const key of keys) exact(row[key], false, `authority.${key}`);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался объект.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался текст.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался boolean.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 1) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидалось положительное целое.`,
|
||||
);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: значение изменилось.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(
|
||||
value: unknown,
|
||||
allowed: T,
|
||||
label: string,
|
||||
): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: значение не поддерживается.`,
|
||||
);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (
|
||||
actual.length !== sortedExpected.length
|
||||
|| actual.some((key, index) => key !== sortedExpected[index])
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: обнаружены неизвестные поля.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { decodePortableCatalog } from "./portableLaboratorySetupDecoder";
|
||||
import type {
|
||||
ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
export type ObservatoryPortableLaboratorySetupFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class ObservatoryPortableLaboratorySetupContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryPortableLaboratorySetupContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryPortableLaboratorySetups(
|
||||
sourceSessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryPortableLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/portable-laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const catalog = decodePortableCatalog(body);
|
||||
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-каталог сетапов относится к другой исходной сессии.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
async function request(
|
||||
fetcher: ObservatoryPortableLaboratorySetupFetch,
|
||||
input: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-каталог сетапов недоступен.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const textBody = await response.text();
|
||||
if (!textBody) return undefined;
|
||||
try {
|
||||
return JSON.parse(textBody) as unknown;
|
||||
} catch {
|
||||
return textBody;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(
|
||||
body: unknown,
|
||||
status: number,
|
||||
): ObservatoryPortableLaboratorySetupContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryPortableLaboratorySetupContractError(
|
||||
typeof detail === "string" && detail.trim()
|
||||
? detail
|
||||
: `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1";
|
||||
const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1";
|
||||
const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ObservatoryRecordedJobState =
|
||||
| "accepted"
|
||||
| "queued"
|
||||
| "claimed"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "preemption-pending"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "reconciliation-required";
|
||||
|
||||
export type ObservatoryRecordedJobPublicationState =
|
||||
| "not-required"
|
||||
| "pending"
|
||||
| "failed"
|
||||
| "published";
|
||||
|
||||
export interface ObservatoryRecordedJob {
|
||||
readonly jobId: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly setupId: string;
|
||||
readonly definitionSha256: string;
|
||||
readonly claimGeneration: number;
|
||||
readonly state: ObservatoryRecordedJobState;
|
||||
readonly restartFromZero: boolean;
|
||||
readonly resultId: string | null;
|
||||
readonly terminalMessage: string | null;
|
||||
readonly publication: {
|
||||
readonly state: ObservatoryRecordedJobPublicationState;
|
||||
readonly attempts: number;
|
||||
readonly error: string | null;
|
||||
readonly publishedAtUtc: string | null;
|
||||
};
|
||||
readonly createdAtUtc: string;
|
||||
readonly updatedAtUtc: string;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedJobFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class ObservatoryRecordedJobContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryRecordedJobContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryRecordedJobs(
|
||||
sourceSessionId: string,
|
||||
setupId: string,
|
||||
{
|
||||
signal,
|
||||
definitionSha256,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
definitionSha256?: string;
|
||||
fetcher?: ObservatoryRecordedJobFetch;
|
||||
} = {},
|
||||
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||
const query = new URLSearchParams({
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setupId,
|
||||
limit: "20",
|
||||
});
|
||||
if (definitionSha256 !== undefined) query.set("definition_sha256", definitionSha256);
|
||||
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const row = record(body, "список расчётов");
|
||||
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
|
||||
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
|
||||
observationAuthority(row.authority);
|
||||
return array(row.items, "items").map(decodeObservatoryRecordedJob).filter((job) => (
|
||||
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||
));
|
||||
}
|
||||
|
||||
export async function submitObservatoryRecordedJob(
|
||||
sourceSessionId: string,
|
||||
setupId: string,
|
||||
idempotencyKey: string,
|
||||
portableBinding: {
|
||||
readonly definitionSha256: string;
|
||||
readonly checkSha256: string;
|
||||
} | null,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryRecordedJobFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryRecordedJob> {
|
||||
const response = await request(fetcher, "/api/v1/observatory/runs", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: SUBMIT_SCHEMA,
|
||||
idempotency_key: idempotencyKey,
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setupId,
|
||||
...(portableBinding === null ? {} : {
|
||||
definition_sha256: portableBinding.definitionSha256,
|
||||
check_sha256: portableBinding.checkSha256,
|
||||
}),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
||||
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
"Расчёт относится к другой сессии, сетапу или версии профиля.",
|
||||
);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function retryObservatoryRecordedJobPublication(
|
||||
jobId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryRecordedJobFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryRecordedJob> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/runs/${encodeURIComponent(jobId)}/publication/retry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.jobId !== jobId) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
"Повтор публикации вернул другой расчёт.",
|
||||
);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
|
||||
const row = record(value, "расчёт");
|
||||
exactKeys(row, [
|
||||
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
|
||||
"preemption_requested", "priority", "publication", "request_sha256", "restart_from_zero", "result",
|
||||
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
|
||||
"updated_at_utc",
|
||||
], "расчёт");
|
||||
exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта");
|
||||
observationAuthority(row.authority);
|
||||
digest(row.request_sha256, "request_sha256");
|
||||
digest(row.identity_sha256, "identity_sha256");
|
||||
digest(row.submission_receipt_sha256, "submission_receipt_sha256");
|
||||
const source = record(row.source, "source");
|
||||
exactKeys(source, [
|
||||
"adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id",
|
||||
], "source");
|
||||
digest(source.catalog_sha256, "source.catalog_sha256");
|
||||
digest(source.bundle_sha256, "source.bundle_sha256");
|
||||
digest(source.capability_manifest_sha256, "source.capability_manifest_sha256");
|
||||
const setup = record(row.setup, "setup");
|
||||
exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup");
|
||||
digest(setup.definition_sha256, "setup.definition_sha256");
|
||||
const state = oneOf(row.state, [
|
||||
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||
"succeeded", "failed", "reconciliation-required",
|
||||
] as const, "state");
|
||||
if (row.claim_lease !== null) {
|
||||
const lease = record(row.claim_lease, "claim_lease");
|
||||
exactKeys(
|
||||
lease,
|
||||
["claimed_at_utc", "expires_at_utc", "heartbeat_at_utc", "renewal_count"],
|
||||
"claim_lease",
|
||||
);
|
||||
text(lease.claimed_at_utc, "claim_lease.claimed_at_utc");
|
||||
text(lease.expires_at_utc, "claim_lease.expires_at_utc");
|
||||
text(lease.heartbeat_at_utc, "claim_lease.heartbeat_at_utc");
|
||||
nonNegativeInteger(lease.renewal_count, "claim_lease.renewal_count");
|
||||
}
|
||||
const result = row.result === null ? null : record(row.result, "result");
|
||||
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
|
||||
const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
|
||||
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
|
||||
const publication = record(row.publication, "publication");
|
||||
exactKeys(
|
||||
publication,
|
||||
["attempts", "error", "published_at_utc", "state"],
|
||||
"publication",
|
||||
);
|
||||
const publicationState = oneOf(publication.state, [
|
||||
"not-required", "pending", "failed", "published",
|
||||
] as const, "publication.state");
|
||||
const publicationError = publication.error === null
|
||||
? null
|
||||
: text(publication.error, "publication.error");
|
||||
const publishedAtUtc = publication.published_at_utc === null
|
||||
? null
|
||||
: text(publication.published_at_utc, "publication.published_at_utc");
|
||||
return {
|
||||
jobId: text(row.job_id, "job_id"),
|
||||
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
|
||||
sourceSessionId: text(source.session_id, "source.session_id"),
|
||||
setupId: text(setup.setup_id, "setup.setup_id"),
|
||||
definitionSha256: String(setup.definition_sha256),
|
||||
claimGeneration: nonNegativeInteger(row.claim_generation, "claim_generation"),
|
||||
state,
|
||||
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
|
||||
resultId: result === null ? null : text(result.result_id, "result.result_id"),
|
||||
terminalMessage: terminal === null || terminal.message === null
|
||||
? null
|
||||
: text(terminal.message, "terminal.message"),
|
||||
publication: {
|
||||
state: publicationState,
|
||||
attempts: nonNegativeInteger(publication.attempts, "publication.attempts"),
|
||||
error: publicationError,
|
||||
publishedAtUtc,
|
||||
},
|
||||
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
|
||||
exactKeys(row, keys, "authority");
|
||||
keys.forEach((key) => exact(row[key], false, `authority.${key}`));
|
||||
}
|
||||
|
||||
async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна.");
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const body = await response.text();
|
||||
if (!body) return undefined;
|
||||
try { return JSON.parse(body) as unknown; } catch { return body; }
|
||||
}
|
||||
|
||||
function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryRecordedJobContractError(
|
||||
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
const valueText = text(value, label);
|
||||
if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`);
|
||||
return valueText;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { ObservatoryRecordedJob } from "./recordedJobs";
|
||||
|
||||
const PHASES = {
|
||||
"source-transfer": "Передаём входные данные",
|
||||
"source-preparation": "Подготавливаем вход",
|
||||
computing: "Обрабатываем запись",
|
||||
"result-assembly": "Собираем результат",
|
||||
"result-transfer": "Передаём результат",
|
||||
} as const;
|
||||
const UNITS = { frames: "кадров", members: "файлов", steps: "операций" } as const;
|
||||
type Phase = keyof typeof PHASES;
|
||||
type Unit = keyof typeof UNITS;
|
||||
|
||||
export interface RecordedProgressView {
|
||||
readonly jobId: string;
|
||||
readonly claimGeneration: number;
|
||||
readonly sequence: number;
|
||||
readonly phase: Phase;
|
||||
readonly unit: Unit;
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly ageSeconds: number;
|
||||
readonly elapsedSeconds: number;
|
||||
readonly phaseElapsedSeconds: number;
|
||||
}
|
||||
|
||||
export async function fetchRecordedProgress(
|
||||
job: ObservatoryRecordedJob,
|
||||
{ signal, fetcher = globalThis.fetch }: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
} = {},
|
||||
): Promise<RecordedProgressView | null> {
|
||||
const request = new AbortController();
|
||||
const abort = () => request.abort();
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) request.abort();
|
||||
const timer = globalThis.setTimeout(abort, 2_500);
|
||||
try {
|
||||
const response = await fetcher(
|
||||
`/api/v1/observatory/runs/${encodeURIComponent(job.jobId)}/progress`,
|
||||
{ signal: request.signal, headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!response.ok) throw new Error("Прогресс расчёта недоступен.");
|
||||
return decodeRecordedProgress(await response.json(), job);
|
||||
} finally {
|
||||
globalThis.clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeRecordedProgress(
|
||||
value: unknown, job: ObservatoryRecordedJob,
|
||||
): RecordedProgressView | null {
|
||||
const row = object(value);
|
||||
keys(row, ["schema_version", "job_id", "source_session_id", "setup_id",
|
||||
"definition_sha256", "claim_generation", "state", "received_at_utc", "age_seconds", "progress"]);
|
||||
if (row.schema_version !== "missioncore.observatory-recorded-progress-view/v1"
|
||||
|| row.job_id !== job.jobId || row.source_session_id !== job.sourceSessionId
|
||||
|| row.setup_id !== job.setupId || row.definition_sha256 !== job.definitionSha256
|
||||
|| row.claim_generation !== job.claimGeneration) throw new Error("Прогресс другой попытки.");
|
||||
if (row.progress === null || row.state !== job.state) return null;
|
||||
const progress = object(row.progress);
|
||||
keys(progress, ["schema_version", "claim_generation", "sequence", "phase_index",
|
||||
"phase", "unit", "completed", "total", "elapsed_seconds", "phase_elapsed_seconds"]);
|
||||
if (progress.schema_version !== "missioncore.observatory-recorded-progress/v1"
|
||||
|| progress.claim_generation !== row.claim_generation
|
||||
|| !(typeof progress.phase === "string" && Object.hasOwn(PHASES, progress.phase))
|
||||
|| !(typeof progress.unit === "string" && Object.hasOwn(UNITS, progress.unit))
|
||||
|| typeof row.received_at_utc !== "string" || !Number.isFinite(Date.parse(row.received_at_utc))) {
|
||||
throw new Error("Некорректный прогресс.");
|
||||
}
|
||||
const completed = integer(progress.completed);
|
||||
const total = progress.total === null ? null : integer(progress.total, 1);
|
||||
const elapsed = finite(progress.elapsed_seconds);
|
||||
const phaseElapsed = finite(progress.phase_elapsed_seconds);
|
||||
if ((total !== null && completed > total) || phaseElapsed > elapsed) {
|
||||
throw new Error("Некорректные счётчики прогресса.");
|
||||
}
|
||||
integer(progress.phase_index);
|
||||
return {
|
||||
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
|
||||
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
|
||||
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
|
||||
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordedProgressLabel(
|
||||
job: ObservatoryRecordedJob | null, progress: RecordedProgressView | null,
|
||||
): string {
|
||||
if (job?.publication.state === "pending") return "Сохраняем результат";
|
||||
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
|
||||
if (job.state === "failed") return "Ошибка расчёта";
|
||||
if (job.state === "succeeded") return "Расчёт завершён";
|
||||
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
|
||||
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
|
||||
if (!progress || progress.jobId !== job.jobId
|
||||
|| progress.claimGeneration !== job.claimGeneration) return "Ожидаем данные расчёта";
|
||||
if (progress.ageSeconds > 15) return "Ожидаем обновление прогресса";
|
||||
const count = progress.total === null
|
||||
? (progress.completed > 0 ? ` · ${progress.completed.toLocaleString("ru-RU")} ${UNITS[progress.unit]}` : "")
|
||||
: ` · ${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} ${UNITS[progress.unit]}`;
|
||||
return PHASES[progress.phase] + count;
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Прогресс: ожидался объект.");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function keys(row: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(row).length !== expected.length || expected.some((key) => !Object.hasOwn(row, key))) {
|
||||
throw new Error("Прогресс: неизвестные поля.");
|
||||
}
|
||||
}
|
||||
function finite(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Прогресс: некорректное число.");
|
||||
return value;
|
||||
}
|
||||
function integer(value: unknown, minimum = 0): number {
|
||||
const number = finite(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum) throw new Error("Прогресс: некорректный счётчик.");
|
||||
return number;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { LaboratoryFetch } from "../laboratory/advancedResults";
|
||||
import {
|
||||
fetchVegetationShadowResultMetadata,
|
||||
type VegetationFullRouteReview,
|
||||
type VegetationShadowResult,
|
||||
} from "../laboratory/vegetationShadow";
|
||||
import type { ObservationLabInstance } from "../observation/sessionArchive";
|
||||
import {
|
||||
decodeLabReplayCapability,
|
||||
decodeCanonicalLabReplayCapabilityProvenance,
|
||||
decodePortableLabReplayCapabilityProvenance,
|
||||
ObservationLabReplayCapabilityContractError,
|
||||
} from "../observation/labReplayCapability";
|
||||
|
||||
const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||
const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live";
|
||||
|
||||
interface ObservatoryRecordedRunBindingBase {
|
||||
readonly evidenceSessionId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly resultId: string;
|
||||
readonly activation: "explicit";
|
||||
}
|
||||
|
||||
export interface ObservatoryCanonicalRecordedRunBinding
|
||||
extends ObservatoryRecordedRunBindingBase {
|
||||
readonly kind: "canonical-recorded-rerun";
|
||||
readonly viewerProfile: "recorded-session";
|
||||
readonly timeline: "session_time";
|
||||
}
|
||||
|
||||
export interface ObservatoryPortableResultBinding
|
||||
extends ObservatoryRecordedRunBindingBase {
|
||||
readonly kind: "portable-result-review";
|
||||
readonly viewerProfile: "portable-result";
|
||||
readonly timeline: "result-defined";
|
||||
readonly definitionSha256: string;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedRunBinding =
|
||||
| ObservatoryCanonicalRecordedRunBinding
|
||||
| ObservatoryPortableResultBinding;
|
||||
|
||||
export interface ObservatoryPortableResultReview {
|
||||
readonly kind: "portable-result";
|
||||
readonly resultId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly resultKind: string;
|
||||
readonly definitionSha256: string;
|
||||
readonly calculationProfile: Readonly<Record<string, unknown>> | null;
|
||||
readonly artifactManifestId: string;
|
||||
readonly artifacts: readonly {
|
||||
readonly role: string;
|
||||
readonly mediaType: string;
|
||||
readonly sha256: string;
|
||||
readonly byteLength: number;
|
||||
}[];
|
||||
readonly resultDocument: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedRunReview =
|
||||
| { readonly kind: "canonical-recorded-rerun"; readonly review: VegetationFullRouteReview }
|
||||
| ObservatoryPortableResultReview;
|
||||
|
||||
export class ObservatoryRecordedRunContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryRecordedRunContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export function observatoryRecordedRunBinding(
|
||||
evidenceSessionId: string,
|
||||
lab: ObservationLabInstance,
|
||||
): ObservatoryRecordedRunBinding | null {
|
||||
const capability = lab.replayCapability;
|
||||
if (!capability) return null;
|
||||
if (capability.kind === "portable-result-review") {
|
||||
if (
|
||||
evidenceSessionId !== lab.resultId
|
||||
|| lab.sourceSessionId === evidenceSessionId
|
||||
|| lab.configSha256 === null
|
||||
|| !/^[a-f0-9]{64}$/.test(lab.configSha256)
|
||||
|| capability.viewerProfile !== "portable-result"
|
||||
|| capability.timeline !== "result-defined"
|
||||
|| capability.activation !== "explicit"
|
||||
|| capability.commandsEnabled !== false
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
`Portable-результат ${lab.resultId} не допущен к универсальному viewer.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
decodePortableLabReplayCapabilityProvenance(
|
||||
lab.provenance,
|
||||
evidenceSessionId,
|
||||
lab.resultId,
|
||||
lab.sourceSessionId,
|
||||
lab.configSha256,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||
throw new ObservatoryRecordedRunContractError(error.message);
|
||||
}
|
||||
return {
|
||||
kind: "portable-result-review",
|
||||
evidenceSessionId,
|
||||
sourceSessionId: lab.sourceSessionId,
|
||||
resultId: lab.resultId,
|
||||
viewerProfile: "portable-result",
|
||||
timeline: "result-defined",
|
||||
activation: "explicit",
|
||||
definitionSha256: lab.configSha256,
|
||||
};
|
||||
}
|
||||
try {
|
||||
decodeCanonicalLabReplayCapabilityProvenance(
|
||||
lab.provenance,
|
||||
evidenceSessionId,
|
||||
lab.resultId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||
throw new ObservatoryRecordedRunContractError(error.message);
|
||||
}
|
||||
if (
|
||||
evidenceSessionId !== lab.resultId
|
||||
|| lab.sourceSessionId !== CANONICAL_SOURCE_SESSION_ID
|
||||
|| lab.labId !== "LAB V1"
|
||||
|| lab.resultKind !== "recorded-perception-qualification"
|
||||
|| !CANONICAL_RESULT_ID.test(lab.resultId)
|
||||
|| lab.configSha256 !== null
|
||||
|| lab.sourceResultId === null
|
||||
|| !CANONICAL_RESULT_ID.test(lab.sourceResultId)
|
||||
|| capability.kind !== "canonical-recorded-rerun"
|
||||
|| capability.viewerProfile !== "recorded-session"
|
||||
|| capability.timeline !== "session_time"
|
||||
|| capability.activation !== "explicit"
|
||||
|| capability.commandsEnabled !== false
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
`LAB-результат ${lab.resultId} не допущен к каноническому recorded replay.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId,
|
||||
sourceSessionId: lab.sourceSessionId,
|
||||
resultId: lab.resultId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchObservatoryRecordedRunReview(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
{
|
||||
selectedSourceSessionId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
selectedSourceSessionId: string;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<ObservatoryRecordedRunReview> {
|
||||
if (binding.sourceSessionId !== selectedSourceSessionId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запуск не связан с выбранной исходной сессией.",
|
||||
);
|
||||
}
|
||||
if (binding.kind === "portable-result-review") {
|
||||
return fetchPortableResultReview(binding, { fetcher, signal });
|
||||
}
|
||||
const result = await fetchVegetationShadowResultMetadata(binding.resultId, {
|
||||
fetcher,
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
kind: "canonical-recorded-rerun",
|
||||
review: admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
selectedSourceSessionId,
|
||||
result,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function admitObservatoryRecordedRunReview(
|
||||
binding: ObservatoryCanonicalRecordedRunBinding,
|
||||
selectedSourceSessionId: string,
|
||||
result: VegetationShadowResult,
|
||||
): VegetationFullRouteReview {
|
||||
if (binding.sourceSessionId !== selectedSourceSessionId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запуск не связан с выбранной исходной сессией.",
|
||||
);
|
||||
}
|
||||
if (result.resultId !== binding.resultId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запечатанный результат не совпадает с выбранным запуском.",
|
||||
);
|
||||
}
|
||||
const review = result.routeFullReview;
|
||||
if (!review || review.sessionId !== binding.sourceSessionId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запечатанный результат потерял точную связь с исходной сессией.",
|
||||
);
|
||||
}
|
||||
return review;
|
||||
}
|
||||
|
||||
async function fetchPortableResultReview(
|
||||
binding: ObservatoryPortableResultBinding,
|
||||
{
|
||||
fetcher,
|
||||
signal,
|
||||
}: {
|
||||
fetcher: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<ObservatoryPortableResultReview> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/observatory/portable-results/${encodeURIComponent(binding.resultId)}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Portable viewer получил некорректный ответ сервера.",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
isRecord(body) && typeof body.detail === "string"
|
||||
? body.detail
|
||||
: `Portable viewer вернул HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
return decodePortableResultReview(body, binding);
|
||||
}
|
||||
|
||||
function decodePortableResultReview(
|
||||
value: unknown,
|
||||
binding: ObservatoryPortableResultBinding,
|
||||
): ObservatoryPortableResultReview {
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer вернул не объект.");
|
||||
}
|
||||
const expected = new Set([
|
||||
"schema_version",
|
||||
"result_id",
|
||||
"source_session_id",
|
||||
"result_kind",
|
||||
"definition_sha256",
|
||||
"viewer_capability",
|
||||
"calculation_profile",
|
||||
"artifact_manifest_id",
|
||||
"artifacts",
|
||||
"result_document",
|
||||
]);
|
||||
if (Object.keys(value).some((key) => !expected.has(key)) || Object.keys(value).length !== expected.size) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer изменил контракт ответа.");
|
||||
}
|
||||
let viewerCapability;
|
||||
try {
|
||||
viewerCapability = decodeLabReplayCapability(
|
||||
value.viewer_capability,
|
||||
binding.evidenceSessionId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||
throw new ObservatoryRecordedRunContractError(error.message);
|
||||
}
|
||||
if (
|
||||
value.schema_version !== "missioncore.observatory-portable-result-view/v1"
|
||||
|| value.result_id !== binding.resultId
|
||||
|| value.source_session_id !== binding.sourceSessionId
|
||||
|| value.definition_sha256 !== binding.definitionSha256
|
||||
|| typeof value.result_kind !== "string"
|
||||
|| typeof value.artifact_manifest_id !== "string"
|
||||
|| !/^[a-f0-9]{64}$/.test(value.artifact_manifest_id)
|
||||
|| !Array.isArray(value.artifacts)
|
||||
|| !isRecord(value.result_document)
|
||||
|| !isRecord(value.calculation_profile)
|
||||
|| viewerCapability === null
|
||||
|| viewerCapability.kind !== "portable-result-review"
|
||||
|| viewerCapability.viewerProfile !== binding.viewerProfile
|
||||
|| viewerCapability.timeline !== binding.timeline
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer потерял identity результата.");
|
||||
}
|
||||
const artifacts = value.artifacts.map((item) => {
|
||||
if (
|
||||
!isRecord(item)
|
||||
|| Object.keys(item).length !== 4
|
||||
|| !["role", "media_type", "sha256", "byte_length"].every(
|
||||
(key) => Object.prototype.hasOwnProperty.call(item, key),
|
||||
)
|
||||
|| typeof item.role !== "string"
|
||||
|| typeof item.media_type !== "string"
|
||||
|| typeof item.sha256 !== "string"
|
||||
|| !/^[a-f0-9]{64}$/.test(item.sha256)
|
||||
|| typeof item.byte_length !== "number"
|
||||
|| !Number.isSafeInteger(item.byte_length)
|
||||
|| item.byte_length < 0
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer получил неверный artifact.");
|
||||
}
|
||||
return {
|
||||
role: item.role,
|
||||
mediaType: item.media_type,
|
||||
sha256: item.sha256,
|
||||
byteLength: item.byte_length,
|
||||
};
|
||||
});
|
||||
return {
|
||||
kind: "portable-result",
|
||||
resultId: binding.resultId,
|
||||
sourceSessionId: binding.sourceSessionId,
|
||||
resultKind: value.result_kind,
|
||||
definitionSha256: binding.definitionSha256,
|
||||
calculationProfile: value.calculation_profile,
|
||||
artifactManifestId: value.artifact_manifest_id,
|
||||
artifacts,
|
||||
resultDocument: value.result_document,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
AI_MODULE_SETUP_IDS,
|
||||
fetchAIModuleCatalog,
|
||||
fetchAICompositionJobs,
|
||||
fetchAICompositionRuns,
|
||||
type AICompositionRun,
|
||||
} from "./aiComposition";
|
||||
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
|
||||
import type { ObservatoryRecordedJob } from "./recordedJobs";
|
||||
|
||||
const OPEN = new Set(["accepted", "queued", "claimed", "running", "paused",
|
||||
"preemption-pending", "reconciliation-required"]);
|
||||
|
||||
interface Snapshot {
|
||||
readonly sourceSessionId: string;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly runs: readonly AICompositionRun[];
|
||||
readonly moduleLabelsBySetup: Readonly<Record<string, string>>;
|
||||
readonly progress: Readonly<Record<string, RecordedProgressView>>;
|
||||
readonly error: string | null;
|
||||
}
|
||||
|
||||
export function useAICompositionJobs(sourceSessionId: string) {
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<Snapshot>({
|
||||
sourceSessionId: "", jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
|
||||
});
|
||||
const current = snapshot.sourceSessionId === sourceSessionId ? snapshot : {
|
||||
sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceSessionId) {
|
||||
setSnapshot({ sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null });
|
||||
return;
|
||||
}
|
||||
const request = new AbortController();
|
||||
void Promise.all([
|
||||
fetchAICompositionJobs(sourceSessionId, request.signal),
|
||||
fetchAICompositionRuns(sourceSessionId, request.signal),
|
||||
fetchAIModuleCatalog(request.signal),
|
||||
]).then(async ([jobs, runs, catalog]) => {
|
||||
const active = jobs.filter((job) => OPEN.has(job.state));
|
||||
const samples = await Promise.all(active.map(async (job) => [
|
||||
job.jobId, await fetchRecordedProgress(job, { signal: request.signal }).catch(() => null),
|
||||
] as const));
|
||||
if (request.signal.aborted) return;
|
||||
const catalogLabels = catalog.groups.flatMap(({ modules }) => (
|
||||
modules.flatMap((module) => {
|
||||
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
|
||||
return setupId ? [[setupId, module.label] as const] : [];
|
||||
})
|
||||
));
|
||||
const projectedLabels = runs.flatMap((run) => run.presentation.modules.flatMap((module) => {
|
||||
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
|
||||
return setupId ? [[setupId, module.label] as const] : [];
|
||||
}));
|
||||
setSnapshot({
|
||||
sourceSessionId, jobs, runs,
|
||||
moduleLabelsBySetup: Object.fromEntries([...catalogLabels, ...projectedLabels]),
|
||||
progress: Object.fromEntries(samples.filter(
|
||||
(sample): sample is readonly [string, RecordedProgressView] => sample[1] !== null,
|
||||
)),
|
||||
error: null,
|
||||
});
|
||||
}).catch((error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
setSnapshot((value) => ({
|
||||
sourceSessionId, jobs: value.sourceSessionId === sourceSessionId ? value.jobs : [],
|
||||
runs: value.sourceSessionId === sourceSessionId ? value.runs : [],
|
||||
moduleLabelsBySetup: value.sourceSessionId === sourceSessionId
|
||||
? value.moduleLabelsBySetup : {},
|
||||
progress: value.sourceSessionId === sourceSessionId ? value.progress : {},
|
||||
error: error instanceof Error ? error.message : "Очередь AI-слоёв недоступна.",
|
||||
}));
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
|
||||
const active = current.runs.some((run) => run.state === "running")
|
||||
|| current.jobs.some((job) => OPEN.has(job.state) || job.publication.state === "pending");
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = globalThis.setTimeout(() => setRevision((value) => value + 1), 1_500);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [active, revision]);
|
||||
|
||||
return {
|
||||
jobs: current.jobs,
|
||||
runs: current.runs,
|
||||
moduleLabelsBySetup: current.moduleLabelsBySetup,
|
||||
progress: current.progress,
|
||||
error: current.error,
|
||||
refresh: useCallback(() => setRevision((value) => value + 1), []),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
applyObservatoryCatalogMutationOverlay,
|
||||
fetchObservatoryCatalog,
|
||||
reconcileObservatoryCatalogMutationOverlay,
|
||||
type ObservatoryCatalog,
|
||||
type ObservatoryCatalogMutationOverlay,
|
||||
} from "./catalog";
|
||||
|
||||
export type ObservatoryCatalogState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "refreshing"
|
||||
| "error";
|
||||
|
||||
export interface ObservatoryCatalogController {
|
||||
readonly catalog: ObservatoryCatalog | null;
|
||||
readonly state: ObservatoryCatalogState;
|
||||
readonly error: string | null;
|
||||
readonly refresh: () => Promise<ObservatoryCatalog | null>;
|
||||
readonly applyEvidenceRename: (
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => void;
|
||||
readonly applyEvidenceDeletion: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface ActiveCatalogRequest {
|
||||
readonly id: number;
|
||||
readonly controller: AbortController;
|
||||
}
|
||||
|
||||
type ObservatoryCatalogMutationDraft =
|
||||
| { readonly kind: "rename"; readonly displayName: string }
|
||||
| { readonly kind: "delete" };
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Каталог AI Inference недоступен.";
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useObservatoryCatalog(): ObservatoryCatalogController {
|
||||
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
||||
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
||||
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
|
||||
const mutationRevisionRef = useRef(0);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
|
||||
|
||||
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
|
||||
const id = requestSequenceRef.current + 1;
|
||||
requestSequenceRef.current = id;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequestRef.current = { id, controller };
|
||||
const requestMutationRevision = mutationRevisionRef.current;
|
||||
|
||||
setState(catalogRef.current ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
try {
|
||||
const serverCatalog = await fetchObservatoryCatalog({
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted || requestSequenceRef.current !== id) {
|
||||
return null;
|
||||
}
|
||||
const reconciled = reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog,
|
||||
overlayRef.current,
|
||||
requestMutationRevision,
|
||||
);
|
||||
overlayRef.current = reconciled.overlay;
|
||||
catalogRef.current = reconciled.catalog;
|
||||
activeRequestRef.current = null;
|
||||
setCatalog(reconciled.catalog);
|
||||
setState("ready");
|
||||
return reconciled.catalog;
|
||||
} catch (loadError: unknown) {
|
||||
if (
|
||||
controller.signal.aborted
|
||||
|| requestSequenceRef.current !== id
|
||||
|| isAbortError(loadError)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
setError(errorMessage(loadError));
|
||||
setState("error");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
return () => {
|
||||
requestSequenceRef.current += 1;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
activeRequestRef.current = null;
|
||||
};
|
||||
}, [loadCatalog]);
|
||||
|
||||
const applyMutation = useCallback((
|
||||
sessionId: string,
|
||||
mutation: ObservatoryCatalogMutationDraft,
|
||||
) => {
|
||||
const revision = mutationRevisionRef.current + 1;
|
||||
mutationRevisionRef.current = revision;
|
||||
const overlay = new Map(overlayRef.current);
|
||||
overlay.set(sessionId, { ...mutation, revision });
|
||||
overlayRef.current = overlay;
|
||||
if (!catalogRef.current) return;
|
||||
const projected = applyObservatoryCatalogMutationOverlay(
|
||||
catalogRef.current,
|
||||
overlay,
|
||||
);
|
||||
catalogRef.current = projected;
|
||||
setCatalog(projected);
|
||||
}, []);
|
||||
|
||||
const applyEvidenceRename = useCallback((
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => {
|
||||
applyMutation(sessionId, { kind: "rename", displayName });
|
||||
}, [applyMutation]);
|
||||
|
||||
const applyEvidenceDeletion = useCallback((sessionId: string) => {
|
||||
applyMutation(sessionId, { kind: "delete" });
|
||||
}, [applyMutation]);
|
||||
|
||||
return {
|
||||
catalog,
|
||||
state,
|
||||
error,
|
||||
refresh: loadCatalog,
|
||||
applyEvidenceRename,
|
||||
applyEvidenceDeletion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryPortableLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
selectableObservatorySetups,
|
||||
type ObservatoryLaboratoryRunPreflight,
|
||||
type ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error";
|
||||
export type SetupPreflightState =
|
||||
| { readonly kind: "idle" }
|
||||
| { readonly kind: "checking" }
|
||||
| { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight }
|
||||
| { readonly kind: "error"; readonly message: string };
|
||||
|
||||
export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
||||
const [catalog, setCatalog] = useState<ObservatoryLaboratorySetupCatalog | null>(null);
|
||||
const [state, setState] = useState<SetupCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSetupId, setSelectedSetupId] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [preflight, setPreflight] = useState<SetupPreflightState>({ kind: "idle" });
|
||||
const requestSequence = useRef(0);
|
||||
const preflightRequest = useRef<AbortController | null>(null);
|
||||
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
|
||||
const selectableSetups = useMemo(() => selectableObservatorySetups(activeCatalog), [activeCatalog]);
|
||||
const selectedSetup = selectableSetups.find((setup) => setup.setupId === selectedSetupId) ?? null;
|
||||
const selectedDefinitionSha256 = selectedSetup?.runDefinition?.definitionSha256 ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
if (!sourceSessionId) {
|
||||
setCatalog(null);
|
||||
setState("idle");
|
||||
setError(null);
|
||||
setSelectedSetupId("");
|
||||
setPreflight({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequence.current;
|
||||
const request = new AbortController();
|
||||
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
setPreflight({ kind: "idle" });
|
||||
// Archived definitions belong to evidence, not to the calculation selector.
|
||||
void fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||
.then((next) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setCatalog(next);
|
||||
const selectable = selectableObservatorySetups(next);
|
||||
setSelectedSetupId((current) => (
|
||||
selectable.some((setup) => setup.setupId === current)
|
||||
? current
|
||||
: selectable[0]?.setupId ?? ""
|
||||
));
|
||||
setState("ready");
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setState("error");
|
||||
setError(caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Каталог профилей недоступен.");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
|
||||
useEffect(() => () => preflightRequest.current?.abort(), []);
|
||||
|
||||
useEffect(() => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
|
||||
}, [selectedSetupId, sourceSessionId, selectedDefinitionSha256]);
|
||||
|
||||
const selectSetup = useCallback((setupId: string) => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
setSelectedSetupId(setupId);
|
||||
setPreflight({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return;
|
||||
preflightRequest.current?.abort();
|
||||
const request = new AbortController();
|
||||
preflightRequest.current = request;
|
||||
setPreflight({ kind: "checking" });
|
||||
try {
|
||||
const value = await preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId,
|
||||
selectedSetup,
|
||||
{ signal: request.signal },
|
||||
);
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({ kind: "ready", value });
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({
|
||||
kind: "error",
|
||||
message: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось проверить возможность запуска.",
|
||||
});
|
||||
} finally {
|
||||
if (preflightRequest.current === request) preflightRequest.current = null;
|
||||
}
|
||||
}, [preflight.kind, selectedSetup, sourceSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return;
|
||||
void check();
|
||||
}, [check, preflight.kind, selectedSetup, state]);
|
||||
|
||||
return {
|
||||
catalog: activeCatalog,
|
||||
selectableSetups,
|
||||
state,
|
||||
error,
|
||||
selectedSetupId: selectedSetup?.setupId ?? "",
|
||||
selectedSetup,
|
||||
selectSetup,
|
||||
refresh,
|
||||
preflight,
|
||||
check,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
|
||||
|
||||
import {
|
||||
fetchObservatoryRecordedJobs,
|
||||
retryObservatoryRecordedJobPublication,
|
||||
submitObservatoryRecordedJob,
|
||||
type ObservatoryRecordedJob,
|
||||
} from "./recordedJobs";
|
||||
|
||||
type RecordedJobsState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "refreshing"
|
||||
| "submitting"
|
||||
| "retrying-publication"
|
||||
| "error";
|
||||
|
||||
interface JobSnapshot {
|
||||
readonly selectionKey: string;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly state: RecordedJobsState;
|
||||
readonly error: string | null;
|
||||
readonly progress?: RecordedProgressView | null;
|
||||
}
|
||||
const EMPTY_JOBS = [] as const;
|
||||
const OPEN_STATES = new Set([
|
||||
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||
"reconciliation-required",
|
||||
]);
|
||||
const POLL_INTERVAL_MS = 1_500;
|
||||
|
||||
export function useObservatoryRecordedJobs(
|
||||
sourceSessionId: string,
|
||||
setupId: string,
|
||||
definitionSha256: string,
|
||||
) {
|
||||
const selectionKey = JSON.stringify([sourceSessionId, setupId, definitionSha256]);
|
||||
const [snapshot, setSnapshot] = useState<JobSnapshot>({
|
||||
selectionKey: "", jobs: EMPTY_JOBS, state: "idle", error: null,
|
||||
});
|
||||
const [revision, setRevision] = useState(0);
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const idempotencyKeys = useRef(new Map<string, string>());
|
||||
const observedJobId = useRef<string | null>(null);
|
||||
const hasSelection = Boolean(sourceSessionId && setupId && definitionSha256);
|
||||
// A source/profile change must not display even one render of the previous queue.
|
||||
const current = snapshot.selectionKey === selectionKey ? snapshot : null;
|
||||
const jobs = current?.jobs ?? EMPTY_JOBS;
|
||||
const state = current?.state ?? (hasSelection ? "loading" : "idle");
|
||||
const error = current?.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
requestRef.current?.abort();
|
||||
requestRef.current = null;
|
||||
if (!hasSelection) {
|
||||
setSnapshot({ selectionKey, jobs: EMPTY_JOBS, state: "idle", error: null });
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequence.current;
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
setSnapshot({
|
||||
selectionKey, jobs,
|
||||
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
|
||||
progress: current?.progress ?? null,
|
||||
});
|
||||
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
|
||||
definitionSha256, signal: request.signal,
|
||||
}).then(async (next) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
const pending = next.find((job) => OPEN_STATES.has(job.state));
|
||||
const progress = pending
|
||||
? await fetchRecordedProgress(pending, { signal: request.signal }).catch(() => null)
|
||||
: null;
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
if (pending) observedJobId.current = pending.jobId;
|
||||
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null, progress });
|
||||
}).catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setSnapshot({
|
||||
selectionKey, jobs, state: "error",
|
||||
error: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Очередь расчётов недоступна.",
|
||||
});
|
||||
}).finally(() => {
|
||||
if (requestRef.current === request) requestRef.current = null;
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, selectionKey]);
|
||||
|
||||
useEffect(() => () => requestRef.current?.abort(), []);
|
||||
|
||||
const latestJob = jobs[0] ?? null;
|
||||
const activeJob = jobs.find((job) => OPEN_STATES.has(job.state)) ?? null;
|
||||
const publicationPending = jobs.some((job) => job.publication.state === "pending");
|
||||
// Do not promote historical terminal logs to a new operator action's error.
|
||||
const computationFailed = latestJob?.state === "failed"
|
||||
&& latestJob.jobId === observedJobId.current;
|
||||
|
||||
useEffect(() => {
|
||||
if ((state !== "ready" && state !== "error") || (!activeJob && !publicationPending)) return;
|
||||
const timer = globalThis.setTimeout(
|
||||
() => setRevision((value) => value + 1),
|
||||
POLL_INTERVAL_MS,
|
||||
);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [activeJob, publicationPending, revision, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestJob || activeJob) return;
|
||||
idempotencyKeys.current.delete(selectionKey);
|
||||
}, [activeJob, latestJob, selectionKey]);
|
||||
|
||||
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
const submit = useCallback(async (
|
||||
portableBinding: {
|
||||
readonly definitionSha256: string;
|
||||
readonly checkSha256: string;
|
||||
} | null = null,
|
||||
): Promise<ObservatoryRecordedJob | null> => {
|
||||
if (!hasSelection || state !== "ready"
|
||||
|| portableBinding?.definitionSha256 !== definitionSha256
|
||||
|| publicationPending || latestJob?.publication.state === "failed") return null;
|
||||
if (activeJob) return activeJob;
|
||||
requestRef.current?.abort();
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
const key = idempotencyKeys.current.get(selectionKey) ?? createIdempotencyKey();
|
||||
idempotencyKeys.current.set(selectionKey, key);
|
||||
setSnapshot({ selectionKey, jobs, state: "submitting", error: null });
|
||||
try {
|
||||
const job = await submitObservatoryRecordedJob(
|
||||
sourceSessionId, setupId, key, portableBinding, { signal: request.signal },
|
||||
);
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
observedJobId.current = job.jobId;
|
||||
setSnapshot({
|
||||
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||
state: "ready", error: null,
|
||||
});
|
||||
return job;
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
setSnapshot({
|
||||
selectionKey, jobs, state: "error",
|
||||
error: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось поставить расчёт в очередь.",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
if (requestRef.current === request) requestRef.current = null;
|
||||
}
|
||||
}, [activeJob, definitionSha256, hasSelection, jobs, latestJob,
|
||||
publicationPending, selectionKey, setupId, sourceSessionId, state]);
|
||||
|
||||
const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
|
||||
if (!latestJob || latestJob.publication.state !== "failed"
|
||||
|| state === "retrying-publication") return null;
|
||||
requestRef.current?.abort();
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
setSnapshot({ selectionKey, jobs, state: "retrying-publication", error: null });
|
||||
try {
|
||||
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
|
||||
signal: request.signal,
|
||||
});
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
setSnapshot({
|
||||
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||
state: "ready", error: null,
|
||||
});
|
||||
return job;
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
setSnapshot({
|
||||
selectionKey, jobs, state: "error",
|
||||
error: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось повторить публикацию результата.",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
if (requestRef.current === request) requestRef.current = null;
|
||||
}
|
||||
}, [jobs, latestJob, selectionKey, state]);
|
||||
|
||||
return {
|
||||
jobs, latestJob, activeJob, publicationPending, computationFailed,
|
||||
progress: current?.progress ?? null,
|
||||
state, error, refresh, submit, retryPublication,
|
||||
};
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const entropy = typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
||||
return `observatory-ui:${entropy}`;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedJobsController = ReturnType<typeof useObservatoryRecordedJobs>;
|
||||
@@ -38,6 +38,8 @@ export function useWorkerTelemetry(
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setTelemetry(null);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
const stopPolling = startSequentialPolling(async () => {
|
||||
setLoading(true);
|
||||
@@ -48,6 +50,7 @@ export function useWorkerTelemetry(
|
||||
setError(null);
|
||||
} catch (reason: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
setTelemetry(null);
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
|
||||
@@ -114,6 +114,9 @@ export interface WorkerTelemetry {
|
||||
power_watts?: number;
|
||||
temperature_celsius?: number;
|
||||
memory_used_percent?: number;
|
||||
sm_clock_mhz?: number | null;
|
||||
memory_clock_mhz?: number | null;
|
||||
driver_version?: string | null;
|
||||
} | null;
|
||||
triton: {
|
||||
ready: boolean;
|
||||
@@ -131,6 +134,13 @@ export interface WorkerTelemetry {
|
||||
completed_runs: number | null;
|
||||
failed_runs: number | null;
|
||||
model_load_seconds: number | null;
|
||||
profile_name?: string | null;
|
||||
input_state?: string | null;
|
||||
wait_reason?: string | null;
|
||||
live_children?: number | null;
|
||||
input_pauses?: number | null;
|
||||
pending_bundles?: number | null;
|
||||
buffer_bytes?: number | null;
|
||||
stages: WorkerPipelineStage[];
|
||||
};
|
||||
network: {
|
||||
@@ -189,7 +199,9 @@ export async function fetchWorkerTelemetry(
|
||||
`/api/v1/system/contours/${encodeURIComponent(contourId)}/telemetry?history=90`,
|
||||
{
|
||||
method: "GET",
|
||||
signal,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(5_000)])
|
||||
: AbortSignal.timeout(5_000),
|
||||
},
|
||||
),
|
||||
"missioncore.worker-telemetry/v1",
|
||||
|
||||
@@ -16,6 +16,7 @@ export type WorkspaceKind =
|
||||
| "map"
|
||||
| "timeline"
|
||||
| "missions"
|
||||
| "vehicles"
|
||||
| "catalog"
|
||||
| "contour-health"
|
||||
| "compute-modules"
|
||||
@@ -23,7 +24,8 @@ export type WorkspaceKind =
|
||||
| "datasets"
|
||||
| "artifact-health"
|
||||
| "lab-archive"
|
||||
| "simulations";
|
||||
| "simulations"
|
||||
| "observatory";
|
||||
|
||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||
|
||||
@@ -166,6 +168,17 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
kind: "simulations",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "AI Inference",
|
||||
title: "AI Inference",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / AI INFERENCE",
|
||||
description: "Сессии и квалификация без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "contour-health",
|
||||
root: "fleet",
|
||||
@@ -181,23 +194,12 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
id: "vehicles",
|
||||
root: "fleet",
|
||||
label: "Аппараты",
|
||||
title: "Реестр аппаратов",
|
||||
title: "Аппараты",
|
||||
eyebrow: "ПАРК / РЕЕСТР",
|
||||
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
|
||||
description: "Аппараты и их бортовые компьютеры в частном контуре.",
|
||||
icon: "apps",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Идентичность аппарата",
|
||||
description: "Никакой привязки продуктовой модели к конкретному производителю.",
|
||||
capabilities: [
|
||||
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
|
||||
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
|
||||
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
|
||||
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
|
||||
],
|
||||
},
|
||||
],
|
||||
kind: "vehicles",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "local-device",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
@import "./styles/observation.css";
|
||||
@import "./styles/observatory.css";
|
||||
@import "./styles/environment-settings.css";
|
||||
@import "./styles/system-telemetry.css";
|
||||
@import "./styles/artifact-health.css";
|
||||
|
||||
@@ -27,6 +27,78 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__unified-content {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__unified-content > *,
|
||||
.m4-replay-threat-visual__unified-content .rerun-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"] > .nodedc-split-pane {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.nodedc-split-pane__separator,
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane-toolbar,
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane-toolbar * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.m4-replay-threat-visual__pane-toolbar,
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.laboratory-evidence-viewer__controls,
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.laboratory-evidence-viewer__transport {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay[data-presentation-state="loading"]
|
||||
.m4-replay-threat-visual__unified-content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__loading {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--nodedc-canvas);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -73,12 +145,14 @@
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]:has(
|
||||
@@ -88,12 +162,17 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
||||
right: 3.8rem;
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"],
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.m4-replay-threat-visual__deck:not([data-split="true"])
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
/* The two viewer-wide actions stay at the deck's right edge. Keep their
|
||||
complete hit area out of this viewport-owned toolbar at every split. */
|
||||
right: 7rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar > *,
|
||||
@@ -102,18 +181,15 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: none;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
|
||||
display: none;
|
||||
.m4-replay-threat-visual__pane-toolbar > .m4-replay-threat-visual__pane-layer-controls,
|
||||
.m4-replay-threat-visual__spatial-toolbar-end > .m4-replay-threat-visual__pane-layer-controls {
|
||||
/* A viewport toolbar is one adaptive flex sequence. `display: contents`
|
||||
keeps the semantic layer group while allowing each control to move to the
|
||||
next row only when the viewport itself runs out of width. */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-mode-controls {
|
||||
@@ -162,7 +238,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--nodedc-glass-outline);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.42rem 0.58rem;
|
||||
@@ -183,6 +260,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.laboratory-evidence-viewer__controls {
|
||||
right: 0.6rem;
|
||||
left: auto;
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -207,6 +293,30 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.18rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
padding: 0.12rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button:focus-visible {
|
||||
background: var(--nodedc-focus-surface);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__review-controls,
|
||||
.m4-replay-threat-visual__review-controls > *,
|
||||
.m4-replay-threat-visual__single-pane-controls,
|
||||
@@ -232,6 +342,10 @@
|
||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback {
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
.observatory-workspace {
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
container-name: observatory-workspace;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice,
|
||||
.observatory-evidence > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observatory-notice {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__copy,
|
||||
.observatory-evidence header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar h3,
|
||||
.observatory-session-summary h3,
|
||||
.observatory-evidence h3 {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar h3,
|
||||
.observatory-session-summary h3,
|
||||
.observatory-evidence h3 {
|
||||
font-size: var(--nodedc-font-size-lg);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.observatory-state p,
|
||||
.observatory-evidence-empty p {
|
||||
max-width: 52rem;
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar,
|
||||
.observatory-session-summary {
|
||||
align-self: start;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__copy {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
align-content: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar h3 {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||
flex: 1 1 18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls > .nodedc-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__run {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.observatory-state {
|
||||
display: grid;
|
||||
min-height: 19rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observatory-state h3 {
|
||||
max-width: 42rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-stack {
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
gap: 0.9rem;
|
||||
container-name: observatory-session;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-session-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity h3 {
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.24rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.observatory-session-summary__end {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
justify-items: end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.observatory-modalities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-evidence {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.observatory-evidence > header {
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-evidence__ordering {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort {
|
||||
display: grid;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort:hover,
|
||||
.observatory-evidence__sort:focus-visible {
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observatory-evidence__sort svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort path {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort[data-direction="oldest"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.observatory-evidence-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.observatory-evidence-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-height: 4.25rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__icon {
|
||||
display: grid;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
place-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-icon-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy strong,
|
||||
.observatory-evidence-card__copy span,
|
||||
.observatory-evidence-card__copy small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy strong {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-evidence-card--progress {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__progress {
|
||||
height: 0.28rem;
|
||||
margin: 0.18rem 0 0.08rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--nodedc-accent-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
transition: width 180ms ease-out;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__actions,
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state,
|
||||
.observatory-replay-state__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.observatory-replay-state {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-replay__header > div,
|
||||
.observatory-replay-state > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observatory-replay,
|
||||
.observatory-replay-state {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-replay {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observatory-replay__header {
|
||||
padding: 0.25rem 0.25rem 0;
|
||||
}
|
||||
|
||||
.observatory-replay__header h3,
|
||||
.observatory-replay-state h3 {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-replay__header h3 {
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.observatory-replay__header p,
|
||||
.observatory-replay-state p,
|
||||
.observatory-replay__description {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-family: var(--nodedc-font-family);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-regular);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.observatory-replay__description {
|
||||
margin-right: 0.25rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.observatory-replay .m4-replay-threat-visual__buffering {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-replay .laboratory-evidence-viewer {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.observatory-evidence-empty {
|
||||
display: grid;
|
||||
min-height: 13rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.55rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observatory-notice {
|
||||
justify-content: flex-start;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-notice__copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.observatory-rename-form,
|
||||
.observatory-delete-confirmation {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.observatory-delete-confirmation p,
|
||||
.observatory-mutation-error {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.observatory-mutation-error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 46rem) {
|
||||
.observatory-session-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.observatory-session-summary__end,
|
||||
.observatory-modalities {
|
||||
justify-items: start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 48rem) {
|
||||
.observatory-evidence-card {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observatory-evidence-card > .nodedc-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 38rem) {
|
||||
.observatory-session-summary__facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy small {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-workspace (max-width: 920px) {
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__run {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.observatory-ai-config {
|
||||
display: grid;
|
||||
gap: var(--nodedc-space-4);
|
||||
}
|
||||
|
||||
.observatory-ai-config__lead,
|
||||
.observatory-ai-config__error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observatory-ai-config__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observatory-ai-config__duplicate {
|
||||
margin-right: auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes observatory-ai-calculate-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background-color: var(--nodedc-primary-action-bg);
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--nodedc-primary-action-bg) 88%,
|
||||
var(--nodedc-canvas-soft)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.observatory-ai-config__calculate--saving:disabled {
|
||||
animation: observatory-ai-calculate-pulse 1.8s ease-in-out infinite;
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: var(--nodedc-shadow-window);
|
||||
}
|
||||
|
||||
.observatory-ai-config-window::before,
|
||||
.observatory-ai-config-window::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--nodedc-space-3);
|
||||
padding: var(--nodedc-space-3) var(--nodedc-space-4);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header .nodedc-status {
|
||||
min-width: 0;
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-ai-module-group__body {
|
||||
padding: var(--nodedc-space-4);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group__body .nodedc-select-anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window .nodedc-select-trigger,
|
||||
.observatory-ai-config-window .nodedc-window__close {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window .nodedc-select-trigger:focus-visible,
|
||||
.observatory-ai-config-window .nodedc-window__close:focus-visible {
|
||||
background-color: var(--nodedc-focus-surface);
|
||||
box-shadow:
|
||||
var(--nodedc-glass-control-shadow),
|
||||
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.observatory-ai-config-window .nodedc-window__title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header .nodedc-status {
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.observatory-ai-config__calculate--saving:disabled {
|
||||
animation: none;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--nodedc-primary-action-bg) 94%,
|
||||
var(--nodedc-canvas-soft)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
|
||||
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
@@ -88,6 +88,13 @@
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__runtime {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
||||
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
@@ -40,13 +42,13 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
|
||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
||||
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
if (status === "contract") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||
return (
|
||||
<div className="feature-inventory">
|
||||
@@ -76,7 +78,6 @@ function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||
return (
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
@@ -89,7 +90,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
@@ -181,7 +181,7 @@ function SpatialWorkspace({
|
||||
);
|
||||
const recordedPerceptionSupported =
|
||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready";
|
||||
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||
const recordedPerceptionEnabled =
|
||||
showDetections2d || showSegmentation || showCuboids3d;
|
||||
// The native recorded camera remains the authoritative original. Only 2D
|
||||
@@ -261,7 +261,7 @@ function SpatialWorkspace({
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
@@ -381,6 +381,35 @@ function SpatialWorkspace({
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
const rerunViewerProfile = recordedSource
|
||||
? recordedSessionRerunProfile({
|
||||
sourceUrl,
|
||||
artifact: recordedReplay,
|
||||
autoplayWhenReady: true,
|
||||
presentationGate: recordedSessionGate,
|
||||
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
|
||||
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
|
||||
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
|
||||
view: "spatial",
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: followRecordedTrajectory,
|
||||
perceptionLayers: {
|
||||
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
},
|
||||
perceptionRetryGeneration,
|
||||
lockPerceptionCameraInteraction: unifiedPerception,
|
||||
})
|
||||
: liveAcquisitionRerunProfile({
|
||||
sourceUrl,
|
||||
liveActivitySequence: livePresentationActivitySequence,
|
||||
liveStreamId: state?.spatialSource?.id ?? null,
|
||||
liveRecoveryAuthorityIdentity: streamActive
|
||||
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
|
||||
: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -405,7 +434,7 @@ function SpatialWorkspace({
|
||||
variant={detections2dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={detections2dActive}
|
||||
disabled={recordedSource && !recordedPerceptionReady}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowDetections2d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
@@ -420,7 +449,7 @@ function SpatialWorkspace({
|
||||
variant={segmentationActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="image" />}
|
||||
aria-pressed={segmentationActive}
|
||||
disabled={recordedSource && !recordedPerceptionReady}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowSegmentation((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
@@ -435,7 +464,7 @@ function SpatialWorkspace({
|
||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="apps" />}
|
||||
aria-pressed={cuboids3dActive}
|
||||
disabled={recordedSource && !recordedPerceptionReady}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowCuboids3d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
@@ -489,35 +518,8 @@ function SpatialWorkspace({
|
||||
>
|
||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
||||
followLive={liveRerunSource}
|
||||
liveActivitySequence={livePresentationActivitySequence}
|
||||
liveStreamId={state?.spatialSource?.id}
|
||||
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
|
||||
autoplayWhenReady={recordedSource}
|
||||
presentationGate={recordedSessionGate}
|
||||
expectedTimelineStartSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.startSeconds
|
||||
: undefined}
|
||||
expectedTimelineEndSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.endSeconds
|
||||
: undefined}
|
||||
initialPlaybackStartSeconds={initialRecordedPlaybackStartSeconds}
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
recordedViewResetGeneration={recordedViewResetGeneration}
|
||||
recordedFollowTrajectory={followRecordedTrajectory}
|
||||
recordedPerceptionLayers={{
|
||||
enabled:
|
||||
recordedPerceptionSupported &&
|
||||
recordedPerceptionReady &&
|
||||
recordedPerceptionEnabled,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
}}
|
||||
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
|
||||
lockPerceptionCameraInteraction={unifiedPerception}
|
||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||
onPointColorLoadChange={onPointColorLoadChange}
|
||||
onStatusChange={onStatusChange}
|
||||
@@ -905,7 +907,7 @@ function CamerasWorkspace({
|
||||
if (!recordedReplay) return true;
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedReplay, recordedSessionAdmission]);
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||
@@ -1166,6 +1168,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "contour-health":
|
||||
@@ -1191,6 +1195,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
);
|
||||
case "simulations":
|
||||
return <SimulationWorkspace />;
|
||||
case "observatory":
|
||||
return <ObservatoryWorkspace definition={props.definition} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface LaboratoryViewAction {
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
fleetCreateRequest?: number;
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {useMemo} from 'react';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity: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'};},
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
|
||||
import "./fleet.css";
|
||||
import { VehicleSensors } from "./VehicleSensors";
|
||||
|
||||
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
|
||||
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
|
||||
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
|
||||
|
||||
export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) {
|
||||
const fleet = useFleet();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [platform, setPlatform] = useState("ugv");
|
||||
const [preview, setPreview] = useState<FleetPreview | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
|
||||
async function inspect(event: React.FormEvent) {
|
||||
event.preventDefault(); if (pending) return;
|
||||
setPending(true); setError("");
|
||||
try { const value = await fleetRequest<FleetPreview>("/preview", "POST", { code: code.trim() }); setPreview(value); setName(current => current || value.name); setCode(""); }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось проверить приглашение."); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function add() {
|
||||
if (!preview || pending) return;
|
||||
setPending(true); setError("");
|
||||
try {
|
||||
const item = await fleetRequest<Vehicle>("", "POST", { preview_id: preview.preview_id, name: name.trim(), platform });
|
||||
await fleet.refresh(); setSelected(item.id); setAdding(false); setPreview(null); setCode(""); setName("");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "Не удалось добавить аппарат."); void fleet.refresh().catch(() => undefined); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
{detail ? <>
|
||||
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
|
||||
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
|
||||
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</form>
|
||||
</Window>
|
||||
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
|
||||
.fleet-workspace { padding: var(--nodedc-space-4); }
|
||||
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
|
||||
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
|
||||
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.fleet-facts dt { color: var(--nodedc-text-secondary); }
|
||||
.fleet-facts dd { color: var(--nodedc-text-muted); }
|
||||
@@ -51,6 +51,7 @@ import { M48TRiskQualityResultView } from "./M48TRiskQualityResult";
|
||||
import { M49TgsFailClosedResultView } from "./M49TgsFailClosedResult";
|
||||
import { M49TgsFullShadowResultView } from "./M49TgsFullShadowResult";
|
||||
import { VegetationShadowResultView } from "./VegetationShadowResult";
|
||||
import { VegetationBenchmarkResultView } from "./VegetationBenchmarkResult";
|
||||
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
@@ -93,6 +94,9 @@ export function AdvancedLaboratoryResult({
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "lab-v1-vegetation-benchmark" && results.vegetationBenchmark) {
|
||||
return <VegetationBenchmarkResultView rigLabel={rigLabel} result={results.vegetationBenchmark} />;
|
||||
}
|
||||
if (workId === "lab-v1-vegetation-shadow" && results.vegetationShadow) {
|
||||
return <VegetationShadowResultView rigLabel={rigLabel} result={results.vegetationShadow} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
buildLaboratoryProfiles,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
isLegacyPublishedLaboratoryWork,
|
||||
workOptionsForExperiment,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
import {
|
||||
@@ -499,12 +500,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
onDeleteBegin: props.sessionArchive.onDeleteBegin,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => (
|
||||
session.lab !== null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)).sort((left, right) => (
|
||||
() => sessions.items.filter(isLegacyPublishedLaboratoryWork).sort((left, right) => (
|
||||
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
|
||||
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
|
||||
)),
|
||||
|
||||
@@ -71,7 +71,6 @@ export function M49TgsFullShadowEvidence({
|
||||
const controller = new AbortController();
|
||||
setSemantic(null);
|
||||
setSemanticError(null);
|
||||
if (semanticOverride) return () => controller.abort();
|
||||
void fetchE47SemanticSlamResult({
|
||||
resultId: result.source.linkedSemanticResultId,
|
||||
signal: controller.signal,
|
||||
@@ -87,7 +86,7 @@ export function M49TgsFullShadowEvidence({
|
||||
if (!controller.signal.aborted) setSemanticError(message(caught));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId, semanticOverride]);
|
||||
}, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -201,15 +200,40 @@ export function M49TgsFullShadowEvidence({
|
||||
const handleSequenceChange = useCallback((sequence: number | null) => {
|
||||
setActiveSequence(sequence);
|
||||
}, []);
|
||||
const semanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(() => [
|
||||
...(semantic ? [{
|
||||
id: "urban",
|
||||
controlLabel: "ГОРОД · EoMT",
|
||||
resultId: semantic.resultId,
|
||||
taxonomy: semantic.taxonomy,
|
||||
label: "EoMT Cityscapes semantic · recorded video",
|
||||
maskAriaLabel: "EoMT urban semantic prediction",
|
||||
}] : []),
|
||||
...(semanticOverride ? [{
|
||||
...semanticOverride,
|
||||
id: semanticOverride.id ?? "vegetation",
|
||||
controlLabel: semanticOverride.controlLabel ?? "ПРИРОДА · DDRNet",
|
||||
}] : []),
|
||||
], [semantic, semanticOverride]);
|
||||
const spatialSemantic = useMemo<M4ReplayThreatSemanticLayer | undefined>(() => (
|
||||
semantic ? {
|
||||
id: "spatial-urban",
|
||||
controlLabel: "SEMANTICS",
|
||||
resultId: semantic.resultId,
|
||||
spatialResultId: semantic.resultId,
|
||||
taxonomy: semantic.taxonomy,
|
||||
label: "EoMT Cityscapes semantic · point-aligned E47",
|
||||
maskAriaLabel: "EoMT urban semantic prediction",
|
||||
} : undefined
|
||||
), [semantic]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<M4ReplayThreatVisual
|
||||
resultId={result.source.linkedVisualResultId}
|
||||
semantic={semanticOverride ?? (semantic ? {
|
||||
resultId: semantic.resultId,
|
||||
taxonomy: semantic.taxonomy,
|
||||
} : undefined)}
|
||||
semanticLayers={semanticLayers}
|
||||
spatialSemantic={spatialSemantic}
|
||||
initialSemanticLayerId={semanticOverride ? "vegetation" : "urban"}
|
||||
showReviewAnchorBoxes={false}
|
||||
reviewLabel="4 489 source-paced TGS frames"
|
||||
evidenceLabel={evidenceLabel}
|
||||
@@ -227,7 +251,7 @@ export function M49TgsFullShadowEvidence({
|
||||
replacePointCloud: false,
|
||||
}}
|
||||
/>
|
||||
{!semanticOverride && semanticError ? (
|
||||
{semanticError ? (
|
||||
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
||||
Semantic overlay недоступен: {semanticError}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
SegmentedControl,
|
||||
SplitPane,
|
||||
type SplitPaneOrientation,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import {
|
||||
CanonicalRecordedLabReplay,
|
||||
useCanonicalRecordedLabReplayState,
|
||||
} from "../../components/laboratory/CanonicalRecordedLabReplay";
|
||||
import {
|
||||
LaboratoryMetricEvidenceScene,
|
||||
type LaboratoryMetricCellEvidence,
|
||||
@@ -35,6 +45,7 @@ import {
|
||||
type E47SemanticClass,
|
||||
type E47SemanticTimelineFrame,
|
||||
} from "../../core/laboratory/e47SemanticSlam";
|
||||
import { laboratoryRecordedEvidenceDemand } from "../../core/laboratory/recordedEvidenceProfile";
|
||||
import type {
|
||||
M4ThreatCameraProposal,
|
||||
M4ThreatTimelineFrame,
|
||||
@@ -52,8 +63,6 @@ import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
||||
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
|
||||
|
||||
type M4ThreatMediaMode = "video" | "camera";
|
||||
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
|
||||
type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none";
|
||||
|
||||
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||
if (proposal.threatDecision === "threat") return "danger";
|
||||
@@ -96,6 +105,8 @@ function SpatialState({ message: text }: { message: string }) {
|
||||
}
|
||||
|
||||
export interface M4ReplayThreatSemanticLayer {
|
||||
id?: string;
|
||||
controlLabel?: string;
|
||||
resultId: string;
|
||||
spatialResultId?: string | null;
|
||||
maskUrl?: (sequence: number) => string;
|
||||
@@ -142,6 +153,7 @@ export interface M4ReplayClassifiedSpatialLayer {
|
||||
label: string;
|
||||
pointLayerLabel: string;
|
||||
cellLayerLabel: string;
|
||||
cellLayerAvailable?: boolean;
|
||||
expectedAtSequence: boolean;
|
||||
frame: M4ReplayClassifiedSpatialFrame | null;
|
||||
loading: boolean;
|
||||
@@ -155,6 +167,9 @@ const EMPTY_REVIEW_ANCHORS: readonly M4ReplayThreatReviewAnchor[] = [];
|
||||
export function M4ReplayThreatVisual({
|
||||
resultId,
|
||||
semantic,
|
||||
semanticLayers,
|
||||
spatialSemantic,
|
||||
initialSemanticLayerId,
|
||||
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
||||
showReviewAnchorBoxes = true,
|
||||
reviewLabel = "Контрольные примеры M4.8R1",
|
||||
@@ -164,10 +179,16 @@ export function M4ReplayThreatVisual({
|
||||
classifiedSpatialLayer,
|
||||
showReferenceMediaLayers = true,
|
||||
showSpatialOverlaySummary = true,
|
||||
playbackTransport = "epoch-stream",
|
||||
spatialPlaybackTransport = "auto",
|
||||
recoverTimestampStalls = false,
|
||||
onActiveSequenceChange,
|
||||
}: {
|
||||
resultId: string;
|
||||
semantic?: M4ReplayThreatSemanticLayer;
|
||||
semanticLayers?: readonly M4ReplayThreatSemanticLayer[];
|
||||
spatialSemantic?: M4ReplayThreatSemanticLayer;
|
||||
initialSemanticLayerId?: string;
|
||||
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
||||
showReviewAnchorBoxes?: boolean;
|
||||
reviewLabel?: string;
|
||||
@@ -177,12 +198,26 @@ export function M4ReplayThreatVisual({
|
||||
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
||||
showReferenceMediaLayers?: boolean;
|
||||
showSpatialOverlaySummary?: boolean;
|
||||
playbackTransport?: "segmented" | "epoch-stream";
|
||||
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||
recoverTimestampStalls?: boolean;
|
||||
onActiveSequenceChange?: (sequence: number | null) => void;
|
||||
}) {
|
||||
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>(
|
||||
const {
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
splitView,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
expanded,
|
||||
onMediaModeChange: handleMediaModeChange,
|
||||
onSpatialModeChange: handleSpatialModeChange,
|
||||
onSplitPrimarySizeChange: setSplitPrimarySize,
|
||||
onExpandedChange: setExpanded,
|
||||
} = useCanonicalRecordedLabReplayState<M4ThreatMediaMode, LaboratoryMetricSceneMode>({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode,
|
||||
);
|
||||
});
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
@@ -191,14 +226,58 @@ export function M4ReplayThreatVisual({
|
||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
||||
const [showStaticObstacles, setShowStaticObstacles] = useState(true);
|
||||
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
||||
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||
? "horizontal"
|
||||
: "vertical"
|
||||
));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
|
||||
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
|
||||
() => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [],
|
||||
[semantic, semanticLayers],
|
||||
);
|
||||
const semanticLayerIdentity = availableSemanticLayers
|
||||
.map((layer, index) => layer.id ?? `${layer.resultId}:${index}`)
|
||||
.join("|");
|
||||
const [selectedSemanticLayerId, setSelectedSemanticLayerId] = useState(
|
||||
initialSemanticLayerId ?? "",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!availableSemanticLayers.length) {
|
||||
setSelectedSemanticLayerId("");
|
||||
return;
|
||||
}
|
||||
const selectedStillExists = availableSemanticLayers.some(
|
||||
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
|
||||
);
|
||||
if (selectedStillExists) return;
|
||||
const preferred = initialSemanticLayerId
|
||||
? availableSemanticLayers.find((layer) => layer.id === initialSemanticLayerId)
|
||||
: null;
|
||||
const next = preferred ?? availableSemanticLayers[0]!;
|
||||
const nextIndex = availableSemanticLayers.indexOf(next);
|
||||
setSelectedSemanticLayerId(next.id ?? `${next.resultId}:${nextIndex}`);
|
||||
}, [availableSemanticLayers, initialSemanticLayerId, semanticLayerIdentity, selectedSemanticLayerId]);
|
||||
const activeSemantic = availableSemanticLayers.find(
|
||||
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
|
||||
) ?? availableSemanticLayers[0];
|
||||
const activeSpatialSemantic = spatialSemantic ?? activeSemantic;
|
||||
const evidenceDemand = useMemo(() => laboratoryRecordedEvidenceDemand({
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic,
|
||||
showSpatialSemantic: Boolean(activeSpatialSemantic) && showSpatialSemantic,
|
||||
showMediaPoints,
|
||||
classifiedSpatialMode: !classifiedSpatialLayer || classifiedSpatialLayer.cellLayerAvailable === false
|
||||
? "none"
|
||||
: classifiedSpatialLayer.replacePointCloud
|
||||
? "replace-source"
|
||||
: "overlay",
|
||||
}), [
|
||||
activeSemantic,
|
||||
activeSpatialSemantic,
|
||||
classifiedSpatialLayer,
|
||||
mediaMode,
|
||||
showMediaPoints,
|
||||
showMediaSemantic,
|
||||
showSpatialSemantic,
|
||||
spatialMode,
|
||||
]);
|
||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||
const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
|
||||
const playbackRange = useMemo(() => metadata.timeline ? ({
|
||||
@@ -206,7 +285,7 @@ export function M4ReplayThreatVisual({
|
||||
endSeconds: metadata.timeline.timelineEndSeconds,
|
||||
}) : null, [metadata.timeline]);
|
||||
const playbackController = useRecordedEvidencePlayback(playbackRange, {
|
||||
clock: mediaMode === "video" ? "external" : "animation",
|
||||
clock: "external",
|
||||
});
|
||||
const seekPlayback = playbackController.seek;
|
||||
const setPlaybackPlaying = playbackController.setPlaying;
|
||||
@@ -214,7 +293,9 @@ export function M4ReplayThreatVisual({
|
||||
resultId,
|
||||
timeline: metadata.timeline,
|
||||
currentSeconds: playbackController.playback.currentSeconds,
|
||||
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
|
||||
endpointRoot: timelineEndpointRoot,
|
||||
spatialPlaybackTransport,
|
||||
});
|
||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||
const [videoLoading, setVideoLoading] = useState(false);
|
||||
@@ -225,16 +306,12 @@ export function M4ReplayThreatVisual({
|
||||
setVideoError(null);
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(max-width: 900px)");
|
||||
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeline = metadata.timeline;
|
||||
if (!evidenceDemand.recordedVideo) {
|
||||
setVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!timeline || videoSource) return;
|
||||
const controller = new AbortController();
|
||||
setVideoLoading(true);
|
||||
@@ -269,7 +346,7 @@ export function M4ReplayThreatVisual({
|
||||
if (!controller.signal.aborted) setVideoLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [metadata.timeline, videoSource]);
|
||||
}, [evidenceDemand.recordedVideo, metadata.timeline, videoSource]);
|
||||
|
||||
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -284,39 +361,53 @@ export function M4ReplayThreatVisual({
|
||||
resultId: string;
|
||||
frame: M4ThreatTimelineFrame;
|
||||
} | null>(null);
|
||||
if (frame?.spatialAvailable) {
|
||||
lastSpatialFrameRef.current = { resultId, frame };
|
||||
useEffect(() => {
|
||||
lastSpatialFrameRef.current = null;
|
||||
}, [evidenceDemand.sourceSpatialPoints, resultId]);
|
||||
const latestAvailableSpatialFrame = [...timelineFrame.availableFrames]
|
||||
.reverse()
|
||||
.find((candidate) => (
|
||||
candidate.spatialAvailable
|
||||
&& (timelineFrame.activeSequence === null
|
||||
|| candidate.sequence <= timelineFrame.activeSequence)
|
||||
)) ?? null;
|
||||
const currentSpatialFrame = frame?.spatialAvailable ? frame : latestAvailableSpatialFrame;
|
||||
if (currentSpatialFrame) {
|
||||
lastSpatialFrameRef.current = { resultId, frame: currentSpatialFrame };
|
||||
}
|
||||
const spatialFrame = frame?.spatialAvailable
|
||||
? frame
|
||||
const spatialFrame = currentSpatialFrame
|
||||
? currentSpatialFrame
|
||||
: lastSpatialFrameRef.current?.resultId === resultId
|
||||
? lastSpatialFrameRef.current.frame
|
||||
: null;
|
||||
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
||||
enabled: showReferenceMediaLayers && showMediaPoints,
|
||||
enabled: showReferenceMediaLayers && evidenceDemand.cameraPointOverlay,
|
||||
resultId,
|
||||
sequence: frame?.sequence ?? null,
|
||||
endpointRoot: timelineEndpointRoot,
|
||||
});
|
||||
const semanticSpatialResultId = semantic
|
||||
? semantic.spatialResultId === undefined ? semantic.resultId : semantic.spatialResultId
|
||||
const semanticSpatialResultId = activeSpatialSemantic
|
||||
? activeSpatialSemantic.spatialResultId === undefined
|
||||
? activeSpatialSemantic.resultId
|
||||
: activeSpatialSemantic.spatialResultId
|
||||
: null;
|
||||
const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>(
|
||||
() => semanticSpatialResultId && semantic
|
||||
? semantic.taxonomy.map((item) => ({
|
||||
() => semanticSpatialResultId && activeSpatialSemantic
|
||||
? activeSpatialSemantic.taxonomy.map((item) => ({
|
||||
classId: item.classId,
|
||||
label: item.label,
|
||||
disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled",
|
||||
colorRgb: item.colorRgb,
|
||||
}))
|
||||
: [],
|
||||
[semantic, semanticSpatialResultId],
|
||||
[activeSpatialSemantic, semanticSpatialResultId],
|
||||
);
|
||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||
resultId: semanticSpatialResultId,
|
||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||
frameCount: metadata.timeline?.frameCount ?? 0,
|
||||
taxonomy: spatialSemanticTaxonomy,
|
||||
enabled: evidenceDemand.selectedSemanticPoints,
|
||||
});
|
||||
const displayingBufferedFrame = Boolean(
|
||||
frame
|
||||
@@ -386,14 +477,14 @@ export function M4ReplayThreatVisual({
|
||||
[frame, reviewAnchorBoxes, showReferenceMediaLayers, staticObstacleBoxes],
|
||||
);
|
||||
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
() => activeSemantic?.taxonomy.map((item) => ({
|
||||
id: item.classId,
|
||||
label: `semantic: ${item.label}`,
|
||||
})) ?? [],
|
||||
[semantic?.taxonomy],
|
||||
[activeSemantic?.taxonomy],
|
||||
);
|
||||
const semanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
() => activeSemantic?.taxonomy.map((item) => ({
|
||||
classId: item.classId,
|
||||
color: item.disposition === "undefined"
|
||||
? { kind: "transparent" as const }
|
||||
@@ -404,7 +495,28 @@ export function M4ReplayThreatVisual({
|
||||
? 0
|
||||
: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||
})) ?? [],
|
||||
[semantic?.taxonomy],
|
||||
[activeSemantic?.taxonomy],
|
||||
);
|
||||
const spatialSemanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||
() => activeSpatialSemantic?.taxonomy.map((item) => ({
|
||||
id: item.classId,
|
||||
label: `semantic: ${item.label}`,
|
||||
})) ?? [],
|
||||
[activeSpatialSemantic?.taxonomy],
|
||||
);
|
||||
const spatialSemanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
|
||||
() => activeSpatialSemantic?.taxonomy.map((item) => ({
|
||||
classId: item.classId,
|
||||
color: item.disposition === "undefined"
|
||||
? { kind: "transparent" as const }
|
||||
: item.disposition === "ambiguous"
|
||||
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||
opacity: item.disposition === "undefined"
|
||||
? 0
|
||||
: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||
})) ?? [],
|
||||
[activeSpatialSemantic?.taxonomy],
|
||||
);
|
||||
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||
? semanticTimeline.activeFrame
|
||||
@@ -422,7 +534,7 @@ export function M4ReplayThreatVisual({
|
||||
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
|
||||
? lastSpatialSemanticFrameRef.current.frame
|
||||
: null;
|
||||
const semanticIntegrityError = semantic && spatialFrame && spatialSemanticFrame && (
|
||||
const semanticIntegrityError = activeSpatialSemantic && spatialFrame && spatialSemanticFrame && (
|
||||
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|
||||
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|
||||
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
|
||||
@@ -431,7 +543,7 @@ export function M4ReplayThreatVisual({
|
||||
: null;
|
||||
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||
if (
|
||||
!semantic
|
||||
!activeSpatialSemantic
|
||||
|| !showSpatialSemantic
|
||||
|| !spatialFrame
|
||||
|| !spatialSemanticFrame
|
||||
@@ -441,18 +553,24 @@ export function M4ReplayThreatVisual({
|
||||
const status = spatialSemanticFrame.statusCodes[index];
|
||||
return status === 2 || status === 3 ? classId : null;
|
||||
});
|
||||
}, [semantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
||||
}, [activeSpatialSemantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
||||
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
|
||||
? spatialFrame
|
||||
: null;
|
||||
const classifiedSpatialFrame = classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
||||
const hasClassifiedSpatialOutput = Boolean(
|
||||
classifiedSpatialLayer && classifiedSpatialLayer.cellLayerAvailable !== false,
|
||||
);
|
||||
const classifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||
&& classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
||||
? classifiedSpatialLayer?.frame ?? null
|
||||
: null;
|
||||
const lastClassifiedSpatialFrameRef = useRef<{
|
||||
resultId: string;
|
||||
frame: M4ReplayClassifiedSpatialFrame;
|
||||
} | null>(null);
|
||||
const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null;
|
||||
const incomingClassifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||
? classifiedSpatialLayer?.frame ?? null
|
||||
: null;
|
||||
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
||||
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
||||
}
|
||||
@@ -481,7 +599,9 @@ export function M4ReplayThreatVisual({
|
||||
? spatialFrame
|
||||
: null)
|
||||
: null;
|
||||
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true;
|
||||
const replaceClassifiedPointCloud = hasClassifiedSpatialOutput
|
||||
? classifiedSpatialLayer?.replacePointCloud ?? true
|
||||
: false;
|
||||
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
||||
const mapGravityLocalSensorToBodyGround = useCallback((
|
||||
point: readonly [number, number, number],
|
||||
@@ -599,7 +719,12 @@ export function M4ReplayThreatVisual({
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
||||
const localSurface = useMemo(() => spatialFrame?.localSlamBodyXyzM?.length ? ({
|
||||
pointsBodyXyzM: spatialFrame.localSlamBodyXyzM,
|
||||
sourceFrameCount: spatialFrame.localSlamSourceFrameCount ?? 0,
|
||||
sourcePointCount: spatialFrame.localSlamSourcePointCount ?? 0,
|
||||
voxelCount: spatialFrame.localSlamBodyXyzM.length,
|
||||
}) : buildM4LocalSurface(
|
||||
timelineFrame.availableFrames,
|
||||
spatialFrame,
|
||||
metadata.timeline?.localSurfaceVisualization ?? {
|
||||
@@ -610,19 +735,19 @@ export function M4ReplayThreatVisual({
|
||||
},
|
||||
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||
semantic && showMediaSemantic && frame
|
||||
activeSemantic && evidenceDemand.selectedSemanticMask && frame
|
||||
? {
|
||||
src: semantic.maskUrl?.(frame.sequence)
|
||||
?? e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
||||
src: activeSemantic.maskUrl?.(frame.sequence)
|
||||
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
|
||||
prefetchSrcs: Array.from({ length: 12 }, (_, index) => index + 1)
|
||||
.map((offset) => frame.sequence + offset)
|
||||
.filter((sequence) => sequence < (metadata.timeline?.frameCount ?? 0))
|
||||
.map((sequence) => semantic.maskUrl?.(sequence)
|
||||
?? e47SemanticMaskUrl(semantic.resultId, sequence)),
|
||||
.map((sequence) => activeSemantic.maskUrl?.(sequence)
|
||||
?? e47SemanticMaskUrl(activeSemantic.resultId, sequence)),
|
||||
classes: semanticClasses,
|
||||
palette: semanticPalette,
|
||||
opacity: 0.9,
|
||||
ariaLabel: `${semantic.maskAriaLabel ?? "Semantic prediction"} frame ${frame.sequence + 1}`,
|
||||
ariaLabel: `${activeSemantic.maskAriaLabel ?? "Semantic prediction"} frame ${frame.sequence + 1}`,
|
||||
}
|
||||
: undefined;
|
||||
const accumulatedCameraPoints = cameraPointOverlay.overlay?.sequence === frame?.sequence
|
||||
@@ -647,52 +772,17 @@ export function M4ReplayThreatVisual({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
|
||||
if (next === "none") return;
|
||||
setMediaMode((current) => current === next ? null : next);
|
||||
};
|
||||
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
|
||||
if (next === "none") return;
|
||||
setSpatialMode((current) => current === next ? null : next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackController.playback.playing || !frame) return;
|
||||
if (
|
||||
playbackController.playback.playing
|
||||
|| !evidenceDemand.exactCameraFrame
|
||||
|| !frame
|
||||
) return;
|
||||
const image = new Image();
|
||||
image.src = frame.cameraUrl;
|
||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
||||
}, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]);
|
||||
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
|
||||
const mediaModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
||||
<SegmentedControl
|
||||
value={mediaMode ?? "none"}
|
||||
items={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
label="Видео и камера"
|
||||
onChange={handleMediaModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const spatialModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
||||
<SegmentedControl
|
||||
value={spatialMode ?? "none"}
|
||||
items={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
label="3D и план"
|
||||
onChange={handleSpatialModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const mediaLayerControls = semantic
|
||||
const mediaLayerControls = activeSemantic
|
||||
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
|
||||
|| (showReferenceMediaLayers && metadata.timeline?.cameraObstacleProjectionDelivery) ? (
|
||||
<div
|
||||
@@ -700,7 +790,7 @@ export function M4ReplayThreatVisual({
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
{semantic ? (
|
||||
{activeSemantic ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
@@ -711,6 +801,20 @@ export function M4ReplayThreatVisual({
|
||||
SEMANTICS
|
||||
</Button>
|
||||
) : null}
|
||||
{availableSemanticLayers.length > 1 ? (
|
||||
<SegmentedControl
|
||||
value={selectedSemanticLayerId}
|
||||
items={availableSemanticLayers.map((layer, index) => ({
|
||||
value: layer.id ?? `${layer.resultId}:${index}`,
|
||||
label: layer.controlLabel ?? layer.label ?? `SEMANTIC ${index + 1}`,
|
||||
}))}
|
||||
label="Источник семантики"
|
||||
onChange={(value) => {
|
||||
setSelectedSemanticLayerId(value);
|
||||
setShowMediaSemantic(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery ? (
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -768,16 +872,24 @@ export function M4ReplayThreatVisual({
|
||||
shape="pill"
|
||||
variant={showRollingMap ? "primary" : "secondary"}
|
||||
aria-pressed={showRollingMap}
|
||||
disabled={classifiedSpatialLayer.cellLayerAvailable === false}
|
||||
title={classifiedSpatialLayer.cellLayerAvailable === false
|
||||
? `${classifiedSpatialLayer.cellLayerLabel} недоступен: для этой записи нет запечатанного полного результата`
|
||||
: undefined}
|
||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||
>
|
||||
{classifiedSpatialLayer.cellLayerLabel}
|
||||
</Button>
|
||||
{semanticSpatialResultId ? (
|
||||
{activeSpatialSemantic ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showSpatialSemantic}
|
||||
disabled={!semanticSpatialResultId}
|
||||
title={semanticSpatialResultId
|
||||
? "Point-aligned semantic evidence"
|
||||
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
@@ -830,12 +942,16 @@ export function M4ReplayThreatVisual({
|
||||
LOW-STEP
|
||||
</Button>
|
||||
) : null}
|
||||
{semanticSpatialResultId ? (
|
||||
{activeSpatialSemantic ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showSpatialSemantic}
|
||||
disabled={!semanticSpatialResultId}
|
||||
title={semanticSpatialResultId
|
||||
? "Point-aligned semantic evidence"
|
||||
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
@@ -939,14 +1055,14 @@ export function M4ReplayThreatVisual({
|
||||
<>
|
||||
<div>
|
||||
<span>Spatial evidence</span>
|
||||
<strong>{classifiedSpatialLayer
|
||||
<strong>{hasClassifiedSpatialOutput
|
||||
? classifiedSpatialFrame
|
||||
? replaceClassifiedPointCloud
|
||||
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
||||
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
||||
: "TGS spatial buffer"
|
||||
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
||||
<small>{classifiedSpatialLayer
|
||||
<small>{hasClassifiedSpatialOutput
|
||||
? classifiedSpatialFrame
|
||||
? classifiedSpatialFrame.sampleAvailable === false
|
||||
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
||||
@@ -955,9 +1071,9 @@ export function M4ReplayThreatVisual({
|
||||
: activeSpatialFrame
|
||||
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
||||
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
||||
: classifiedSpatialLayer.error
|
||||
?? classifiedSpatialLayer.loadingLabel
|
||||
?? `Открываем ${classifiedSpatialLayer.label}`
|
||||
: classifiedSpatialLayer?.error
|
||||
?? classifiedSpatialLayer?.loadingLabel
|
||||
?? `Открываем ${classifiedSpatialLayer?.label ?? "spatial evidence"}`
|
||||
: (
|
||||
<>
|
||||
{spatialFrame
|
||||
@@ -982,13 +1098,13 @@ export function M4ReplayThreatVisual({
|
||||
)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"}</span>
|
||||
<strong>{classifiedSpatialLayer
|
||||
<span>{hasClassifiedSpatialOutput ? "TGS fail-closed" : "Virtual corridor"}</span>
|
||||
<strong>{hasClassifiedSpatialOutput
|
||||
? classifiedSpatialFrame
|
||||
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
||||
: classifiedSpatialLayer.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
||||
: classifiedSpatialLayer?.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
||||
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
|
||||
<small>{classifiedSpatialLayer
|
||||
<small>{hasClassifiedSpatialOutput
|
||||
? classifiedSpatialFrame
|
||||
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
||||
: "visual review only · navigation authority OFF"
|
||||
@@ -1000,7 +1116,12 @@ export function M4ReplayThreatVisual({
|
||||
) : undefined;
|
||||
|
||||
const timeline = metadata.timeline;
|
||||
let content;
|
||||
let content: ReactNode = null;
|
||||
let canonicalContent: {
|
||||
mediaContent: ReactNode;
|
||||
spatialContent: ReactNode;
|
||||
deckOverlays: ReactNode;
|
||||
} | null = null;
|
||||
if (metadata.error) {
|
||||
content = <SpatialState message={metadata.error} />;
|
||||
} else if (!timeline) {
|
||||
@@ -1011,22 +1132,8 @@ export function M4ReplayThreatVisual({
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const mediaPane = (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="media"
|
||||
aria-label={mediaMode === "camera" ? "Камера" : "Видео"}
|
||||
hidden={!mediaMode}
|
||||
>
|
||||
{splitView ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="media"
|
||||
>
|
||||
{mediaLayerControls}
|
||||
{mediaModeControls}
|
||||
</div>
|
||||
) : null}
|
||||
const mediaContent = (
|
||||
<>
|
||||
<div
|
||||
className="m4-replay-threat-visual__media-layer"
|
||||
data-media="video"
|
||||
@@ -1039,6 +1146,7 @@ export function M4ReplayThreatVisual({
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
overlaySeconds={frame?.sessionSeconds}
|
||||
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
||||
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
|
||||
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||
@@ -1050,7 +1158,9 @@ export function M4ReplayThreatVisual({
|
||||
}
|
||||
segmentCount={timeline.frameCount}
|
||||
onPlaybackChange={playbackController.synchronize}
|
||||
onPlayingRejected={() => playbackController.setPlaying(false)}
|
||||
playbackAuthority="media"
|
||||
playbackTransport={playbackTransport}
|
||||
recoverTimestampStalls={recoverTimestampStalls}
|
||||
/>
|
||||
) : videoError ? (
|
||||
<SpatialState message={videoError} />
|
||||
@@ -1072,34 +1182,20 @@ export function M4ReplayThreatVisual({
|
||||
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
const spatialPane = spatialMode ? (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="spatial"
|
||||
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||
>
|
||||
{splitView ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="spatial"
|
||||
>
|
||||
{resetSpatialView}
|
||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||
{spatialLayerControls}
|
||||
{spatialModeControls}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
const spatialContent = spatialMode ? (
|
||||
<>
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? classifiedPointsBody
|
||||
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []}
|
||||
: classifiedContextSpatialFrame?.pointCloudBodyXyzM
|
||||
?? activeSpatialFrame?.pointCloudBodyXyzM
|
||||
?? []}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={classifiedSpatialLayer ? [] : sceneObstacles}
|
||||
obstacles={hasClassifiedSpatialOutput ? [] : sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
||||
@@ -1108,22 +1204,22 @@ export function M4ReplayThreatVisual({
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
showLowStep={classifiedSpatialLayer ? false : showLowStep}
|
||||
showLowStep={hasClassifiedSpatialOutput ? false : showLowStep}
|
||||
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.pointClassIds
|
||||
: alignedSemanticPointIds}
|
||||
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.classes
|
||||
: semanticClasses}
|
||||
: spatialSemanticClasses}
|
||||
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.palette
|
||||
: semanticPalette}
|
||||
: spatialSemanticPalette}
|
||||
classifiedCells={classifiedCellsBody}
|
||||
classifiedPackedCells={classifiedPackedCellsBody}
|
||||
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
||||
showClassifiedCells={showRollingMap}
|
||||
/>
|
||||
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
||||
{hasClassifiedSpatialOutput && classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
||||
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
||||
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||
? <span className="busy-indicator" aria-hidden="true" />
|
||||
@@ -1154,31 +1250,11 @@ export function M4ReplayThreatVisual({
|
||||
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
content = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__deck"
|
||||
data-split={splitView ? "true" : undefined}
|
||||
data-empty={!mediaMode && !spatialMode ? "true" : undefined}
|
||||
>
|
||||
<SplitPane
|
||||
primary={mediaPane}
|
||||
secondary={spatialPane ?? <div />}
|
||||
primarySize={splitView ? splitPrimarySize : mediaMode ? 100 : 0}
|
||||
onPrimarySizeChange={setSplitPrimarySize}
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView}
|
||||
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
|
||||
/>
|
||||
{!mediaMode && !spatialMode ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте.
|
||||
</div>
|
||||
) : null}
|
||||
const deckOverlays = (
|
||||
<>
|
||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
@@ -1209,8 +1285,9 @@ export function M4ReplayThreatVisual({
|
||||
<span>{semanticIntegrityError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
canonicalContent = { mediaContent, spatialContent, deckOverlays };
|
||||
}
|
||||
|
||||
const transport = timeline ? (
|
||||
@@ -1235,38 +1312,59 @@ export function M4ReplayThreatVisual({
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
if (!timeline || !canonicalContent) {
|
||||
return (
|
||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||
<LaboratoryEvidenceViewer
|
||||
label={`${evidenceLabel} recorded-realtime replay`}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode="video"
|
||||
modes={[{ value: "video", label: "VIDEO" }]}
|
||||
expanded={expanded}
|
||||
onModeChange={() => undefined}
|
||||
onExpandedChange={setExpanded}
|
||||
>
|
||||
{content}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||
<LaboratoryEvidenceViewer
|
||||
label={semantic
|
||||
? semantic.label ?? "Semantic diagnostic replay"
|
||||
: `${evidenceLabel} recorded-realtime replay`}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mediaMode ?? "none"}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
secondaryMode={{
|
||||
value: spatialMode ?? "none",
|
||||
modes: [
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
],
|
||||
label: "3D и план",
|
||||
onChange: handleSpatialModeChange,
|
||||
}}
|
||||
expanded={expanded}
|
||||
onModeChange={handleMediaModeChange}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={!splitView}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
trailingActions={trailingActions}
|
||||
>
|
||||
{content}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
<CanonicalRecordedLabReplay
|
||||
label={activeSemantic
|
||||
? activeSemantic.label ?? "Semantic diagnostic replay"
|
||||
: `${evidenceLabel} recorded-realtime replay`}
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
|
||||
spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer={availableSemanticLayers.length > 1}
|
||||
mediaContent={canonicalContent.mediaContent}
|
||||
spatialContent={canonicalContent.spatialContent}
|
||||
emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте."
|
||||
deckOverlays={canonicalContent.deckOverlays}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
trailingActions={trailingActions}
|
||||
onMediaModeChange={handleMediaModeChange}
|
||||
onSpatialModeChange={handleSpatialModeChange}
|
||||
onExpandedChange={setExpanded}
|
||||
onSplitPrimarySizeChange={setSplitPrimarySize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { VegetationShadowResult } from "../../core/laboratory/vegetationShadow";
|
||||
import {
|
||||
M48MaskComparisonVisual,
|
||||
type M48MaskComparisonCase,
|
||||
} from "./M48FailureAtlasVisual";
|
||||
|
||||
function decimal(value: number, digits = 1): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
const VEGETATION_LABELS: Readonly<Record<string, string>> = {
|
||||
high_grass: "Высокая трава",
|
||||
low_grass: "Низкая трава",
|
||||
bush: "Куст",
|
||||
tree_trunk: "Ствол дерева",
|
||||
tree_crown: "Крона дерева",
|
||||
hedge: "Живая изгородь",
|
||||
forest: "Лесная растительность",
|
||||
crops: "Посевы",
|
||||
};
|
||||
|
||||
function comparisonCases(result: VegetationShadowResult): readonly M48MaskComparisonCase[] {
|
||||
return result.validationCases.map((item) => {
|
||||
const focus = item.focus!;
|
||||
return {
|
||||
caseId: item.caseId,
|
||||
title: `${VEGETATION_LABELS[focus.className] ?? focus.className} · truth ${decimal(focus.truthFraction * 100, 1)}% кадра`,
|
||||
sourceUrl: item.assets.source,
|
||||
truthUrl: item.assets.truth,
|
||||
predictions: {
|
||||
ddrnet: item.assets.ddrnet,
|
||||
ppliteseg: item.assets.ppliteseg,
|
||||
},
|
||||
errors: {
|
||||
ddrnet: item.assets.ddrnet_error,
|
||||
ppliteseg: item.assets.ppliteseg_error,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function VegetationBenchmarkResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: VegetationShadowResult;
|
||||
}) {
|
||||
const selected = result.candidates.find(
|
||||
(candidate) => candidate.candidate === result.selectedCandidate,
|
||||
)!;
|
||||
const alternative = result.candidates.find(
|
||||
(candidate) => candidate.candidate !== result.selectedCandidate,
|
||||
)!;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8 · архивный benchmark растительности"
|
||||
description="Отдельный truth-backed контур GOOSE для сравнения готовых fine-64 весов. Он не является частью RAVNOVES00 realtime LAB и открывается автономно без Worker 006."
|
||||
status="ARCHIVE ANALYSIS · model qualification only · commands OFF"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Источник", value: "GOOSE validation · 962 размеченных кадра · 12 hard cases" },
|
||||
{ label: "Сравнение", value: "DDRNet-39 vs PPLiteSeg · official fine-64 weights" },
|
||||
{ label: "Кейсы", value: "трава · куст · ствол · крона · изгородь · лес · посевы" },
|
||||
{ label: "Authority", value: `${rigLabel} · MODEL QUALIFICATION ONLY · commands OFF` },
|
||||
]}
|
||||
brief={{
|
||||
question: "Какие готовые веса лучше различают проезжаемую траву, кусты и стволы на размеченных off-road кадрах?",
|
||||
approach: "Обе модели прогнаны на 962 кадрах, а 12 визуальных кейсов выбраны детерминированно по truth-поддержке восьми растительных классов. Viewer показывает source, ручной truth, prediction и error.",
|
||||
principalResult: `${selected.loadedModelName} лидирует по vegetation IoU: ${decimal(selected.vegetationMeanIouPercent, 2)}% против ${decimal(alternative.vegetationMeanIouPercent, 2)}%.`,
|
||||
limitation: "GOOSE — внешний размеченный домен. Результат выбирает стартовые веса, но не доказывает качество на fisheye RAVNOVES00 и не даёт navigation authority.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "ai-inference",
|
||||
pipelineId: "goose-fine64-ready-weights-benchmark-archive/v1",
|
||||
components: result.candidates.map((candidate) => ({
|
||||
kind: "model" as const,
|
||||
name: candidate.loadedModelName,
|
||||
version: candidate.candidate,
|
||||
role: candidate.candidate === result.selectedCandidate
|
||||
? "selected vegetation candidate"
|
||||
: "comparison candidate",
|
||||
identitySha256: candidate.checkpointSha256,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.8 · GOOSE VEGETATION HARD CASES"
|
||||
title="TRUTH — ручная разметка · PREDICTION — ответ модели · ERROR — расхождение"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M48MaskComparisonVisual
|
||||
cases={comparisonCases(result)}
|
||||
initialCandidate={result.selectedCandidate}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="DDRNet выбран как стартовый vegetation candidate"
|
||||
status={`${selected.loadedModelName} · перенос на ровер не доказан`}
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "GOOSE mIoU",
|
||||
value: `${decimal(selected.meanIouPercent, 2)}% / ${decimal(alternative.meanIouPercent, 2)}%`,
|
||||
hint: `${selected.candidate} / ${alternative.candidate} · полный validation split`,
|
||||
},
|
||||
{
|
||||
label: "Vegetation IoU",
|
||||
value: `${decimal(selected.vegetationMeanIouPercent, 2)}% / ${decimal(alternative.vegetationMeanIouPercent, 2)}%`,
|
||||
hint: "grass/vegetation/bush/tree и родственные fine-64 labels",
|
||||
},
|
||||
{
|
||||
label: "Worker shadow p95",
|
||||
value: `${decimal(selected.shadowLatencyP95Ms, 2)} / ${decimal(alternative.shadowLatencyP95Ms, 2)} ms`,
|
||||
hint: "чистый inference · одна тяжёлая модель за раз",
|
||||
},
|
||||
{
|
||||
label: "Peak VRAM",
|
||||
value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} / ${decimal(alternative.peakReservedVramBytes / 1024 ** 3, 2)} GiB`,
|
||||
hint: `${selected.candidate} / ${alternative.candidate} · RTX 4090`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Обе готовые fine-64 модели воспроизводимо запускаются; DDRNet лучше по aggregate vegetation IoU.",
|
||||
notProved: "Не доказаны accuracy на нашем fisheye, temporal stability, collision safety и физическое поведение ровера.",
|
||||
decision: "Хранить как архив квалификации весов. Проверку на RAVNOVES00 вести только в основной многослойной LAB.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,56 +8,109 @@ import {
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import {
|
||||
vegetationVideoMaskUrl,
|
||||
type VegetationFullRouteReview,
|
||||
type VegetationShadowResult,
|
||||
} from "../../core/laboratory/vegetationShadow";
|
||||
import {
|
||||
fetchM49TgsFullShadowResult,
|
||||
type M49TgsFullShadowResult,
|
||||
} from "../../core/laboratory/m49TgsFullShadow";
|
||||
import {
|
||||
M48MaskComparisonVisual,
|
||||
type M48MaskComparisonCase,
|
||||
} from "./M48FailureAtlasVisual";
|
||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
||||
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
||||
import { CanonicalVegetationRerunReplay } from "./CanonicalVegetationRerunReplay";
|
||||
|
||||
function decimal(value: number, digits = 1): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
const VEGETATION_LABELS: Readonly<Record<string, string>> = {
|
||||
high_grass: "Высокая трава",
|
||||
low_grass: "Низкая трава",
|
||||
bush: "Куст",
|
||||
tree_trunk: "Ствол дерева",
|
||||
tree_crown: "Крона дерева",
|
||||
hedge: "Живая изгородь",
|
||||
forest: "Лесная растительность",
|
||||
crops: "Посевы",
|
||||
};
|
||||
function FullRouteReviewEvidence({
|
||||
resultId,
|
||||
review,
|
||||
}: {
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
return <CanonicalVegetationRerunReplay resultId={resultId} review={review} />;
|
||||
}
|
||||
|
||||
function comparisonCases(result: VegetationShadowResult): readonly M48MaskComparisonCase[] {
|
||||
return result.validationCases.map((item) => {
|
||||
const focus = item.focus!;
|
||||
return {
|
||||
caseId: item.caseId,
|
||||
title: `${VEGETATION_LABELS[focus.className] ?? focus.className} · truth ${decimal(focus.truthFraction * 100, 1)}% кадра`,
|
||||
sourceUrl: item.assets.source,
|
||||
truthUrl: item.assets.truth,
|
||||
predictions: {
|
||||
ddrnet: item.assets.ddrnet,
|
||||
ppliteseg: item.assets.ppliteseg,
|
||||
},
|
||||
errors: {
|
||||
ddrnet: item.assets.ddrnet_error,
|
||||
ppliteseg: item.assets.ppliteseg_error,
|
||||
},
|
||||
};
|
||||
});
|
||||
function FullRouteReviewResult({
|
||||
rigLabel,
|
||||
resultId,
|
||||
review,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB V1 · RAVNOVES004TREE · полный маршрут"
|
||||
description="Принятый инструмент записанной LAB воспроизводит RAV004 без отдельного viewer: единый таймлайн, правая камера, исходные точки, ограниченный Local SLAM и переключаемые EoMT/DDRNet."
|
||||
status="ПОЛНЫЙ ПРОСМОТР ЗАПИСИ · эталон отсутствует · команды ВЫКЛ"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} кадров камеры` },
|
||||
{ label: "3D", value: "1444 приращения исходного облака · стабильная по гравитации RFU → корпус" },
|
||||
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} кадра/с` },
|
||||
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} кадра/с` },
|
||||
{ label: "TGS", value: "существуют 10 контрольных якорей · артефакт полного маршрута отсутствует" },
|
||||
{ label: "Полномочия", value: `${rigLabel} · ТОЛЬКО ВИЗУАЛЬНЫЙ ПРОСМОТР · команды ВЫКЛ` },
|
||||
]}
|
||||
brief={{
|
||||
question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
|
||||
approach: "RAV004 поставляет только data/provider configuration в тот же M4 recorded viewer. Видеодекодер владеет clock; новые source increments проецируются в gravity-stable forward/left/up frame, Local SLAM ограничен пятью секундами.",
|
||||
principalResult: "RAV004 больше не имеет отдельной логики окон, таймера, seek, cache или 3D controls. Модели и подписи меняются конфигурацией, архитектура переключения остаётся общей.",
|
||||
limitation: "Full-route TGS, независимый person/vehicle detector, ручной truth и point-aligned 3D semantics пока не запечатаны. Semantic-derived рамки диагностические и не являются STOP-authority.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "ai-inference",
|
||||
pipelineId: "canonical-recorded-lab-rav004tree/v3",
|
||||
components: [
|
||||
{ kind: "algorithm", name: "Canonical recorded replay", version: "media-clock / one viewer", role: "shared camera + spatial transport", identitySha256: null },
|
||||
{ kind: "algorithm", name: "Recorded source points + bounded Local SLAM", version: "source-paced-ground-v3", role: "gravity-stable spatial evidence", identitySha256: null },
|
||||
{ kind: "model", name: review.city.name, version: "sealed Worker 006 run", role: "urban semantic review", identitySha256: null },
|
||||
{ kind: "model", name: review.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE"
|
||||
title="КАМЕРА + ИСХОДНЫЕ ТОЧКИ + ЛОКАЛЬНЫЙ SLAM + КАРТА TGS + СЕМАНТИКА · 6830/6830"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
<FullRouteReviewEvidence resultId={resultId} review={review} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="RAV004 переведён на общий replay-каркас; safety evidence ещё не полно"
|
||||
status="Записанные доказательства · навигация/управление ВЫКЛ"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Таймлайн камеры", value: "6830 кадров · ≈9,51 Гц", hint: "единые часы управляют видео, слоями и пространством" },
|
||||
{ label: "Исходная геометрия", value: "1444 приращения · ≈2 Гц", hint: "между поступлениями удерживается последний подтверждённый пространственный кадр" },
|
||||
{ label: "Пропускная способность EoMT", value: `${decimal(review.city.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; не стек реального времени" },
|
||||
{ label: "Пропускная способность DDRNet", value: `${decimal(review.vegetation.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; временная стабильность не принята" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Камера, перемотка, пространственные слои и переключение семантики используют один принятый переиспользуемый viewer и единые часы; исходная геометрия RFU больше не наследует крен и тангаж LiDAR.",
|
||||
notProved: "Не доказаны непрерывная TGS, независимый детектор/STOP, точность относительно эталона, временная стабильность DDRNet и ≥10 кадров/с совместного стека реального времени.",
|
||||
decision: "Продолжать как визуальный аудит. До запечатанной TGS полного маршрута и барьера детектора/нагрузки навигация и управление остаются выключенными.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
|
||||
const route = result.routeVideo!;
|
||||
const linkedTgsResultId = route.linkedTgsResultId;
|
||||
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
||||
const [tgsError, setTgsError] = useState<string | null>(null);
|
||||
|
||||
@@ -65,8 +118,8 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
||||
const controller = new AbortController();
|
||||
setTgs(null);
|
||||
setTgsError(null);
|
||||
if (!route.linkedTgsResultId) return () => controller.abort();
|
||||
void fetchM49TgsFullShadowResult(route.linkedTgsResultId, {
|
||||
if (!linkedTgsResultId) return () => controller.abort();
|
||||
void fetchM49TgsFullShadowResult(linkedTgsResultId, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -80,48 +133,43 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [route.baseM4ResultId, route.linkedTgsResultId]);
|
||||
}, [linkedTgsResultId, route.baseM4ResultId]);
|
||||
|
||||
if (!linkedTgsResultId) {
|
||||
throw new Error("Vegetation LAB result has no linked canonical M4.9 TGS evidence.");
|
||||
}
|
||||
|
||||
const semantic = {
|
||||
id: "vegetation",
|
||||
controlLabel: "ПРИРОДА · DDRNet",
|
||||
resultId: route.workerResultId,
|
||||
spatialResultId: null,
|
||||
taxonomy: route.taxonomy,
|
||||
maskUrl: (sequence: number) => vegetationVideoMaskUrl(result.resultId, sequence),
|
||||
label: route.viewKind === "coarse-material-policy-review"
|
||||
? "Coarse material evidence · recorded video"
|
||||
: "DDRNet vegetation prediction · recorded video",
|
||||
maskAriaLabel: route.viewKind === "coarse-material-policy-review"
|
||||
? "Coarse material policy evidence"
|
||||
: "DDRNet vegetation prediction",
|
||||
label: "DDRNet coarse vegetation material · recorded video",
|
||||
maskAriaLabel: "DDRNet vegetation material prediction",
|
||||
} as const;
|
||||
|
||||
if (route.linkedTgsResultId && tgs) {
|
||||
if (tgsError) {
|
||||
return (
|
||||
<M49TgsFullShadowEvidence
|
||||
result={tgs}
|
||||
semanticOverride={semantic}
|
||||
evidenceLabel="LAB V1 · MATERIAL + YOLOX + TGS"
|
||||
/>
|
||||
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
||||
Канонический M4.9 TGS слой недоступен: {tgsError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (route.linkedTgsResultId && !tgsError) {
|
||||
return <div className="m4-replay-threat-visual__pane-status" role="status">Открываем sealed TGS и coarse material timeline…</div>;
|
||||
if (!tgs) {
|
||||
return (
|
||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||
Открываем sealed EoMT, TGS и coarse vegetation timeline…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<M4ReplayThreatVisual
|
||||
resultId={route.baseM4ResultId}
|
||||
evidenceLabel="LAB V1 · DDRNet"
|
||||
showReferenceMediaLayers={route.viewKind === "coarse-material-policy-review"}
|
||||
showSpatialOverlaySummary={false}
|
||||
semantic={semantic}
|
||||
/>
|
||||
{tgsError ? (
|
||||
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
||||
TGS слой недоступен: {tgsError}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
<M49TgsFullShadowEvidence
|
||||
result={tgs}
|
||||
semanticOverride={semantic}
|
||||
evidenceLabel="LAB V1 · EoMT + DDRNet + YOLOX + TGS"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,146 +180,83 @@ export function VegetationShadowResultView({
|
||||
rigLabel: string;
|
||||
result: VegetationShadowResult;
|
||||
}) {
|
||||
if (result.routeFullReview) {
|
||||
return (
|
||||
<FullRouteReviewResult
|
||||
rigLabel={rigLabel}
|
||||
resultId={result.resultId}
|
||||
review={result.routeFullReview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!result.routeVideo?.linkedTgsResultId) {
|
||||
throw new Error(
|
||||
"Vegetation LAB result has no canonical M4 source timeline and linked M4.9 TGS evidence.",
|
||||
);
|
||||
}
|
||||
const route = result.routeVideo;
|
||||
const selected = result.candidates.find(
|
||||
(candidate) => candidate.candidate === result.selectedCandidate,
|
||||
)!;
|
||||
const alternative = result.candidates.find(
|
||||
(candidate) => candidate.candidate !== result.selectedCandidate,
|
||||
)!;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB V1 · готовые модели растительности"
|
||||
description={result.routeVideo
|
||||
? result.routeVideo.viewKind === "coarse-material-policy-review"
|
||||
? "M4.8 сохраняет truth-backed сравнение моделей, а штатный M4.7 синхронно показывает coarse material evidence, frozen YOLOX vetoes и causal TGS на всей записи RAVNOVES00. Все слои запечатаны локально и открываются без Worker 006."
|
||||
: "M4.8 сохраняет truth-backed сравнение моделей, а штатный M4.7 viewer показывает фактический DDRNet prediction на всей записи RAVNOVES00. Все 4489 масок запечатаны локально и открываются без Worker 006."
|
||||
: "Штатный M4.8-инструмент сравнивает две готовые fine-64 модели на полном GOOSE validation split и на 12 truth-backed hard cases, выбранных только по наличию нужной растительности. Sealed evidence открывается локально без Worker 006."}
|
||||
status={result.routeVideo
|
||||
? result.routeVideo.viewKind === "coarse-material-policy-review"
|
||||
? "MULTILAYER POLICY REVIEW · commands OFF · route truth отсутствует"
|
||||
: "DDRNet full-video prediction ready · route truth отсутствует"
|
||||
: "Truth-backed model comparison · route transfer не принят"}
|
||||
title="LAB V1 · карта ровера · город + растительность"
|
||||
description="Один recorded-контур RAVNOVES00 синхронно показывает городской EoMT, природный DDRNet, frozen YOLOX detections и causal TGS. Семантические маски переключаются, чтобы их цвета не скрывали друг друга; геометрическое veto остаётся независимым."
|
||||
status="MULTILAYER RECORDED REVIEW · commands OFF · route truth отсутствует"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Источник", value: "GOOSE validation · 962 размеченных кадра · 12 vegetation hard cases" },
|
||||
{ label: "Сравнение", value: "DDRNet-39 vs PPLiteSeg · official fine-64 weights" },
|
||||
{ label: "Кейсы", value: "трава · куст · ствол · крона · изгородь · лес · посевы" },
|
||||
...(result.routeVideo ? [{
|
||||
label: "Видео",
|
||||
value: result.routeVideo.viewKind === "coarse-material-policy-review"
|
||||
? "RAVNOVES00 · 4489/4489 coarse masks + YOLOX + TGS · exact sequence"
|
||||
: "RAVNOVES00 · 4489/4489 DDRNet masks · exact recorded sequence",
|
||||
}] : []),
|
||||
{ label: "Authority", value: `${rigLabel} · MODEL QUALIFICATION ONLY · commands OFF` },
|
||||
{ label: "Источник", value: "RAVNOVES00 · sensor.camera.right · 4489 recorded frames" },
|
||||
{ label: "Город", value: "EoMT Cityscapes · sealed E47 semantic archive" },
|
||||
{ label: "Растительность", value: "DDRNet-39 fine-64 → coarse mission-neutral materials" },
|
||||
{ label: "Safety", value: "YOLOX object boxes + causal TGS · semantic masks не снимают veto" },
|
||||
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
|
||||
]}
|
||||
brief={{
|
||||
question: "Какие готовые веса лучше различают проезжаемую траву, кусты и стволы на размеченных off-road кадрах?",
|
||||
approach: "Обе модели последовательно прогнаны в одном изолированном CUDA-runtime на 962 кадрах. 12 визуальных кейсов выбраны детерминированно по truth-поддержке восьми растительных классов; один M4.8 viewer показывает source, truth, prediction и material-error для выбранной модели.",
|
||||
principalResult: `${selected.loadedModelName} лидирует по vegetation IoU: ${decimal(selected.vegetationMeanIouPercent, 2)}% против ${decimal(alternative.vegetationMeanIouPercent, 2)}%. ${result.routeVideo?.viewKind === "coarse-material-policy-review" ? "Fine-64 prediction сведён к mission-neutral материалам; YOLOX и TGS сохраняют независимое veto." : result.routeVideo ? "Его фактическая temporal stability теперь видна на всех 4489 кадрах штатного recorded viewer." : "Ошибки по каждому типу проверяются в одном штатном инструменте."}`,
|
||||
limitation: "GOOSE — внешний размеченный домен; RAVNOVES00 — наш fisheye, но без ручной truth-разметки. Материалы — prediction, а не доказательство проходимости. TGS не проецируется в пиксели без отдельной принятой калибровки.",
|
||||
question: "Можно ли одновременно видеть городской и природный semantic stack, не теряя независимую геометрическую защиту?",
|
||||
approach: "EoMT и DDRNet сохранены как два независимых sealed слоя на одной M4 timeline. В штатном M4.9 viewer пользователь переключает только отображаемую маску; YOLOX и TGS остаются активными слоями evidence.",
|
||||
principalResult: "Оба semantic archive доступны в одном viewer. Это не пиксельный fusion и не единая новая модель: городской и природный ответы остаются раздельными.",
|
||||
limitation: "RAVNOVES00 не имеет ручной truth. DDRNet заметно прыгает между HIGH GRASS, WOODY и UNKNOWN; поэтому subtype нельзя подавать напрямую в planner. Отсутствие класса никогда не означает свободный путь.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "ai-inference",
|
||||
pipelineId: "goose-fine64-ready-weights-to-ravnoves-policy-shadow/v1",
|
||||
components: result.candidates.map((candidate) => ({
|
||||
kind: "model" as const,
|
||||
name: candidate.loadedModelName,
|
||||
version: candidate.candidate,
|
||||
role: candidate.candidate === result.selectedCandidate ? "selected policy provider" : "comparison candidate",
|
||||
identitySha256: candidate.checkpointSha256,
|
||||
})),
|
||||
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
|
||||
components: [
|
||||
{ kind: "model", name: "EoMT Cityscapes semantic", version: "sealed E47 archive", role: "urban semantic review", identitySha256: null },
|
||||
{ kind: "model", name: selected.loadedModelName, version: selected.candidate, role: "vegetation material candidate", identitySha256: selected.checkpointSha256 },
|
||||
{ kind: "algorithm", name: "Frozen YOLOX + causal TGS", version: "linked M4/M4.9 archives", role: "independent object and geometry veto", identitySha256: null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<>
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.8 · GOOSE VEGETATION HARD CASES"
|
||||
title="ERROR: красный — пропуск · жёлтый — лишнее · фиолетовый — перепутан тип · зелёный — совпадение"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M48MaskComparisonVisual
|
||||
cases={comparisonCases(result)}
|
||||
initialCandidate={result.selectedCandidate}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
{result.routeVideo ? (
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO"
|
||||
title={result.routeVideo.viewKind === "coarse-material-policy-review"
|
||||
? "COARSE MATERIAL + YOLOX VETO + CAUSAL TGS · 4489/4489 · TRUTH отсутствует"
|
||||
: "DDRNet PREDICTION · 4489/4489 кадров · TRUTH для этой записи отсутствует"}
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<VegetationRouteEvidence result={result} />
|
||||
</LaboratoryEvidence>
|
||||
) : null}
|
||||
</>
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.9 · RAVNOVES00 FULL VIDEO"
|
||||
title="EoMT CITY / DDRNet VEGETATION + YOLOX + CAUSAL TGS · 4489/4489 · TRUTH отсутствует"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<VegetationRouteEvidence result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title={result.routeVideo?.viewKind === "coarse-material-policy-review"
|
||||
? "Слои собраны для визуального policy review; управление не авторизовано"
|
||||
: "DDRNet — стартовые веса; перенос на ровер ещё не доказан"}
|
||||
status={result.routeVideo?.viewKind === "coarse-material-policy-review"
|
||||
? "Materials are advisory · YOLOX/TGS veto cannot be cleared"
|
||||
: `${selected.loadedModelName} выбран только как vegetation candidate`}
|
||||
title="Многослойный visual review собран; управление не авторизовано"
|
||||
status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "GOOSE mIoU",
|
||||
value: `${decimal(selected.meanIouPercent, 2)}% / ${decimal(alternative.meanIouPercent, 2)}%`,
|
||||
hint: `${selected.candidate} / ${alternative.candidate} · полный validation split`,
|
||||
},
|
||||
{
|
||||
label: "Vegetation IoU",
|
||||
value: `${decimal(selected.vegetationMeanIouPercent, 2)}% / ${decimal(alternative.vegetationMeanIouPercent, 2)}%`,
|
||||
hint: "агрегация классов grass/vegetation/bush/tree и родственных fine-64 labels",
|
||||
},
|
||||
{
|
||||
label: "Worker shadow p95",
|
||||
value: `${decimal(selected.shadowLatencyP95Ms, 2)} / ${decimal(alternative.shadowLatencyP95Ms, 2)} ms`,
|
||||
hint: "чистый inference · одна тяжёлая модель за раз",
|
||||
},
|
||||
{
|
||||
label: "Cold prewarm",
|
||||
value: `${decimal(selected.shadowPrewarmLatencyMs, 1)} / ${decimal(alternative.shadowPrewarmLatencyMs, 1)} ms`,
|
||||
hint: "один явный inference до допуска кадров; исключён из steady-state p95",
|
||||
},
|
||||
{
|
||||
label: "Worker throughput",
|
||||
value: `${decimal(selected.shadowThroughputFps, 1)} / ${decimal(alternative.shadowThroughputFps, 1)} FPS`,
|
||||
hint: "изолированный Worker 006 · не realtime graph целиком",
|
||||
},
|
||||
{
|
||||
label: "Peak VRAM",
|
||||
value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} / ${decimal(alternative.peakReservedVramBytes / 1024 ** 3, 2)} GiB`,
|
||||
hint: `${selected.candidate} / ${alternative.candidate} · RTX 4090`,
|
||||
},
|
||||
{
|
||||
label: "Hard-case evidence",
|
||||
value: "12 truth-backed cases",
|
||||
hint: "8 vegetation strata · Worker для открытия не требуется",
|
||||
},
|
||||
...(result.routeVideo ? [{
|
||||
label: "Route video",
|
||||
value: "4489/4489 masks",
|
||||
hint: result.routeVideo.viewKind === "coarse-material-policy-review"
|
||||
? "9 coarse states · YOLOX + causal TGS · Worker-independent playback"
|
||||
: "DDRNet prediction · exact sequence · Worker-independent playback",
|
||||
}] : []),
|
||||
{ label: "Route masks", value: `${route.frameCount}/${route.frameCount}`, hint: "sealed local playback · Worker для открытия не нужен" },
|
||||
{ label: "Semantic sources", value: "2 independent layers", hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse" },
|
||||
{ label: "Vegetation worker p95", value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`, hint: "изолированный DDRNet inference; не совместный realtime stack" },
|
||||
{ label: "Vegetation peak VRAM", value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`, hint: "DDRNet candidate на Worker 006" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Обе официальные fine-64 модели воспроизводимо запускаются на Worker 006; DDRNet лучше по aggregate vegetation IoU. Truth-backed hard cases прямо показывают траву, кусты и стволы, а не случайные автомобили и здания.",
|
||||
notProved: "Не доказаны accuracy на нашем fisheye-домене, папоротник как отдельный материал, collision safety и physical-live поведение ровера. Видео позволяет увидеть temporal stability, но без truth не превращает её в метрику качества.",
|
||||
decision: result.routeVideo?.viewKind === "coarse-material-policy-review"
|
||||
? "На одном M4.7 проверить ложные LOW GRASS/HIGH GRASS кандидаты против YOLOX и TGS. До truth-кейсов и integrated load этот слой не подключать к planner/actuation."
|
||||
: "Смотреть полный prediction на видео и собирать конкретные temporal/domain failure cases. DDRNet остаётся diagnostic candidate; LiDAR/TGS fail-closed геометрию не ослаблять.",
|
||||
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
|
||||
notProved: "Не доказаны совместный live-runtime EoMT+DDRNet, truth accuracy на fisheye, стабильные vegetation subtypes и безопасное управление ровером.",
|
||||
decision: "Использовать маски только для диагностики. Следующий qualification gate — motion-aware temporal vegetation fusion и отдельный совместный realtime load test; до него planner/actuation остаются OFF.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+4
-2
@@ -10,6 +10,7 @@ interface M48EvidenceModeControlProps {
|
||||
mode: M48BlindEvidenceMode;
|
||||
cameraVisible: boolean;
|
||||
spatialAvailable: boolean;
|
||||
planAvailable?: boolean;
|
||||
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
||||
onCameraVisibleChange: (visible: boolean) => void;
|
||||
}
|
||||
@@ -34,6 +35,7 @@ export function M48EvidenceModeControls({
|
||||
mode,
|
||||
cameraVisible,
|
||||
spatialAvailable,
|
||||
planAvailable = spatialAvailable,
|
||||
onModeChange,
|
||||
onCameraVisibleChange,
|
||||
}: M48EvidenceModeControlProps) {
|
||||
@@ -68,9 +70,9 @@ export function M48EvidenceModeControls({
|
||||
<IconButton
|
||||
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
||||
aria-pressed={spatialMode === "plan"}
|
||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||
disabled={!planAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||
onClick={() => {
|
||||
if (!spatialAvailable) return;
|
||||
if (!planAvailable) return;
|
||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
||||
}}
|
||||
>
|
||||
|
||||
+14
-14
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import type { L34RightYoloxTruthIslandResult } from "../../../core/laboratory/l34RightYoloxTruthIsland";
|
||||
import type { L34DResult } from "../../../core/laboratory/l34dCumulativePostprocessing";
|
||||
@@ -28,19 +28,19 @@ export function useL34AnnotationCapability({
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false);
|
||||
const openWorkspace = useCallback(() => setOpen(true), []);
|
||||
const available = selectedWorkId === "l34-right-yolox-truth-island-freeze"
|
||||
&& l34Result
|
||||
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
||||
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
||||
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
||||
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
||||
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
||||
: selectedWorkId === "l34d-cumulative-postprocessing-candidate"
|
||||
&& l34dResult
|
||||
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
||||
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
||||
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
||||
: null;
|
||||
const available = useMemo(() => (
|
||||
selectedWorkId === "l34-right-yolox-truth-island-freeze" && l34Result
|
||||
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
||||
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
||||
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
||||
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
||||
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
||||
: selectedWorkId === "l34d-cumulative-postprocessing-candidate" && l34dResult
|
||||
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
||||
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
||||
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
||||
: null
|
||||
), [e46Result, e46aResult, l34Result, l34dResult, l34eResult, selectedWorkId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type LaboratoryProfileId =
|
||||
| "rig-camera-local-surface-v1"
|
||||
| "rig-track-geometry-temporal-v1"
|
||||
| "rig-ravnoves-perception-gate-v1"
|
||||
| "rig-goose-vegetation-benchmark-v1"
|
||||
| "rig-pointpillars-transfer-v1"
|
||||
| "rig-right-yolox-lidar-range-v1"
|
||||
| "rig-nvidia-ready-stack-v1"
|
||||
@@ -63,12 +64,19 @@ interface KnownWorkDefinition {
|
||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||
|
||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
||||
"lab-v1-vegetation-benchmark": {
|
||||
profileId: "rig-goose-vegetation-benchmark-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} · GOOSE vegetation archive`,
|
||||
experimentId: "lab-v1-vegetation-benchmark-archive",
|
||||
experimentName: "DDRNet vs PPLiteSeg · truth-backed archival comparison",
|
||||
variantName: "M4.8 · GOOSE truth · архивный анализ моделей",
|
||||
},
|
||||
"lab-v1-vegetation-shadow": {
|
||||
profileId: "rig-ravnoves-perception-gate-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} · GOOSE vegetation qualification`,
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} · RAVNOVES00 rover perception gate`,
|
||||
experimentId: "lab-v1-vegetation-mission-policy",
|
||||
experimentName: "DDRNet vs PPLiteSeg · truth-backed vegetation hard cases",
|
||||
variantName: "LAB V1 · готовые vegetation weights · GOOSE truth",
|
||||
experimentName: "RAVNOVES00 · city + vegetation + TGS review",
|
||||
variantName: "LAB V1 · EoMT + DDRNet + YOLOX + TGS · commands OFF",
|
||||
},
|
||||
"m48-object-centric-quality": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
@@ -451,6 +459,17 @@ function pipelineIdForSession(session: ObservationSessionSummary): string {
|
||||
return session.lab?.resultKind ?? "legacy-perception";
|
||||
}
|
||||
|
||||
/** Keep capability projections owned by Observatory out of the legacy LAB surface. */
|
||||
export function isLegacyPublishedLaboratoryWork(
|
||||
session: ObservationSessionSummary,
|
||||
): boolean {
|
||||
return session.lab !== null
|
||||
&& session.lab.replayCapability === null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud");
|
||||
}
|
||||
|
||||
export function buildLaboratoryCatalog({
|
||||
rigLabel,
|
||||
knownWorks,
|
||||
|
||||
@@ -18,6 +18,7 @@ function mergeResults(
|
||||
next: AdvancedLaboratoryResults,
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
vegetationBenchmark: next.vegetationBenchmark ?? current.vegetationBenchmark,
|
||||
vegetationShadow: next.vegetationShadow ?? current.vegetationShadow,
|
||||
m47Graph: next.m47Graph ?? current.m47Graph,
|
||||
m48: next.m48 ?? current.m48,
|
||||
@@ -122,6 +123,7 @@ export function useAdvancedLaboratoryCatalog({
|
||||
const indexedResultId = index.find((item) => item.workId === selectedWorkId)?.resultId;
|
||||
if (
|
||||
[
|
||||
"lab-v1-vegetation-benchmark",
|
||||
"lab-v1-vegetation-shadow",
|
||||
"m47-reference-graph-shadow",
|
||||
"m48-object-centric-quality",
|
||||
|
||||
@@ -11,11 +11,30 @@ const CHUNK_SIZE = 24;
|
||||
const RETAINED_CHUNK_COUNT = 8;
|
||||
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||
|
||||
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] {
|
||||
return Array.from(
|
||||
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
||||
(_, index) => activeStart + (index - 1) * CHUNK_SIZE,
|
||||
).filter((start) => start >= 0 && start < frameCount);
|
||||
export function e47SemanticChunkWindowStarts(
|
||||
activeStart: number,
|
||||
frameCount: number,
|
||||
): readonly number[] {
|
||||
return [
|
||||
activeStart,
|
||||
...Array.from(
|
||||
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||
(_, index) => activeStart + (index + 1) * CHUNK_SIZE,
|
||||
),
|
||||
activeStart - CHUNK_SIZE,
|
||||
].filter((start) => start >= 0 && start < frameCount);
|
||||
}
|
||||
|
||||
export function cancelE47SemanticRequestsOutsideWindow<T extends { abort(): void }>(
|
||||
inFlight: Map<number, T>,
|
||||
desiredStarts: readonly number[],
|
||||
): void {
|
||||
const desired = new Set(desiredStarts);
|
||||
for (const [start, controller] of inFlight) {
|
||||
if (desired.has(start)) continue;
|
||||
controller.abort();
|
||||
inFlight.delete(start);
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
@@ -29,11 +48,13 @@ export function useE47SemanticTimelineFrame({
|
||||
activeSequence,
|
||||
frameCount,
|
||||
taxonomy,
|
||||
enabled = true,
|
||||
}: {
|
||||
resultId: string | null;
|
||||
activeSequence: number | null;
|
||||
frameCount: number;
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
||||
() => new Map(),
|
||||
@@ -55,16 +76,21 @@ export function useE47SemanticTimelineFrame({
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
};
|
||||
}, [resultId]);
|
||||
}, [enabled, resultId]);
|
||||
|
||||
const activeStart = activeSequence === null
|
||||
const activeStart = !enabled || activeSequence === null
|
||||
? null
|
||||
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
||||
activeStartRef.current = activeStart;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resultId || activeStart === null || frameCount < 1) return;
|
||||
for (const start of chunkWindowStarts(activeStart, frameCount)) {
|
||||
if (!enabled || !resultId || activeStart === null || frameCount < 1) {
|
||||
cancelE47SemanticRequestsOutsideWindow(inFlight.current, []);
|
||||
return;
|
||||
}
|
||||
const starts = e47SemanticChunkWindowStarts(activeStart, frameCount);
|
||||
cancelE47SemanticRequestsOutsideWindow(inFlight.current, starts);
|
||||
for (const start of starts) {
|
||||
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||
const controller = new AbortController();
|
||||
inFlight.current.set(start, controller);
|
||||
@@ -95,8 +121,11 @@ export function useE47SemanticTimelineFrame({
|
||||
.finally(() => {
|
||||
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
||||
});
|
||||
// Semantic point arrays are large JSON payloads. Admit the active chunk
|
||||
// first, then advance the bounded prefetch window one request per render.
|
||||
break;
|
||||
}
|
||||
}, [activeStart, frameCount, resultId, taxonomy]);
|
||||
}, [activeStart, chunks, enabled, frameCount, resultId, taxonomy]);
|
||||
|
||||
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
|
||||
if (activeSequence === null || activeStart === null) return null;
|
||||
@@ -107,7 +136,7 @@ export function useE47SemanticTimelineFrame({
|
||||
|
||||
return {
|
||||
activeFrame,
|
||||
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||
loading: enabled && Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
const REQUESTED_CHUNK_FRAMES = 24;
|
||||
const RETAINED_CHUNK_COUNT = 4;
|
||||
const RETAINED_CHUNKS_BEHIND = 1;
|
||||
const PREFETCH_CHUNKS_AHEAD = 1;
|
||||
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
||||
|
||||
@@ -31,10 +32,17 @@ export function m4ThreatChunkWindowStarts(
|
||||
frameCount: number,
|
||||
): readonly number[] {
|
||||
if (chunkSize < 1 || frameCount < 1) return [];
|
||||
return Array.from(
|
||||
{ length: PREFETCH_CHUNKS_AHEAD + 1 },
|
||||
(_, index) => activeChunkStart + index * chunkSize,
|
||||
).filter((start) => start >= 0 && start < frameCount);
|
||||
return [
|
||||
activeChunkStart,
|
||||
...Array.from(
|
||||
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||
(_, index) => activeChunkStart + (index + 1) * chunkSize,
|
||||
),
|
||||
...Array.from(
|
||||
{ length: RETAINED_CHUNKS_BEHIND },
|
||||
(_, index) => activeChunkStart - (index + 1) * chunkSize,
|
||||
),
|
||||
].filter((start) => start >= 0 && start < frameCount);
|
||||
}
|
||||
|
||||
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
|
||||
@@ -76,12 +84,16 @@ export function useM4ThreatTimelineFrame({
|
||||
resultId,
|
||||
timeline,
|
||||
currentSeconds,
|
||||
includeSpatialPoints = true,
|
||||
endpointRoot,
|
||||
spatialPlaybackTransport = "auto",
|
||||
}: {
|
||||
resultId: string;
|
||||
timeline: M4ThreatTimeline | null;
|
||||
currentSeconds: number;
|
||||
includeSpatialPoints?: boolean;
|
||||
endpointRoot?: string;
|
||||
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||
}) {
|
||||
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
||||
() => new Map(),
|
||||
@@ -94,8 +106,10 @@ export function useM4ThreatTimelineFrame({
|
||||
totalBytes: 0,
|
||||
});
|
||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||
const binaryPlayback = endpointRoot === undefined
|
||||
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT;
|
||||
const binaryPlayback = spatialPlaybackTransport === "sealed-binary"
|
||||
|| (spatialPlaybackTransport === "auto" && (
|
||||
endpointRoot === undefined || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT
|
||||
));
|
||||
const inFlight = useRef(new Map<number, AbortController>());
|
||||
const chunksRef = useRef(chunks);
|
||||
const activeChunkStartRef = useRef<number | null>(null);
|
||||
@@ -107,7 +121,7 @@ export function useM4ThreatTimelineFrame({
|
||||
setPlaybackError(null);
|
||||
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||
if (!timeline) return () => controller.abort();
|
||||
if (!binaryPlayback) {
|
||||
if (!binaryPlayback || !includeSpatialPoints) {
|
||||
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||
return () => controller.abort();
|
||||
}
|
||||
@@ -127,7 +141,7 @@ export function useM4ThreatTimelineFrame({
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [binaryPlayback, endpointRoot, resultId, timeline]);
|
||||
}, [binaryPlayback, endpointRoot, includeSpatialPoints, resultId, timeline]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
@@ -140,7 +154,7 @@ export function useM4ThreatTimelineFrame({
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
};
|
||||
}, [resultId, timeline]);
|
||||
}, [includeSpatialPoints, resultId, timeline]);
|
||||
|
||||
const activeSequence = useMemo(
|
||||
() => timeline
|
||||
@@ -158,7 +172,11 @@ export function useM4ThreatTimelineFrame({
|
||||
activeChunkStartRef.current = activeChunkStart;
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return;
|
||||
if (
|
||||
!timeline
|
||||
|| activeChunkStart === null
|
||||
|| (binaryPlayback && includeSpatialPoints && !playbackManifest)
|
||||
) return;
|
||||
const starts = m4ThreatChunkWindowStarts(
|
||||
activeChunkStart,
|
||||
chunkSize,
|
||||
@@ -170,7 +188,7 @@ export function useM4ThreatTimelineFrame({
|
||||
const controller = new AbortController();
|
||||
inFlight.current.set(start, controller);
|
||||
void (async () => {
|
||||
const playbackPointPack = binaryPlayback && playbackManifest
|
||||
const playbackPointPack = binaryPlayback && includeSpatialPoints && playbackManifest
|
||||
? await fetchM4ThreatPlaybackPointChunk(
|
||||
playbackManifest,
|
||||
Math.floor(start / playbackManifest.chunkFrameCount),
|
||||
@@ -189,6 +207,7 @@ export function useM4ThreatTimelineFrame({
|
||||
endpointRoot,
|
||||
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||
playbackPointPack,
|
||||
includePoints: includeSpatialPoints,
|
||||
});
|
||||
})()
|
||||
.then((chunk) => {
|
||||
@@ -220,7 +239,7 @@ export function useM4ThreatTimelineFrame({
|
||||
// loaded first, then the next chunk is prefetched on the following render.
|
||||
break;
|
||||
}
|
||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]);
|
||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, includeSpatialPoints, playbackManifest, resultId, timeline]);
|
||||
|
||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||
if (activeSequence === null || activeChunkStart === null) return null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,14 @@ import {
|
||||
} from "../../core/system/useWorkerTelemetry";
|
||||
|
||||
function pipelineStateLabel(state: string): string {
|
||||
if (state === "busy") return "Выполняет задачу";
|
||||
if (state === "busy" || state === "running") return "Выполняет задачу";
|
||||
if (state === "waiting") return "Ожидает восстановления данных";
|
||||
if (state === "synchronizing") return "Синхронизирует поток";
|
||||
if (state === "starting") return "Загружает профиль";
|
||||
if (state === "stopped" || state === "cancelled") return "Профиль остановлен";
|
||||
if (state === "failed") return "Ошибка профиля";
|
||||
if (state === "ready") return "Готов к задаче";
|
||||
return "Нет live-состояния";
|
||||
return "Нет свежих данных о задаче";
|
||||
}
|
||||
|
||||
function formatResourcePair(
|
||||
@@ -116,7 +121,7 @@ export function ComputeModulesWorkspace() {
|
||||
{!legacyDiagnostic && !agentTelemetry ? (
|
||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||
<StatusBadge tone="warning">
|
||||
Агентный data-plane ещё не опубликовал нормализованный срез этого контура.
|
||||
Ожидаем свежую телеметрию выбранного узла.
|
||||
</StatusBadge>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
@@ -187,6 +192,8 @@ export function ComputeModulesWorkspace() {
|
||||
? `${node.gpu.temperature_celsius} °C`
|
||||
: "—"
|
||||
}</dd></div>
|
||||
<div><dt>Частота GPU</dt><dd>{node?.gpu?.sm_clock_mhz == null ? "—" : `${node.gpu.sm_clock_mhz} МГц`}</dd></div>
|
||||
<div><dt>Частота памяти GPU</dt><dd>{node?.gpu?.memory_clock_mhz == null ? "—" : `${node.gpu.memory_clock_mhz} МГц`}</dd></div>
|
||||
<div><dt>Мощность</dt><dd>{
|
||||
typeof node?.gpu?.power_watts === "number"
|
||||
? `${node.gpu.power_watts.toFixed(1)} Вт`
|
||||
@@ -214,7 +221,7 @@ export function ComputeModulesWorkspace() {
|
||||
<div>
|
||||
<span className="section-eyebrow">PROCESSING RUNTIME</span>
|
||||
<h3>Контейнеры Mission Core</h3>
|
||||
<p>Два ограниченных runtime: inference server и прикладной perception pipeline.</p>
|
||||
<p>Состояние контейнера и готовность вычислительного профиля проверяются отдельно.</p>
|
||||
</div>
|
||||
<StatusBadge tone={node?.triton.ready ? "success" : "danger"}>
|
||||
{node?.triton.ready ? "Triton ready" : "Triton недоступен"}
|
||||
@@ -233,19 +240,31 @@ export function ComputeModulesWorkspace() {
|
||||
<span className="section-eyebrow">ТЕКУЩАЯ ЗАДАЧА</span>
|
||||
<h3>{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}</h3>
|
||||
<p>
|
||||
{telemetry?.pipeline.profile_name ?? "Perception"}
|
||||
{" · "}
|
||||
{telemetry?.pipeline.active_request_id
|
||||
? `Run ${telemetry.pipeline.active_request_id}`
|
||||
: "Очередь свободна; модели остаются загруженными в persistent worker."}
|
||||
: telemetry?.pipeline.service_state === "ready"
|
||||
? "Готов к приёму данных."
|
||||
: "Готовность моделей не подтверждена."}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={
|
||||
telemetry?.pipeline.service_state === "busy" ? "warning"
|
||||
["busy", "running", "waiting", "synchronizing", "starting"].includes(telemetry?.pipeline.service_state ?? "") ? "warning"
|
||||
: telemetry?.pipeline.service_state === "ready" ? "success"
|
||||
: "danger"
|
||||
}>
|
||||
{telemetry?.pipeline.service_state ?? "unavailable"}
|
||||
{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
{telemetry?.pipeline.profile_name ? (
|
||||
<div className="worker-pipeline__summary">
|
||||
<div><span>Процессы профиля</span><strong>{telemetry.pipeline.live_children ?? "—"}</strong></div>
|
||||
<div><span>Ожиданий потока</span><strong>{telemetry.pipeline.input_pauses ?? "—"}</strong></div>
|
||||
<div><span>Пакетов в очереди</span><strong>{telemetry.pipeline.pending_bundles ?? "—"}</strong></div>
|
||||
<div><span>Входные буферы</span><strong>{formatBytes(telemetry.pipeline.buffer_bytes)}</strong></div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="worker-pipeline__summary">
|
||||
<div><span>Завершено запусков</span><strong>{telemetry?.pipeline.completed_runs ?? "—"}</strong></div>
|
||||
<div><span>Ошибок запусков</span><strong>{telemetry?.pipeline.failed_runs ?? "—"}</strong></div>
|
||||
|
||||
@@ -11,6 +11,7 @@ let fetchM49TgsAnchorSpatial;
|
||||
let AdvancedLaboratoryContractError;
|
||||
let buildLaboratoryCatalog;
|
||||
let buildLaboratoryProfiles;
|
||||
let isLegacyPublishedLaboratoryWork;
|
||||
let experimentOptionsForProfile;
|
||||
let freshestLaboratorySelection;
|
||||
let workOptionsForExperiment;
|
||||
@@ -928,6 +929,7 @@ before(async () => {
|
||||
({
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
isLegacyPublishedLaboratoryWork,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
workOptionsForExperiment,
|
||||
@@ -962,6 +964,31 @@ after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("capability-owned projections never enter the legacy LAB catalog", () => {
|
||||
const legacy = {
|
||||
status: "ready",
|
||||
replayable: true,
|
||||
modalities: ["point-cloud", "trajectory"],
|
||||
lab: { replayCapability: null },
|
||||
};
|
||||
const capabilityProjection = {
|
||||
...legacy,
|
||||
lab: {
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isLegacyPublishedLaboratoryWork(legacy), true);
|
||||
assert.equal(isLegacyPublishedLaboratoryWork(capabilityProjection), false);
|
||||
});
|
||||
|
||||
test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
|
||||
@@ -105,23 +105,52 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
|
||||
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
||||
});
|
||||
|
||||
test("central composition files cannot silently become monoliths again", async () => {
|
||||
const ratchets = [
|
||||
["App.tsx", 1_250],
|
||||
["workspaces/Workspaces.tsx", 1_200],
|
||||
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
|
||||
["core/laboratory/advancedResults.ts", 1_000],
|
||||
["core/laboratory/e40ProductGate.ts", 500],
|
||||
["styles/workspaces.css", 4_350],
|
||||
["styles/laboratory.css", 900],
|
||||
["styles/laboratory-reporting.css", 100],
|
||||
];
|
||||
test("Observatory owns a bounded explicit-open lifecycle around the shared recorded viewer", async () => {
|
||||
const workspaceHub = await read("workspaces/Workspaces.tsx");
|
||||
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
|
||||
const observatoryCore = await read("core/observatory/catalog.ts");
|
||||
const recordedRun = await read("core/observatory/recordedRun.ts");
|
||||
const sharedReplay = await read(
|
||||
"components/laboratory/CanonicalResultRerunReplay.tsx",
|
||||
);
|
||||
const workspaceCss = await read("styles/workspaces.css");
|
||||
const observatoryCss = await read("styles/observatory.css");
|
||||
|
||||
for (const [relativePath, maximumLines] of ratchets) {
|
||||
const lineCount = (await read(relativePath)).split("\n").length;
|
||||
assert.ok(
|
||||
lineCount <= maximumLines,
|
||||
`${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
|
||||
);
|
||||
assert.match(
|
||||
workspaceHub,
|
||||
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
|
||||
);
|
||||
assert.match(observatory, /export function ObservatoryWorkspace/);
|
||||
assert.match(observatory, /useObservatoryCatalog/);
|
||||
assert.match(observatory, /data-observatory-authority="observation-only"/);
|
||||
assert.match(
|
||||
observatory,
|
||||
/data-observatory-viewer={replay\.kind === "ready" \|\| replay\.kind === "composition" \? "attached" : "detached"}/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
`${observatory}\n${observatoryCore}`,
|
||||
/(?:core|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
|
||||
);
|
||||
assert.match(observatory, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||
assert.match(observatory, /closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||
assert.doesNotMatch(
|
||||
observatory,
|
||||
/replayObservationSession|deleteObservationSession|useObservationSessions|useAdvancedLaboratoryCatalog/,
|
||||
);
|
||||
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
|
||||
assert.doesNotMatch(
|
||||
recordedRun,
|
||||
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||
const source = await read(`components/laboratory/${adapter}.tsx`);
|
||||
assert.match(source, /<CanonicalResultRerunReplay/);
|
||||
assert.doesNotMatch(source, /<RerunViewport|<video|setInterval|Canvas|THREE\./);
|
||||
}
|
||||
assert.doesNotMatch(workspaceCss, /\.observatory-/);
|
||||
assert.match(observatoryCss, /\.observatory-workspace/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let canonicalMapGravityLocalPointToBodyGround;
|
||||
let canonicalRecordedLabPackedTgsCells;
|
||||
let canonicalRecordedLabTgsIsCurrent;
|
||||
let resolveCanonicalLabReplay;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
canonicalMapGravityLocalPointToBodyGround,
|
||||
canonicalRecordedLabPackedTgsCells,
|
||||
canonicalRecordedLabTgsIsCurrent,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/canonicalRecordedLab.ts"));
|
||||
({ resolveCanonicalLabReplay } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/canonicalLabReplay.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const identity = [
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1],
|
||||
];
|
||||
|
||||
test("canonical TGS validity never retains a sparse anchor beyond its sealed history", () => {
|
||||
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_000, 1_000_000_000), true);
|
||||
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_001, 1_000_000_000), false);
|
||||
assert.equal(canonicalRecordedLabTgsIsCurrent(999_999_999, 1_000_000_000), false);
|
||||
});
|
||||
|
||||
test("map-gravity-local TGS uses sensor translation and current ground body exactly once", () => {
|
||||
const anchor = {
|
||||
originMapXyzM: [10, 20, 0.68],
|
||||
sensorOriginMapXyzM: [10, 20, 1],
|
||||
basisMapFromBody: identity,
|
||||
};
|
||||
const current = {
|
||||
originMapXyzM: [8, 20, 0],
|
||||
sensorOriginMapXyzM: [8, 20, 0.32],
|
||||
basisMapFromBody: identity,
|
||||
};
|
||||
assert.deepEqual(
|
||||
canonicalMapGravityLocalPointToBodyGround([1, 2, -1], anchor, current),
|
||||
[3, 2, 0],
|
||||
);
|
||||
const packed = canonicalRecordedLabPackedTgsCells({
|
||||
centersXyM: [[1, 2]],
|
||||
stateCodes: [2],
|
||||
zBoundsM: [[-1, 0]],
|
||||
}, anchor, current);
|
||||
assert.deepEqual([...packed.centersBodyXyM], [3, 2]);
|
||||
assert.deepEqual([...packed.zBoundsM], [0, 1]);
|
||||
assert.deepEqual([...packed.stateCodes], [2]);
|
||||
});
|
||||
|
||||
test("recorded LAB spatial loading is shared, profile-bound and experiment-neutral", async () => {
|
||||
const [contract, scheduler, vegetation] = await Promise.all([
|
||||
readFile(new URL("../src/core/laboratory/canonicalRecordedLabSpatial.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/useCanonicalRecordedLabSpatialFrame.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/core/laboratory/vegetationShadow.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(contract, /CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
|
||||
assert.match(contract, /profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
|
||||
assert.match(scheduler, /Shared latest-request-wins scheduler/);
|
||||
assert.match(scheduler, /identityRef\.current !== requestIdentity/);
|
||||
assert.doesNotMatch(scheduler, /RAVNOVES|vegetation|DDRNet/);
|
||||
assert.doesNotMatch(vegetation, /fetchCanonicalRecordedLabSpatialFrame|CanonicalRecordedLabSpatialFrame/);
|
||||
});
|
||||
|
||||
test("canonical LAB resolves one generation-bound merged RRD", async () => {
|
||||
const baseGeneration = "a".repeat(64);
|
||||
const replayGeneration = "b".repeat(64);
|
||||
const resultId = `lab-v1-vegetation-shadow-${"c".repeat(64)}`;
|
||||
let request;
|
||||
const replay = await resolveCanonicalLabReplay(resultId, {
|
||||
kind: "rerun-recording",
|
||||
sessionId: "session-001",
|
||||
sourceUrl: "/api/v1/observation-sessions/session-001/recording.rrd",
|
||||
viewerSourceUrl:
|
||||
`/api/v1/observation-sessions/session-001/recording.rrd?generation=${baseGeneration}`,
|
||||
mediaType: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 10,
|
||||
seekable: true,
|
||||
byteLength: 100,
|
||||
sha256: baseGeneration,
|
||||
playback: { speed: 1, loop: false },
|
||||
mediaSources: [],
|
||||
}, {
|
||||
origin: "http://mission-core.test",
|
||||
fetcher: async (url, options) => {
|
||||
request = { url, options };
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "234567",
|
||||
"ETag": `"${replayGeneration}"`,
|
||||
"X-Rerun-Format": "RRF2",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sourceUrl =
|
||||
`/api/v1/laboratory/vegetation-shadow/${resultId}/canonical-replay.rrd`;
|
||||
assert.equal(
|
||||
request.url,
|
||||
`http://mission-core.test${sourceUrl}?base_generation=${baseGeneration}`,
|
||||
);
|
||||
assert.equal(request.options.method, "HEAD");
|
||||
assert.deepEqual(replay, {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${replayGeneration}`,
|
||||
byteLength: 234567,
|
||||
sha256: replayGeneration,
|
||||
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
|
||||
});
|
||||
});
|
||||
|
||||
for (const [prefix, sourceKind] of [
|
||||
["m49-tgs-portable-review", "portable-tgs"],
|
||||
["lab-v1-eomt-ddrnet", "portable-semantic"],
|
||||
]) test(`${sourceKind} resolves Core cache for the exact result and base, never a legacy LAB`, async () => {
|
||||
const base = "a".repeat(64), generation = "b".repeat(64);
|
||||
const resultId = `${prefix}-${"c".repeat(64)}`;
|
||||
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
|
||||
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
|
||||
sha256: base };
|
||||
let calls = 0;
|
||||
const fetcher = async (url, options) => {
|
||||
calls++;
|
||||
assert.equal(url, `http://mission-core.test/api/v1/observatory/portable-results/${resultId}/replays/${base}/recording.rrd`);
|
||||
assert.equal(options.method, "HEAD");
|
||||
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
|
||||
};
|
||||
const options = { sourceKind, origin: "http://mission-core.test", fetcher };
|
||||
const replay = await resolveCanonicalLabReplay(resultId, launch, options);
|
||||
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
|
||||
assert.equal(replay.blueprintSourceUrl, "/api/v1/observation-sessions/source/blueprint.rrd");
|
||||
await assert.rejects(resolveCanonicalLabReplay(`lab-v1-vegetation-shadow-${"c".repeat(64)}`, launch, options));
|
||||
await assert.rejects(resolveCanonicalLabReplay(resultId, launch, {
|
||||
...options, sourceKind: sourceKind === "portable-tgs" ? "portable-semantic" : "portable-tgs",
|
||||
}));
|
||||
await assert.rejects(resolveCanonicalLabReplay(resultId, { ...launch, sha256: "d".repeat(64) }, options));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, fetchLabViewProfile, saveLabViewProfile, normalizeLabSceneSettings;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
({ fetchLabViewProfile, saveLabViewProfile } = await server.ssrLoadModule(
|
||||
"/src/core/observatory/labViewProfile.ts"));
|
||||
({ normalizeLabSceneSettings } = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/CanonicalResultRerunReplay.tsx"));
|
||||
});
|
||||
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
const serverProfile = {
|
||||
schema_version: "missioncore.observatory-lab-view-profile/v1",
|
||||
result_id: "result-1",
|
||||
scene_settings: {
|
||||
point_size: 4.7,
|
||||
accumulation_seconds: 8,
|
||||
color_mode: "height",
|
||||
palette: "viridis",
|
||||
show_grid: false,
|
||||
show_labels: true,
|
||||
show_camera_frustums: false,
|
||||
},
|
||||
updated_at_utc: "2026-09-04T09:30:00.000Z",
|
||||
};
|
||||
|
||||
test("LAB view profile round-trips through the result-scoped server endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({ url, init });
|
||||
return new Response(JSON.stringify(serverProfile), {
|
||||
status: 200, headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const settings = await fetchLabViewProfile("result-1");
|
||||
assert.equal(settings.pointSize, 4.7);
|
||||
assert.equal(settings.palette, "viridis");
|
||||
const saved = await saveLabViewProfile("result-1", settings);
|
||||
assert.equal(saved.accumulationSeconds, 8);
|
||||
assert.equal(requests[0].url, "/api/v1/observatory/lab-view-profiles/result-1");
|
||||
assert.equal(requests[1].init.method, "PUT");
|
||||
assert.deepEqual(JSON.parse(requests[1].init.body).scene_settings, serverProfile.scene_settings);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("LAB view profile fails closed on a foreign result identity", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
...serverProfile, result_id: "foreign-result",
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
try {
|
||||
await assert.rejects(fetchLabViewProfile("result-1"), /другой LAB/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("LAB scene settings normalize typed values before persistence", () => {
|
||||
assert.deepEqual(
|
||||
normalizeLabSceneSettings({
|
||||
pointSize: 28,
|
||||
accumulationSeconds: 305,
|
||||
colorMode: "height",
|
||||
palette: "viridis",
|
||||
customColor: "#ffffff",
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showLabels: true,
|
||||
showCameraFrustums: false,
|
||||
projection: "3d",
|
||||
}),
|
||||
{
|
||||
pointSize: 28,
|
||||
accumulationSeconds: 305,
|
||||
colorMode: "height",
|
||||
palette: "viridis",
|
||||
customColor: "#ffffff",
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showLabels: true,
|
||||
showCameraFrustums: false,
|
||||
projection: "3d",
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let laboratoryRecordedEvidenceDemand;
|
||||
let e47SemanticChunkWindowStarts;
|
||||
let cancelE47SemanticRequestsOutsideWindow;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ laboratoryRecordedEvidenceDemand } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/recordedEvidenceProfile.ts",
|
||||
));
|
||||
({
|
||||
e47SemanticChunkWindowStarts,
|
||||
cancelE47SemanticRequestsOutsideWindow,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/useE47SemanticTimeline.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("camera-only LAB presentation does not acquire hidden spatial or semantic payloads", () => {
|
||||
assert.deepEqual(laboratoryRecordedEvidenceDemand({
|
||||
mediaMode: "video",
|
||||
spatialMode: null,
|
||||
showMediaSemantic: false,
|
||||
showSpatialSemantic: true,
|
||||
showMediaPoints: false,
|
||||
classifiedSpatialMode: "overlay",
|
||||
}), {
|
||||
sourceTimelineMetadata: true,
|
||||
recordedVideo: true,
|
||||
exactCameraFrame: false,
|
||||
sourceSpatialPoints: false,
|
||||
cameraPointOverlay: false,
|
||||
selectedSemanticMask: false,
|
||||
selectedSemanticPoints: false,
|
||||
classifiedSpatial: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("visible M4 layers acquire only their selected camera and spatial evidence", () => {
|
||||
assert.deepEqual(laboratoryRecordedEvidenceDemand({
|
||||
mediaMode: "camera",
|
||||
spatialMode: "plan",
|
||||
showMediaSemantic: true,
|
||||
showSpatialSemantic: true,
|
||||
showMediaPoints: true,
|
||||
classifiedSpatialMode: "overlay",
|
||||
}), {
|
||||
sourceTimelineMetadata: true,
|
||||
recordedVideo: false,
|
||||
exactCameraFrame: true,
|
||||
sourceSpatialPoints: true,
|
||||
cameraPointOverlay: true,
|
||||
selectedSemanticMask: true,
|
||||
selectedSemanticPoints: true,
|
||||
classifiedSpatial: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a classified replacement does not also load the hidden source point track", () => {
|
||||
const demand = laboratoryRecordedEvidenceDemand({
|
||||
mediaMode: null,
|
||||
spatialMode: "3d",
|
||||
showMediaSemantic: false,
|
||||
showSpatialSemantic: false,
|
||||
showMediaPoints: false,
|
||||
classifiedSpatialMode: "replace-source",
|
||||
});
|
||||
assert.equal(demand.sourceSpatialPoints, false);
|
||||
assert.equal(demand.classifiedSpatial, true);
|
||||
});
|
||||
|
||||
test("semantic point chunks are ordered active-first and stale requests are cancelled", () => {
|
||||
assert.deepEqual(e47SemanticChunkWindowStarts(48, 4_489), [48, 72, 96, 24]);
|
||||
assert.deepEqual(e47SemanticChunkWindowStarts(0, 4_489), [0, 24, 48]);
|
||||
|
||||
const cancelled = [];
|
||||
const requests = new Map([
|
||||
[0, { abort: () => cancelled.push(0) }],
|
||||
[24, { abort: () => cancelled.push(24) }],
|
||||
[240, { abort: () => cancelled.push(240) }],
|
||||
]);
|
||||
cancelE47SemanticRequestsOutsideWindow(requests, [240, 264]);
|
||||
assert.deepEqual(cancelled, [0, 24]);
|
||||
assert.deepEqual([...requests.keys()], [240]);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ let createLiveViewerDiagnosticLifecycle;
|
||||
let createAbortFencedBuildVerifier;
|
||||
let createUiBuildStaleCoordinator;
|
||||
let liveViewerDiagnosticBody;
|
||||
let reloadRecordedViewerAfterStaleModuleFailure;
|
||||
let server;
|
||||
let uiBuildIdFromModuleScripts;
|
||||
let verifyLiveViewerClientBuild;
|
||||
@@ -22,6 +23,7 @@ before(async () => {
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createUiBuildStaleCoordinator,
|
||||
liveViewerDiagnosticBody,
|
||||
reloadRecordedViewerAfterStaleModuleFailure,
|
||||
uiBuildIdFromModuleScripts,
|
||||
verifyLiveViewerClientBuild,
|
||||
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
||||
@@ -204,6 +206,36 @@ test("build drift is reported once with the exact loaded build", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("a recorded viewer reloads only when its failed lazy module belongs to a stale build", async () => {
|
||||
const reloads = [];
|
||||
const loadedUiBuildId = "/assets/index-abcdefgh.js";
|
||||
const currentUiBuildId = "/assets/index-ijklmnop.js";
|
||||
const fetcher = async (_url, options) => {
|
||||
assert.equal(options.headers["X-MissionCore-UI-Build"], loadedUiBuildId);
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
|
||||
});
|
||||
};
|
||||
|
||||
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
|
||||
loadedUiBuildId,
|
||||
fetcher,
|
||||
reload: () => reloads.push("reload"),
|
||||
}), true);
|
||||
assert.deepEqual(reloads, ["reload"]);
|
||||
|
||||
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
|
||||
loadedUiBuildId: currentUiBuildId,
|
||||
fetcher: async () => new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
|
||||
}),
|
||||
reload: () => reloads.push("unexpected"),
|
||||
}), false);
|
||||
assert.deepEqual(reloads, ["reload"]);
|
||||
});
|
||||
|
||||
test("last unsubscribe fences an already queued build verification callback", () => {
|
||||
const controller = new AbortController();
|
||||
const observedSignals = [];
|
||||
|
||||
@@ -18,6 +18,7 @@ let nextM48ObjectId;
|
||||
let laboratoryMetricLegendEntries;
|
||||
let nearestLaboratoryRecordedClipFrame;
|
||||
let laboratoryRecordedClipEndExclusiveNs;
|
||||
let laboratoryRecordedClipClockGate;
|
||||
let m48SpatialPlaybackWindow;
|
||||
let trimM48SpatialPlaybackCache;
|
||||
let nextM48CameraVisibility;
|
||||
@@ -45,6 +46,7 @@ before(async () => {
|
||||
({
|
||||
nearestLaboratoryRecordedClipFrame,
|
||||
laboratoryRecordedClipEndExclusiveNs,
|
||||
laboratoryRecordedClipClockGate,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
||||
));
|
||||
@@ -342,6 +344,21 @@ test("shared recorded clip clock selects exact frames and one stable loop bounda
|
||||
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
|
||||
});
|
||||
|
||||
test("shared recorded clip rejects stale media callbacks until an explicit seek lands", () => {
|
||||
assert.deepEqual(laboratoryRecordedClipClockGate(1, 123), {
|
||||
accept: false,
|
||||
pendingSequence: 1,
|
||||
});
|
||||
assert.deepEqual(laboratoryRecordedClipClockGate(1, 1), {
|
||||
accept: true,
|
||||
pendingSequence: null,
|
||||
});
|
||||
assert.deepEqual(laboratoryRecordedClipClockGate(null, 124), {
|
||||
accept: true,
|
||||
pendingSequence: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
|
||||
assert.equal(nextM48CameraVisibility("camera", true), true);
|
||||
assert.equal(nextM48CameraVisibility("3d", true), false);
|
||||
|
||||
@@ -108,8 +108,13 @@ test("M4.9T5 viewer prefers autonomous chunks and keeps a sealed legacy fallback
|
||||
assert.doesNotMatch(source, /centersXyM\.map\(/);
|
||||
assert.match(source, /fetchE47SemanticSlamResult/);
|
||||
assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/);
|
||||
assert.match(source, /semantic=\{semanticOverride \?\? \(semantic \? \{/);
|
||||
assert.match(source, /semanticLayers=\{semanticLayers\}/);
|
||||
assert.match(source, /ГОРОД · EoMT/);
|
||||
assert.match(source, /ПРИРОДА · DDRNet/);
|
||||
assert.match(source, /semanticOverride/);
|
||||
assert.doesNotMatch(source, /if \(semanticOverride\) return/);
|
||||
assert.match(visual, /label="Источник семантики"/);
|
||||
assert.match(visual, /availableSemanticLayers\.length > 1/);
|
||||
assert.doesNotMatch(visual, /classifiedSpatialLayer \|\| !showReferenceMediaLayers \? \[\]/);
|
||||
assert.match(
|
||||
visual,
|
||||
|
||||
@@ -379,14 +379,16 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
|
||||
});
|
||||
|
||||
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
||||
const pointOffsets = [0, ...Array(4489).fill(2)];
|
||||
const playbackResultId = `lab-v1-vegetation-shadow-${"b".repeat(64)}`;
|
||||
const frameCount = 48;
|
||||
const pointOffsets = [0, ...Array(frameCount).fill(2)];
|
||||
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
||||
const pointBytes = points.buffer;
|
||||
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
||||
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
||||
const chunks = Array.from({ length: 188 }, (_, index) => {
|
||||
const chunks = Array.from({ length: Math.ceil(frameCount / 24) }, (_, index) => {
|
||||
const start = index * 24;
|
||||
const count = Math.min(24, 4489 - start);
|
||||
const count = Math.min(24, frameCount - start);
|
||||
const pointStart = pointOffsets[start];
|
||||
const pointStop = pointOffsets[start + count];
|
||||
const pointCount = pointStop - pointStart;
|
||||
@@ -396,7 +398,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
||||
count,
|
||||
point_start: pointStart,
|
||||
point_count: pointCount,
|
||||
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`,
|
||||
url: `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/${index}`,
|
||||
media_type: "application/octet-stream",
|
||||
dtype: "<f4",
|
||||
shape: [pointCount, 3],
|
||||
@@ -406,8 +408,8 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
||||
});
|
||||
const manifestPayload = {
|
||||
schema_version: "missioncore.recorded-spatial-playback/v1",
|
||||
result_id: resultId,
|
||||
frame_count: 4489,
|
||||
result_id: playbackResultId,
|
||||
frame_count: frameCount,
|
||||
point_count: 2,
|
||||
point_offsets: pointOffsets,
|
||||
chunk_frame_count: 24,
|
||||
@@ -416,7 +418,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
||||
chunks,
|
||||
track: {
|
||||
id: "points-map-f32",
|
||||
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`,
|
||||
url: `${endpointRoot}/${playbackResultId}/timeline/playback/tracks/points-map-f32`,
|
||||
media_type: "application/octet-stream",
|
||||
dtype: "<f4",
|
||||
shape: [2, 3],
|
||||
@@ -434,12 +436,12 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
||||
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
||||
return new Response(null, { status: 404 });
|
||||
};
|
||||
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher });
|
||||
const manifest = await fetchM4ThreatPlaybackManifest(playbackResultId, { fetcher });
|
||||
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
||||
|
||||
assert.deepEqual(requested, [
|
||||
`${endpointRoot}/${resultId}/timeline/playback`,
|
||||
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`,
|
||||
`${endpointRoot}/${playbackResultId}/timeline/playback`,
|
||||
`${endpointRoot}/${playbackResultId}/timeline/playback/chunks/0`,
|
||||
]);
|
||||
assert.equal(chunk.pointCount, 2);
|
||||
assert.equal(chunk.pointStart, 0);
|
||||
@@ -769,7 +771,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b
|
||||
});
|
||||
|
||||
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]);
|
||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72, 24]);
|
||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
||||
});
|
||||
|
||||
@@ -786,8 +788,8 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps",
|
||||
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...inFlight.keys()], [4488]);
|
||||
assert.deepEqual(aborted, [0, 24, 1488, 1512]);
|
||||
assert.deepEqual([...inFlight.keys()], [4488, 4464]);
|
||||
assert.deepEqual(aborted, [0, 24, 1488, 1512, 1464]);
|
||||
});
|
||||
|
||||
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
||||
@@ -847,8 +849,9 @@ test("recorded VIDEO clock cannot reverse an explicit operator pause", () => {
|
||||
});
|
||||
|
||||
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
|
||||
const [visual, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
|
||||
const [visual, canonical, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
|
||||
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
||||
@@ -858,10 +861,11 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
||||
assert.match(visual, /<RecordedEvidenceImageScene/);
|
||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||
assert.match(visual, /m4-replay-threat-visual__deck/);
|
||||
assert.match(visual, /<CanonicalRecordedLabReplay/);
|
||||
assert.match(canonical, /m4-replay-threat-visual__deck/);
|
||||
assert.match(visual, /lastFrameRef/);
|
||||
assert.match(visual, /lastSpatialFrameRef/);
|
||||
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
|
||||
assert.match(visual, /const spatialFrame = currentSpatialFrame/);
|
||||
assert.match(visual, /<ObservationTimeline/);
|
||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
|
||||
@@ -876,19 +880,33 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visual, /pointCloudOverlay=/);
|
||||
assert.match(visual, /mediaMode/);
|
||||
assert.match(visual, /spatialMode/);
|
||||
assert.match(visual, /current === next \? null : next/);
|
||||
assert.match(visual, /data-split=\{splitView \? "true" : undefined\}/);
|
||||
assert.match(visual, /<SplitPane/);
|
||||
assert.match(visual, /primarySize=\{splitView \? splitPrimarySize : mediaMode \? 100 : 0\}/);
|
||||
assert.match(visual, /resizable=\{splitView\}/);
|
||||
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||
assert.match(visual, /secondaryMode=\{\{/);
|
||||
assert.match(canonical, /current === next \? null : next/);
|
||||
assert.match(canonical, /data-split=\{splitView \? "true" : undefined\}/);
|
||||
assert.match(canonical, /<SplitPane/);
|
||||
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||
assert.match(canonical, /secondary=\{spatialPane/);
|
||||
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||
assert.match(canonical, /resizable=\{splitView\}/);
|
||||
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||
assert.match(canonical, /secondaryMode=\{\{/);
|
||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||
assert.match(visual, /clock: mediaMode === "video" \? "external" : "animation"/);
|
||||
assert.match(visual, /clock: "external"/);
|
||||
assert.match(visual, /useCanonicalRecordedLabReplayState/);
|
||||
assert.match(canonical, /current === next \? null : next/);
|
||||
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
|
||||
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
|
||||
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
|
||||
assert.match(visual, /onPlayingRejected=\{\(\) => playbackController\.setPlaying\(false\)\}/);
|
||||
assert.match(
|
||||
await readFile(new URL("../src/components/laboratory/useRecordedEvidencePlayback.ts", import.meta.url), "utf8"),
|
||||
/if \(clock === "animation"\) return;/,
|
||||
);
|
||||
assert.match(visual, /playbackAuthority="media"/);
|
||||
assert.match(visual, /playbackTransport = "epoch-stream"/);
|
||||
assert.match(visual, /playbackTransport=\{playbackTransport\}/);
|
||||
assert.match(
|
||||
await readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
|
||||
/if \(playbackAuthority === "host"\) return;/,
|
||||
);
|
||||
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
|
||||
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
|
||||
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
|
||||
@@ -912,6 +930,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
||||
assert.match(visualCss, /bottom: auto/);
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(videoScene, /!playback\.playing && Math\.abs\(next\.currentSeconds - playback\.currentSeconds\) > 0\.35/);
|
||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
||||
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
||||
@@ -920,19 +939,20 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(metricScene, /OrbitControls/);
|
||||
assert.match(visual, /LOCAL SLAM/);
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(visual, /const latestAvailableSpatialFrame = \[\.\.\.timelineFrame\.availableFrames\][\s\S]*candidate\.spatialAvailable[\s\S]*candidate\.sequence <= timelineFrame\.activeSequence/);
|
||||
assert.match(
|
||||
visual,
|
||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? activeSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? \[\]\}/,
|
||||
);
|
||||
assert.match(
|
||||
visual,
|
||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||
/const classifiedSpatialFrame = hasClassifiedSpatialOutput[\s\S]*classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||
);
|
||||
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
||||
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
||||
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
||||
assert.match(visual, /\{activeSemantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
||||
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
|
||||
assert.match(
|
||||
visual,
|
||||
@@ -951,12 +971,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
});
|
||||
|
||||
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
|
||||
const visual = await readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(visual, /const mediaPane = \(/);
|
||||
assert.match(visual, /hidden=\{!mediaMode\}/);
|
||||
const [visual, canonical] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
assert.match(canonical, /const mediaPane = \(/);
|
||||
assert.match(canonical, /hidden=\{mediaMode === "none"\}/);
|
||||
assert.match(visual, /data-media="video"/);
|
||||
assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
|
||||
assert.match(visual, /\{videoSource \? \(/);
|
||||
|
||||
@@ -77,6 +77,70 @@ function session(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalLab(overrides = {}) {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
return {
|
||||
lab_id: "LAB V1",
|
||||
source_session_id: "20260828T130511Z_viewer_live",
|
||||
result_kind: "recorded-perception-qualification",
|
||||
result_id: resultId,
|
||||
source_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||
config_sha256: null,
|
||||
run_created_at_utc: "2026-08-29T18:05:11.329061+00:00",
|
||||
published_at_utc: "2026-08-30T12:00:00.000Z",
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewer_profile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
},
|
||||
provenance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyCalculationProfile(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.observatory-calculation-profile/v1",
|
||||
setup_id: "lab-v1-ravnoves004tree-final",
|
||||
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||
origin: "existing-result",
|
||||
definition_id: null,
|
||||
definition_version: null,
|
||||
definition_sha256: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalProjectionProvenance(resultId, replayCapability) {
|
||||
return {
|
||||
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||
evidence_identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||
result_document_sha256: "a".repeat(64),
|
||||
replay_capability: replayCapability,
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
method: {
|
||||
schema_version: "missioncore.laboratory-method/v1",
|
||||
completeness: "legacy-partial",
|
||||
execution_class: "ai-inference",
|
||||
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||
components: [{
|
||||
kind: "source",
|
||||
name: "sealed full-route LAB result",
|
||||
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
role: "immutable Session catalog projection",
|
||||
identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replay(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
const sourceUrl = overrides.source_url ??
|
||||
@@ -134,6 +198,7 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
||||
modalities: ["point-cloud", "pose"],
|
||||
durationSeconds: 1_450.744,
|
||||
replayable: true,
|
||||
captureAttestation: null,
|
||||
preparation: null,
|
||||
lab: null,
|
||||
}]);
|
||||
@@ -141,6 +206,137 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("session catalog strictly decodes the explicit recorded LAB replay capability", () => {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const decoded = decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: canonicalLab() })],
|
||||
}).items[0].lab;
|
||||
|
||||
assert.deepEqual(decoded.replayCapability, {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
});
|
||||
const rollingUpgradeLab = canonicalLab();
|
||||
rollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||
resultId,
|
||||
rollingUpgradeLab.replay_capability,
|
||||
);
|
||||
delete rollingUpgradeLab.replay_capability;
|
||||
assert.deepEqual(
|
||||
decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: rollingUpgradeLab })],
|
||||
}).items[0].lab.replayCapability,
|
||||
decoded.replayCapability,
|
||||
);
|
||||
const invalidComponents = [
|
||||
{ kind: "tool" },
|
||||
{ name: "unsealed result" },
|
||||
{ version: "missioncore.lab-v1-vegetation-shadow/v0" },
|
||||
{ role: "mutable projection" },
|
||||
{ identity_sha256: "0".repeat(64) },
|
||||
{ unexpected: true },
|
||||
];
|
||||
for (const componentOverride of invalidComponents) {
|
||||
const invalidRollingUpgradeLab = canonicalLab();
|
||||
invalidRollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||
resultId,
|
||||
invalidRollingUpgradeLab.replay_capability,
|
||||
);
|
||||
invalidRollingUpgradeLab.provenance.method.components = [{
|
||||
...invalidRollingUpgradeLab.provenance.method.components[0],
|
||||
...componentOverride,
|
||||
}];
|
||||
delete invalidRollingUpgradeLab.replay_capability;
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: invalidRollingUpgradeLab })],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({
|
||||
replay_capability: {
|
||||
...canonicalLab().replay_capability,
|
||||
commands_enabled: true,
|
||||
},
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("session catalog decodes a typed calculation profile without inferring unknown results", () => {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const decoded = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({ calculation_profile: legacyCalculationProfile() }),
|
||||
})],
|
||||
}).items[0].lab;
|
||||
|
||||
assert.deepEqual(decoded.calculationProfile, {
|
||||
schemaVersion: "missioncore.observatory-calculation-profile/v1",
|
||||
setupId: "lab-v1-ravnoves004tree-final",
|
||||
displayName: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||
origin: "existing-result",
|
||||
definitionId: null,
|
||||
definitionVersion: null,
|
||||
definitionSha256: null,
|
||||
});
|
||||
assert.equal(
|
||||
decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: canonicalLab() })],
|
||||
}).items[0].lab.calculationProfile,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({ calculation_profile: null }),
|
||||
})],
|
||||
}).items[0].lab.calculationProfile,
|
||||
null,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({
|
||||
calculation_profile: legacyCalculationProfile({
|
||||
origin: "archived-definition",
|
||||
definition_id: "lab-v1-portable",
|
||||
}),
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({
|
||||
calculation_profile: {
|
||||
...legacyCalculationProfile(),
|
||||
guessed_from_provenance: true,
|
||||
},
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const styles = await readFile(
|
||||
@@ -246,7 +442,7 @@ test("source and laboratory catalogs are requested as disjoint backend projectio
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"/api/v1/observation-sessions?limit=100&scope=source",
|
||||
"/api/v1/observation-sessions?limit=100&scope=laboratory",
|
||||
"/api/v1/observation-sessions?limit=100&scope=laboratory&lab_contract=v3",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,8 +40,11 @@ let formatAccumulationDuration;
|
||||
let resolveRerunSourceUrl;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let recordedCameraJournalContract;
|
||||
let resolveRecordedPerceptionUrl;
|
||||
let fetchRecordedPerceptionRrd;
|
||||
let resolveRecordedPerceptionViewerSourceUrl;
|
||||
let probeRecordedPerceptionViewerSource;
|
||||
let resolveRecordedPointColorsUrl;
|
||||
let fetchRecordedPointColorsRrd;
|
||||
let recordedPointColorKey;
|
||||
@@ -108,13 +111,16 @@ before(async () => {
|
||||
resolveRerunSourceUrl,
|
||||
resolveRecordedBlueprintUrl,
|
||||
fetchRecordedBlueprintRrd,
|
||||
recordedCameraJournalContract,
|
||||
resolveRecordedPerceptionUrl,
|
||||
fetchRecordedPerceptionRrd,
|
||||
resolveRecordedPerceptionViewerSourceUrl,
|
||||
probeRecordedPerceptionViewerSource,
|
||||
resolveRecordedPointColorsUrl,
|
||||
fetchRecordedPointColorsRrd,
|
||||
recordedPointColorKey,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
||||
@@ -125,6 +131,25 @@ before(async () => {
|
||||
));
|
||||
});
|
||||
|
||||
test("follow toggles retain the current recorded camera journal", () => {
|
||||
const before = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: false,
|
||||
});
|
||||
const afterFollowToggle = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: true,
|
||||
});
|
||||
const afterPlanToggle = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: true, followTrajectory: true,
|
||||
});
|
||||
const afterReset = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 1, planView: false, followTrajectory: false,
|
||||
});
|
||||
|
||||
assert.equal(afterFollowToggle, before);
|
||||
assert.notEqual(afterPlanToggle, before);
|
||||
assert.notEqual(afterReset, before);
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
const bounds = { width: 1280, height: 720 };
|
||||
const left = initialObservationWindowRect(0, 2, bounds);
|
||||
@@ -407,6 +432,15 @@ test("recorded blueprint endpoint is derived only from canonical same-origin RRD
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"a".repeat(64)}`
|
||||
+ "/canonical-replay.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
"/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
||||
@@ -431,6 +465,7 @@ test("recorded replay becomes ready only after the complete declared timeline is
|
||||
|
||||
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||
const calls = [];
|
||||
const cameraLimits = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
@@ -451,6 +486,15 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
activeView: "perception3d",
|
||||
viewResetGeneration: 1,
|
||||
followTrajectory: true,
|
||||
cameraEye: {
|
||||
position: [3, 4, 5],
|
||||
lookTarget: [1, 2, 0],
|
||||
eyeUp: [0, 0, 1],
|
||||
},
|
||||
eyeRelativeToTracking: true,
|
||||
currentTimeNs: 39_215_263_458,
|
||||
onCameraMaxOrbitalRadius: value => cameraLimits.push(value),
|
||||
unifiedCameraShare: 0.73,
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
detections2d: true,
|
||||
@@ -461,7 +505,10 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"X-MissionCore-Camera-Max-Orbital-Radius": "686.024231",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -484,11 +531,21 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
active_view: "perception3d",
|
||||
view_reset_generation: 1,
|
||||
follow_trajectory: true,
|
||||
current_time_ns: 39_215_263_458,
|
||||
unified_perception: true,
|
||||
unified_camera_share: 0.73,
|
||||
semantic_layer: null,
|
||||
plan_view: false,
|
||||
eye_position: [3, 4, 5],
|
||||
eye_look_target: [1, 2, 0],
|
||||
eye_up: [0, 0, 1],
|
||||
eye_relative_to_tracking: true,
|
||||
show_camera_image: true,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
show_cuboids_3d: true,
|
||||
});
|
||||
assert.deepEqual(cameraLimits, [686.024231]);
|
||||
|
||||
await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
@@ -522,6 +579,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
},
|
||||
);
|
||||
assert.equal(calls[1].body.unified_perception, false);
|
||||
assert.equal(calls[1].body.unified_camera_share, 0.46);
|
||||
assert.equal(calls[1].body.show_cuboids_3d, true);
|
||||
|
||||
await assert.rejects(
|
||||
@@ -664,6 +722,74 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
|
||||
assert.equal(absent, null);
|
||||
});
|
||||
|
||||
test("LAB perception sidecar is streamed by native Rerun from one generation-bound URL", async () => {
|
||||
const endpoint =
|
||||
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
|
||||
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
|
||||
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
|
||||
endpoint,
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
"b".repeat(64),
|
||||
"http://127.0.0.1:5174",
|
||||
);
|
||||
assert.equal(
|
||||
sourceUrl,
|
||||
`${endpoint}?application_id=nodedc_mission_core_recorded` +
|
||||
`&recording_id=recording-001&generation=${"b".repeat(64)}`,
|
||||
);
|
||||
|
||||
const overlayGeneration = "c".repeat(64);
|
||||
const probe = await probeRecordedPerceptionViewerSource(sourceUrl, {
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async (input, init) => {
|
||||
assert.equal(String(input), sourceUrl);
|
||||
assert.equal(init.method, "HEAD");
|
||||
assert.equal(new Headers(init.headers).get("Range"), null);
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "186058411",
|
||||
"ETag": `"${overlayGeneration}"`,
|
||||
"X-Rerun-Format": "RRF2",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(probe, {
|
||||
sourceUrl: `${sourceUrl}&overlay_generation=${overlayGeneration}`,
|
||||
byteLength: 186_058_411,
|
||||
});
|
||||
});
|
||||
|
||||
test("LAB native source rejects an unsealed overlay descriptor", async () => {
|
||||
const endpoint =
|
||||
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
|
||||
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
|
||||
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
|
||||
endpoint,
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
"b".repeat(64),
|
||||
"http://127.0.0.1:5174",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
probeRecordedPerceptionViewerSource(sourceUrl, {
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async () => new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "186058411",
|
||||
"ETag": `"${"c".repeat(64)}"`,
|
||||
"X-Rerun-Format": "RRF1",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
/Invalid recorded perception viewer response/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||
const sources = recordedObservationSources({
|
||||
kind: "rerun-recording",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user