Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
Generated
+4
-5
@@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@nodedc/mission-core-control-station",
|
"name": "@nodedc/mission-core-control-station",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"hasInstallScript": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||||
@@ -15,7 +14,7 @@
|
|||||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
"@rerun-io/web-viewer": "0.36.3",
|
||||||
"meshoptimizer": "1.1.1",
|
"meshoptimizer": "1.1.1",
|
||||||
"playcanvas": "2.21.4",
|
"playcanvas": "2.21.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@@ -900,9 +899,9 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@rerun-io/web-viewer": {
|
"node_modules/@rerun-io/web-viewer": {
|
||||||
"version": "0.34.1",
|
"version": "0.36.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
||||||
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
|
"integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -18,7 +17,7 @@
|
|||||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
"@rerun-io/web-viewer": "0.36.3",
|
||||||
"meshoptimizer": "1.1.1",
|
"meshoptimizer": "1.1.1",
|
||||||
"playcanvas": "2.21.4",
|
"playcanvas": "2.21.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -806,6 +806,8 @@ export default function App() {
|
|||||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||||
) : activeDefinition.kind === "lab-archive" ? (
|
) : activeDefinition.kind === "lab-archive" ? (
|
||||||
laboratoryAnnotation.control
|
laboratoryAnnotation.control
|
||||||
|
) : activeDefinition.kind === "observatory" ? (
|
||||||
|
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||||
) : activeDefinition.root === "system" ? (
|
) : activeDefinition.root === "system" ? (
|
||||||
<SystemWorkspaceSelector
|
<SystemWorkspaceSelector
|
||||||
value={activeDefinition.id}
|
value={activeDefinition.id}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "
|
|||||||
|
|
||||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||||
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
|
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
|
||||||
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000;
|
// A valid local fragment becomes decoder-ready well below one second. Keeping
|
||||||
|
// a damaged GOP on screen for ten seconds only delays the keyframe recovery.
|
||||||
|
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 2_500;
|
||||||
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
|
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
|
||||||
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
|
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
|
||||||
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
|
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
|
||||||
@@ -72,6 +74,39 @@ export function recordedMediaDecodeStartSequence(
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nextRecordedMediaRandomAccessSequence(
|
||||||
|
randomAccessSequences: readonly number[],
|
||||||
|
failedSequence: number,
|
||||||
|
): number | null {
|
||||||
|
if (!Number.isInteger(failedSequence) || failedSequence < 1) return null;
|
||||||
|
for (const sequence of randomAccessSequences) {
|
||||||
|
if (!Number.isInteger(sequence) || sequence < 1) return null;
|
||||||
|
if (sequence > failedSequence) return sequence;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedMediaRecoveryTargetSequence(
|
||||||
|
requestedSequence: number | null,
|
||||||
|
failedSequence: number | null,
|
||||||
|
recoverySequence: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (requestedSequence === null) return null;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(requestedSequence)
|
||||||
|
|| requestedSequence < 1
|
||||||
|
|| failedSequence === null
|
||||||
|
|| recoverySequence === null
|
||||||
|
|| !Number.isInteger(failedSequence)
|
||||||
|
|| !Number.isInteger(recoverySequence)
|
||||||
|
|| failedSequence < 1
|
||||||
|
|| recoverySequence <= failedSequence
|
||||||
|
) return requestedSequence;
|
||||||
|
return requestedSequence >= failedSequence && requestedSequence < recoverySequence
|
||||||
|
? recoverySequence
|
||||||
|
: requestedSequence;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaSegmentAppendOrder(
|
export function recordedMediaSegmentAppendOrder(
|
||||||
appended: ReadonlySet<number>,
|
appended: ReadonlySet<number>,
|
||||||
decodeStartSequence: number,
|
decodeStartSequence: number,
|
||||||
@@ -90,6 +125,32 @@ export function recordedMediaSegmentAppendOrder(
|
|||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a source-clock timestamp to the first fMP4 fragment covering it. */
|
||||||
|
export function recordedMediaSegmentSequenceAtTime(
|
||||||
|
segmentEndTimesSeconds: readonly number[],
|
||||||
|
epochStartSeconds: number,
|
||||||
|
currentSeconds: number,
|
||||||
|
): number | null {
|
||||||
|
if (
|
||||||
|
!segmentEndTimesSeconds.length ||
|
||||||
|
!Number.isFinite(epochStartSeconds) ||
|
||||||
|
!Number.isFinite(currentSeconds)
|
||||||
|
) return null;
|
||||||
|
const localSeconds = Math.max(0, currentSeconds - epochStartSeconds);
|
||||||
|
let left = 0;
|
||||||
|
let right = segmentEndTimesSeconds.length - 1;
|
||||||
|
while (left < right) {
|
||||||
|
const middle = Math.floor((left + right) / 2);
|
||||||
|
const endSeconds = segmentEndTimesSeconds[middle];
|
||||||
|
if (!Number.isFinite(endSeconds) || endSeconds <= 0) return null;
|
||||||
|
if (endSeconds + 0.001 >= localSeconds) right = middle;
|
||||||
|
else left = middle + 1;
|
||||||
|
}
|
||||||
|
const finalEndSeconds = segmentEndTimesSeconds[left];
|
||||||
|
if (!Number.isFinite(finalEndSeconds) || finalEndSeconds + 0.001 < localSeconds) return null;
|
||||||
|
return left + 1;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaCanRollTarget(
|
export function recordedMediaCanRollTarget(
|
||||||
previousSequence: number,
|
previousSequence: number,
|
||||||
nextSequence: number,
|
nextSequence: number,
|
||||||
@@ -121,6 +182,27 @@ function recordedMediaTimeRangesContain(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordedMediaTimestampStallRecoveryTarget(
|
||||||
|
currentSeconds: number,
|
||||||
|
bufferedRanges: readonly (readonly [number, number])[],
|
||||||
|
skipSeconds = 0.18,
|
||||||
|
): number | null {
|
||||||
|
if (!Number.isFinite(currentSeconds) || !Number.isFinite(skipSeconds) || skipSeconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const [startSeconds, endSeconds] of bufferedRanges) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(startSeconds)
|
||||||
|
|| !Number.isFinite(endSeconds)
|
||||||
|
|| currentSeconds < startSeconds - 0.05
|
||||||
|
|| currentSeconds > endSeconds
|
||||||
|
) continue;
|
||||||
|
const targetSeconds = Math.min(currentSeconds + skipSeconds, endSeconds - 0.05);
|
||||||
|
return targetSeconds >= currentSeconds + 0.04 ? targetSeconds : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaPresentationState(
|
export function recordedMediaPresentationState(
|
||||||
state: "loading" | "ready" | "error",
|
state: "loading" | "ready" | "error",
|
||||||
readyGeneration: string | null,
|
readyGeneration: string | null,
|
||||||
@@ -151,6 +233,24 @@ export function selectRecordedMediaEpoch(
|
|||||||
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
|
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep admission bounded even when the shared Rerun clock is currently before,
|
||||||
|
* between, or after camera epochs. Presentation still reports `waiting`; this
|
||||||
|
* selector only chooses the nearest epoch whose first/last fragment can prove
|
||||||
|
* that the camera transport is usable without downloading the whole MP4.
|
||||||
|
*/
|
||||||
|
export function selectRecordedMediaPreparationEpoch(
|
||||||
|
epochs: readonly ObservationRecordedMediaEpoch[],
|
||||||
|
currentSeconds: number,
|
||||||
|
): ObservationRecordedMediaEpoch | null {
|
||||||
|
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
|
||||||
|
const active = selectRecordedMediaEpoch(epochs, currentSeconds);
|
||||||
|
if (active) return active;
|
||||||
|
return epochs.find((epoch) => epoch.timelineStartSeconds > currentSeconds)
|
||||||
|
?? epochs.at(-1)
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaSeekableCoverage(
|
export function recordedMediaSeekableCoverage(
|
||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
seekableEndSeconds: number,
|
seekableEndSeconds: number,
|
||||||
@@ -231,29 +331,14 @@ export async function fetchRecordedMediaArchive(
|
|||||||
return { manifest, byteLength: totalBytes };
|
return { manifest, byteLength: totalBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function videoHasSeekableArchive(
|
function waitForRecordedVideoInitialFrame(
|
||||||
video: HTMLVideoElement,
|
video: HTMLVideoElement,
|
||||||
declaredDurationSeconds: number,
|
|
||||||
): boolean {
|
|
||||||
if (video.readyState < 1 || video.seekable.length < 1) return false;
|
|
||||||
return recordedMediaSeekableCoverage(
|
|
||||||
video.duration,
|
|
||||||
video.seekable.end(video.seekable.length - 1),
|
|
||||||
declaredDurationSeconds,
|
|
||||||
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
|
||||||
video.seekable.start(0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForSeekableArchive(
|
|
||||||
video: HTMLVideoElement,
|
|
||||||
declaredDurationSeconds: number,
|
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||||
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
|
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
|
const events = ["loadeddata", "canplay", "progress"] as const;
|
||||||
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||||
const armStallTimer = () => {
|
const armStallTimer = () => {
|
||||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||||
@@ -270,7 +355,7 @@ function waitForSeekableArchive(
|
|||||||
};
|
};
|
||||||
const onProgress = () => {
|
const onProgress = () => {
|
||||||
armStallTimer();
|
armStallTimer();
|
||||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
@@ -313,11 +398,7 @@ async function mountRecordedEpochStream(
|
|||||||
};
|
};
|
||||||
video.load();
|
video.load();
|
||||||
try {
|
try {
|
||||||
await waitForSeekableArchive(
|
await waitForRecordedVideoInitialFrame(video, signal);
|
||||||
video,
|
|
||||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -336,6 +417,11 @@ interface RecordedSegmentTarget {
|
|||||||
resetAttempts: number;
|
resetAttempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecordedSegmentRecovery {
|
||||||
|
readonly failedSequence: number;
|
||||||
|
readonly recoverySequence: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RecordedSegmentStreamRuntime {
|
interface RecordedSegmentStreamRuntime {
|
||||||
readonly generation: string;
|
readonly generation: string;
|
||||||
readonly mediaSource: MediaSource;
|
readonly mediaSource: MediaSource;
|
||||||
@@ -716,6 +802,9 @@ export function RecordedFmp4Player({
|
|||||||
onAdmissionChange,
|
onAdmissionChange,
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
onPlayingRejected,
|
onPlayingRejected,
|
||||||
|
playbackAuthority = "media",
|
||||||
|
playbackTransport = "segmented",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
playback?: RecordedObservationPlayback | null;
|
playback?: RecordedObservationPlayback | null;
|
||||||
@@ -728,6 +817,9 @@ export function RecordedFmp4Player({
|
|||||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||||
onPlayingRejected?: () => void;
|
onPlayingRejected?: () => void;
|
||||||
|
playbackAuthority?: "media" | "host";
|
||||||
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||||
@@ -768,12 +860,16 @@ export function RecordedFmp4Player({
|
|||||||
);
|
);
|
||||||
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
||||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||||
const [bufferRevision, setBufferRevision] = useState(0);
|
const [bufferRevision, setBufferRevision] = useState(0);
|
||||||
|
const [segmentRecoveryGeneration, setSegmentRecoveryGeneration] = useState(0);
|
||||||
|
const [segmentRecovery, setSegmentRecovery] = useState<RecordedSegmentRecovery | null>(null);
|
||||||
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
|
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
|
||||||
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
|
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
|
||||||
const targetRevisionRef = useRef(0);
|
const targetRevisionRef = useRef(0);
|
||||||
const targetReadyAbortRef = useRef<AbortController | null>(null);
|
const targetReadyAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const lastSegmentRecoveryRef = useRef<string | null>(null);
|
||||||
const playAttemptRevisionRef = useRef(0);
|
const playAttemptRevisionRef = useRef(0);
|
||||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||||
const playbackPlayingRef = useRef(Boolean(playback?.playing));
|
const playbackPlayingRef = useRef(Boolean(playback?.playing));
|
||||||
@@ -781,25 +877,63 @@ export function RecordedFmp4Player({
|
|||||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||||
? Math.min(4, Math.max(0.25, playback.rate))
|
? Math.min(4, Math.max(0.25, playback.rate))
|
||||||
: 1;
|
: 1;
|
||||||
const epoch = useMemo(
|
const playbackRateRef = useRef(playbackRate);
|
||||||
|
playbackRateRef.current = playbackRate;
|
||||||
|
const presentationEpoch = useMemo(
|
||||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||||
[archive?.manifest.epochs, currentSeconds],
|
[archive?.manifest.epochs, currentSeconds],
|
||||||
);
|
);
|
||||||
|
const epoch = useMemo(
|
||||||
|
() => selectRecordedMediaPreparationEpoch(
|
||||||
|
archive?.manifest.epochs ?? [],
|
||||||
|
currentSeconds,
|
||||||
|
),
|
||||||
|
[archive?.manifest.epochs, currentSeconds],
|
||||||
|
);
|
||||||
|
const segmentClockSeconds = epoch
|
||||||
|
? Math.min(
|
||||||
|
Math.max(currentSeconds, epoch.timelineStartSeconds),
|
||||||
|
epoch.timelineEndSeconds,
|
||||||
|
)
|
||||||
|
: currentSeconds;
|
||||||
|
const effectiveSegmentCount = segmentCount ?? epoch?.segmentCount ?? null;
|
||||||
|
const requestedSegmentSequence = segmentSequence ?? (epoch
|
||||||
|
? recordedMediaSegmentSequenceAtTime(
|
||||||
|
epoch.segmentEndTimesSeconds,
|
||||||
|
epoch.timelineStartSeconds,
|
||||||
|
segmentClockSeconds,
|
||||||
|
)
|
||||||
|
: null);
|
||||||
|
const effectiveSegmentSequence = recordedMediaRecoveryTargetSequence(
|
||||||
|
requestedSegmentSequence,
|
||||||
|
segmentRecovery?.failedSequence ?? null,
|
||||||
|
segmentRecovery?.recoverySequence ?? null,
|
||||||
|
);
|
||||||
|
const holdingForSegmentRecovery = Boolean(
|
||||||
|
segmentRecovery
|
||||||
|
&& requestedSegmentSequence !== null
|
||||||
|
&& effectiveSegmentSequence !== requestedSegmentSequence,
|
||||||
|
);
|
||||||
const segmented = Boolean(
|
const segmented = Boolean(
|
||||||
segmentCount !== null
|
playbackTransport === "segmented"
|
||||||
&& Number.isInteger(segmentCount)
|
&& requestedSegmentSequence !== null
|
||||||
&& segmentCount >= 1
|
&& Number.isInteger(requestedSegmentSequence)
|
||||||
|
&& requestedSegmentSequence >= 1
|
||||||
|
&&
|
||||||
|
effectiveSegmentCount !== null
|
||||||
|
&& Number.isInteger(effectiveSegmentCount)
|
||||||
|
&& effectiveSegmentCount >= 1
|
||||||
&& typeof MediaSource !== "undefined"
|
&& typeof MediaSource !== "undefined"
|
||||||
&& epoch
|
&& epoch
|
||||||
&& epoch.segmentCount === segmentCount
|
&& epoch.segmentCount === effectiveSegmentCount
|
||||||
&& epoch.randomAccessSequences.length > 0
|
&& epoch.randomAccessSequences.length > 0
|
||||||
&& epoch.segmentEndTimesSeconds.length === segmentCount
|
&& epoch.segmentEndTimesSeconds.length === effectiveSegmentCount
|
||||||
&& MediaSource.isTypeSupported(epoch.mediaType),
|
&& MediaSource.isTypeSupported(epoch.mediaType),
|
||||||
);
|
);
|
||||||
const directPlaybackSeconds = segmented ? null : currentSeconds;
|
const directPlaybackSeconds = segmented ? null : currentSeconds;
|
||||||
const waitingForEpoch = Boolean(archive && !epoch);
|
const waitingForEpoch = Boolean(archive && !presentationEpoch);
|
||||||
const selectedGeneration = contract && epoch
|
const selectedGeneration = contract && presentationEpoch
|
||||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
? `${contract.manifestGenerationSha256}:${presentationEpoch.ordinal}:${presentationEpoch.timelineStartSeconds}:${presentationEpoch.timelineEndSeconds}`
|
||||||
: null;
|
: null;
|
||||||
const visualState = recordedMediaPresentationState(
|
const visualState = recordedMediaPresentationState(
|
||||||
state,
|
state,
|
||||||
@@ -813,6 +947,7 @@ export function RecordedFmp4Player({
|
|||||||
if (!contract) {
|
if (!contract) {
|
||||||
setArchive(null);
|
setArchive(null);
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Некорректный descriptor записанной камеры.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -825,6 +960,9 @@ export function RecordedFmp4Player({
|
|||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
setArchive(null);
|
setArchive(null);
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setSegmentRecovery(null);
|
||||||
|
lastSegmentRecoveryRef.current = null;
|
||||||
|
setErrorMessage(null);
|
||||||
setState("loading");
|
setState("loading");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "loading",
|
phase: "loading",
|
||||||
@@ -842,6 +980,7 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
setArchive(null);
|
setArchive(null);
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Архив записанной камеры не прошёл проверку.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -853,43 +992,14 @@ export function RecordedFmp4Player({
|
|||||||
}, [admissionKey, contract, prepare]);
|
}, [admissionKey, contract, prepare]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!archive || !contract || !prepare || segmented) return;
|
if (!segmentRecovery || requestedSegmentSequence === null) return;
|
||||||
const abort = new AbortController();
|
|
||||||
let disposed = false;
|
|
||||||
void (async () => {
|
|
||||||
for (const candidate of archive.manifest.epochs) {
|
|
||||||
const probe = document.createElement("video");
|
|
||||||
probe.muted = true;
|
|
||||||
probe.playsInline = true;
|
|
||||||
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
|
|
||||||
cleanup();
|
|
||||||
if (disposed || abort.signal.aborted) return;
|
|
||||||
}
|
|
||||||
if (disposed || abort.signal.aborted) return;
|
|
||||||
reportAdmission({
|
|
||||||
phase: "ready",
|
|
||||||
byteLength: archive.byteLength,
|
|
||||||
message: null,
|
|
||||||
});
|
|
||||||
})().catch((error: unknown) => {
|
|
||||||
if (
|
if (
|
||||||
disposed ||
|
requestedSegmentSequence >= segmentRecovery.failedSequence
|
||||||
abort.signal.aborted ||
|
&& requestedSegmentSequence < segmentRecovery.recoverySequence
|
||||||
(error instanceof DOMException && error.name === "AbortError")
|
|
||||||
) return;
|
) return;
|
||||||
setReadyGeneration(null);
|
lastSegmentRecoveryRef.current = null;
|
||||||
setState("error");
|
setSegmentRecovery(null);
|
||||||
reportAdmission({
|
}, [requestedSegmentSequence, segmentRecovery]);
|
||||||
phase: "error",
|
|
||||||
byteLength: archive.byteLength,
|
|
||||||
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
abort.abort();
|
|
||||||
};
|
|
||||||
}, [admissionKey, archive, contract, prepare, segmented]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -910,6 +1020,7 @@ export function RecordedFmp4Player({
|
|||||||
|
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
setSegmentedRuntimeGeneration(null);
|
setSegmentedRuntimeGeneration(null);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("loading");
|
setState("loading");
|
||||||
video.pause();
|
video.pause();
|
||||||
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
|
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
|
||||||
@@ -957,6 +1068,7 @@ export function RecordedFmp4Player({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Покадровый буфер записанной камеры не открылся.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -982,7 +1094,14 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
};
|
};
|
||||||
}, [archive, contract, epoch, segmentCount, segmented]);
|
}, [
|
||||||
|
archive,
|
||||||
|
contract,
|
||||||
|
effectiveSegmentCount,
|
||||||
|
epoch,
|
||||||
|
segmented,
|
||||||
|
segmentRecoveryGeneration,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runtime = segmentedRuntimeRef.current;
|
const runtime = segmentedRuntimeRef.current;
|
||||||
@@ -993,18 +1112,19 @@ export function RecordedFmp4Player({
|
|||||||
|| !archive
|
|| !archive
|
||||||
|| !segmented
|
|| !segmented
|
||||||
|| segmentedRuntimeGeneration !== runtime.generation
|
|| segmentedRuntimeGeneration !== runtime.generation
|
||||||
|| segmentSequence === null
|
|| effectiveSegmentSequence === null
|
||||||
|| !Number.isInteger(segmentSequence)
|
|| !Number.isInteger(effectiveSegmentSequence)
|
||||||
|| segmentSequence < 1
|
|| effectiveSegmentSequence < 1
|
||||||
|| segmentSequence > runtime.segmentCount
|
|| effectiveSegmentSequence > runtime.segmentCount
|
||||||
) return;
|
) return;
|
||||||
const archiveByteLength = archive.byteLength;
|
const archiveByteLength = archive.byteLength;
|
||||||
const decodeStart = recordedMediaDecodeStartSequence(
|
const decodeStart = recordedMediaDecodeStartSequence(
|
||||||
runtime.randomAccessSequences,
|
runtime.randomAccessSequences,
|
||||||
segmentSequence,
|
effectiveSegmentSequence,
|
||||||
);
|
);
|
||||||
if (decodeStart === null) {
|
if (decodeStart === null) {
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Для кадра записанной камеры нет random-access фрагмента.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1015,10 +1135,11 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
const targetSeconds = recordedSegmentStartSeconds(
|
const targetSeconds = recordedSegmentStartSeconds(
|
||||||
runtime.segmentEndTimesSeconds,
|
runtime.segmentEndTimesSeconds,
|
||||||
segmentSequence,
|
effectiveSegmentSequence,
|
||||||
);
|
);
|
||||||
if (targetSeconds === null) {
|
if (targetSeconds === null) {
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Для кадра записанной камеры нет точной media timestamp.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1030,15 +1151,15 @@ export function RecordedFmp4Player({
|
|||||||
const previousTarget = runtime.target;
|
const previousTarget = runtime.target;
|
||||||
const readyEnd = Math.min(
|
const readyEnd = Math.min(
|
||||||
runtime.segmentCount,
|
runtime.segmentCount,
|
||||||
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
effectiveSegmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
||||||
);
|
);
|
||||||
const desiredEnd = Math.min(
|
const desiredEnd = Math.min(
|
||||||
runtime.segmentCount,
|
runtime.segmentCount,
|
||||||
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
effectiveSegmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
||||||
);
|
);
|
||||||
const candidateTarget: RecordedSegmentTarget = {
|
const candidateTarget: RecordedSegmentTarget = {
|
||||||
revision: previousTarget?.revision ?? 0,
|
revision: previousTarget?.revision ?? 0,
|
||||||
sequence: segmentSequence,
|
sequence: effectiveSegmentSequence,
|
||||||
decodeStart,
|
decodeStart,
|
||||||
readyEnd,
|
readyEnd,
|
||||||
desiredEnd,
|
desiredEnd,
|
||||||
@@ -1046,6 +1167,24 @@ export function RecordedFmp4Player({
|
|||||||
forceReset: false,
|
forceReset: false,
|
||||||
resetAttempts: 0,
|
resetAttempts: 0,
|
||||||
};
|
};
|
||||||
|
const recoverFromSegmentFailure = (failedSequence: number): boolean => {
|
||||||
|
const recoverySequence = nextRecordedMediaRandomAccessSequence(
|
||||||
|
runtime.randomAccessSequences,
|
||||||
|
failedSequence,
|
||||||
|
);
|
||||||
|
if (recoverySequence === null) return false;
|
||||||
|
const recoveryToken = `${runtime.generation}:${failedSequence}:${recoverySequence}`;
|
||||||
|
if (lastSegmentRecoveryRef.current === recoveryToken) return true;
|
||||||
|
lastSegmentRecoveryRef.current = recoveryToken;
|
||||||
|
setReadyGeneration(null);
|
||||||
|
setSegmentRecovery({ failedSequence, recoverySequence });
|
||||||
|
setErrorMessage(
|
||||||
|
`Восстанавливаем камеру с ключевого кадра ${recoverySequence}.`,
|
||||||
|
);
|
||||||
|
setState("loading");
|
||||||
|
setSegmentRecoveryGeneration((generation) => generation + 1);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
|
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
|
||||||
previousTarget.sequence,
|
previousTarget.sequence,
|
||||||
candidateTarget.sequence,
|
candidateTarget.sequence,
|
||||||
@@ -1059,21 +1198,62 @@ export function RecordedFmp4Player({
|
|||||||
|| runtime.abort.signal.aborted
|
|| runtime.abort.signal.aborted
|
||||||
|| (error instanceof DOMException && error.name === "AbortError")
|
|| (error instanceof DOMException && error.name === "AbortError")
|
||||||
) return;
|
) return;
|
||||||
|
const failedSequence = runtime.target?.sequence ?? effectiveSegmentSequence;
|
||||||
|
if (failedSequence !== null && recoverFromSegmentFailure(failedSequence)) return;
|
||||||
|
const detail = error instanceof Error && error.message
|
||||||
|
? `: ${error.message}`
|
||||||
|
: ".";
|
||||||
|
const message = `Покадровый фрагмент записанной камеры недоступен${detail}`;
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage(message);
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
byteLength: archiveByteLength,
|
byteLength: archiveByteLength,
|
||||||
message: "Покадровый фрагмент записанной камеры недоступен.",
|
message,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const resumePlaybackIfRequested = async (revision: number) => {
|
||||||
|
if (
|
||||||
|
runtime.disposed
|
||||||
|
|| segmentedRuntimeRef.current !== runtime
|
||||||
|
|| runtime.target?.revision !== revision
|
||||||
|
|| !playbackPlayingRef.current
|
||||||
|
) return;
|
||||||
|
video.playbackRate = playbackRateRef.current;
|
||||||
|
try {
|
||||||
|
await video.play();
|
||||||
|
} catch {
|
||||||
|
if (
|
||||||
|
runtime.disposed
|
||||||
|
|| segmentedRuntimeRef.current !== runtime
|
||||||
|
|| runtime.target?.revision !== revision
|
||||||
|
) return;
|
||||||
|
// Canonical recorded LABs run one host-owned clock for camera and
|
||||||
|
// spatial evidence. A transient MSE play() rejection (commonly a
|
||||||
|
// pause/reset race while the next fragment is admitted) must stay a
|
||||||
|
// decoder concern: the rolling target will retry and catch up.
|
||||||
|
if (playbackAuthority === "host") return;
|
||||||
|
onPlayingRejectedRef.current?.();
|
||||||
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Запуск записанной камеры отклонён браузером.");
|
||||||
|
setState("error");
|
||||||
|
reportAdmission({
|
||||||
|
phase: "error",
|
||||||
|
byteLength: archiveByteLength,
|
||||||
|
message: "Запуск записанной камеры отклонён браузером.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
if (rollingTarget && previousTarget) {
|
if (rollingTarget && previousTarget) {
|
||||||
runtime.target = {
|
runtime.target = {
|
||||||
...candidateTarget,
|
...candidateTarget,
|
||||||
revision: previousTarget.revision,
|
revision: previousTarget.revision,
|
||||||
};
|
};
|
||||||
runtime.onTargetBuffered = null;
|
runtime.onTargetBuffered = null;
|
||||||
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
|
void pumpRecordedSegmentWindow(runtime)
|
||||||
|
.then(() => resumePlaybackIfRequested(previousTarget.revision))
|
||||||
|
.catch(reportPumpError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,18 +1308,22 @@ export function RecordedFmp4Player({
|
|||||||
setBufferRevision((revision) => revision + 1);
|
setBufferRevision((revision) => revision + 1);
|
||||||
runtime.hasPresentedFrame = true;
|
runtime.hasPresentedFrame = true;
|
||||||
setReadyGeneration(runtime.generation);
|
setReadyGeneration(runtime.generation);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("ready");
|
setState("ready");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "ready",
|
phase: "ready",
|
||||||
byteLength: archiveByteLength,
|
byteLength: archiveByteLength,
|
||||||
message: null,
|
message: null,
|
||||||
});
|
});
|
||||||
|
await resumePlaybackIfRequested(bufferedTarget.revision);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
targetReadyAbort.signal.aborted
|
targetReadyAbort.signal.aborted
|
||||||
|| (error instanceof DOMException && error.name === "AbortError")
|
|| (error instanceof DOMException && error.name === "AbortError")
|
||||||
) return;
|
) return;
|
||||||
|
if (recoverFromSegmentFailure(bufferedTarget.sequence)) return;
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Кадр записанной камеры не стал decoder-ready.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1160,7 +1344,13 @@ export function RecordedFmp4Player({
|
|||||||
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
|
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
|
||||||
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
|
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
|
||||||
};
|
};
|
||||||
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]);
|
}, [
|
||||||
|
archive?.byteLength,
|
||||||
|
effectiveSegmentSequence,
|
||||||
|
playbackAuthority,
|
||||||
|
segmented,
|
||||||
|
segmentedRuntimeGeneration,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -1170,6 +1360,7 @@ export function RecordedFmp4Player({
|
|||||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||||
: null;
|
: null;
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("loading");
|
setState("loading");
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -1185,7 +1376,13 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
setBufferRevision((revision) => revision + 1);
|
setBufferRevision((revision) => revision + 1);
|
||||||
setReadyGeneration(generation);
|
setReadyGeneration(generation);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("ready");
|
setState("ready");
|
||||||
|
reportAdmission({
|
||||||
|
phase: "ready",
|
||||||
|
byteLength: archive?.byteLength ?? null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
disposed ||
|
disposed ||
|
||||||
@@ -1195,11 +1392,12 @@ export function RecordedFmp4Player({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Записанная камера не открыла первый декодируемый кадр.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
byteLength: archive?.byteLength ?? null,
|
byteLength: archive?.byteLength ?? null,
|
||||||
message: "Записанная камера не стала seekable.",
|
message: "Записанная камера не открыла первый декодируемый кадр.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1234,6 +1432,7 @@ export function RecordedFmp4Player({
|
|||||||
video.currentTime = target;
|
video.currentTime = target;
|
||||||
} catch {
|
} catch {
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Seek записанной камеры завершился ошибкой.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1244,11 +1443,13 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
video.playbackRate = playbackRate;
|
video.playbackRate = playbackRate;
|
||||||
if (playback?.playing) {
|
if (playback?.playing && !holdingForSegmentRecovery) {
|
||||||
void video.play().catch(() => {
|
void video.play().catch(() => {
|
||||||
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
|
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
|
||||||
|
if (playbackAuthority === "host") return;
|
||||||
onPlayingRejectedRef.current?.();
|
onPlayingRejectedRef.current?.();
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Запуск записанной камеры отклонён браузером.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1264,12 +1465,95 @@ export function RecordedFmp4Player({
|
|||||||
bufferRevision,
|
bufferRevision,
|
||||||
directPlaybackSeconds,
|
directPlaybackSeconds,
|
||||||
epoch,
|
epoch,
|
||||||
|
holdingForSegmentRecovery,
|
||||||
playback?.playing,
|
playback?.playing,
|
||||||
|
playbackAuthority,
|
||||||
playbackRate,
|
playbackRate,
|
||||||
segmented,
|
segmented,
|
||||||
visualState,
|
visualState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !recoverTimestampStalls || !playback?.playing || visualState !== "ready") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lastSeconds = video.currentTime;
|
||||||
|
let lastProgressAtMs = performance.now();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
if (
|
||||||
|
!playbackPlayingRef.current
|
||||||
|
|| video.paused
|
||||||
|
|| video.ended
|
||||||
|
|| video.seeking
|
||||||
|
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||||
|
) {
|
||||||
|
lastSeconds = video.currentTime;
|
||||||
|
lastProgressAtMs = performance.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nowMs = performance.now();
|
||||||
|
if (video.currentTime >= lastSeconds + 0.02) {
|
||||||
|
lastSeconds = video.currentTime;
|
||||||
|
lastProgressAtMs = nowMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nowMs - lastProgressAtMs < 1_250) return;
|
||||||
|
const bufferedRanges = Array.from(
|
||||||
|
{ length: video.buffered.length },
|
||||||
|
(_, index) => [video.buffered.start(index), video.buffered.end(index)] as const,
|
||||||
|
);
|
||||||
|
const targetSeconds = recordedMediaTimestampStallRecoveryTarget(
|
||||||
|
video.currentTime,
|
||||||
|
bufferedRanges,
|
||||||
|
);
|
||||||
|
lastProgressAtMs = nowMs;
|
||||||
|
if (targetSeconds === null) return;
|
||||||
|
// Field recordings can contain non-monotonic or corrupt H.264 timestamps.
|
||||||
|
// If decoded time is frozen despite proven buffered media ahead, skip only
|
||||||
|
// the broken timestamp interval and return authority to the media clock.
|
||||||
|
video.currentTime = targetSeconds;
|
||||||
|
lastSeconds = targetSeconds;
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [playback?.playing, recoverTimestampStalls, visualState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !segmented || !playback?.playing || visualState !== "ready") return;
|
||||||
|
let cancelled = false;
|
||||||
|
const resumeIfDecoderReady = () => {
|
||||||
|
if (cancelled || !playbackPlayingRef.current || !video.paused) return;
|
||||||
|
const runtime = segmentedRuntimeRef.current;
|
||||||
|
const target = runtime?.target;
|
||||||
|
if (
|
||||||
|
!runtime
|
||||||
|
|| !target
|
||||||
|
|| runtime.disposed
|
||||||
|
|| video.seeking
|
||||||
|
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||||
|
|| Math.abs(video.currentTime - target.targetSeconds) > 0.25
|
||||||
|
|| !recordedMediaTimeRangesContain(video.buffered, target.targetSeconds)
|
||||||
|
) return;
|
||||||
|
playAttemptRevisionRef.current += 1;
|
||||||
|
const playAttemptRevision = playAttemptRevisionRef.current;
|
||||||
|
video.playbackRate = playbackRateRef.current;
|
||||||
|
void video.play().catch(() => {
|
||||||
|
if (cancelled || playAttemptRevisionRef.current !== playAttemptRevision) return;
|
||||||
|
if (playbackAuthority === "host") return;
|
||||||
|
onPlayingRejectedRef.current?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const queueResume = () => window.queueMicrotask(resumeIfDecoderReady);
|
||||||
|
const events = ["pause", "canplay", "seeked"] as const;
|
||||||
|
for (const event of events) video.addEventListener(event, queueResume);
|
||||||
|
resumeIfDecoderReady();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
for (const event of events) video.removeEventListener(event, queueResume);
|
||||||
|
};
|
||||||
|
}, [bufferRevision, playback?.playing, playbackAuthority, segmented, visualState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
||||||
@@ -1327,7 +1611,9 @@ export function RecordedFmp4Player({
|
|||||||
{visualState === "waiting"
|
{visualState === "waiting"
|
||||||
? "Камера на этой позиции ещё не записывалась"
|
? "Камера на этой позиции ещё не записывалась"
|
||||||
: visualState === "error"
|
: visualState === "error"
|
||||||
? "Записанное видео недоступно"
|
? errorMessage ?? "Записанное видео недоступно"
|
||||||
|
: errorMessage
|
||||||
|
? errorMessage
|
||||||
: archive
|
: archive
|
||||||
? "Проверяем seek и codec записанного видео…"
|
? "Проверяем seek и codec записанного видео…"
|
||||||
: "Читаем manifest записанного видео…"}
|
: "Читаем manifest записанного видео…"}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { SegmentedControl, SplitPane, type SplitPaneOrientation } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import { LaboratoryEvidenceViewer } from "./LaboratoryEvidenceViewer";
|
||||||
|
|
||||||
|
export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
|
||||||
|
"missioncore.canonical-recorded-lab-replay/v1";
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabMode<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interaction contract from the accepted recorded-LAB instrument.
|
||||||
|
*
|
||||||
|
* Keeping pane selection, collapse semantics, responsive split orientation,
|
||||||
|
* expansion and splitter state here prevents individual experiments from
|
||||||
|
* quietly growing their own replay behaviour. Experiments provide evidence
|
||||||
|
* layers; they do not reimplement the laboratory shell.
|
||||||
|
*/
|
||||||
|
export function useCanonicalRecordedLabReplayState<
|
||||||
|
TMedia extends string,
|
||||||
|
TSpatial extends string,
|
||||||
|
>({
|
||||||
|
initialMediaMode,
|
||||||
|
initialSpatialMode,
|
||||||
|
}: {
|
||||||
|
initialMediaMode: TMedia;
|
||||||
|
initialSpatialMode: TSpatial | null;
|
||||||
|
}) {
|
||||||
|
const [mediaMode, setMediaMode] = useState<TMedia | null>(initialMediaMode);
|
||||||
|
const [spatialMode, setSpatialMode] = useState<TSpatial | null>(initialSpatialMode);
|
||||||
|
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
||||||
|
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||||
|
? "horizontal"
|
||||||
|
: "vertical"
|
||||||
|
));
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia("(max-width: 900px)");
|
||||||
|
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
||||||
|
update();
|
||||||
|
query.addEventListener("change", update);
|
||||||
|
return () => query.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onMediaModeChange = useCallback((next: TMedia | "none") => {
|
||||||
|
if (next === "none") return;
|
||||||
|
setMediaMode((current) => current === next ? null : next);
|
||||||
|
}, []);
|
||||||
|
const onSpatialModeChange = useCallback((next: TSpatial | "none") => {
|
||||||
|
if (next === "none") return;
|
||||||
|
setSpatialMode((current) => current === next ? null : next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
splitView: mediaMode !== null && spatialMode !== null,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
expanded,
|
||||||
|
onMediaModeChange,
|
||||||
|
onSpatialModeChange,
|
||||||
|
onSplitPrimarySizeChange: setSplitPrimarySize,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanonicalRecordedLabReplay<
|
||||||
|
TMedia extends string,
|
||||||
|
TSpatial extends string,
|
||||||
|
>({
|
||||||
|
label,
|
||||||
|
mediaMode,
|
||||||
|
mediaModes,
|
||||||
|
spatialMode,
|
||||||
|
spatialModes,
|
||||||
|
expanded,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
mediaAriaLabel,
|
||||||
|
spatialAriaLabel,
|
||||||
|
mediaLayerControls,
|
||||||
|
spatialLayerControls,
|
||||||
|
spatialLeadingControl,
|
||||||
|
mediaMultiLayer = false,
|
||||||
|
mediaContent,
|
||||||
|
spatialContent,
|
||||||
|
unifiedContent,
|
||||||
|
emptyMessage,
|
||||||
|
deckOverlays,
|
||||||
|
actions,
|
||||||
|
overlay,
|
||||||
|
transport,
|
||||||
|
trailingActions,
|
||||||
|
onMediaModeChange,
|
||||||
|
onSpatialModeChange,
|
||||||
|
onExpandedChange,
|
||||||
|
onSplitPrimarySizeChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
mediaMode: TMedia;
|
||||||
|
mediaModes: readonly CanonicalRecordedLabMode<TMedia>[];
|
||||||
|
spatialMode: TSpatial;
|
||||||
|
spatialModes: readonly CanonicalRecordedLabMode<TSpatial>[];
|
||||||
|
expanded: boolean;
|
||||||
|
splitPrimarySize: number;
|
||||||
|
splitOrientation: SplitPaneOrientation;
|
||||||
|
mediaAriaLabel: string;
|
||||||
|
spatialAriaLabel: string;
|
||||||
|
mediaLayerControls?: ReactNode;
|
||||||
|
spatialLayerControls?: ReactNode;
|
||||||
|
spatialLeadingControl?: ReactNode;
|
||||||
|
mediaMultiLayer?: boolean;
|
||||||
|
mediaContent?: ReactNode;
|
||||||
|
spatialContent?: ReactNode;
|
||||||
|
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
|
||||||
|
unifiedContent?: ReactNode;
|
||||||
|
emptyMessage: string;
|
||||||
|
deckOverlays?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
overlay?: ReactNode;
|
||||||
|
transport?: ReactNode;
|
||||||
|
trailingActions?: ReactNode;
|
||||||
|
onMediaModeChange: (mode: TMedia) => void;
|
||||||
|
onSpatialModeChange: (mode: TSpatial) => void;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
onSplitPrimarySizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const splitView = mediaMode !== "none" && spatialMode !== "none";
|
||||||
|
const mediaModeControls = (
|
||||||
|
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
||||||
|
<SegmentedControl
|
||||||
|
value={mediaMode}
|
||||||
|
items={[...mediaModes]}
|
||||||
|
label="Видео и камера"
|
||||||
|
size="dense"
|
||||||
|
onChange={onMediaModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const spatialModeControls = (
|
||||||
|
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
||||||
|
<SegmentedControl
|
||||||
|
value={spatialMode}
|
||||||
|
items={[...spatialModes]}
|
||||||
|
label="3D и план"
|
||||||
|
size="dense"
|
||||||
|
onChange={onSpatialModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const mediaPane = (
|
||||||
|
<section
|
||||||
|
className="m4-replay-threat-visual__pane"
|
||||||
|
data-pane="media"
|
||||||
|
aria-label={mediaAriaLabel}
|
||||||
|
hidden={mediaMode === "none"}
|
||||||
|
>
|
||||||
|
{splitView ? (
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__pane-toolbar"
|
||||||
|
data-pane-toolbar="media"
|
||||||
|
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{mediaLayerControls}
|
||||||
|
{mediaModeControls}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{mediaContent}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
const spatialPane = spatialMode !== "none" ? (
|
||||||
|
<section
|
||||||
|
className="m4-replay-threat-visual__pane"
|
||||||
|
data-pane="spatial"
|
||||||
|
aria-label={spatialAriaLabel}
|
||||||
|
>
|
||||||
|
{splitView ? (
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__pane-toolbar"
|
||||||
|
data-pane-toolbar="spatial"
|
||||||
|
>
|
||||||
|
{spatialLeadingControl}
|
||||||
|
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||||
|
{spatialLayerControls}
|
||||||
|
{spatialModeControls}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{spatialContent}
|
||||||
|
</section>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="l3-visual-audit m4-replay-threat-visual"
|
||||||
|
data-contract={CANONICAL_RECORDED_LAB_REPLAY_CONTRACT}
|
||||||
|
>
|
||||||
|
<LaboratoryEvidenceViewer
|
||||||
|
label={label}
|
||||||
|
className="m4-replay-threat-evidence-viewer"
|
||||||
|
mode={mediaMode}
|
||||||
|
modes={[...mediaModes]}
|
||||||
|
secondaryMode={{
|
||||||
|
value: spatialMode,
|
||||||
|
modes: [...spatialModes],
|
||||||
|
label: "3D и план",
|
||||||
|
onChange: onSpatialModeChange,
|
||||||
|
}}
|
||||||
|
expanded={expanded}
|
||||||
|
onModeChange={onMediaModeChange}
|
||||||
|
onExpandedChange={onExpandedChange}
|
||||||
|
modeControlsVisible={!splitView}
|
||||||
|
actions={actions}
|
||||||
|
overlay={overlay}
|
||||||
|
transport={transport}
|
||||||
|
trailingActions={trailingActions}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__deck"
|
||||||
|
data-split={splitView ? "true" : undefined}
|
||||||
|
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
|
||||||
|
data-native-rerun={unifiedContent ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{unifiedContent ? (
|
||||||
|
<div className="m4-replay-threat-visual__unified-content">
|
||||||
|
{unifiedContent}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<SplitPane
|
||||||
|
primary={mediaPane}
|
||||||
|
secondary={spatialPane ?? <div />}
|
||||||
|
primarySize={splitView ? splitPrimarySize : mediaMode !== "none" ? 100 : 0}
|
||||||
|
onPrimarySizeChange={onSplitPrimarySizeChange}
|
||||||
|
orientation={splitOrientation}
|
||||||
|
minPrimarySize={splitView ? 24 : 0}
|
||||||
|
minSecondarySize={splitView ? 24 : 0}
|
||||||
|
resizable={splitView && !unifiedContent}
|
||||||
|
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||||
|
/>
|
||||||
|
{mediaMode === "none" && spatialMode === "none" ? (
|
||||||
|
<div className="l3-visual-audit__state" role="status">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{deckOverlays}
|
||||||
|
</div>
|
||||||
|
</LaboratoryEvidenceViewer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
SegmentedControl,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import { ObservationTimeline } from "../ObservationTimeline";
|
||||||
|
import {
|
||||||
|
RerunViewport,
|
||||||
|
isRecordedPlaybackPresentationReady,
|
||||||
|
type RerunPlaybackController,
|
||||||
|
type RerunPlaybackState,
|
||||||
|
type RerunViewportStatus,
|
||||||
|
} from "../RerunViewport";
|
||||||
|
import {
|
||||||
|
CanonicalRecordedLabReplay,
|
||||||
|
useCanonicalRecordedLabReplayState,
|
||||||
|
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||||
|
import {
|
||||||
|
resolveCanonicalLabReplay,
|
||||||
|
type CanonicalLabReplayDescriptor,
|
||||||
|
} from "../../core/laboratory/canonicalLabReplay";
|
||||||
|
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
|
||||||
|
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||||
|
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||||
|
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||||
|
import { defaultSceneSettings } from "../../sceneSettings";
|
||||||
|
|
||||||
|
type MediaMode = "video" | "camera";
|
||||||
|
type SpatialMode = "3d" | "plan";
|
||||||
|
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
||||||
|
type SemanticLayer = "city" | "vegetation";
|
||||||
|
|
||||||
|
interface CanonicalReplayLaunch {
|
||||||
|
base: ObservationSessionReplayLaunch;
|
||||||
|
replay: CanonicalLabReplayDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||||
|
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
||||||
|
|
||||||
|
export function CanonicalVegetationRerunReplay({
|
||||||
|
resultId,
|
||||||
|
review,
|
||||||
|
}: {
|
||||||
|
resultId: string;
|
||||||
|
review: VegetationFullRouteReview;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
expanded,
|
||||||
|
onMediaModeChange,
|
||||||
|
onSpatialModeChange,
|
||||||
|
onSplitPrimarySizeChange,
|
||||||
|
onExpandedChange,
|
||||||
|
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||||
|
initialMediaMode: "video",
|
||||||
|
initialSpatialMode: "3d",
|
||||||
|
});
|
||||||
|
const splitView = mediaMode !== null && spatialMode !== null;
|
||||||
|
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||||
|
const [showSemantics, setShowSemantics] = useState(true);
|
||||||
|
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||||
|
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
|
||||||
|
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||||
|
const [playbackController, setPlaybackController] =
|
||||||
|
useState<RerunPlaybackController | null>(null);
|
||||||
|
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||||
|
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||||
|
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||||
|
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||||
|
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const previousSplitViewRef = useRef(splitView);
|
||||||
|
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const stopNativeSplitTracking = useCallback(() => {
|
||||||
|
nativeSplitTrackingCleanupRef.current?.();
|
||||||
|
nativeSplitTrackingCleanupRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (
|
||||||
|
!splitView
|
||||||
|
|| event.button !== 0
|
||||||
|
|| !(event.target instanceof HTMLCanvasElement)
|
||||||
|
) return;
|
||||||
|
const frame = viewerFrameRef.current;
|
||||||
|
if (!frame) return;
|
||||||
|
const bounds = frame.getBoundingClientRect();
|
||||||
|
if (bounds.width <= 0) return;
|
||||||
|
const dividerX = bounds.left
|
||||||
|
+ bounds.width * nativeSplitPercentRef.current / 100;
|
||||||
|
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
|
||||||
|
|
||||||
|
stopNativeSplitTracking();
|
||||||
|
const pointerId = event.pointerId;
|
||||||
|
const update = (pointerEvent: PointerEvent) => {
|
||||||
|
if (pointerEvent.pointerId !== pointerId) return;
|
||||||
|
const currentBounds = frame.getBoundingClientRect();
|
||||||
|
if (currentBounds.width <= 0) return;
|
||||||
|
const next = Math.min(90, Math.max(
|
||||||
|
10,
|
||||||
|
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
|
||||||
|
));
|
||||||
|
nativeSplitPercentRef.current = next;
|
||||||
|
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
|
||||||
|
};
|
||||||
|
const stop = (pointerEvent: PointerEvent) => {
|
||||||
|
if (pointerEvent.pointerId !== pointerId) return;
|
||||||
|
stopNativeSplitTracking();
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", update, true);
|
||||||
|
window.addEventListener("pointerup", stop, true);
|
||||||
|
window.addEventListener("pointercancel", stop, true);
|
||||||
|
nativeSplitTrackingCleanupRef.current = () => {
|
||||||
|
window.removeEventListener("pointermove", update, true);
|
||||||
|
window.removeEventListener("pointerup", stop, true);
|
||||||
|
window.removeEventListener("pointercancel", stop, true);
|
||||||
|
};
|
||||||
|
}, [splitView, stopNativeSplitTracking]);
|
||||||
|
|
||||||
|
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!splitView) stopNativeSplitTracking();
|
||||||
|
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||||
|
if (
|
||||||
|
trackedLaunchSha256Ref.current !== launchSha256
|
||||||
|
|| (splitView && !previousSplitViewRef.current)
|
||||||
|
) {
|
||||||
|
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
||||||
|
}
|
||||||
|
trackedLaunchSha256Ref.current = launchSha256;
|
||||||
|
previousSplitViewRef.current = splitView;
|
||||||
|
const cameraPanePercent = mediaMode === null
|
||||||
|
? 0
|
||||||
|
: splitView
|
||||||
|
? nativeSplitPercentRef.current
|
||||||
|
: 100;
|
||||||
|
viewerFrameRef.current?.style.setProperty(
|
||||||
|
"--canonical-rerun-camera-pane",
|
||||||
|
`${cameraPanePercent}%`,
|
||||||
|
);
|
||||||
|
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLaunch(null);
|
||||||
|
setLaunchError(null);
|
||||||
|
setPlayback(null);
|
||||||
|
setPlaybackController(null);
|
||||||
|
setViewerStatus("idle");
|
||||||
|
void resolveObservationSessionReplay(review.sessionId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
maximumWaitMs: 30 * 60 * 1000,
|
||||||
|
onUpdate: () => undefined,
|
||||||
|
}).then(async (value) => ({
|
||||||
|
base: value,
|
||||||
|
replay: await resolveCanonicalLabReplay(resultId, value, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
})).then((value) => {
|
||||||
|
if (!controller.signal.aborted) setLaunch(value);
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLaunchError(
|
||||||
|
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [resultId, review.sessionId]);
|
||||||
|
|
||||||
|
const presentationReady = playbackController !== null
|
||||||
|
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||||
|
const presentationState = launchError || viewerStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: presentationReady
|
||||||
|
? "ready"
|
||||||
|
: "loading";
|
||||||
|
const sceneSettings = useMemo(() => ({
|
||||||
|
...defaultSceneSettings,
|
||||||
|
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||||
|
showPoints: spatialMode !== null,
|
||||||
|
showTrajectory: spatialMode !== null,
|
||||||
|
showGrid: spatialMode !== null,
|
||||||
|
pointSize: 3.8,
|
||||||
|
}), [spatialLayer, spatialMode]);
|
||||||
|
const profile = launch ? recordedSessionRerunProfile({
|
||||||
|
sourceUrl: launch.replay.sourceUrl,
|
||||||
|
artifact: {
|
||||||
|
sourceUrl: launch.replay.sourceUrl,
|
||||||
|
viewerSourceUrl: launch.replay.viewerSourceUrl,
|
||||||
|
byteLength: launch.replay.byteLength,
|
||||||
|
sha256: launch.replay.sha256,
|
||||||
|
},
|
||||||
|
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
|
||||||
|
autoplayWhenReady: false,
|
||||||
|
presentationGate: "ready",
|
||||||
|
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||||
|
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
||||||
|
initialPlaybackStartSeconds: review.timelineStartSeconds,
|
||||||
|
view: mediaMode !== null ? "perception" : "spatial",
|
||||||
|
viewResetGeneration,
|
||||||
|
followTrajectory: true,
|
||||||
|
semanticLayer,
|
||||||
|
unifiedPerception: splitView,
|
||||||
|
planView: spatialMode === "plan",
|
||||||
|
perceptionLayers: {
|
||||||
|
enabled: mediaMode !== null,
|
||||||
|
detections2d: mediaMode === "video",
|
||||||
|
segmentation: mediaMode === "video" && showSemantics,
|
||||||
|
cuboids3d: false,
|
||||||
|
},
|
||||||
|
perceptionRetryGeneration: 0,
|
||||||
|
lockPerceptionCameraInteraction: mediaMode !== null,
|
||||||
|
}) : null;
|
||||||
|
|
||||||
|
const mediaLayerControls = (
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__pane-layer-controls"
|
||||||
|
role="group"
|
||||||
|
aria-label="Слои камеры и видео"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="dense"
|
||||||
|
shape="pill"
|
||||||
|
variant={showSemantics ? "primary" : "secondary"}
|
||||||
|
aria-pressed={showSemantics}
|
||||||
|
onClick={() => setShowSemantics((visible) => !visible)}
|
||||||
|
>
|
||||||
|
СЕМАНТИКА
|
||||||
|
</Button>
|
||||||
|
<SegmentedControl
|
||||||
|
value={semanticLayer}
|
||||||
|
items={[
|
||||||
|
{ value: "city", label: "ГОРОД · EoMT" },
|
||||||
|
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||||
|
]}
|
||||||
|
label="Источник семантики"
|
||||||
|
size="dense"
|
||||||
|
onChange={(value) => {
|
||||||
|
setSemanticLayer(value);
|
||||||
|
setShowSemantics(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const spatialLayerControls = (
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__pane-layer-controls"
|
||||||
|
role="group"
|
||||||
|
aria-label="Пространственные слои RAV004"
|
||||||
|
>
|
||||||
|
<SegmentedControl
|
||||||
|
value={spatialLayer}
|
||||||
|
items={[
|
||||||
|
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
||||||
|
{ value: "local", label: "ЛОК. SLAM" },
|
||||||
|
{ value: "tgs", label: "TGS", disabled: true },
|
||||||
|
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
||||||
|
]}
|
||||||
|
label="Пространственные слои"
|
||||||
|
size="dense"
|
||||||
|
onChange={setSpatialLayer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const resetSpatialView = (
|
||||||
|
<Button
|
||||||
|
size="dense"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<Icon name="refresh" size={14} />}
|
||||||
|
aria-label="Сбросить положение 3D камеры"
|
||||||
|
title="Сбросить положение 3D камеры"
|
||||||
|
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = presentationReady && playback && playbackController ? (
|
||||||
|
<ObservationTimeline
|
||||||
|
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
||||||
|
active
|
||||||
|
sourceCount={3}
|
||||||
|
mode="recorded"
|
||||||
|
seekable
|
||||||
|
synchronization="shared-clock"
|
||||||
|
rangeNs={playback.rangeNs}
|
||||||
|
currentNs={playback.currentNs}
|
||||||
|
playing={playback.playing}
|
||||||
|
onSeek={playbackController.seek}
|
||||||
|
onPlayingChange={playbackController.setPlaying}
|
||||||
|
showJumpToEnd={false}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="canonical-vegetation-rerun-replay"
|
||||||
|
data-presentation-state={presentationState}
|
||||||
|
aria-busy={presentationState === "loading"}
|
||||||
|
>
|
||||||
|
<CanonicalRecordedLabReplay
|
||||||
|
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||||
|
mediaMode={mediaMode ?? "none"}
|
||||||
|
mediaModes={[
|
||||||
|
{ value: "video", label: "ВИДЕО" },
|
||||||
|
{ value: "camera", label: "КАМЕРА" },
|
||||||
|
]}
|
||||||
|
spatialMode={spatialMode ?? "none"}
|
||||||
|
spatialModes={[
|
||||||
|
{ value: "3d", label: "3D" },
|
||||||
|
{ value: "plan", label: "ПЛАН" },
|
||||||
|
]}
|
||||||
|
expanded={expanded}
|
||||||
|
splitPrimarySize={splitPrimarySize}
|
||||||
|
splitOrientation={splitOrientation}
|
||||||
|
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||||
|
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||||
|
mediaLayerControls={mediaLayerControls}
|
||||||
|
spatialLayerControls={spatialLayerControls}
|
||||||
|
spatialLeadingControl={resetSpatialView}
|
||||||
|
mediaMultiLayer
|
||||||
|
unifiedContent={profile ? (
|
||||||
|
<div
|
||||||
|
ref={viewerFrameRef}
|
||||||
|
className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||||
|
data-split-view={splitView ? "true" : undefined}
|
||||||
|
style={{
|
||||||
|
"--canonical-rerun-camera-pane": `${
|
||||||
|
mediaMode === null
|
||||||
|
? 0
|
||||||
|
: splitView
|
||||||
|
? nativeSplitPercentRef.current
|
||||||
|
: 100
|
||||||
|
}%`,
|
||||||
|
} as CSSProperties}
|
||||||
|
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
|
||||||
|
>
|
||||||
|
<RerunViewport
|
||||||
|
profile={profile}
|
||||||
|
sceneSettings={sceneSettings}
|
||||||
|
onStatusChange={setViewerStatus}
|
||||||
|
onPlaybackChange={setPlayback}
|
||||||
|
onPlaybackControllerChange={setPlaybackController}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : launchError ? (
|
||||||
|
<div className="l3-visual-audit__state" role="alert">
|
||||||
|
{launchError}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||||
|
deckOverlays={presentationState === "loading" ? (
|
||||||
|
<div className="canonical-vegetation-rerun-replay__loading">
|
||||||
|
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
transport={transport}
|
||||||
|
onMediaModeChange={onMediaModeChange}
|
||||||
|
onSpatialModeChange={onSpatialModeChange}
|
||||||
|
onExpandedChange={onExpandedChange}
|
||||||
|
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,16 @@ export function laboratoryRecordedClipEndExclusiveNs(
|
|||||||
return last.sourceTimeNs + typicalDelta;
|
return last.sourceTimeNs + typicalDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function laboratoryRecordedClipClockGate(
|
||||||
|
pendingSequence: number | null,
|
||||||
|
observedSequence: number,
|
||||||
|
): { accept: boolean; pendingSequence: number | null } {
|
||||||
|
if (pendingSequence !== null && pendingSequence !== observedSequence) {
|
||||||
|
return { accept: false, pendingSequence };
|
||||||
|
}
|
||||||
|
return { accept: true, pendingSequence: null };
|
||||||
|
}
|
||||||
|
|
||||||
export function LaboratoryRecordedClipPlayer({
|
export function LaboratoryRecordedClipPlayer({
|
||||||
source,
|
source,
|
||||||
segmentCount,
|
segmentCount,
|
||||||
@@ -94,7 +104,8 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
}) {
|
}) {
|
||||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||||
const lastEmittedSequenceRef = useRef(sequence);
|
const lastEmittedSequenceRef = useRef(sequence);
|
||||||
lastEmittedSequenceRef.current = sequence;
|
const lastObservedSequenceRef = useRef<number | null>(sequence);
|
||||||
|
const pendingSequenceRef = useRef<number | null>(null);
|
||||||
const frame = useMemo(
|
const frame = useMemo(
|
||||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||||
[frames, sequence],
|
[frames, sequence],
|
||||||
@@ -113,23 +124,42 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||||
}, [continuousPlayback, onPlayingChange, playing]);
|
}, [continuousPlayback, onPlayingChange, playing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastObservedSequenceRef.current !== sequence) {
|
||||||
|
pendingSequenceRef.current = sequence;
|
||||||
|
}
|
||||||
|
lastEmittedSequenceRef.current = sequence;
|
||||||
|
}, [sequence]);
|
||||||
|
|
||||||
const emitSequence = useCallback((nextSequence: number) => {
|
const emitSequence = useCallback((nextSequence: number) => {
|
||||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||||
lastEmittedSequenceRef.current = nextSequence;
|
lastEmittedSequenceRef.current = nextSequence;
|
||||||
onSequenceChange(nextSequence);
|
onSequenceChange(nextSequence);
|
||||||
}, [onSequenceChange]);
|
}, [onSequenceChange]);
|
||||||
|
|
||||||
|
const requestSequence = useCallback((nextSequence: number) => {
|
||||||
|
pendingSequenceRef.current = nextSequence;
|
||||||
|
emitSequence(nextSequence);
|
||||||
|
}, [emitSequence]);
|
||||||
|
|
||||||
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
||||||
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
||||||
const first = frames[0];
|
const first = frames[0];
|
||||||
if (!first || endExclusiveNs === null) return;
|
if (!first || endExclusiveNs === null) return;
|
||||||
if (sourceTimeNs >= endExclusiveNs) {
|
if (sourceTimeNs >= endExclusiveNs) {
|
||||||
emitSequence(first.sequence);
|
requestSequence(first.sequence);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||||
if (nearest) emitSequence(nearest.sequence);
|
if (!nearest) return;
|
||||||
}, [emitSequence, endExclusiveNs, frames]);
|
lastObservedSequenceRef.current = nearest.sequence;
|
||||||
|
const gate = laboratoryRecordedClipClockGate(
|
||||||
|
pendingSequenceRef.current,
|
||||||
|
nearest.sequence,
|
||||||
|
);
|
||||||
|
pendingSequenceRef.current = gate.pendingSequence;
|
||||||
|
if (gate.accept) emitSequence(nearest.sequence);
|
||||||
|
}, [emitSequence, endExclusiveNs, frames, requestSequence]);
|
||||||
|
|
||||||
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
||||||
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||||
@@ -142,7 +172,7 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
className="laboratory-recorded-clip-player__spatial"
|
className="laboratory-recorded-clip-player__spatial"
|
||||||
aria-hidden={cameraPresentation === "primary"}
|
aria-hidden={cameraPresentation === "primary"}
|
||||||
>
|
>
|
||||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
{alternativeScene}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const cameraPane = (
|
const cameraPane = (
|
||||||
@@ -202,7 +232,7 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||||
onSeek={(timeNs) => {
|
onSeek={(timeNs) => {
|
||||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||||
if (nearest) emitSequence(nearest.sequence);
|
if (nearest) requestSequence(nearest.sequence);
|
||||||
}}
|
}}
|
||||||
showJumpToEnd={false}
|
showJumpToEnd={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RecordedFmp4Player,
|
RecordedFmp4Player,
|
||||||
type RecordedObservationPlayback,
|
type RecordedObservationPlayback,
|
||||||
} from "../RecordedFmp4Player";
|
} from "../RecordedFmp4Player";
|
||||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||||
|
import type { RecordedCameraAdmissionState } from "../../core/observation/recordedSessionAdmission";
|
||||||
import {
|
import {
|
||||||
RecordedEvidenceBoxOverlay,
|
RecordedEvidenceBoxOverlay,
|
||||||
type RecordedEvidenceBox,
|
type RecordedEvidenceBox,
|
||||||
@@ -25,6 +28,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
boxes,
|
boxes,
|
||||||
|
overlaySeconds,
|
||||||
semanticOverlay,
|
semanticOverlay,
|
||||||
pointCloudOverlay,
|
pointCloudOverlay,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
@@ -33,12 +37,17 @@ export function RecordedEvidenceVideoScene({
|
|||||||
segmentCount,
|
segmentCount,
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
onPlayingRejected,
|
onPlayingRejected,
|
||||||
|
onAdmissionChange,
|
||||||
|
playbackAuthority = "media",
|
||||||
|
playbackTransport = "segmented",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
playback: RecordedObservationPlayback;
|
playback: RecordedObservationPlayback;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
boxes: readonly RecordedEvidenceBox[];
|
boxes: readonly RecordedEvidenceBox[];
|
||||||
|
overlaySeconds?: number;
|
||||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||||
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -47,7 +56,41 @@ export function RecordedEvidenceVideoScene({
|
|||||||
segmentCount?: number;
|
segmentCount?: number;
|
||||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||||
onPlayingRejected?: () => void;
|
onPlayingRejected?: () => void;
|
||||||
|
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||||
|
playbackAuthority?: "media" | "host";
|
||||||
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const generation = source.delivery?.kind === "recorded-fmp4-manifest"
|
||||||
|
? source.delivery.manifestGenerationSha256
|
||||||
|
: "invalid";
|
||||||
|
const [admissionPhase, setAdmissionPhase] = useState<RecordedCameraAdmissionState["phase"]>(
|
||||||
|
"loading",
|
||||||
|
);
|
||||||
|
const [presentedSeconds, setPresentedSeconds] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setAdmissionPhase("loading");
|
||||||
|
setPresentedSeconds(null);
|
||||||
|
}, [generation, source.id]);
|
||||||
|
const handleAdmissionChange = (next: RecordedCameraAdmissionState) => {
|
||||||
|
setAdmissionPhase(next.phase);
|
||||||
|
onAdmissionChange?.(next);
|
||||||
|
};
|
||||||
|
const sourceReady = admissionPhase === "ready";
|
||||||
|
const overlaysPresented = sourceReady
|
||||||
|
&& presentedSeconds !== null
|
||||||
|
&& Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25
|
||||||
|
&& (overlaySeconds === undefined || Math.abs(presentedSeconds - overlaySeconds) <= 0.25);
|
||||||
|
const handlePlaybackChange = (next: RecordedObservationPlayback) => {
|
||||||
|
setPresentedSeconds(next.currentSeconds);
|
||||||
|
// During a paused operator seek the existing media element can emit its old
|
||||||
|
// timestamp while the requested MSE window is being rebuilt. That stale
|
||||||
|
// callback must not undo the host target before the decoder reaches it.
|
||||||
|
if (!playback.playing && Math.abs(next.currentSeconds - playback.currentSeconds) > 0.35) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPlaybackChange?.(next);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="recorded-evidence-video-scene">
|
<div className="recorded-evidence-video-scene">
|
||||||
<RecordedFmp4Player
|
<RecordedFmp4Player
|
||||||
@@ -57,29 +100,35 @@ export function RecordedEvidenceVideoScene({
|
|||||||
prepare
|
prepare
|
||||||
segmentSequence={segmentSequence}
|
segmentSequence={segmentSequence}
|
||||||
segmentCount={segmentCount}
|
segmentCount={segmentCount}
|
||||||
onPlaybackChange={onPlaybackChange}
|
onPlaybackChange={handlePlaybackChange}
|
||||||
onPlayingRejected={onPlayingRejected}
|
onPlayingRejected={onPlayingRejected}
|
||||||
|
onAdmissionChange={handleAdmissionChange}
|
||||||
|
playbackAuthority={playbackAuthority}
|
||||||
|
playbackTransport={playbackTransport}
|
||||||
|
recoverTimestampStalls={recoverTimestampStalls}
|
||||||
/>
|
/>
|
||||||
{semanticOverlay ? (
|
{overlaysPresented && semanticOverlay ? (
|
||||||
<RecordedEvidenceSemanticMaskOverlay
|
<RecordedEvidenceSemanticMaskOverlay
|
||||||
{...semanticOverlay}
|
{...semanticOverlay}
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{pointCloudOverlay ? (
|
{overlaysPresented && pointCloudOverlay ? (
|
||||||
<RecordedEvidencePointCloudOverlay
|
<RecordedEvidencePointCloudOverlay
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
overlay={pointCloudOverlay}
|
overlay={pointCloudOverlay}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{overlaysPresented ? (
|
||||||
<RecordedEvidenceBoxOverlay
|
<RecordedEvidenceBoxOverlay
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
boxes={boxes}
|
boxes={boxes}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+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) => {
|
const synchronize = useCallback((next: RecordedObservationPlayback) => {
|
||||||
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
|
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
|
||||||
|
// Animation-clock mode is retained only for non-media diagnostics. A
|
||||||
|
// recorded LAB with video uses the external media clock so spatial and
|
||||||
|
// overlays never advance past the frame the decoder actually presented.
|
||||||
|
if (clock === "animation") return;
|
||||||
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
|
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
|
||||||
}, [range]);
|
}, [clock, range]);
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
playback,
|
playback,
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
private renderIntervalMs = 1000 / 60;
|
private renderIntervalMs = 1000 / 60;
|
||||||
private renderAccumulatorMs = 0;
|
private renderAccumulatorMs = 0;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
private requestGsplatFrame = (): void => {
|
||||||
|
this.requestRender();
|
||||||
|
};
|
||||||
private updateFrame = (deltaSeconds: number): void => {
|
private updateFrame = (deltaSeconds: number): void => {
|
||||||
if (!this.app || !this.camera || this.disposed) return;
|
if (!this.app || !this.camera || this.disposed) return;
|
||||||
this.ugvController?.update(deltaSeconds);
|
this.ugvController?.update(deltaSeconds);
|
||||||
@@ -158,6 +161,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
app.autoRender = false;
|
app.autoRender = false;
|
||||||
app.on("update", this.updateFrame);
|
app.on("update", this.updateFrame);
|
||||||
|
app.systems.gsplat?.on("frame:request", this.requestGsplatFrame);
|
||||||
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||||
app.setCanvasResolution(RESOLUTION_AUTO);
|
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||||
@@ -379,6 +383,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
if (this.app) {
|
if (this.app) {
|
||||||
this.app.autoRender = false;
|
this.app.autoRender = false;
|
||||||
this.app.renderNextFrame = false;
|
this.app.renderNextFrame = false;
|
||||||
|
this.app.systems.gsplat?.off("frame:request", this.requestGsplatFrame);
|
||||||
this.app.off("update", this.updateFrame);
|
this.app.off("update", this.updateFrame);
|
||||||
this.app.destroy();
|
this.app.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ const PHYSICS_PROXY_ERROR = 0.0003;
|
|||||||
const PHYSICS_PROXY_MAX_TRIANGLES = 240_000;
|
const PHYSICS_PROXY_MAX_TRIANGLES = 240_000;
|
||||||
const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8;
|
const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8;
|
||||||
const COAST_DECELERATION_MPS2 = 0.18;
|
const COAST_DECELERATION_MPS2 = 0.18;
|
||||||
const BRAKE_ATTITUDE_DAMPING = 6;
|
const PARKING_BRAKE_HOLD_DECELERATION_MPS2 = 6;
|
||||||
|
const PARKING_BRAKE_ENGAGE_SPEED_MPS = 0.08;
|
||||||
|
const TYRE_FRICTION_SLIP = 8.5;
|
||||||
|
const TYRE_STATIC_FRICTION_COEFFICIENT = 0.95;
|
||||||
|
const TYRE_KINETIC_FRICTION_COEFFICIENT = 0.78;
|
||||||
|
const TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND = 10;
|
||||||
|
const GRAVITY_METERS_PER_SECOND_SQUARED = 9.81;
|
||||||
|
const MIN_TYRE_NORMAL_FORCE_NEWTONS = 1;
|
||||||
const DEFAULT_ORBIT_PITCH = 0.48;
|
const DEFAULT_ORBIT_PITCH = 0.48;
|
||||||
const CAMERA_RETURN_DELAY_SECONDS = 1.2;
|
const CAMERA_RETURN_DELAY_SECONDS = 1.2;
|
||||||
const CAMERA_RETURN_DURATION_SECONDS = 2;
|
const CAMERA_RETURN_DURATION_SECONDS = 2;
|
||||||
@@ -79,12 +86,20 @@ interface NativeTransform extends NativeObject {
|
|||||||
getRotation(): NativeQuaternion;
|
getRotation(): NativeQuaternion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NativeRaycastInfo extends NativeObject {
|
||||||
|
get_m_contactNormalWS(): NativeVector3;
|
||||||
|
get_m_contactPointWS(): NativeVector3;
|
||||||
|
get_m_wheelAxleWS(): NativeVector3;
|
||||||
|
}
|
||||||
|
|
||||||
interface NativeWheelInfo extends NativeObject {
|
interface NativeWheelInfo extends NativeObject {
|
||||||
set_m_suspensionStiffness(value: number): void;
|
set_m_suspensionStiffness(value: number): void;
|
||||||
set_m_wheelsDampingRelaxation(value: number): void;
|
set_m_wheelsDampingRelaxation(value: number): void;
|
||||||
set_m_wheelsDampingCompression(value: number): void;
|
set_m_wheelsDampingCompression(value: number): void;
|
||||||
set_m_frictionSlip(value: number): void;
|
set_m_frictionSlip(value: number): void;
|
||||||
set_m_rollInfluence(value: number): void;
|
set_m_rollInfluence(value: number): void;
|
||||||
|
get_m_wheelsSuspensionForce(): number;
|
||||||
|
get_m_raycastInfo(): NativeRaycastInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NativeRaycastVehicle extends NativeObject {
|
interface NativeRaycastVehicle extends NativeObject {
|
||||||
@@ -102,6 +117,7 @@ interface NativeRaycastVehicle extends NativeObject {
|
|||||||
setBrake(force: number, wheel: number): void;
|
setBrake(force: number, wheel: number): void;
|
||||||
setSteeringValue(value: number, wheel: number): void;
|
setSteeringValue(value: number, wheel: number): void;
|
||||||
getNumWheels(): number;
|
getNumWheels(): number;
|
||||||
|
getWheelInfo(wheel: number): NativeWheelInfo;
|
||||||
updateWheelTransform(wheel: number, interpolated: boolean): void;
|
updateWheelTransform(wheel: number, interpolated: boolean): void;
|
||||||
getWheelTransformWS(wheel: number): NativeTransform;
|
getWheelTransformWS(wheel: number): NativeTransform;
|
||||||
getForwardVector(): NativeVector3;
|
getForwardVector(): NativeVector3;
|
||||||
@@ -126,6 +142,11 @@ interface NativeDynamicsWorld extends NativeObject {
|
|||||||
removeAction(action: NativeObject): void;
|
removeAction(action: NativeObject): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NativeRigidBody extends NativeObject {
|
||||||
|
applyImpulse(impulse: NativeVector3, relativePosition: NativeVector3): void;
|
||||||
|
setActivationState(state: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
interface PhysicsSystemAccess {
|
interface PhysicsSystemAccess {
|
||||||
systems: {
|
systems: {
|
||||||
rigidbody: {
|
rigidbody: {
|
||||||
@@ -137,7 +158,7 @@ interface PhysicsSystemAccess {
|
|||||||
|
|
||||||
interface NativeRigidBodyAccess {
|
interface NativeRigidBodyAccess {
|
||||||
rigidbody?: {
|
rigidbody?: {
|
||||||
body: NativeObject | null;
|
body: NativeRigidBody | null;
|
||||||
linearVelocity: Vec3;
|
linearVelocity: Vec3;
|
||||||
angularVelocity: Vec3;
|
angularVelocity: Vec3;
|
||||||
teleport(position: Vec3, rotation?: Vec3 | Quat): void;
|
teleport(position: Vec3, rotation?: Vec3 | Quat): void;
|
||||||
@@ -192,6 +213,13 @@ export class SimulationUgvController {
|
|||||||
private readonly smoothedCamera = new Vec3();
|
private readonly smoothedCamera = new Vec3();
|
||||||
private readonly limitedLinearVelocity = new Vec3();
|
private readonly limitedLinearVelocity = new Vec3();
|
||||||
private readonly limitedAngularVelocity = new Vec3();
|
private readonly limitedAngularVelocity = new Vec3();
|
||||||
|
private readonly tyreContactNormal = new Vec3();
|
||||||
|
private readonly tyreLateralDirection = new Vec3();
|
||||||
|
private readonly tyreLongitudinalDirection = new Vec3();
|
||||||
|
private readonly tyreContactPoint = new Vec3();
|
||||||
|
private readonly tyreRelativePosition = new Vec3();
|
||||||
|
private readonly tyreAngularContactVelocity = new Vec3();
|
||||||
|
private readonly tyreContactVelocity = new Vec3();
|
||||||
private readonly spawnPosition = UGV_SPAWN_POSITION.clone();
|
private readonly spawnPosition = UGV_SPAWN_POSITION.clone();
|
||||||
private orbitPointerId: number | null = null;
|
private orbitPointerId: number | null = null;
|
||||||
private orbitPointerX = 0;
|
private orbitPointerX = 0;
|
||||||
@@ -204,8 +232,9 @@ export class SimulationUgvController {
|
|||||||
private active = false;
|
private active = false;
|
||||||
private vehicleEntity: Entity | null = null;
|
private vehicleEntity: Entity | null = null;
|
||||||
private vehicle: NativeRaycastVehicle | null = null;
|
private vehicle: NativeRaycastVehicle | null = null;
|
||||||
private vehicleTuning: NativeObject | null = null;
|
|
||||||
private vehicleRaycaster: NativeObject | null = null;
|
private vehicleRaycaster: NativeObject | null = null;
|
||||||
|
private tyreImpulseNative: NativeVector3 | null = null;
|
||||||
|
private tyreRelativePositionNative: NativeVector3 | null = null;
|
||||||
private dynamicsWorld: NativeDynamicsWorld | null = null;
|
private dynamicsWorld: NativeDynamicsWorld | null = null;
|
||||||
private chassisMaterial: StandardMaterial | null = null;
|
private chassisMaterial: StandardMaterial | null = null;
|
||||||
private wheelMaterial: StandardMaterial | null = null;
|
private wheelMaterial: StandardMaterial | null = null;
|
||||||
@@ -308,9 +337,27 @@ export class SimulationUgvController {
|
|||||||
const braking = this.pressed.has("Space");
|
const braking = this.pressed.has("Space");
|
||||||
const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody;
|
const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody;
|
||||||
const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6;
|
const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6;
|
||||||
|
const nativeForward = this.vehicle.getForwardVector();
|
||||||
|
const nativeForwardLength = Math.hypot(
|
||||||
|
nativeForward.x(),
|
||||||
|
nativeForward.y(),
|
||||||
|
nativeForward.z(),
|
||||||
|
);
|
||||||
|
const longitudinalSpeedMetersPerSecond = rigidbody && nativeForwardLength > 0.001
|
||||||
|
? Math.abs(
|
||||||
|
(
|
||||||
|
rigidbody.linearVelocity.x * nativeForward.x()
|
||||||
|
+ rigidbody.linearVelocity.y * nativeForward.y()
|
||||||
|
+ rigidbody.linearVelocity.z * nativeForward.z()
|
||||||
|
) / nativeForwardLength,
|
||||||
|
)
|
||||||
|
: Math.abs(speedMetersPerSecond);
|
||||||
const maxSpeed = this.settings.maxSpeedMetersPerSecond;
|
const maxSpeed = this.settings.maxSpeedMetersPerSecond;
|
||||||
const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180;
|
const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180;
|
||||||
const pureTurn = !braking && forwardInput === 0 && turnInput !== 0;
|
const pureTurn = !braking && forwardInput === 0 && turnInput !== 0;
|
||||||
|
const holding = !braking && forwardInput === 0 && turnInput === 0;
|
||||||
|
const parkingBrakeEngaged = holding
|
||||||
|
&& longitudinalSpeedMetersPerSecond <= PARKING_BRAKE_ENGAGE_SPEED_MPS;
|
||||||
const desiredSpeed = forwardInput * maxSpeed;
|
const desiredSpeed = forwardInput * maxSpeed;
|
||||||
const speedError = desiredSpeed - speedMetersPerSecond;
|
const speedError = desiredSpeed - speedMetersPerSecond;
|
||||||
const speedResponseRange = Math.max(0.35, maxSpeed * 0.2);
|
const speedResponseRange = Math.max(0.35, maxSpeed * 0.2);
|
||||||
@@ -325,13 +372,27 @@ export class SimulationUgvController {
|
|||||||
const leftCommand = clamp(forwardCommand - turnCommand, -1, 1);
|
const leftCommand = clamp(forwardCommand - turnCommand, -1, 1);
|
||||||
const rightCommand = clamp(forwardCommand + turnCommand, -1, 1);
|
const rightCommand = clamp(forwardCommand + turnCommand, -1, 1);
|
||||||
const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel;
|
const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel;
|
||||||
|
const brakeDeceleration = braking
|
||||||
|
? SERVICE_BRAKE_DECELERATION_MPS2
|
||||||
|
: holding
|
||||||
|
? parkingBrakeEngaged
|
||||||
|
? PARKING_BRAKE_HOLD_DECELERATION_MPS2
|
||||||
|
: COAST_DECELERATION_MPS2
|
||||||
|
: 0;
|
||||||
|
const wheelBrakeForce = this.settings.massKg * brakeDeceleration
|
||||||
|
/ Math.max(1, this.wheelDefinitions.length);
|
||||||
|
|
||||||
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
||||||
const definition = this.wheelDefinitions[index];
|
const definition = this.wheelDefinitions[index];
|
||||||
const command = definition.left ? leftCommand : rightCommand;
|
const command = definition.left ? leftCommand : rightCommand;
|
||||||
|
// Parking contact is solved below with one 2D Coulomb limit; disable the
|
||||||
|
// raycast vehicle's parallel friction impulse so grip is not counted twice.
|
||||||
|
this.vehicle.getWheelInfo(index).set_m_frictionSlip(
|
||||||
|
parkingBrakeEngaged ? 0 : TYRE_FRICTION_SLIP,
|
||||||
|
);
|
||||||
this.vehicle.setSteeringValue(0, index);
|
this.vehicle.setSteeringValue(0, index);
|
||||||
this.vehicle.applyEngineForce(command * engineForce, index);
|
this.vehicle.applyEngineForce(command * engineForce, index);
|
||||||
this.vehicle.setBrake(0, index);
|
this.vehicle.setBrake(wheelBrakeForce, index);
|
||||||
this.vehicle.updateWheelTransform(index, true);
|
this.vehicle.updateWheelTransform(index, true);
|
||||||
const transform = this.vehicle.getWheelTransformWS(index);
|
const transform = this.vehicle.getWheelTransformWS(index);
|
||||||
const position = transform.getOrigin();
|
const position = transform.getOrigin();
|
||||||
@@ -340,22 +401,21 @@ export class SimulationUgvController {
|
|||||||
definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w());
|
definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = rigidbody?.body ?? null;
|
||||||
|
if (rigidbody && body) {
|
||||||
|
this.applyParkingTyreContact(
|
||||||
|
deltaSeconds,
|
||||||
|
rigidbody,
|
||||||
|
body,
|
||||||
|
parkingBrakeEngaged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (rigidbody) {
|
if (rigidbody) {
|
||||||
const linearVelocity = rigidbody.linearVelocity;
|
const linearVelocity = rigidbody.linearVelocity;
|
||||||
let nextLinearX = linearVelocity.x;
|
let nextLinearX = linearVelocity.x;
|
||||||
let nextLinearZ = linearVelocity.z;
|
let nextLinearZ = linearVelocity.z;
|
||||||
let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ);
|
let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ);
|
||||||
const coasting = !braking && forwardInput === 0 && turnInput === 0;
|
|
||||||
if ((braking || coasting) && horizontalSpeed > 0.001) {
|
|
||||||
const deceleration = braking
|
|
||||||
? SERVICE_BRAKE_DECELERATION_MPS2
|
|
||||||
: COAST_DECELERATION_MPS2;
|
|
||||||
const nextSpeed = Math.max(0, horizontalSpeed - deceleration * Math.max(0, deltaSeconds));
|
|
||||||
const scale = nextSpeed / horizontalSpeed;
|
|
||||||
nextLinearX *= scale;
|
|
||||||
nextLinearZ *= scale;
|
|
||||||
horizontalSpeed = nextSpeed;
|
|
||||||
}
|
|
||||||
if (pureTurn && horizontalSpeed > 0.001) {
|
if (pureTurn && horizontalSpeed > 0.001) {
|
||||||
const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8);
|
const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8);
|
||||||
nextLinearX *= pivotDamping;
|
nextLinearX *= pivotDamping;
|
||||||
@@ -372,9 +432,6 @@ export class SimulationUgvController {
|
|||||||
rigidbody.linearVelocity = this.limitedLinearVelocity;
|
rigidbody.linearVelocity = this.limitedLinearVelocity;
|
||||||
}
|
}
|
||||||
const angularVelocity = rigidbody.angularVelocity;
|
const angularVelocity = rigidbody.angularVelocity;
|
||||||
const attitudeDamping = braking
|
|
||||||
? Math.exp(-Math.max(0, deltaSeconds) * BRAKE_ATTITUDE_DAMPING)
|
|
||||||
: 1;
|
|
||||||
let nextAngularY = angularVelocity.y;
|
let nextAngularY = angularVelocity.y;
|
||||||
if (pureTurn) {
|
if (pureTurn) {
|
||||||
const desiredYawRate = -turnInput * maxTurnRate;
|
const desiredYawRate = -turnInput * maxTurnRate;
|
||||||
@@ -387,24 +444,137 @@ export class SimulationUgvController {
|
|||||||
} else if (Math.abs(angularVelocity.y) > maxTurnRate) {
|
} else if (Math.abs(angularVelocity.y) > maxTurnRate) {
|
||||||
nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate;
|
nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate;
|
||||||
}
|
}
|
||||||
if (attitudeDamping !== 1 || nextAngularY !== angularVelocity.y) {
|
if (nextAngularY !== angularVelocity.y) {
|
||||||
this.limitedAngularVelocity.set(
|
this.limitedAngularVelocity.set(
|
||||||
angularVelocity.x * attitudeDamping,
|
angularVelocity.x,
|
||||||
nextAngularY,
|
nextAngularY,
|
||||||
angularVelocity.z * attitudeDamping,
|
angularVelocity.z,
|
||||||
);
|
);
|
||||||
rigidbody.angularVelocity = this.limitedAngularVelocity;
|
rigidbody.angularVelocity = this.limitedAngularVelocity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody?.body as {
|
body?.setActivationState(DISABLE_DEACTIVATION);
|
||||||
setActivationState?: (state: number) => void;
|
|
||||||
} | null;
|
|
||||||
body?.setActivationState?.(DISABLE_DEACTIVATION);
|
|
||||||
if (this.vehicleEntity.getPosition().y < -8) this.reset();
|
if (this.vehicleEntity.getPosition().y < -8) this.reset();
|
||||||
this.updateCamera(deltaSeconds);
|
this.updateCamera(deltaSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyParkingTyreContact(
|
||||||
|
deltaSeconds: number,
|
||||||
|
rigidbody: NonNullable<NativeRigidBodyAccess["rigidbody"]>,
|
||||||
|
body: NativeRigidBody,
|
||||||
|
parkingBrakeEngaged: boolean,
|
||||||
|
): void {
|
||||||
|
if (!this.vehicle || !this.vehicleEntity || !this.tyreImpulseNative
|
||||||
|
|| !this.tyreRelativePositionNative) return;
|
||||||
|
|
||||||
|
if (!parkingBrakeEngaged) return;
|
||||||
|
|
||||||
|
const timeStep = Math.min(Math.max(0, deltaSeconds), 1 / 30);
|
||||||
|
if (timeStep === 0) return;
|
||||||
|
const wheelEffectiveMass = this.settings.massKg
|
||||||
|
/ Math.max(1, this.wheelDefinitions.length);
|
||||||
|
const chassisPosition = this.vehicleEntity.getPosition();
|
||||||
|
|
||||||
|
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
||||||
|
const wheel = this.vehicle.getWheelInfo(index);
|
||||||
|
const raycast = wheel.get_m_raycastInfo();
|
||||||
|
const normalForce = wheel.get_m_wheelsSuspensionForce();
|
||||||
|
if (!Number.isFinite(normalForce) || normalForce < MIN_TYRE_NORMAL_FORCE_NEWTONS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeNormal = raycast.get_m_contactNormalWS();
|
||||||
|
this.tyreContactNormal.set(nativeNormal.x(), nativeNormal.y(), nativeNormal.z());
|
||||||
|
if (this.tyreContactNormal.lengthSq() < 0.001) continue;
|
||||||
|
this.tyreContactNormal.normalize();
|
||||||
|
|
||||||
|
const nativeAxle = raycast.get_m_wheelAxleWS();
|
||||||
|
this.tyreLateralDirection.set(nativeAxle.x(), nativeAxle.y(), nativeAxle.z());
|
||||||
|
this.tyreLateralDirection.addScaled(
|
||||||
|
this.tyreContactNormal,
|
||||||
|
-this.tyreLateralDirection.dot(this.tyreContactNormal),
|
||||||
|
);
|
||||||
|
if (this.tyreLateralDirection.lengthSq() < 0.001) continue;
|
||||||
|
this.tyreLateralDirection.normalize();
|
||||||
|
this.tyreLongitudinalDirection.cross(
|
||||||
|
this.tyreContactNormal,
|
||||||
|
this.tyreLateralDirection,
|
||||||
|
).normalize();
|
||||||
|
|
||||||
|
const nativeContactPoint = raycast.get_m_contactPointWS();
|
||||||
|
this.tyreContactPoint.set(
|
||||||
|
nativeContactPoint.x(),
|
||||||
|
nativeContactPoint.y(),
|
||||||
|
nativeContactPoint.z(),
|
||||||
|
);
|
||||||
|
this.tyreRelativePosition.sub2(this.tyreContactPoint, chassisPosition);
|
||||||
|
this.tyreAngularContactVelocity.cross(
|
||||||
|
rigidbody.angularVelocity,
|
||||||
|
this.tyreRelativePosition,
|
||||||
|
);
|
||||||
|
this.tyreContactVelocity.add2(
|
||||||
|
rigidbody.linearVelocity,
|
||||||
|
this.tyreAngularContactVelocity,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lateralSlipSpeed = this.tyreContactVelocity.dot(this.tyreLateralDirection);
|
||||||
|
const longitudinalSlipSpeed = this.tyreContactVelocity.dot(
|
||||||
|
this.tyreLongitudinalDirection,
|
||||||
|
);
|
||||||
|
// Static tyre friction is a contact constraint: it balances the component
|
||||||
|
// of gravity along the surface and damps slip at the contact patch. The
|
||||||
|
// force still passes through a Coulomb circle and is applied at the wheel,
|
||||||
|
// so the chassis remains a fully dynamic rigid body.
|
||||||
|
const lateralGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
|
||||||
|
* this.tyreLateralDirection.y;
|
||||||
|
const longitudinalGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
|
||||||
|
* this.tyreLongitudinalDirection.y;
|
||||||
|
const trialLateralForce = -wheelEffectiveMass * (
|
||||||
|
lateralGravityAcceleration
|
||||||
|
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * lateralSlipSpeed
|
||||||
|
);
|
||||||
|
const trialLongitudinalForce = -wheelEffectiveMass * (
|
||||||
|
longitudinalGravityAcceleration
|
||||||
|
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * longitudinalSlipSpeed
|
||||||
|
);
|
||||||
|
const staticFrictionLimit = TYRE_STATIC_FRICTION_COEFFICIENT * normalForce;
|
||||||
|
let lateralForce = trialLateralForce;
|
||||||
|
let longitudinalForce = trialLongitudinalForce;
|
||||||
|
|
||||||
|
if (Math.hypot(trialLateralForce, trialLongitudinalForce) > staticFrictionLimit) {
|
||||||
|
const slipSpeed = Math.hypot(lateralSlipSpeed, longitudinalSlipSpeed);
|
||||||
|
const kineticFrictionLimit = TYRE_KINETIC_FRICTION_COEFFICIENT * normalForce;
|
||||||
|
if (slipSpeed > 0.0001) {
|
||||||
|
lateralForce = -(lateralSlipSpeed / slipSpeed) * kineticFrictionLimit;
|
||||||
|
longitudinalForce = -(longitudinalSlipSpeed / slipSpeed) * kineticFrictionLimit;
|
||||||
|
} else {
|
||||||
|
const forceScale = staticFrictionLimit
|
||||||
|
/ Math.hypot(trialLateralForce, trialLongitudinalForce);
|
||||||
|
lateralForce = trialLateralForce * forceScale;
|
||||||
|
longitudinalForce = trialLongitudinalForce * forceScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lateralImpulse = lateralForce * timeStep;
|
||||||
|
const longitudinalImpulse = longitudinalForce * timeStep;
|
||||||
|
this.tyreImpulseNative.setValue(
|
||||||
|
this.tyreLateralDirection.x * lateralImpulse
|
||||||
|
+ this.tyreLongitudinalDirection.x * longitudinalImpulse,
|
||||||
|
this.tyreLateralDirection.y * lateralImpulse
|
||||||
|
+ this.tyreLongitudinalDirection.y * longitudinalImpulse,
|
||||||
|
this.tyreLateralDirection.z * lateralImpulse
|
||||||
|
+ this.tyreLongitudinalDirection.z * longitudinalImpulse,
|
||||||
|
);
|
||||||
|
this.tyreRelativePositionNative.setValue(
|
||||||
|
this.tyreRelativePosition.x,
|
||||||
|
this.tyreRelativePosition.y,
|
||||||
|
this.tyreRelativePosition.z,
|
||||||
|
);
|
||||||
|
body.applyImpulse(this.tyreImpulseNative, this.tyreRelativePositionNative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> {
|
private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> {
|
||||||
const models = collisionWorld.findComponents("model") as ModelComponent[];
|
const models = collisionWorld.findComponents("model") as ModelComponent[];
|
||||||
if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV.");
|
if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV.");
|
||||||
@@ -428,7 +598,12 @@ export class SimulationUgvController {
|
|||||||
this.app,
|
this.app,
|
||||||
meshInstance.mesh,
|
meshInstance.mesh,
|
||||||
new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()),
|
new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()),
|
||||||
targetRatio,
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
Math.floor((meshInstance.mesh.primitive[0]?.count ?? 0) / 3) * targetRatio,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (!physicsMesh) continue;
|
if (!physicsMesh) continue;
|
||||||
const entity = new Entity(`UGV physics proxy ${index + 1}`);
|
const entity = new Entity(`UGV physics proxy ${index + 1}`);
|
||||||
@@ -464,8 +639,9 @@ export class SimulationUgvController {
|
|||||||
type: "dynamic",
|
type: "dynamic",
|
||||||
mass: this.settings.massKg,
|
mass: this.settings.massKg,
|
||||||
friction: 0.85,
|
friction: 0.85,
|
||||||
linearDamping: 0.08,
|
rollingFriction: 0.12,
|
||||||
angularDamping: 0.45,
|
linearDamping: 0.12,
|
||||||
|
angularDamping: 0.6,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05));
|
this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05));
|
||||||
@@ -525,7 +701,10 @@ export class SimulationUgvController {
|
|||||||
applyMaterial(wheelMesh, this.wheelMaterial);
|
applyMaterial(wheelMesh, this.wheelMaterial);
|
||||||
anchor.addChild(wheelMesh);
|
anchor.addChild(wheelMesh);
|
||||||
vehicle.addChild(anchor);
|
vehicle.addChild(anchor);
|
||||||
this.wheelDefinitions.push({ ...definition, anchor });
|
this.wheelDefinitions.push({
|
||||||
|
...definition,
|
||||||
|
anchor,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
vehicle.setLocalPosition(this.spawnPosition);
|
vehicle.setLocalPosition(this.spawnPosition);
|
||||||
@@ -559,16 +738,17 @@ export class SimulationUgvController {
|
|||||||
wheel.set_m_suspensionStiffness(24);
|
wheel.set_m_suspensionStiffness(24);
|
||||||
wheel.set_m_wheelsDampingRelaxation(3.2);
|
wheel.set_m_wheelsDampingRelaxation(3.2);
|
||||||
wheel.set_m_wheelsDampingCompression(4.8);
|
wheel.set_m_wheelsDampingCompression(4.8);
|
||||||
wheel.set_m_frictionSlip(5.5);
|
wheel.set_m_frictionSlip(TYRE_FRICTION_SLIP);
|
||||||
wheel.set_m_rollInfluence(0.08);
|
wheel.set_m_rollInfluence(0.08);
|
||||||
}
|
}
|
||||||
this.ammo.destroy(axle);
|
this.ammo.destroy(axle);
|
||||||
this.ammo.destroy(direction);
|
this.ammo.destroy(direction);
|
||||||
this.ammo.destroy(connection);
|
this.ammo.destroy(connection);
|
||||||
|
this.tyreImpulseNative = new this.ammo.btVector3(0, 0, 0);
|
||||||
|
this.tyreRelativePositionNative = new this.ammo.btVector3(0, 0, 0);
|
||||||
|
|
||||||
dynamicsWorld.addAction(nativeVehicle);
|
dynamicsWorld.addAction(nativeVehicle);
|
||||||
this.vehicleEntity = vehicle;
|
this.vehicleEntity = vehicle;
|
||||||
this.vehicleTuning = tuning;
|
|
||||||
this.vehicleRaycaster = raycaster;
|
this.vehicleRaycaster = raycaster;
|
||||||
this.vehicle = nativeVehicle;
|
this.vehicle = nativeVehicle;
|
||||||
this.dynamicsWorld = dynamicsWorld;
|
this.dynamicsWorld = dynamicsWorld;
|
||||||
@@ -684,10 +864,12 @@ export class SimulationUgvController {
|
|||||||
}
|
}
|
||||||
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
|
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
|
||||||
if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject));
|
if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject));
|
||||||
if (this.vehicleTuning) runCleanup("destroy vehicle tuning", () => this.ammo.destroy(this.vehicleTuning as NativeObject));
|
if (this.tyreImpulseNative) runCleanup("destroy tyre impulse vector", () => this.ammo.destroy(this.tyreImpulseNative as NativeObject));
|
||||||
|
if (this.tyreRelativePositionNative) runCleanup("destroy tyre relative-position vector", () => this.ammo.destroy(this.tyreRelativePositionNative as NativeObject));
|
||||||
this.vehicle = null;
|
this.vehicle = null;
|
||||||
this.vehicleRaycaster = null;
|
this.vehicleRaycaster = null;
|
||||||
this.vehicleTuning = null;
|
this.tyreImpulseNative = null;
|
||||||
|
this.tyreRelativePositionNative = null;
|
||||||
this.dynamicsWorld = null;
|
this.dynamicsWorld = null;
|
||||||
|
|
||||||
if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy());
|
if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy());
|
||||||
@@ -824,7 +1006,7 @@ function createPhysicsProxyMesh(
|
|||||||
app: Application,
|
app: Application,
|
||||||
source: Mesh,
|
source: Mesh,
|
||||||
localTransform: Mat4,
|
localTransform: Mat4,
|
||||||
targetRatio: number,
|
targetTriangleCount: number,
|
||||||
): Mesh | null {
|
): Mesh | null {
|
||||||
const primitive = source.primitive[0];
|
const primitive = source.primitive[0];
|
||||||
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
|
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
|
||||||
@@ -858,8 +1040,11 @@ function createPhysicsProxyMesh(
|
|||||||
if (triangleIndexCount < 3) return null;
|
if (triangleIndexCount < 3) return null;
|
||||||
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
|
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
|
||||||
|
|
||||||
if (targetRatio < 1) {
|
const targetIndexCount = Math.max(
|
||||||
const targetIndexCount = Math.max(3, Math.floor(indices.length * targetRatio / 3) * 3);
|
3,
|
||||||
|
Math.min(indices.length, Math.floor(targetTriangleCount) * 3),
|
||||||
|
);
|
||||||
|
if (indices.length > targetIndexCount) {
|
||||||
const [simplifiedIndices] = MeshoptSimplifier.simplify(
|
const [simplifiedIndices] = MeshoptSimplifier.simplify(
|
||||||
indices,
|
indices,
|
||||||
transformed,
|
transformed,
|
||||||
@@ -868,6 +1053,26 @@ function createPhysicsProxyMesh(
|
|||||||
PHYSICS_PROXY_ERROR,
|
PHYSICS_PROXY_ERROR,
|
||||||
);
|
);
|
||||||
indices = new Uint32Array(simplifiedIndices);
|
indices = new Uint32Array(simplifiedIndices);
|
||||||
|
if (indices.length > targetIndexCount) {
|
||||||
|
// Topologically noisy scanner meshes can make the quality simplifier stop
|
||||||
|
// above its requested target. Ammo must never receive that unbounded result:
|
||||||
|
// the spatial fallback preserves the surface envelope while enforcing the
|
||||||
|
// same deterministic physics budget for every imported location.
|
||||||
|
const [boundedIndices] = MeshoptSimplifier.simplifySloppy(
|
||||||
|
indices,
|
||||||
|
transformed,
|
||||||
|
3,
|
||||||
|
null,
|
||||||
|
targetIndexCount,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
indices = new Uint32Array(boundedIndices);
|
||||||
|
}
|
||||||
|
if (indices.length > targetIndexCount) {
|
||||||
|
throw new Error(
|
||||||
|
`Physics proxy превысил лимит: ${Math.floor(indices.length / 3)} > ${Math.floor(targetIndexCount / 3)} треугольников.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactedIndices = new Uint32Array(indices);
|
const compactedIndices = new Uint32Array(indices);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export interface E31LaboratoryResult {
|
|||||||
limitations: readonly string[];
|
limitations: readonly string[];
|
||||||
access: "read-only";
|
access: "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E32LaboratoryResult {
|
export interface E32LaboratoryResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string | null;
|
createdAtUtc: string | null;
|
||||||
@@ -53,7 +52,6 @@ export interface E32LaboratoryResult {
|
|||||||
};
|
};
|
||||||
access: "read-only";
|
access: "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E33LaboratoryResult {
|
export interface E33LaboratoryResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string | null;
|
createdAtUtc: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ObservationSessionReplayLaunch } from "../observation/sessionArchive";
|
||||||
|
import type { RecordedRrdArtifactDescriptor } from "../observation/viewerProfile";
|
||||||
|
|
||||||
|
const SAFE_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||||
|
const SAFE_SESSION_SOURCE = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
||||||
|
const MAX_CANONICAL_REPLAY_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface CanonicalLabReplayDescriptor extends RecordedRrdArtifactDescriptor {
|
||||||
|
blueprintSourceUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the one immutable RRD used by the canonical recorded LAB.
|
||||||
|
*
|
||||||
|
* The server caches the merge of the sealed spatial recording and the LAB AI
|
||||||
|
* evidence. Rerun therefore opens one source and cannot present the base store
|
||||||
|
* before a second receiver has finished decoding the semantic layer.
|
||||||
|
*/
|
||||||
|
export async function resolveCanonicalLabReplay(
|
||||||
|
resultId: string,
|
||||||
|
launch: ObservationSessionReplayLaunch,
|
||||||
|
{
|
||||||
|
origin = window.location.origin,
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
origin?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: typeof globalThis.fetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<CanonicalLabReplayDescriptor> {
|
||||||
|
const base = new URL(origin);
|
||||||
|
if (
|
||||||
|
!SAFE_RESULT_ID.test(resultId)
|
||||||
|
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||||
|
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||||
|
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||||
|
) {
|
||||||
|
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
||||||
|
}
|
||||||
|
const sourceUrl =
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
|
||||||
|
+ "/canonical-replay.rrd";
|
||||||
|
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||||
|
descriptorUrl.searchParams.set("base_generation", launch.sha256);
|
||||||
|
if (descriptorUrl.origin !== base.origin) {
|
||||||
|
throw new Error("Канонический replay LAB должен быть same-origin.");
|
||||||
|
}
|
||||||
|
const response = await fetcher(descriptorUrl.href, {
|
||||||
|
method: "HEAD",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/vnd.rerun.rrd" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||||
|
const byteLength = Number(response.headers.get("Content-Length"));
|
||||||
|
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||||
|
if (
|
||||||
|
response.status !== 200
|
||||||
|
|| contentType !== "application/vnd.rerun.rrd"
|
||||||
|
|| response.headers.get("X-Rerun-Format") !== "RRF2"
|
||||||
|
|| !sha256
|
||||||
|
|| !Number.isSafeInteger(byteLength)
|
||||||
|
|| byteLength < 4
|
||||||
|
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
|
||||||
|
) {
|
||||||
|
throw new Error("Единый replay LAB не прошёл проверку.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceUrl,
|
||||||
|
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
|
||||||
|
byteLength,
|
||||||
|
sha256,
|
||||||
|
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export const CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS = 1;
|
||||||
|
export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v3";
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabPackedCellEvidence {
|
||||||
|
centersBodyXyM: Float32Array;
|
||||||
|
zBoundsM: Float32Array;
|
||||||
|
stateCodes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabBodyGroundFrame {
|
||||||
|
originMapXyzM: readonly [number, number, number];
|
||||||
|
sensorOriginMapXyzM: readonly [number, number, number];
|
||||||
|
basisMapFromBody: readonly [
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabTgsCostmap {
|
||||||
|
centersXyM: readonly (readonly [number, number])[];
|
||||||
|
stateCodes: readonly number[];
|
||||||
|
zBoundsM: readonly (readonly [number | null, number | null])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalRecordedLabTgsIsCurrent(
|
||||||
|
currentTimeNs: number,
|
||||||
|
anchorTimeNs: number,
|
||||||
|
historySeconds = CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(currentTimeNs)
|
||||||
|
|| !Number.isSafeInteger(anchorTimeNs)
|
||||||
|
|| !Number.isFinite(historySeconds)
|
||||||
|
|| historySeconds <= 0
|
||||||
|
) return false;
|
||||||
|
const ageNs = currentTimeNs - anchorTimeNs;
|
||||||
|
return ageNs >= 0 && ageNs <= Math.round(historySeconds * 1_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalMapGravityLocalPointToBodyGround(
|
||||||
|
point: readonly [number, number, number],
|
||||||
|
anchor: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
current: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
): readonly [number, number, number] {
|
||||||
|
// TGS is translation-only map-gravity-local: its axes are map axes and its
|
||||||
|
// origin is the LiDAR at the source frame. It is not an anchor body frame.
|
||||||
|
const map: readonly [number, number, number] = [
|
||||||
|
anchor.sensorOriginMapXyzM[0] + point[0],
|
||||||
|
anchor.sensorOriginMapXyzM[1] + point[1],
|
||||||
|
anchor.sensorOriginMapXyzM[2] + point[2],
|
||||||
|
];
|
||||||
|
const delta: readonly [number, number, number] = [
|
||||||
|
map[0] - current.originMapXyzM[0],
|
||||||
|
map[1] - current.originMapXyzM[1],
|
||||||
|
map[2] - current.originMapXyzM[2],
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
current.basisMapFromBody[0][0] * delta[0]
|
||||||
|
+ current.basisMapFromBody[1][0] * delta[1]
|
||||||
|
+ current.basisMapFromBody[2][0] * delta[2],
|
||||||
|
current.basisMapFromBody[0][1] * delta[0]
|
||||||
|
+ current.basisMapFromBody[1][1] * delta[1]
|
||||||
|
+ current.basisMapFromBody[2][1] * delta[2],
|
||||||
|
current.basisMapFromBody[0][2] * delta[0]
|
||||||
|
+ current.basisMapFromBody[1][2] * delta[1]
|
||||||
|
+ current.basisMapFromBody[2][2] * delta[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalRecordedLabPackedTgsCells(
|
||||||
|
costmap: CanonicalRecordedLabTgsCostmap,
|
||||||
|
anchor: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
current: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
): CanonicalRecordedLabPackedCellEvidence {
|
||||||
|
if (
|
||||||
|
costmap.centersXyM.length !== costmap.stateCodes.length
|
||||||
|
|| costmap.centersXyM.length !== costmap.zBoundsM.length
|
||||||
|
) throw new Error("Canonical recorded LAB TGS accounting changed");
|
||||||
|
const centers: number[] = [];
|
||||||
|
const zBounds: number[] = [];
|
||||||
|
costmap.centersXyM.forEach(([x, y], index) => {
|
||||||
|
const bounds = costmap.zBoundsM[index] ?? [null, null];
|
||||||
|
const center = canonicalMapGravityLocalPointToBodyGround([x, y, 0], anchor, current);
|
||||||
|
centers.push(center[0], center[1]);
|
||||||
|
if (bounds[0] === null || bounds[1] === null) {
|
||||||
|
zBounds.push(Number.NaN, Number.NaN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bottom = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[0]], anchor, current);
|
||||||
|
const top = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[1]], anchor, current);
|
||||||
|
zBounds.push(Math.min(bottom[2], top[2]), Math.max(bottom[2], top[2]));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
centersBodyXyM: Float32Array.from(centers),
|
||||||
|
zBoundsM: Float32Array.from(zBounds),
|
||||||
|
stateCodes: Uint8Array.from(costmap.stateCodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import type { LaboratoryFetch } from "./advancedResults";
|
||||||
|
import { CANONICAL_RECORDED_LAB_SPATIAL_PROFILE } from "./canonicalRecordedLab";
|
||||||
|
|
||||||
|
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
export class CanonicalRecordedLabSpatialContractError extends Error {}
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabSpatialFrame {
|
||||||
|
targetTimeNs: number;
|
||||||
|
sourceTimeNs: number;
|
||||||
|
poseTimeNs: number;
|
||||||
|
trajectoryTimeNs: number;
|
||||||
|
sourcePointCount: number;
|
||||||
|
coordinateFrame: "body-ground";
|
||||||
|
sensorHeight: {
|
||||||
|
meters: number;
|
||||||
|
source: "local-source-cloud-ground-quantile-median" | "session-source-cloud-fallback";
|
||||||
|
sampleCount: number;
|
||||||
|
madM: number;
|
||||||
|
authority: "visual-derived";
|
||||||
|
};
|
||||||
|
spatialProfile: {
|
||||||
|
profileId: typeof CANONICAL_RECORDED_LAB_SPATIAL_PROFILE;
|
||||||
|
localSlamHistorySeconds: number;
|
||||||
|
localSlamRadiusM: number;
|
||||||
|
localSlamVoxelSizeM: number;
|
||||||
|
localSlamPointLimit: number;
|
||||||
|
};
|
||||||
|
bodyFrame: {
|
||||||
|
originMapXyzM: readonly [number, number, number];
|
||||||
|
sensorOriginMapXyzM: readonly [number, number, number];
|
||||||
|
basisMapFromBody: readonly [
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
sourcePointsBodyXyzM: readonly (readonly [number, number, number])[];
|
||||||
|
localSlamSourceFrameCount: number;
|
||||||
|
localSlamSourcePointCount: number;
|
||||||
|
localSlamBodyXyzM: readonly (readonly [number, number, number])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayValue(value: unknown, label: string): unknown[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался массив.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(value: unknown, expected: unknown, label: string): void {
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: контракт изменён.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown, label: string): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось число.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerValue(value: unknown, label: string): number {
|
||||||
|
const parsed = numberValue(value, label);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось целое значение.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointList(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): readonly (readonly [number, number, number])[] {
|
||||||
|
return arrayValue(value, label).map((entry, index) => {
|
||||||
|
const point = arrayValue(entry, `${label}[${index}]`).map(
|
||||||
|
(channel, channelIndex) => numberValue(channel, `${label}[${index}][${channelIndex}]`),
|
||||||
|
);
|
||||||
|
if (point.length !== 3) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}[${index}]: размер изменён.`);
|
||||||
|
}
|
||||||
|
return [point[0]!, point[1]!, point[2]!] as const;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCanonicalRecordedLabSpatialFrame(
|
||||||
|
sessionId: string,
|
||||||
|
generationSha256: string,
|
||||||
|
targetTimeNs: number,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
|
): Promise<CanonicalRecordedLabSpatialFrame> {
|
||||||
|
if (
|
||||||
|
!SAFE_SESSION_ID.test(sessionId)
|
||||||
|
|| !SHA256.test(generationSha256)
|
||||||
|
|| !Number.isSafeInteger(targetTimeNs)
|
||||||
|
|| targetTimeNs < 0
|
||||||
|
) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"Canonical LAB spatial identity недопустима.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
generation: generationSha256,
|
||||||
|
time_ns: String(targetTimeNs),
|
||||||
|
profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||||
|
});
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`
|
||||||
|
+ `/canonical-lab/spatial-frame?${query.toString()}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
`Canonical LAB spatial frame недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
|
||||||
|
exact(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
|
"canonical_lab.spatial_frame.schema_version",
|
||||||
|
);
|
||||||
|
exact(payload.coordinate_frame, "body-ground", "canonical_lab.spatial_frame.coordinate_frame");
|
||||||
|
exact(payload.target_time_ns, targetTimeNs, "canonical_lab.spatial_frame.target_time_ns");
|
||||||
|
const sourcePoints = pointList(
|
||||||
|
payload.source_points_body_xyz_m,
|
||||||
|
"canonical_lab.spatial_frame.source_points",
|
||||||
|
);
|
||||||
|
const localSlam = pointList(
|
||||||
|
payload.local_slam_body_xyz_m,
|
||||||
|
"canonical_lab.spatial_frame.local_slam",
|
||||||
|
);
|
||||||
|
const sourcePointCount = integerValue(
|
||||||
|
payload.source_point_count,
|
||||||
|
"canonical_lab.spatial_frame.source_point_count",
|
||||||
|
);
|
||||||
|
const localSlamPointCount = integerValue(
|
||||||
|
payload.local_slam_point_count,
|
||||||
|
"canonical_lab.spatial_frame.local_slam_point_count",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
sourcePointCount !== sourcePoints.length
|
||||||
|
|| sourcePointCount > 100_000
|
||||||
|
|| localSlamPointCount !== localSlam.length
|
||||||
|
|| localSlam.length > 27_000
|
||||||
|
) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"Canonical LAB spatial accounting изменён.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bodyFrame = objectValue(payload.body_frame, "canonical_lab.spatial_frame.body_frame");
|
||||||
|
const origin = pointList(
|
||||||
|
[bodyFrame.origin_map_xyz_m],
|
||||||
|
"canonical_lab.spatial_frame.body_frame.origin",
|
||||||
|
)[0]!;
|
||||||
|
const sensorOrigin = pointList(
|
||||||
|
[bodyFrame.sensor_origin_map_xyz_m],
|
||||||
|
"canonical_lab.spatial_frame.body_frame.sensor_origin",
|
||||||
|
)[0]!;
|
||||||
|
const basisRows = pointList(
|
||||||
|
bodyFrame.basis_map_from_body,
|
||||||
|
"canonical_lab.spatial_frame.body_frame.basis",
|
||||||
|
);
|
||||||
|
if (basisRows.length !== 3) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"Canonical LAB spatial basis изменён.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sensorHeight = objectValue(payload.sensor_height, "canonical_lab.spatial_frame.sensor_height");
|
||||||
|
if (
|
||||||
|
sensorHeight.source !== "local-source-cloud-ground-quantile-median"
|
||||||
|
&& sensorHeight.source !== "session-source-cloud-fallback"
|
||||||
|
) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"canonical_lab.spatial_frame.sensor_height.source: контракт изменён.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exact(
|
||||||
|
sensorHeight.authority,
|
||||||
|
"visual-derived",
|
||||||
|
"canonical_lab.spatial_frame.sensor_height.authority",
|
||||||
|
);
|
||||||
|
const spatialProfile = objectValue(
|
||||||
|
payload.spatial_profile,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
spatialProfile.profile_id,
|
||||||
|
CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.profile_id",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
targetTimeNs,
|
||||||
|
sourceTimeNs: integerValue(payload.source_time_ns, "canonical_lab.spatial_frame.source_time_ns"),
|
||||||
|
poseTimeNs: integerValue(payload.pose_time_ns, "canonical_lab.spatial_frame.pose_time_ns"),
|
||||||
|
trajectoryTimeNs: integerValue(
|
||||||
|
payload.trajectory_time_ns,
|
||||||
|
"canonical_lab.spatial_frame.trajectory_time_ns",
|
||||||
|
),
|
||||||
|
sourcePointCount,
|
||||||
|
coordinateFrame: "body-ground",
|
||||||
|
sensorHeight: {
|
||||||
|
meters: numberValue(sensorHeight.meters, "canonical_lab.spatial_frame.sensor_height.meters"),
|
||||||
|
source: sensorHeight.source,
|
||||||
|
sampleCount: integerValue(
|
||||||
|
sensorHeight.sample_count,
|
||||||
|
"canonical_lab.spatial_frame.sensor_height.sample_count",
|
||||||
|
),
|
||||||
|
madM: numberValue(sensorHeight.mad_m, "canonical_lab.spatial_frame.sensor_height.mad_m"),
|
||||||
|
authority: "visual-derived",
|
||||||
|
},
|
||||||
|
spatialProfile: {
|
||||||
|
profileId: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||||
|
localSlamHistorySeconds: numberValue(
|
||||||
|
spatialProfile.local_slam_history_seconds,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.history",
|
||||||
|
),
|
||||||
|
localSlamRadiusM: numberValue(
|
||||||
|
spatialProfile.local_slam_radius_m,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.radius",
|
||||||
|
),
|
||||||
|
localSlamVoxelSizeM: numberValue(
|
||||||
|
spatialProfile.local_slam_voxel_size_m,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.voxel",
|
||||||
|
),
|
||||||
|
localSlamPointLimit: integerValue(
|
||||||
|
spatialProfile.local_slam_point_limit,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.limit",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
bodyFrame: {
|
||||||
|
originMapXyzM: origin,
|
||||||
|
sensorOriginMapXyzM: sensorOrigin,
|
||||||
|
basisMapFromBody: [basisRows[0]!, basisRows[1]!, basisRows[2]!],
|
||||||
|
},
|
||||||
|
sourcePointsBodyXyzM: sourcePoints,
|
||||||
|
localSlamSourceFrameCount: integerValue(
|
||||||
|
payload.local_slam_source_frame_count,
|
||||||
|
"canonical_lab.spatial_frame.local_slam_source_frames",
|
||||||
|
),
|
||||||
|
localSlamSourcePointCount: integerValue(
|
||||||
|
payload.local_slam_source_point_count,
|
||||||
|
"canonical_lab.spatial_frame.local_slam_source_points",
|
||||||
|
),
|
||||||
|
localSlamBodyXyzM: localSlam,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -154,6 +154,9 @@ export interface M4ThreatTimelineFrame {
|
|||||||
pointCloudSourceCount: number;
|
pointCloudSourceCount: number;
|
||||||
pointCloudSampleCount: number;
|
pointCloudSampleCount: number;
|
||||||
pointCloudLayer: "current-increment";
|
pointCloudLayer: "current-increment";
|
||||||
|
localSlamBodyXyzM?: readonly M4Point3[];
|
||||||
|
localSlamSourceFrameCount?: number;
|
||||||
|
localSlamSourcePointCount?: number;
|
||||||
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
|
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
|
||||||
cameraProjectedSourceCount: number;
|
cameraProjectedSourceCount: number;
|
||||||
cameraProjectedPointCount: number;
|
cameraProjectedPointCount: number;
|
||||||
@@ -168,10 +171,11 @@ export interface M4ThreatTimelineFrame {
|
|||||||
|
|
||||||
export interface M4ThreatTimeline {
|
export interface M4ThreatTimeline {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
recordedSourceSessionId: string;
|
||||||
|
recordedSourceId: string;
|
||||||
imageWidth: 800;
|
imageWidth: 800;
|
||||||
imageHeight: 600;
|
imageHeight: 600;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
frameTimesNs: readonly number[];
|
frameTimesNs: readonly number[];
|
||||||
timelineStartSeconds: number;
|
timelineStartSeconds: number;
|
||||||
timelineEndSeconds: number;
|
timelineEndSeconds: number;
|
||||||
@@ -234,7 +238,7 @@ export interface M4ThreatPlaybackProgress {
|
|||||||
|
|
||||||
export interface M4ThreatPlaybackPointPack {
|
export interface M4ThreatPlaybackPointPack {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
pointOffsets: Uint32Array;
|
pointOffsets: Uint32Array;
|
||||||
pointsMapXyzM: Float32Array;
|
pointsMapXyzM: Float32Array;
|
||||||
@@ -257,7 +261,7 @@ export interface M4ThreatPlaybackChunkDescriptor {
|
|||||||
|
|
||||||
export interface M4ThreatPlaybackManifest {
|
export interface M4ThreatPlaybackManifest {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
pointOffsets: Uint32Array;
|
pointOffsets: Uint32Array;
|
||||||
chunkFrameCount: 24;
|
chunkFrameCount: 24;
|
||||||
@@ -337,7 +341,7 @@ const motion = (value: unknown): M4ThreatMotion => {
|
|||||||
};
|
};
|
||||||
const resultId = (value: unknown): string => {
|
const resultId = (value: unknown): string => {
|
||||||
const parsed = text(value, "M4.6 result id");
|
const parsed = text(value, "M4.6 result id");
|
||||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
if (!/^[a-z0-9][a-z0-9-]{0,127}-[a-f0-9]{64}$/.test(parsed)) {
|
||||||
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
@@ -704,12 +708,8 @@ export async function fetchM4ThreatTimeline(
|
|||||||
exact(payload.result_id, result, "M4.6 timeline result");
|
exact(payload.result_id, result, "M4.6 timeline result");
|
||||||
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
||||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||||
exact(
|
const recordedSessionId = text(recorded.session_id, "M4.6 recorded session");
|
||||||
recorded.session_id,
|
const recordedSourceId = text(recorded.source_id, "M4.6 recorded source id");
|
||||||
"20260720T065719Z_viewer_live",
|
|
||||||
"M4.6 recorded session",
|
|
||||||
);
|
|
||||||
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
|
||||||
exact(
|
exact(
|
||||||
recorded.representation_id,
|
recorded.representation_id,
|
||||||
"registered-map-increment-v1",
|
"registered-map-increment-v1",
|
||||||
@@ -720,7 +720,10 @@ export async function fetchM4ThreatTimeline(
|
|||||||
"host-arrival-best-effort",
|
"host-arrival-best-effort",
|
||||||
"M4.6 recorded synchronization",
|
"M4.6 recorded synchronization",
|
||||||
);
|
);
|
||||||
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
|
const frameCount = integer(payload.frame_count, "M4.6 timeline frame count");
|
||||||
|
if (frameCount < 1) {
|
||||||
|
throw new M4ThreatContractError("M4.6 timeline frame count: пустой timeline.");
|
||||||
|
}
|
||||||
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
||||||
(value) => integer(value, "M4.6 timeline time"),
|
(value) => integer(value, "M4.6 timeline time"),
|
||||||
);
|
);
|
||||||
@@ -738,7 +741,8 @@ export async function fetchM4ThreatTimeline(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
resultId: result,
|
resultId: result,
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
recordedSourceSessionId: recordedSessionId,
|
||||||
|
recordedSourceId,
|
||||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||||
frameCount,
|
frameCount,
|
||||||
@@ -849,12 +853,14 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
cameraObstacleProjectionDelivery = null,
|
cameraObstacleProjectionDelivery = null,
|
||||||
playbackPointPack,
|
playbackPointPack,
|
||||||
|
includePoints = true,
|
||||||
}: {
|
}: {
|
||||||
fetcher?: LaboratoryFetch;
|
fetcher?: LaboratoryFetch;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
||||||
playbackPointPack?: M4ThreatPlaybackPointPack;
|
playbackPointPack?: M4ThreatPlaybackPointPack;
|
||||||
|
includePoints?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<M4ThreatTimelineChunk> {
|
): Promise<M4ThreatTimelineChunk> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -864,7 +870,7 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
if (cameraObstacleProjectionDelivery !== null) {
|
if (cameraObstacleProjectionDelivery !== null) {
|
||||||
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
||||||
}
|
}
|
||||||
if (playbackPointPack) params.set("include_points", "false");
|
if (playbackPointPack || !includePoints) params.set("include_points", "false");
|
||||||
const response = await fetcher(
|
const response = await fetcher(
|
||||||
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
||||||
{ headers: { Accept: "application/json" }, signal },
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
@@ -995,7 +1001,10 @@ export async function fetchM4ThreatPlaybackManifest(
|
|||||||
exact(manifest.result_id, result, "M4.6 playback result");
|
exact(manifest.result_id, result, "M4.6 playback result");
|
||||||
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
||||||
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
|
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
|
||||||
const frameCount = exact(integer(manifest.frame_count, "M4.6 playback frames"), 4489, "M4.6 playback frames");
|
const frameCount = integer(manifest.frame_count, "M4.6 playback frames");
|
||||||
|
if (frameCount < 1) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback frames: пустой playback недопустим.");
|
||||||
|
}
|
||||||
const pointCount = integer(manifest.point_count, "M4.6 playback points");
|
const pointCount = integer(manifest.point_count, "M4.6 playback points");
|
||||||
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
||||||
if (offsetsRaw.length !== frameCount + 1) {
|
if (offsetsRaw.length !== frameCount + 1) {
|
||||||
@@ -1332,6 +1341,17 @@ function parseTimelineFrame(
|
|||||||
"current-increment",
|
"current-increment",
|
||||||
"M4.6 timeline point layer",
|
"M4.6 timeline point layer",
|
||||||
),
|
),
|
||||||
|
localSlamBodyXyzM: item.local_slam_body_xyz_m === undefined
|
||||||
|
? []
|
||||||
|
: array(item.local_slam_body_xyz_m, "M4.6 local SLAM points").map(
|
||||||
|
(point) => vector(point, 3, "M4.6 local SLAM point") as [number, number, number],
|
||||||
|
),
|
||||||
|
localSlamSourceFrameCount: item.local_slam_source_frame_count === undefined
|
||||||
|
? undefined
|
||||||
|
: integer(item.local_slam_source_frame_count, "M4.6 local SLAM source frames"),
|
||||||
|
localSlamSourcePointCount: item.local_slam_source_point_count === undefined
|
||||||
|
? undefined
|
||||||
|
: integer(item.local_slam_source_point_count, "M4.6 local SLAM source points"),
|
||||||
cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined
|
cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined
|
||||||
? []
|
? []
|
||||||
: array(item.camera_projected_points_xyd, "M4.6 camera points").map(
|
: array(item.camera_projected_points_xyd, "M4.6 camera points").map(
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||||
|
type LaboratoryRecordedEvidenceViewerProfile,
|
||||||
|
} from "../observation/viewerProfile";
|
||||||
|
|
||||||
|
export type LaboratoryRecordedMediaMode = "video" | "camera" | null;
|
||||||
|
export type LaboratoryRecordedSpatialMode = "3d" | "plan" | null;
|
||||||
|
export type LaboratoryClassifiedSpatialMode = "none" | "overlay" | "replace-source";
|
||||||
|
|
||||||
|
export interface LaboratoryRecordedEvidenceVisibility {
|
||||||
|
mediaMode: LaboratoryRecordedMediaMode;
|
||||||
|
spatialMode: LaboratoryRecordedSpatialMode;
|
||||||
|
showMediaSemantic: boolean;
|
||||||
|
showSpatialSemantic: boolean;
|
||||||
|
showMediaPoints: boolean;
|
||||||
|
classifiedSpatialMode: LaboratoryClassifiedSpatialMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaboratoryRecordedEvidenceDemand {
|
||||||
|
sourceTimelineMetadata: true;
|
||||||
|
recordedVideo: boolean;
|
||||||
|
exactCameraFrame: boolean;
|
||||||
|
sourceSpatialPoints: boolean;
|
||||||
|
cameraPointOverlay: boolean;
|
||||||
|
selectedSemanticMask: boolean;
|
||||||
|
selectedSemanticPoints: boolean;
|
||||||
|
classifiedSpatial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate the already-visible M4 evidence composition into explicit data
|
||||||
|
* demand. The profile never starts a hidden point, semantic or camera channel
|
||||||
|
* merely because the selected LAB happens to publish that artifact.
|
||||||
|
*/
|
||||||
|
export function laboratoryRecordedEvidenceDemand(
|
||||||
|
visibility: LaboratoryRecordedEvidenceVisibility,
|
||||||
|
profile: LaboratoryRecordedEvidenceViewerProfile =
|
||||||
|
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||||
|
): LaboratoryRecordedEvidenceDemand {
|
||||||
|
if (profile.loadPolicy !== "explicit-legacy-comparison-only") {
|
||||||
|
throw new Error("Unsupported LAB recorded evidence load policy");
|
||||||
|
}
|
||||||
|
const mediaVisible = visibility.mediaMode !== null;
|
||||||
|
const spatialVisible = visibility.spatialMode !== null;
|
||||||
|
return {
|
||||||
|
sourceTimelineMetadata: true,
|
||||||
|
recordedVideo: visibility.mediaMode === "video",
|
||||||
|
exactCameraFrame: visibility.mediaMode === "camera",
|
||||||
|
sourceSpatialPoints:
|
||||||
|
spatialVisible && visibility.classifiedSpatialMode !== "replace-source",
|
||||||
|
cameraPointOverlay: mediaVisible && visibility.showMediaPoints,
|
||||||
|
selectedSemanticMask: mediaVisible && visibility.showMediaSemantic,
|
||||||
|
selectedSemanticPoints: spatialVisible && visibility.showSpatialSemantic,
|
||||||
|
classifiedSpatial:
|
||||||
|
spatialVisible && visibility.classifiedSpatialMode !== "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -106,6 +106,56 @@ export interface VegetationMixedRouteReview {
|
|||||||
cases: readonly VegetationMixedRouteCase[];
|
cases: readonly VegetationMixedRouteCase[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VegetationRouteTgsAnchor {
|
||||||
|
sourceSequence: number;
|
||||||
|
slot: number;
|
||||||
|
currentPointsXyzM: readonly (readonly [number, number, number])[];
|
||||||
|
costmap: {
|
||||||
|
cellSizeM: 0.45;
|
||||||
|
centersXyM: readonly (readonly [number, number])[];
|
||||||
|
stateCodes: readonly number[];
|
||||||
|
zBoundsM: readonly (readonly [number | null, number | null])[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VegetationFullRouteLayer {
|
||||||
|
name: string;
|
||||||
|
resultId: string;
|
||||||
|
frameCount: 6830;
|
||||||
|
taxonomy: readonly VegetationVideoSemanticClass[];
|
||||||
|
inferenceFps: number;
|
||||||
|
latencyP95Ms: number;
|
||||||
|
peakReservedVramBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VegetationFullRouteReview {
|
||||||
|
sourceId: "RAVNOVES004TREE";
|
||||||
|
sessionId: "20260828T130511Z_viewer_live";
|
||||||
|
linkedRouteReviewResultId: string;
|
||||||
|
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0";
|
||||||
|
sourceJobInputSha256: string;
|
||||||
|
sourceStreamSha256: string;
|
||||||
|
recordedMediaSourceId: "recorded.camera.6a3945242828a038";
|
||||||
|
recordedMediaGenerationSha256: string;
|
||||||
|
frameCount: 6830;
|
||||||
|
width: 800;
|
||||||
|
height: 600;
|
||||||
|
timelineStartSeconds: number;
|
||||||
|
timelineEndSeconds: number;
|
||||||
|
timelineArtifact: {
|
||||||
|
sha256: string;
|
||||||
|
byteLength: number;
|
||||||
|
};
|
||||||
|
frameSourceTimesNs: readonly number[];
|
||||||
|
decodeRepair: {
|
||||||
|
repairedFrameCount: 1;
|
||||||
|
sequence: 6092;
|
||||||
|
method: "duplicate-previous-decoded-frame";
|
||||||
|
};
|
||||||
|
city: VegetationFullRouteLayer;
|
||||||
|
vegetation: VegetationFullRouteLayer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VegetationShadowResult {
|
export interface VegetationShadowResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string;
|
createdAtUtc: string;
|
||||||
@@ -116,6 +166,7 @@ export interface VegetationShadowResult {
|
|||||||
validationCases: readonly VegetationVisualCase[];
|
validationCases: readonly VegetationVisualCase[];
|
||||||
routeVideo: VegetationRouteVideo | null;
|
routeVideo: VegetationRouteVideo | null;
|
||||||
routeReview: VegetationMixedRouteReview | null;
|
routeReview: VegetationMixedRouteReview | null;
|
||||||
|
routeFullReview: VegetationFullRouteReview | null;
|
||||||
limitations: readonly string[];
|
limitations: readonly string[];
|
||||||
visualShadowReady: true;
|
visualShadowReady: true;
|
||||||
missionPolicyReadyForConfiguration: true;
|
missionPolicyReadyForConfiguration: true;
|
||||||
@@ -545,6 +596,265 @@ function mixedRouteReviewValue(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fullRouteTaxonomyValue(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
schema: string,
|
||||||
|
classCount: number,
|
||||||
|
): readonly VegetationVideoSemanticClass[] {
|
||||||
|
const taxonomy = objectValue(value, `${label}.taxonomy`);
|
||||||
|
exact(taxonomy.schema_version, schema, `${label}.taxonomy.schema`);
|
||||||
|
const classes = arrayValue(taxonomy.classes, `${label}.taxonomy.classes`).map(
|
||||||
|
(raw, expectedId): VegetationVideoSemanticClass => {
|
||||||
|
const item = objectValue(raw, `${label}.taxonomy[${expectedId}]`);
|
||||||
|
const classId = integerValue(item.class_id, `${label}.class_id[${expectedId}]`);
|
||||||
|
if (classId !== expectedId) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy order changed.`);
|
||||||
|
}
|
||||||
|
const color = arrayValue(item.color_rgb, `${label}.color[${expectedId}]`)
|
||||||
|
.map((channel, index) => integerValue(channel, `${label}.color[${expectedId}][${index}]`));
|
||||||
|
if (color.length !== 3 || color.some((channel) => channel > 255)) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy color invalid.`);
|
||||||
|
}
|
||||||
|
const disposition = item.disposition;
|
||||||
|
if (
|
||||||
|
disposition !== "labeled"
|
||||||
|
&& disposition !== "ambiguous"
|
||||||
|
&& disposition !== "prediction"
|
||||||
|
&& disposition !== "undefined"
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy disposition changed.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
label: textValue(item.label, `${label}.label[${expectedId}]`),
|
||||||
|
colorRgb: color as unknown as readonly [number, number, number],
|
||||||
|
disposition,
|
||||||
|
materialClass: item.material_class === null || item.material_class === undefined
|
||||||
|
? null
|
||||||
|
: textValue(item.material_class, `${label}.material[${expectedId}]`),
|
||||||
|
evidenceState: item.evidence_state === null || item.evidence_state === undefined
|
||||||
|
? null
|
||||||
|
: textValue(item.evidence_state, `${label}.evidence[${expectedId}]`),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (classes.length !== classCount) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy size changed.`);
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullRouteLayerValue(
|
||||||
|
value: unknown,
|
||||||
|
layer: "city" | "vegetation",
|
||||||
|
): VegetationFullRouteLayer {
|
||||||
|
const label = `vegetation.route_full_review.layers.${layer}`;
|
||||||
|
const row = objectValue(value, label);
|
||||||
|
const resultId = textValue(row.result_id, `${label}.result_id`);
|
||||||
|
const identity = layer === "city"
|
||||||
|
? /^result-[a-f0-9]{64}$/
|
||||||
|
: /^lab-v1-ravnoves-video-ddrnet-[a-f0-9]{64}$/;
|
||||||
|
if (!identity.test(resultId)) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: identity invalid.`);
|
||||||
|
}
|
||||||
|
exact(row.frame_count, 6830, `${label}.frame_count`);
|
||||||
|
const archive = objectValue(row.mask_archive, `${label}.mask_archive`);
|
||||||
|
exact(
|
||||||
|
archive.path,
|
||||||
|
layer === "city" ? "video/eomt-semantic-masks.zip" : "video/ddrnet-semantic-masks.zip",
|
||||||
|
`${label}.mask_archive.path`,
|
||||||
|
);
|
||||||
|
const digest = textValue(archive.sha256, `${label}.mask_archive.sha256`);
|
||||||
|
if (!SHA256.test(digest)) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: archive digest invalid.`);
|
||||||
|
}
|
||||||
|
integerValue(archive.byte_length, `${label}.mask_archive.byte_length`);
|
||||||
|
return {
|
||||||
|
name: textValue(row.name, `${label}.name`),
|
||||||
|
resultId,
|
||||||
|
frameCount: 6830,
|
||||||
|
taxonomy: fullRouteTaxonomyValue(
|
||||||
|
row.taxonomy,
|
||||||
|
label,
|
||||||
|
layer === "city"
|
||||||
|
? "missioncore.recorded-eomt-taxonomy/v1"
|
||||||
|
: "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||||
|
layer === "city" ? 16 : 64,
|
||||||
|
),
|
||||||
|
inferenceFps: numberValue(row.inference_fps, `${label}.inference_fps`),
|
||||||
|
latencyP95Ms: numberValue(row.latency_p95_ms, `${label}.latency_p95_ms`),
|
||||||
|
peakReservedVramBytes: integerValue(
|
||||||
|
row.peak_reserved_vram_bytes,
|
||||||
|
`${label}.peak_reserved_vram_bytes`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const row = objectValue(value, "vegetation.route_full_review");
|
||||||
|
exact(row.source_id, "RAVNOVES004TREE", "vegetation.route_full_review.source_id");
|
||||||
|
exact(
|
||||||
|
row.session_id,
|
||||||
|
"20260828T130511Z_viewer_live",
|
||||||
|
"vegetation.route_full_review.session_id",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
row.source_job_id,
|
||||||
|
"recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
"vegetation.route_full_review.source_job_id",
|
||||||
|
);
|
||||||
|
const linkedRouteReviewResultId = textValue(
|
||||||
|
row.linked_route_review_result_id,
|
||||||
|
"vegetation.route_full_review.linked_route_review_result_id",
|
||||||
|
);
|
||||||
|
if (!RESULT_ID.test(linkedRouteReviewResultId)) {
|
||||||
|
throw new VegetationShadowContractError(
|
||||||
|
"vegetation.route_full_review: linked route review identity invalid.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exact(row.frame_count, 6830, "vegetation.route_full_review.frame_count");
|
||||||
|
exact(row.width, 800, "vegetation.route_full_review.width");
|
||||||
|
exact(row.height, 600, "vegetation.route_full_review.height");
|
||||||
|
exact(row.ground_truth, false, "vegetation.route_full_review.ground_truth");
|
||||||
|
const sourceJobInputSha256 = textValue(
|
||||||
|
row.source_job_input_sha256,
|
||||||
|
"vegetation.route_full_review.source_job_input_sha256",
|
||||||
|
);
|
||||||
|
const sourceStreamSha256 = textValue(
|
||||||
|
row.source_stream_sha256,
|
||||||
|
"vegetation.route_full_review.source_stream_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
sourceJobInputSha256,
|
||||||
|
"eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
|
||||||
|
"vegetation.route_full_review.source_job_input_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
sourceStreamSha256,
|
||||||
|
"e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
"vegetation.route_full_review.source_stream_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
row.recorded_media_source_id,
|
||||||
|
"recorded.camera.6a3945242828a038",
|
||||||
|
"vegetation.route_full_review.recorded_media_source_id",
|
||||||
|
);
|
||||||
|
const recordedMediaGenerationSha256 = textValue(
|
||||||
|
row.recorded_media_generation_sha256,
|
||||||
|
"vegetation.route_full_review.recorded_media_generation_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
recordedMediaGenerationSha256,
|
||||||
|
"b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded",
|
||||||
|
"vegetation.route_full_review.recorded_media_generation_sha256",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!SHA256.test(sourceJobInputSha256)
|
||||||
|
|| !SHA256.test(sourceStreamSha256)
|
||||||
|
|| !SHA256.test(recordedMediaGenerationSha256)
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: source digest invalid.");
|
||||||
|
}
|
||||||
|
const timelineStartSeconds = numberValue(
|
||||||
|
row.timeline_start_seconds,
|
||||||
|
"vegetation.route_full_review.timeline_start_seconds",
|
||||||
|
);
|
||||||
|
const timelineEndSeconds = numberValue(
|
||||||
|
row.timeline_end_seconds,
|
||||||
|
"vegetation.route_full_review.timeline_end_seconds",
|
||||||
|
);
|
||||||
|
if (timelineEndSeconds <= timelineStartSeconds) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: timeline invalid.");
|
||||||
|
}
|
||||||
|
const timeline = objectValue(row.timeline, "vegetation.route_full_review.timeline");
|
||||||
|
exact(
|
||||||
|
timeline.path,
|
||||||
|
"video/frame-source-times-ns.bin",
|
||||||
|
"vegetation.route_full_review.timeline.path",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
timeline.encoding,
|
||||||
|
"uint64-le-nanoseconds",
|
||||||
|
"vegetation.route_full_review.timeline.encoding",
|
||||||
|
);
|
||||||
|
exact(timeline.frame_count, 6830, "vegetation.route_full_review.timeline.frame_count");
|
||||||
|
const timelineSha256 = textValue(
|
||||||
|
timeline.sha256,
|
||||||
|
"vegetation.route_full_review.timeline.sha256",
|
||||||
|
);
|
||||||
|
if (!SHA256.test(timelineSha256)) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: timeline digest invalid.");
|
||||||
|
}
|
||||||
|
const timelineByteLength = integerValue(
|
||||||
|
timeline.byte_length,
|
||||||
|
"vegetation.route_full_review.timeline.byte_length",
|
||||||
|
);
|
||||||
|
exact(timelineByteLength, 6830 * 8, "vegetation.route_full_review.timeline.byte_length");
|
||||||
|
const decodeRepair = objectValue(
|
||||||
|
row.decode_repair,
|
||||||
|
"vegetation.route_full_review.decode_repair",
|
||||||
|
);
|
||||||
|
exact(decodeRepair.repaired_frame_count, 1, "vegetation.route_full_review.decode_repair.count");
|
||||||
|
exact(decodeRepair.sequence, 6092, "vegetation.route_full_review.decode_repair.sequence");
|
||||||
|
exact(
|
||||||
|
decodeRepair.method,
|
||||||
|
"duplicate-previous-decoded-frame",
|
||||||
|
"vegetation.route_full_review.decode_repair.method",
|
||||||
|
);
|
||||||
|
const repairProofs = objectValue(
|
||||||
|
decodeRepair.proofs,
|
||||||
|
"vegetation.route_full_review.decode_repair.proofs",
|
||||||
|
);
|
||||||
|
for (const [key, expectedPath] of Object.entries({
|
||||||
|
eomt: "proofs/decode_repair.json",
|
||||||
|
ddrnet: "proofs/ddrnet_decode_repair.json",
|
||||||
|
})) {
|
||||||
|
const proof = objectValue(
|
||||||
|
repairProofs[key],
|
||||||
|
`vegetation.route_full_review.decode_repair.proofs.${key}`,
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
proof.path,
|
||||||
|
expectedPath,
|
||||||
|
`vegetation.route_full_review.decode_repair.proofs.${key}.path`,
|
||||||
|
);
|
||||||
|
const digest = textValue(
|
||||||
|
proof.sha256,
|
||||||
|
`vegetation.route_full_review.decode_repair.proofs.${key}.sha256`,
|
||||||
|
);
|
||||||
|
if (!SHA256.test(digest)) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: repair proof invalid.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const layers = objectValue(row.layers, "vegetation.route_full_review.layers");
|
||||||
|
return {
|
||||||
|
sourceId: "RAVNOVES004TREE",
|
||||||
|
sessionId: "20260828T130511Z_viewer_live",
|
||||||
|
linkedRouteReviewResultId,
|
||||||
|
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
sourceJobInputSha256,
|
||||||
|
sourceStreamSha256,
|
||||||
|
recordedMediaSourceId: "recorded.camera.6a3945242828a038",
|
||||||
|
recordedMediaGenerationSha256,
|
||||||
|
frameCount: 6830,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
timelineStartSeconds,
|
||||||
|
timelineEndSeconds,
|
||||||
|
timelineArtifact: { sha256: timelineSha256, byteLength: timelineByteLength },
|
||||||
|
frameSourceTimesNs: [],
|
||||||
|
decodeRepair: {
|
||||||
|
repairedFrameCount: 1,
|
||||||
|
sequence: 6092,
|
||||||
|
method: "duplicate-previous-decoded-frame",
|
||||||
|
},
|
||||||
|
city: fullRouteLayerValue(layers.city, "city"),
|
||||||
|
vegetation: fullRouteLayerValue(layers.vegetation, "vegetation"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseResult(
|
function parseResult(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
resultId: string,
|
resultId: string,
|
||||||
@@ -589,9 +899,10 @@ function parseResult(
|
|||||||
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
||||||
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
|
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
|
||||||
const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot);
|
const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot);
|
||||||
|
const routeFullReview = fullRouteReviewValue(payload.route_full_review);
|
||||||
if (
|
if (
|
||||||
routeCases.length !== 0
|
routeCases.length !== 0
|
||||||
|| (routeReview ? validationCases.length !== 0 : validationCases.length !== 12)
|
|| (routeReview || routeFullReview ? validationCases.length !== 0 : validationCases.length !== 12)
|
||||||
) {
|
) {
|
||||||
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
|
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
|
||||||
}
|
}
|
||||||
@@ -605,6 +916,7 @@ function parseResult(
|
|||||||
validationCases,
|
validationCases,
|
||||||
routeVideo: routeVideoValue(payload.route_video),
|
routeVideo: routeVideoValue(payload.route_video),
|
||||||
routeReview,
|
routeReview,
|
||||||
|
routeFullReview,
|
||||||
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
||||||
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
||||||
visualShadowReady: true,
|
visualShadowReady: true,
|
||||||
@@ -625,7 +937,96 @@ export function vegetationVideoMaskUrl(resultId: string, sequence: number): stri
|
|||||||
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`;
|
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVegetationShadowResult(
|
export function vegetationFullRouteMaskUrl(
|
||||||
|
resultId: string,
|
||||||
|
layer: "city" | "vegetation",
|
||||||
|
sequence: number,
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
!RESULT_ID.test(resultId)
|
||||||
|
|| (layer !== "city" && layer !== "vegetation")
|
||||||
|
|| !Number.isInteger(sequence)
|
||||||
|
|| sequence < 0
|
||||||
|
|| sequence >= 6830
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation full-route mask identity недопустима.");
|
||||||
|
}
|
||||||
|
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVegetationRouteTgsAnchor(
|
||||||
|
resultId: string,
|
||||||
|
sourceSequence: number,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
|
): Promise<VegetationRouteTgsAnchor> {
|
||||||
|
if (!RESULT_ID.test(resultId) || !Number.isInteger(sourceSequence) || sourceSequence < 1) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation TGS anchor identity недопустима.");
|
||||||
|
}
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
|
||||||
|
+ `/route-tgs-anchor/${sourceSequence}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new VegetationShadowContractError(`Vegetation TGS anchor недоступен: HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "vegetation.route_tgs_anchor");
|
||||||
|
exact(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.lab-v1-route-tgs-anchor/v1",
|
||||||
|
"vegetation.route_tgs_anchor.schema_version",
|
||||||
|
);
|
||||||
|
exact(payload.source_sequence, sourceSequence, "vegetation.route_tgs_anchor.source_sequence");
|
||||||
|
const pointValue = (value: unknown, label: string): readonly number[] => {
|
||||||
|
const point = arrayValue(value, label).map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||||
|
if (point.length !== 2 && point.length !== 3) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: размер изменён.`);
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
};
|
||||||
|
const points = arrayValue(payload.current_points_xyz_m, "vegetation.route_tgs_anchor.points")
|
||||||
|
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.points[${index}]`));
|
||||||
|
const costmap = objectValue(payload.costmap, "vegetation.route_tgs_anchor.costmap");
|
||||||
|
exact(costmap.cell_size_m, 0.45, "vegetation.route_tgs_anchor.costmap.cell_size_m");
|
||||||
|
const centers = arrayValue(costmap.centers_xy_m, "vegetation.route_tgs_anchor.costmap.centers")
|
||||||
|
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.costmap.centers[${index}]`));
|
||||||
|
const stateCodes = arrayValue(costmap.state_codes, "vegetation.route_tgs_anchor.costmap.states")
|
||||||
|
.map((value, index) => integerValue(value, `vegetation.route_tgs_anchor.costmap.states[${index}]`));
|
||||||
|
const zBounds = arrayValue(costmap.z_bounds_m, "vegetation.route_tgs_anchor.costmap.z_bounds")
|
||||||
|
.map((value, index) => {
|
||||||
|
const row = arrayValue(value, `vegetation.route_tgs_anchor.costmap.z_bounds[${index}]`);
|
||||||
|
if (row.length !== 2 || row.some((item) => item !== null && (typeof item !== "number" || !Number.isFinite(item)))) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_tgs_anchor.costmap.z_bounds: контракт изменён.");
|
||||||
|
}
|
||||||
|
return row as readonly [number | null, number | null];
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
centers.length !== 2244
|
||||||
|
|| stateCodes.length !== 2244
|
||||||
|
|| zBounds.length !== 2244
|
||||||
|
|| stateCodes.some((value) => value > 3)
|
||||||
|
|| points.some((point) => point.length !== 3)
|
||||||
|
|| centers.some((point) => point.length !== 2)
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation TGS anchor shape изменён.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceSequence,
|
||||||
|
slot: integerValue(payload.slot, "vegetation.route_tgs_anchor.slot"),
|
||||||
|
currentPointsXyzM: points.map((point) => [point[0]!, point[1]!, point[2]!] as const),
|
||||||
|
costmap: {
|
||||||
|
cellSizeM: 0.45,
|
||||||
|
centersXyM: centers.map((point) => [point[0]!, point[1]!] as const),
|
||||||
|
stateCodes,
|
||||||
|
zBoundsM: zBounds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVegetationShadowResultMetadata(
|
||||||
resultId: string,
|
resultId: string,
|
||||||
{
|
{
|
||||||
fetcher = fetch,
|
fetcher = fetch,
|
||||||
@@ -642,11 +1043,60 @@ export async function fetchVegetationShadowResult(
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
|
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
|
||||||
}
|
}
|
||||||
return parseResult(
|
const result = parseResult(
|
||||||
await response.json(),
|
await response.json(),
|
||||||
resultId,
|
resultId,
|
||||||
"/api/v1/laboratory/vegetation-shadow",
|
"/api/v1/laboratory/vegetation-shadow",
|
||||||
);
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVegetationShadowResult(
|
||||||
|
resultId: string,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
|
): Promise<VegetationShadowResult> {
|
||||||
|
const result = await fetchVegetationShadowResultMetadata(resultId, { fetcher, signal });
|
||||||
|
if (!result.routeFullReview) return result;
|
||||||
|
const timelineResponse = await fetcher(
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/octet-stream" }, signal },
|
||||||
|
);
|
||||||
|
if (!timelineResponse.ok) {
|
||||||
|
throw new VegetationShadowContractError(
|
||||||
|
`Vegetation LAB timeline недоступна: HTTP ${timelineResponse.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
timelineResponse.headers.get("etag")
|
||||||
|
!== `"${result.routeFullReview.timelineArtifact.sha256}"`
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline digest изменён.");
|
||||||
|
}
|
||||||
|
const timelinePayload = await timelineResponse.arrayBuffer();
|
||||||
|
if (timelinePayload.byteLength !== result.routeFullReview.timelineArtifact.byteLength) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline size изменён.");
|
||||||
|
}
|
||||||
|
const timelineView = new DataView(timelinePayload);
|
||||||
|
const frameSourceTimesNs = Array.from({ length: result.routeFullReview.frameCount }, (_, index) => {
|
||||||
|
const value = Number(timelineView.getBigUint64(index * 8, true));
|
||||||
|
if (!Number.isSafeInteger(value)) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline содержит unsafe time.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
frameSourceTimesNs[0] !== Math.round(result.routeFullReview.timelineStartSeconds * 1_000_000_000)
|
||||||
|
|| frameSourceTimesNs.some((time, index) => index > 0 && time <= frameSourceTimesNs[index - 1]!)
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline нарушена.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
routeFullReview: { ...result.routeFullReview, frameSourceTimesNs },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVegetationBenchmarkResult(
|
export async function fetchVegetationBenchmarkResult(
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
export const OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA =
|
||||||
|
"missioncore.observatory-calculation-profile/v1" as const;
|
||||||
|
|
||||||
|
export type ObservationLabCalculationProfileOrigin =
|
||||||
|
| "archived-definition"
|
||||||
|
| "existing-result";
|
||||||
|
|
||||||
|
export interface ObservationLabCalculationProfile {
|
||||||
|
readonly schemaVersion: typeof OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA;
|
||||||
|
readonly setupId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly origin: ObservationLabCalculationProfileOrigin;
|
||||||
|
readonly definitionId: string | null;
|
||||||
|
readonly definitionVersion: number | null;
|
||||||
|
readonly definitionSha256: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObservationLabCalculationProfileContractError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservationLabCalculationProfileContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE_KEYS = new Set([
|
||||||
|
"schema_version",
|
||||||
|
"setup_id",
|
||||||
|
"display_name",
|
||||||
|
"origin",
|
||||||
|
"definition_id",
|
||||||
|
"definition_version",
|
||||||
|
"definition_sha256",
|
||||||
|
]);
|
||||||
|
const IDENTIFIER = /^[a-z][a-z0-9-]{2,95}$/;
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(value: unknown, field: string, maximum: number): string {
|
||||||
|
if (
|
||||||
|
typeof value !== "string"
|
||||||
|
|| value.length === 0
|
||||||
|
|| value.length > maximum
|
||||||
|
|| value !== value.trim()
|
||||||
|
) {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Поле calculation_profile.${field} должно быть непустой строкой.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireIdentifier(value: unknown, field: string): string {
|
||||||
|
const identifier = requireText(value, field, 96);
|
||||||
|
if (!IDENTIFIER.test(identifier)) {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Поле calculation_profile.${field} содержит некорректный идентификатор.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeObservationLabCalculationProfile(
|
||||||
|
value: unknown,
|
||||||
|
sessionId: string,
|
||||||
|
): ObservationLabCalculationProfile | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (!isRecord(value) || Object.keys(value).some((key) => !PROFILE_KEYS.has(key))) {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Профиль расчёта LAB-сессии ${sessionId} нарушил контракт полей.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.keys(value).length !== PROFILE_KEYS.size
|
||||||
|
|| value.schema_version !== OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA
|
||||||
|
) {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестную версию.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = value.origin;
|
||||||
|
if (origin !== "archived-definition" && origin !== "existing-result") {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестное происхождение.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const definitionId = value.definition_id === null
|
||||||
|
? null
|
||||||
|
: requireIdentifier(value.definition_id, "definition_id");
|
||||||
|
const definitionVersion = value.definition_version === null
|
||||||
|
? null
|
||||||
|
: value.definition_version;
|
||||||
|
if (
|
||||||
|
definitionVersion !== null
|
||||||
|
&& (
|
||||||
|
typeof definitionVersion !== "number"
|
||||||
|
|| !Number.isInteger(definitionVersion)
|
||||||
|
|| definitionVersion < 1
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Профиль расчёта LAB-сессии ${sessionId} содержит некорректную версию определения.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const definitionSha256 = value.definition_sha256 === null
|
||||||
|
? null
|
||||||
|
: requireText(value.definition_sha256, "definition_sha256", 64);
|
||||||
|
if (definitionSha256 !== null && !SHA256.test(definitionSha256)) {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Профиль расчёта LAB-сессии ${sessionId} не содержит SHA-256 определения.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const definitionFieldCount = [
|
||||||
|
definitionId,
|
||||||
|
definitionVersion,
|
||||||
|
definitionSha256,
|
||||||
|
].filter((entry) => entry !== null).length;
|
||||||
|
if (
|
||||||
|
(origin === "existing-result" && definitionFieldCount !== 0)
|
||||||
|
|| (origin === "archived-definition" && definitionFieldCount !== 3)
|
||||||
|
) {
|
||||||
|
throw new ObservationLabCalculationProfileContractError(
|
||||||
|
`Профиль расчёта LAB-сессии ${sessionId} содержит неполную идентичность определения.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA,
|
||||||
|
setupId: requireIdentifier(value.setup_id, "setup_id"),
|
||||||
|
displayName: requireText(value.display_name, "display_name", 256),
|
||||||
|
origin,
|
||||||
|
definitionId,
|
||||||
|
definitionVersion,
|
||||||
|
definitionSha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
export interface ObservationCanonicalLabReplayCapability {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v1";
|
||||||
|
kind: "canonical-recorded-rerun";
|
||||||
|
viewerProfile: "recorded-session";
|
||||||
|
timeline: "session_time";
|
||||||
|
activation: "explicit";
|
||||||
|
commandsEnabled: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationPortableLabReplayCapability {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v2";
|
||||||
|
kind: "portable-result-review";
|
||||||
|
viewerProfile: "portable-result";
|
||||||
|
timeline: "result-defined";
|
||||||
|
activation: "explicit";
|
||||||
|
commandsEnabled: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservationLabReplayCapability =
|
||||||
|
| ObservationCanonicalLabReplayCapability
|
||||||
|
| ObservationPortableLabReplayCapability;
|
||||||
|
|
||||||
|
export class ObservationLabReplayCapabilityContractError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservationLabReplayCapabilityContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
const CAPABILITY_KEYS = new Set([
|
||||||
|
"schema_version",
|
||||||
|
"kind",
|
||||||
|
"viewer_profile",
|
||||||
|
"timeline",
|
||||||
|
"activation",
|
||||||
|
"commands_enabled",
|
||||||
|
]);
|
||||||
|
const CANONICAL_PROVENANCE_KEYS = new Set([
|
||||||
|
"schema_version",
|
||||||
|
"evidence_identity_sha256",
|
||||||
|
"result_document_sha256",
|
||||||
|
"replay_capability",
|
||||||
|
"authority",
|
||||||
|
"method",
|
||||||
|
]);
|
||||||
|
const PORTABLE_PROVENANCE_KEYS = new Set([
|
||||||
|
"schema_version",
|
||||||
|
"authority",
|
||||||
|
"calculation_profile",
|
||||||
|
"calculation_profile_sha256",
|
||||||
|
"job",
|
||||||
|
"source",
|
||||||
|
"run_definition",
|
||||||
|
"result_package",
|
||||||
|
"replay_capability",
|
||||||
|
"storage",
|
||||||
|
"method",
|
||||||
|
]);
|
||||||
|
const AUTHORITY_KEYS = new Set([
|
||||||
|
"commands_enabled",
|
||||||
|
"navigation_or_safety_accepted",
|
||||||
|
"actuation_accepted",
|
||||||
|
]);
|
||||||
|
const METHOD_KEYS = new Set([
|
||||||
|
"schema_version",
|
||||||
|
"completeness",
|
||||||
|
"execution_class",
|
||||||
|
"pipeline_id",
|
||||||
|
"components",
|
||||||
|
]);
|
||||||
|
const METHOD_COMPONENT_KEYS = new Set([
|
||||||
|
"kind",
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"role",
|
||||||
|
"identity_sha256",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
expected: ReadonlySet<string>,
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`${label} содержит неизвестные или пропущенные поля.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeLabReplayCapability(
|
||||||
|
value: unknown,
|
||||||
|
sessionId: string,
|
||||||
|
): ObservationLabReplayCapability | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Replay-возможность LAB-сессии ${sessionId} должна быть объектом или null.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`);
|
||||||
|
if (
|
||||||
|
value.activation !== "explicit"
|
||||||
|
|| value.commands_enabled !== false
|
||||||
|
) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.schema_version === "missioncore.observation-lab-replay-capability/v1"
|
||||||
|
&& value.kind === "canonical-recorded-rerun"
|
||||||
|
&& value.viewer_profile === "recorded-session"
|
||||||
|
&& value.timeline === "session_time"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commandsEnabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.schema_version === "missioncore.observation-lab-replay-capability/v2"
|
||||||
|
&& value.kind === "portable-result-review"
|
||||||
|
&& value.viewer_profile === "portable-result"
|
||||||
|
&& value.timeline === "result-defined"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
|
||||||
|
kind: "portable-result-review",
|
||||||
|
viewerProfile: "portable-result",
|
||||||
|
timeline: "result-defined",
|
||||||
|
activation: "explicit",
|
||||||
|
commandsEnabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Replay-возможность LAB-сессии ${sessionId} содержит неизвестный viewer capability.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeCanonicalLabReplayCapabilityProvenance(
|
||||||
|
provenance: Readonly<Record<string, unknown>>,
|
||||||
|
sessionId: string,
|
||||||
|
resultId: string,
|
||||||
|
): ObservationCanonicalLabReplayCapability {
|
||||||
|
assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`);
|
||||||
|
if (
|
||||||
|
provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1"
|
||||||
|
|| typeof provenance.evidence_identity_sha256 !== "string"
|
||||||
|
|| !SHA256.test(provenance.evidence_identity_sha256)
|
||||||
|
|| resultId !== `lab-v1-vegetation-shadow-${provenance.evidence_identity_sha256}`
|
||||||
|
|| typeof provenance.result_document_sha256 !== "string"
|
||||||
|
|| !SHA256.test(provenance.result_document_sha256)
|
||||||
|
) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecord(provenance.authority)) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical provenance LAB-сессии ${sessionId} не содержит authority.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertExactKeys(provenance.authority, AUTHORITY_KEYS, `Canonical authority LAB-сессии ${sessionId}`);
|
||||||
|
if (
|
||||||
|
provenance.authority.commands_enabled !== false
|
||||||
|
|| provenance.authority.navigation_or_safety_accepted !== false
|
||||||
|
|| provenance.authority.actuation_accepted !== false
|
||||||
|
) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical provenance LAB-сессии ${sessionId} нарушает observation-only authority.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecord(provenance.method)) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical provenance LAB-сессии ${sessionId} не содержит method manifest.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertExactKeys(provenance.method, METHOD_KEYS, `Canonical method LAB-сессии ${sessionId}`);
|
||||||
|
if (
|
||||||
|
provenance.method.schema_version !== "missioncore.laboratory-method/v1"
|
||||||
|
|| provenance.method.completeness !== "legacy-partial"
|
||||||
|
|| provenance.method.execution_class !== "ai-inference"
|
||||||
|
|| provenance.method.pipeline_id
|
||||||
|
!== "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|
||||||
|
|| !Array.isArray(provenance.method.components)
|
||||||
|
|| provenance.method.components.length !== 1
|
||||||
|
) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical method LAB-сессии ${sessionId} не соответствует записанному прогону.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const component = provenance.method.components[0];
|
||||||
|
if (!isRecord(component)) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical method LAB-сессии ${sessionId} не содержит source component.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertExactKeys(
|
||||||
|
component,
|
||||||
|
METHOD_COMPONENT_KEYS,
|
||||||
|
`Canonical source component LAB-сессии ${sessionId}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
component.kind !== "source"
|
||||||
|
|| component.name !== "sealed full-route LAB result"
|
||||||
|
|| component.version !== "missioncore.lab-v1-vegetation-shadow/v1"
|
||||||
|
|| component.role !== "immutable Session catalog projection"
|
||||||
|
|| component.identity_sha256 !== provenance.evidence_identity_sha256
|
||||||
|
) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical source component LAB-сессии ${sessionId} потерял immutable identity.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
|
||||||
|
if (capability === null || capability.kind !== "canonical-recorded-rerun") {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return capability;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodePortableLabReplayCapabilityProvenance(
|
||||||
|
provenance: Readonly<Record<string, unknown>>,
|
||||||
|
sessionId: string,
|
||||||
|
resultId: string,
|
||||||
|
sourceSessionId: string,
|
||||||
|
definitionSha256: string,
|
||||||
|
): ObservationPortableLabReplayCapability {
|
||||||
|
assertExactKeys(
|
||||||
|
provenance,
|
||||||
|
PORTABLE_PROVENANCE_KEYS,
|
||||||
|
`Portable provenance LAB-сессии ${sessionId}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
provenance.schema_version !== "missioncore.observatory-portable-result-publication/v1"
|
||||||
|
|| !isRecord(provenance.source)
|
||||||
|
|| provenance.source.session_id !== sourceSessionId
|
||||||
|
|| !isRecord(provenance.run_definition)
|
||||||
|
|| provenance.run_definition.definition_sha256 !== definitionSha256
|
||||||
|
|| !isRecord(provenance.result_package)
|
||||||
|
|| typeof provenance.result_package.manifest_sha256 !== "string"
|
||||||
|
|| !SHA256.test(provenance.result_package.manifest_sha256)
|
||||||
|
|| typeof provenance.result_package.artifact_manifest_id !== "string"
|
||||||
|
|| !SHA256.test(provenance.result_package.artifact_manifest_id)
|
||||||
|
|| sessionId !== resultId
|
||||||
|
) {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Portable provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
|
||||||
|
if (capability === null || capability.kind !== "portable-result-review") {
|
||||||
|
throw new ObservationLabReplayCapabilityContractError(
|
||||||
|
`Portable provenance LAB-сессии ${sessionId} не содержит viewer capability.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return capability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */
|
||||||
|
export function decodeRollingCanonicalLabReplayCapability(
|
||||||
|
provenance: Readonly<Record<string, unknown>>,
|
||||||
|
sessionId: string,
|
||||||
|
resultId: string,
|
||||||
|
): ObservationLabReplayCapability | null {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(provenance, "replay_capability")) return null;
|
||||||
|
return decodeCanonicalLabReplayCapabilityProvenance(provenance, sessionId, resultId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { isUsableRecordedPlaybackRange } from "./recordedRerunLifecycle";
|
||||||
|
|
||||||
|
export function createReentrantViewerDisposer(
|
||||||
|
cleanupOnce: () => void,
|
||||||
|
releaseNativeViewer: () => void,
|
||||||
|
): () => void {
|
||||||
|
let cleanupComplete = false;
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
if (!cleanupComplete) {
|
||||||
|
cleanupComplete = true;
|
||||||
|
cleanupOnce();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// A deferred native start can resolve after a pre-ready stop. Reapply
|
||||||
|
// release at every boundary so it cannot reopen after React unmount.
|
||||||
|
releaseNativeViewer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveLiveViewerOwner {
|
||||||
|
release: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
|
||||||
|
|
||||||
|
/** Own exactly one native live receiver per application document. */
|
||||||
|
export function claimExclusiveLiveViewer(release: () => void): () => void {
|
||||||
|
const owner = { release };
|
||||||
|
const previous = activeLiveViewerOwner;
|
||||||
|
activeLiveViewerOwner = owner;
|
||||||
|
previous?.release();
|
||||||
|
return () => {
|
||||||
|
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiveRerunPresentationReady(
|
||||||
|
viewerStarted: boolean,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
backendActivitySequence: number | null,
|
||||||
|
): boolean {
|
||||||
|
return viewerStarted
|
||||||
|
&& Number.isSafeInteger(backendActivitySequence)
|
||||||
|
&& (backendActivitySequence ?? 0) > 0
|
||||||
|
&& isUsableRecordedPlaybackRange(rangeNs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveTimelineNeedsSynchronization(
|
||||||
|
followLive: boolean,
|
||||||
|
activeTimeline: string | null | undefined,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
): boolean {
|
||||||
|
return followLive
|
||||||
|
&& isUsableRecordedPlaybackRange(rangeNs)
|
||||||
|
&& activeTimeline !== "stream_time";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveRerunReceiverBindingIdentity(
|
||||||
|
sourceUrl: string,
|
||||||
|
liveStreamId: string | null,
|
||||||
|
followLive: boolean,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
followLive ? "live" : "recorded",
|
||||||
|
sourceUrl.trim(),
|
||||||
|
followLive ? liveStreamId?.trim() ?? "" : "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -327,6 +327,53 @@ export function verifyLiveViewerClientBuild(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a recorded viewer whose lazy module disappeared during a frontend
|
||||||
|
* deployment. Live acquisition deliberately never reloads automatically, but
|
||||||
|
* a saved recording has no device-side authority to preserve and can safely
|
||||||
|
* move to the current immutable application build.
|
||||||
|
*/
|
||||||
|
export async function reloadRecordedViewerAfterStaleModuleFailure({
|
||||||
|
loadedUiBuildId,
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
reload = () => window.location.reload(),
|
||||||
|
}: {
|
||||||
|
loadedUiBuildId: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: typeof globalThis.fetch;
|
||||||
|
reload?: () => void;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID ||
|
||||||
|
!HASHED_UI_BUILD_ID.test(loadedUiBuildId)
|
||||||
|
) return false;
|
||||||
|
try {
|
||||||
|
const response = await fetcher("/api/v1/viewer/client-contract", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-MissionCore-UI-Build": loadedUiBuildId,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (signal?.aborted || !response.ok) return false;
|
||||||
|
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
|
||||||
|
if (
|
||||||
|
!expectedUiBuildId ||
|
||||||
|
expectedUiBuildId === loadedUiBuildId ||
|
||||||
|
!HASHED_UI_BUILD_ID.test(expectedUiBuildId)
|
||||||
|
) return false;
|
||||||
|
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
|
||||||
|
reload();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startBuildMonitor(): void {
|
function startBuildMonitor(): void {
|
||||||
if (buildMonitorAbort || typeof window === "undefined") return;
|
if (buildMonitorAbort || typeof window === "undefined") return;
|
||||||
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import type { SceneSettings } from "../../sceneSettings";
|
||||||
|
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
|
||||||
|
import type { RerunPlaybackState, RerunViewportStatus } from "./viewerProfile";
|
||||||
|
|
||||||
|
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
||||||
|
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
|
||||||
|
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
|
||||||
|
const RECORDED_OPEN_GRACE_MS = 30_000;
|
||||||
|
const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
|
||||||
|
|
||||||
|
export interface RecordedPlaybackBufferState {
|
||||||
|
bufferedEndNs: number | null;
|
||||||
|
expectedStartNs: number | null;
|
||||||
|
expectedEndNs: number | null;
|
||||||
|
bufferProgress: number | null;
|
||||||
|
fullyBuffered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedPointColorKey(
|
||||||
|
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
|
||||||
|
): string {
|
||||||
|
const custom = settings.palette === "custom" || settings.colorMode === "class"
|
||||||
|
? settings.customColor.toLowerCase()
|
||||||
|
: "-";
|
||||||
|
return `${settings.colorMode}|${settings.palette}|${custom}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedOpenWatchdogTimeoutMs(byteLength: number): number {
|
||||||
|
if (!Number.isSafeInteger(byteLength) || byteLength < 4) {
|
||||||
|
throw new Error("Unsafe recorded RRD byte length");
|
||||||
|
}
|
||||||
|
const transferBudgetMs = Math.ceil(
|
||||||
|
(byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000,
|
||||||
|
);
|
||||||
|
return Math.min(
|
||||||
|
RECORDED_OPEN_MAX_TIMEOUT_MS,
|
||||||
|
Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordedOpenWatchdog<T>({
|
||||||
|
byteLength,
|
||||||
|
schedule,
|
||||||
|
cancel,
|
||||||
|
onTimeout,
|
||||||
|
}: {
|
||||||
|
byteLength: number;
|
||||||
|
schedule: (callback: () => void, timeoutMs: number) => T;
|
||||||
|
cancel: (handle: T) => void;
|
||||||
|
onTimeout: () => void;
|
||||||
|
}): { arm: () => void; clear: () => void; pending: () => boolean } {
|
||||||
|
const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength);
|
||||||
|
let handle: T | null = null;
|
||||||
|
return {
|
||||||
|
arm() {
|
||||||
|
if (handle !== null) return;
|
||||||
|
handle = schedule(() => {
|
||||||
|
handle = null;
|
||||||
|
onTimeout();
|
||||||
|
}, timeoutMs);
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
if (handle === null) return;
|
||||||
|
cancel(handle);
|
||||||
|
handle = null;
|
||||||
|
},
|
||||||
|
pending: () => handle !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsableRecordedPlaybackRange(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
): rangeNs is { min: number; max: number } {
|
||||||
|
return Boolean(
|
||||||
|
rangeNs
|
||||||
|
&& Number.isFinite(rangeNs.min)
|
||||||
|
&& Number.isFinite(rangeNs.max)
|
||||||
|
&& rangeNs.max >= rangeNs.min,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedPlaybackBufferState(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
expectedTimelineEndSeconds?: number,
|
||||||
|
expectedTimelineStartSeconds?: number,
|
||||||
|
): RecordedPlaybackBufferState {
|
||||||
|
const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null;
|
||||||
|
const bufferedEndNs = usableRange?.max ?? null;
|
||||||
|
if (expectedTimelineEndSeconds === undefined) {
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs: null,
|
||||||
|
expectedEndNs: null,
|
||||||
|
bufferProgress: null,
|
||||||
|
fullyBuffered: usableRange !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) {
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs: null,
|
||||||
|
expectedEndNs: null,
|
||||||
|
bufferProgress: null,
|
||||||
|
fullyBuffered: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000;
|
||||||
|
const expectedStartNs = expectedTimelineStartSeconds === undefined
|
||||||
|
? 0
|
||||||
|
: expectedTimelineStartSeconds * 1_000_000_000;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(expectedEndNs)
|
||||||
|
|| !Number.isFinite(expectedStartNs)
|
||||||
|
|| expectedStartNs < 0
|
||||||
|
|| expectedEndNs < expectedStartNs
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs: null,
|
||||||
|
expectedEndNs: null,
|
||||||
|
bufferProgress: null,
|
||||||
|
fullyBuffered: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fullyBuffered = usableRange !== null
|
||||||
|
&& usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS
|
||||||
|
&& usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS;
|
||||||
|
const expectedDurationNs = expectedEndNs - expectedStartNs;
|
||||||
|
const bufferProgress = bufferedEndNs === null
|
||||||
|
? 0
|
||||||
|
: expectedDurationNs <= 0
|
||||||
|
? (fullyBuffered ? 1 : 0)
|
||||||
|
: Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs));
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs,
|
||||||
|
expectedEndNs,
|
||||||
|
bufferProgress,
|
||||||
|
fullyBuffered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecordedPlaybackReady(
|
||||||
|
viewerStarted: boolean,
|
||||||
|
artifactVerified: boolean,
|
||||||
|
buffer: RecordedPlaybackBufferState,
|
||||||
|
): boolean {
|
||||||
|
return viewerStarted && artifactVerified && buffer.bufferedEndNs !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedPlaybackRangeWhenReady(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
buffer: RecordedPlaybackBufferState,
|
||||||
|
artifactVerified: boolean,
|
||||||
|
): { min: number; max: number } | null {
|
||||||
|
if (
|
||||||
|
!artifactVerified
|
||||||
|
|| !buffer.fullyBuffered
|
||||||
|
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||||
|
) return null;
|
||||||
|
return {
|
||||||
|
min: buffer.expectedStartNs === null
|
||||||
|
? rangeNs.min
|
||||||
|
: Math.max(rangeNs.min, buffer.expectedStartNs),
|
||||||
|
max: buffer.expectedEndNs === null
|
||||||
|
? rangeNs.max
|
||||||
|
: Math.min(rangeNs.max, buffer.expectedEndNs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecordedPlaybackPresentationReady(
|
||||||
|
status: RerunViewportStatus,
|
||||||
|
playback: RerunPlaybackState | null,
|
||||||
|
): boolean {
|
||||||
|
return status === "ready"
|
||||||
|
&& playback?.fullyBuffered === true
|
||||||
|
&& isUsableRecordedPlaybackRange(playback.rangeNs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rerunPresentationStatus(
|
||||||
|
status: RerunViewportStatus,
|
||||||
|
_gate: RecordedAdmissionPhase,
|
||||||
|
recorded: boolean,
|
||||||
|
): RerunViewportStatus {
|
||||||
|
if (!recorded) return status;
|
||||||
|
return status === "error" ? "error" : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecordedPlaybackFullyBuffered(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
expectedTimelineEndSeconds?: number,
|
||||||
|
): boolean {
|
||||||
|
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attemptRecordedAutoplay(
|
||||||
|
seekToStart: () => void,
|
||||||
|
startPlaying: () => void,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
seekToStart();
|
||||||
|
startPlaying();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordedInitialSeekGate(): {
|
||||||
|
attempt: (
|
||||||
|
viewerStarted: boolean,
|
||||||
|
fullyBuffered: boolean,
|
||||||
|
presentationReady: boolean,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
seekToStart: (startNs: number) => void,
|
||||||
|
preferredStartNs?: number,
|
||||||
|
) => boolean;
|
||||||
|
attempted: () => boolean;
|
||||||
|
} {
|
||||||
|
let consumed = false;
|
||||||
|
return {
|
||||||
|
attempt(
|
||||||
|
viewerStarted,
|
||||||
|
fullyBuffered,
|
||||||
|
presentationReady,
|
||||||
|
rangeNs,
|
||||||
|
seekToStart,
|
||||||
|
preferredStartNs,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
consumed
|
||||||
|
|| !viewerStarted
|
||||||
|
|| !fullyBuffered
|
||||||
|
|| !presentationReady
|
||||||
|
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||||
|
) return false;
|
||||||
|
consumed = true;
|
||||||
|
const startNs = Number.isFinite(preferredStartNs)
|
||||||
|
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
|
||||||
|
: rangeNs.min;
|
||||||
|
try {
|
||||||
|
seekToStart(startNs);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attempted: () => consumed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordedAutoplayGate(): {
|
||||||
|
attempt: (
|
||||||
|
viewerStarted: boolean,
|
||||||
|
fullyBuffered: boolean,
|
||||||
|
presentationReady: boolean,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
seekToStart: (startNs: number) => void,
|
||||||
|
startPlaying: () => void,
|
||||||
|
preferredStartNs?: number,
|
||||||
|
) => boolean;
|
||||||
|
attempted: () => boolean;
|
||||||
|
} {
|
||||||
|
let consumed = false;
|
||||||
|
return {
|
||||||
|
attempt(
|
||||||
|
viewerStarted,
|
||||||
|
fullyBuffered,
|
||||||
|
presentationReady,
|
||||||
|
rangeNs,
|
||||||
|
seekToStart,
|
||||||
|
startPlaying,
|
||||||
|
preferredStartNs,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
consumed
|
||||||
|
|| !viewerStarted
|
||||||
|
|| !fullyBuffered
|
||||||
|
|| !presentationReady
|
||||||
|
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||||
|
) return false;
|
||||||
|
consumed = true;
|
||||||
|
const startNs = Number.isFinite(preferredStartNs)
|
||||||
|
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
|
||||||
|
: rangeNs.min;
|
||||||
|
return attemptRecordedAutoplay(
|
||||||
|
() => seekToStart(startNs),
|
||||||
|
startPlaying,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
attempted: () => consumed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPublishRecordedPlaybackController(
|
||||||
|
readyToRender: boolean,
|
||||||
|
presentationGate: RecordedAdmissionPhase,
|
||||||
|
): boolean {
|
||||||
|
return readyToRender && presentationGate === "ready";
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface ObservationReplayAttempt {
|
||||||
|
readonly signal: AbortSignal;
|
||||||
|
isCurrent: () => boolean;
|
||||||
|
finish: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationReplayCoordinator {
|
||||||
|
begin: () => ObservationReplayAttempt;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest explicit admission owns the viewer, even if an older promise settles later. */
|
||||||
|
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
||||||
|
let sequence = 0;
|
||||||
|
let active: AbortController | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
begin() {
|
||||||
|
active?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const attemptSequence = ++sequence;
|
||||||
|
active = controller;
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
isCurrent: () => (
|
||||||
|
!controller.signal.aborted
|
||||||
|
&& active === controller
|
||||||
|
&& sequence === attemptSequence
|
||||||
|
),
|
||||||
|
finish: () => {
|
||||||
|
if (active !== controller || sequence !== attemptSequence) return false;
|
||||||
|
active = null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
active?.abort();
|
||||||
|
// Keep settlement ownership until finish(). isCurrent() already fails,
|
||||||
|
// while begin() can replace this attempt immediately.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
MAX_RECORDED_CAMERA_SOURCES,
|
MAX_RECORDED_CAMERA_SOURCES,
|
||||||
} from "./recordedSessionAdmission";
|
} from "./recordedSessionAdmission";
|
||||||
|
import {
|
||||||
|
decodeLabReplayCapability,
|
||||||
|
decodeRollingCanonicalLabReplayCapability,
|
||||||
|
ObservationLabReplayCapabilityContractError,
|
||||||
|
type ObservationLabReplayCapability,
|
||||||
|
} from "./labReplayCapability";
|
||||||
|
import {
|
||||||
|
decodeObservationLabCalculationProfile,
|
||||||
|
ObservationLabCalculationProfileContractError,
|
||||||
|
type ObservationLabCalculationProfile,
|
||||||
|
} from "./labCalculationProfile";
|
||||||
|
|
||||||
|
export type { ObservationLabReplayCapability } from "./labReplayCapability";
|
||||||
|
export type { ObservationLabCalculationProfile } from "./labCalculationProfile";
|
||||||
|
|
||||||
export type ObservationSessionStatus =
|
export type ObservationSessionStatus =
|
||||||
| "recording"
|
| "recording"
|
||||||
@@ -20,6 +34,8 @@ export interface ObservationLabInstance {
|
|||||||
configSha256: string | null;
|
configSha256: string | null;
|
||||||
runCreatedAtUtc: string;
|
runCreatedAtUtc: string;
|
||||||
publishedAtUtc: string;
|
publishedAtUtc: string;
|
||||||
|
replayCapability: ObservationLabReplayCapability | null;
|
||||||
|
calculationProfile: ObservationLabCalculationProfile | null;
|
||||||
provenance: Readonly<Record<string, unknown>>;
|
provenance: Readonly<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +48,21 @@ export interface ObservationSessionSummary {
|
|||||||
modalities: readonly string[];
|
modalities: readonly string[];
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
replayable: boolean;
|
replayable: boolean;
|
||||||
|
captureAttestation: ObservationCaptureAttestation | null;
|
||||||
preparation: ObservationSessionCatalogPreparation | null;
|
preparation: ObservationSessionCatalogPreparation | null;
|
||||||
lab: ObservationLabInstance | null;
|
lab: ObservationLabInstance | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ObservationCaptureAttestation {
|
||||||
|
readonly equipmentModelId: string;
|
||||||
|
readonly equipmentModelSha256: string;
|
||||||
|
readonly equipmentDisplayName: string;
|
||||||
|
readonly captureProfileId: string;
|
||||||
|
readonly captureProfileSha256: string;
|
||||||
|
readonly captureAttestationSha256: string;
|
||||||
|
readonly state: "attested" | "attested-with-legacy-evidence" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
export interface ObservationSessionCatalog {
|
export interface ObservationSessionCatalog {
|
||||||
items: readonly ObservationSessionSummary[];
|
items: readonly ObservationSessionSummary[];
|
||||||
}
|
}
|
||||||
@@ -152,10 +179,11 @@ const ITEM_KEYS = new Set([
|
|||||||
"modalities",
|
"modalities",
|
||||||
"duration_seconds",
|
"duration_seconds",
|
||||||
"replayable",
|
"replayable",
|
||||||
|
"capture_attestation",
|
||||||
"preparation",
|
"preparation",
|
||||||
"lab",
|
"lab",
|
||||||
]);
|
]);
|
||||||
const LAB_KEYS = new Set([
|
const LEGACY_LAB_KEYS = new Set([
|
||||||
"lab_id",
|
"lab_id",
|
||||||
"source_session_id",
|
"source_session_id",
|
||||||
"result_kind",
|
"result_kind",
|
||||||
@@ -166,6 +194,27 @@ const LAB_KEYS = new Set([
|
|||||||
"published_at_utc",
|
"published_at_utc",
|
||||||
"provenance",
|
"provenance",
|
||||||
]);
|
]);
|
||||||
|
const LAB_V2_KEYS = new Set([...LEGACY_LAB_KEYS, "replay_capability"]);
|
||||||
|
const LAB_V3_KEYS = new Set([
|
||||||
|
...LAB_V2_KEYS,
|
||||||
|
"calculation_profile",
|
||||||
|
]);
|
||||||
|
const CAPTURE_ATTESTATION_KEYS = new Set([
|
||||||
|
"schema_version",
|
||||||
|
"equipment_model",
|
||||||
|
"capture_profile",
|
||||||
|
"capture_attestation_sha256",
|
||||||
|
"state",
|
||||||
|
]);
|
||||||
|
const EQUIPMENT_MODEL_KEYS = new Set([
|
||||||
|
"equipment_model_id",
|
||||||
|
"equipment_model_sha256",
|
||||||
|
"display_name",
|
||||||
|
]);
|
||||||
|
const CAPTURE_PROFILE_KEYS = new Set([
|
||||||
|
"capture_profile_id",
|
||||||
|
"capture_profile_sha256",
|
||||||
|
]);
|
||||||
const CATALOG_PREPARATION_KEYS = new Set([
|
const CATALOG_PREPARATION_KEYS = new Set([
|
||||||
"preparation_id",
|
"preparation_id",
|
||||||
"state",
|
"state",
|
||||||
@@ -379,11 +428,103 @@ function decodeItem(value: unknown, index: number): ObservationSessionSummary {
|
|||||||
modalities: requireModalities(value.modalities),
|
modalities: requireModalities(value.modalities),
|
||||||
durationSeconds: value.duration_seconds,
|
durationSeconds: value.duration_seconds,
|
||||||
replayable: value.replayable,
|
replayable: value.replayable,
|
||||||
|
captureAttestation: decodeCaptureAttestation(value.capture_attestation, id),
|
||||||
preparation: decodeCatalogPreparation(value.preparation, id),
|
preparation: decodeCatalogPreparation(value.preparation, id),
|
||||||
lab: decodeLabInstance(value.lab, id),
|
lab: decodeLabInstance(value.lab, id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeCaptureAttestation(
|
||||||
|
value: unknown,
|
||||||
|
sessionId: string,
|
||||||
|
): ObservationCaptureAttestation | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ObservationSessionContractError(
|
||||||
|
`Аттестация записи ${sessionId} должна быть объектом или null.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertExactKeys(value, CAPTURE_ATTESTATION_KEYS, `Аттестация записи ${sessionId}`);
|
||||||
|
if (value.schema_version !== "missioncore.recorded-capture-attestation/v1") {
|
||||||
|
throw new ObservationSessionContractError(
|
||||||
|
`Аттестация записи ${sessionId} имеет неизвестную схему.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecord(value.equipment_model) || !isRecord(value.capture_profile)) {
|
||||||
|
throw new ObservationSessionContractError(
|
||||||
|
`Аттестация записи ${sessionId} не содержит оборудование или профиль записи.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertExactKeys(
|
||||||
|
value.equipment_model,
|
||||||
|
EQUIPMENT_MODEL_KEYS,
|
||||||
|
`Оборудование записи ${sessionId}`,
|
||||||
|
);
|
||||||
|
assertExactKeys(
|
||||||
|
value.capture_profile,
|
||||||
|
CAPTURE_PROFILE_KEYS,
|
||||||
|
`Профиль записи ${sessionId}`,
|
||||||
|
);
|
||||||
|
const equipmentModelId = requireString(
|
||||||
|
value.equipment_model.equipment_model_id,
|
||||||
|
"equipment_model_id",
|
||||||
|
128,
|
||||||
|
);
|
||||||
|
const equipmentModelSha256 = requireString(
|
||||||
|
value.equipment_model.equipment_model_sha256,
|
||||||
|
"equipment_model_sha256",
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
const captureProfileId = requireString(
|
||||||
|
value.capture_profile.capture_profile_id,
|
||||||
|
"capture_profile_id",
|
||||||
|
128,
|
||||||
|
);
|
||||||
|
const captureProfileSha256 = requireString(
|
||||||
|
value.capture_profile.capture_profile_sha256,
|
||||||
|
"capture_profile_sha256",
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
const captureAttestationSha256 = requireString(
|
||||||
|
value.capture_attestation_sha256,
|
||||||
|
"capture_attestation_sha256",
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!SAFE_ID.test(equipmentModelId)
|
||||||
|
|| !SAFE_ID.test(captureProfileId)
|
||||||
|
|| !SHA256.test(equipmentModelSha256)
|
||||||
|
|| !SHA256.test(captureProfileSha256)
|
||||||
|
|| !SHA256.test(captureAttestationSha256)
|
||||||
|
) {
|
||||||
|
throw new ObservationSessionContractError(
|
||||||
|
`Аттестация записи ${sessionId} содержит некорректную immutable identity.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.state !== "attested"
|
||||||
|
&& value.state !== "attested-with-legacy-evidence"
|
||||||
|
&& value.state !== "unknown"
|
||||||
|
) {
|
||||||
|
throw new ObservationSessionContractError(
|
||||||
|
`Аттестация записи ${sessionId} содержит неизвестное состояние.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
equipmentModelId,
|
||||||
|
equipmentModelSha256,
|
||||||
|
equipmentDisplayName: requireString(
|
||||||
|
value.equipment_model.display_name,
|
||||||
|
"equipment display_name",
|
||||||
|
128,
|
||||||
|
),
|
||||||
|
captureProfileId,
|
||||||
|
captureProfileSha256,
|
||||||
|
captureAttestationSha256,
|
||||||
|
state: value.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function decodeLabInstance(
|
function decodeLabInstance(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -394,7 +535,23 @@ function decodeLabInstance(
|
|||||||
`LAB-привязка сессии ${sessionId} должна быть объектом или null.`,
|
`LAB-привязка сессии ${sessionId} должна быть объектом или null.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assertExactKeys(value, LAB_KEYS, `LAB-привязка сессии ${sessionId}`);
|
const hasTypedCapability = Object.prototype.hasOwnProperty.call(
|
||||||
|
value,
|
||||||
|
"replay_capability",
|
||||||
|
);
|
||||||
|
const hasCalculationProfile = Object.prototype.hasOwnProperty.call(
|
||||||
|
value,
|
||||||
|
"calculation_profile",
|
||||||
|
);
|
||||||
|
assertExactKeys(
|
||||||
|
value,
|
||||||
|
hasCalculationProfile
|
||||||
|
? LAB_V3_KEYS
|
||||||
|
: hasTypedCapability
|
||||||
|
? LAB_V2_KEYS
|
||||||
|
: LEGACY_LAB_KEYS,
|
||||||
|
`LAB-привязка сессии ${sessionId}`,
|
||||||
|
);
|
||||||
const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36);
|
const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36);
|
||||||
if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) {
|
if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) {
|
||||||
throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер.");
|
throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер.");
|
||||||
@@ -430,6 +587,28 @@ function decodeLabInstance(
|
|||||||
if (!isRecord(value.provenance)) {
|
if (!isRecord(value.provenance)) {
|
||||||
throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом.");
|
throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом.");
|
||||||
}
|
}
|
||||||
|
let replayCapability: ObservationLabReplayCapability | null;
|
||||||
|
try {
|
||||||
|
replayCapability = hasTypedCapability
|
||||||
|
? decodeLabReplayCapability(value.replay_capability, sessionId)
|
||||||
|
: decodeRollingCanonicalLabReplayCapability(value.provenance, sessionId, resultId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ObservationLabReplayCapabilityContractError) {
|
||||||
|
throw new ObservationSessionContractError(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
let calculationProfile: ObservationLabCalculationProfile | null;
|
||||||
|
try {
|
||||||
|
calculationProfile = hasCalculationProfile
|
||||||
|
? decodeObservationLabCalculationProfile(value.calculation_profile, sessionId)
|
||||||
|
: null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ObservationLabCalculationProfileContractError) {
|
||||||
|
throw new ObservationSessionContractError(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
labId,
|
labId,
|
||||||
sourceSessionId,
|
sourceSessionId,
|
||||||
@@ -445,6 +624,8 @@ function decodeLabInstance(
|
|||||||
value.published_at_utc,
|
value.published_at_utc,
|
||||||
`lab(${sessionId}).published_at_utc`,
|
`lab(${sessionId}).published_at_utc`,
|
||||||
),
|
),
|
||||||
|
replayCapability,
|
||||||
|
calculationProfile,
|
||||||
provenance: value.provenance,
|
provenance: value.provenance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1181,6 +1362,7 @@ export async function fetchObservationSessionCatalog({
|
|||||||
queryParameters.set("limit", String(Number(limit)));
|
queryParameters.set("limit", String(Number(limit)));
|
||||||
}
|
}
|
||||||
if (scope !== "all") queryParameters.set("scope", scope);
|
if (scope !== "all") queryParameters.set("scope", scope);
|
||||||
|
if (scope === "laboratory") queryParameters.set("lab_contract", "v3");
|
||||||
const serializedQuery = queryParameters.toString();
|
const serializedQuery = queryParameters.toString();
|
||||||
const query = serializedQuery ? `?${serializedQuery}` : "";
|
const query = serializedQuery ? `?${serializedQuery}` : "";
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import {
|
|||||||
type ObservationSessionScope,
|
type ObservationSessionScope,
|
||||||
type ObservationSessionSummary,
|
type ObservationSessionSummary,
|
||||||
} from "./sessionArchive";
|
} from "./sessionArchive";
|
||||||
|
import {
|
||||||
|
createObservationReplayCoordinator,
|
||||||
|
type ObservationReplayCoordinator,
|
||||||
|
} from "./replayCoordinator";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createObservationReplayCoordinator,
|
||||||
|
type ObservationReplayAttempt,
|
||||||
|
type ObservationReplayCoordinator,
|
||||||
|
} from "./replayCoordinator";
|
||||||
|
|
||||||
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
|
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
|
||||||
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
|
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
|
||||||
@@ -41,17 +51,6 @@ export interface ObservationSessionsController {
|
|||||||
remove: (sessionId: string) => Promise<boolean>;
|
remove: (sessionId: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ObservationReplayAttempt {
|
|
||||||
readonly signal: AbortSignal;
|
|
||||||
isCurrent: () => boolean;
|
|
||||||
finish: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObservationReplayCoordinator {
|
|
||||||
begin: () => ObservationReplayAttempt;
|
|
||||||
cancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteObservationSessionAfterTeardown(
|
export async function deleteObservationSessionAfterTeardown(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
{
|
{
|
||||||
@@ -93,41 +92,6 @@ export class ObservationPreparationStalledError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Latest selection wins, even if an obsolete server job finishes later. */
|
|
||||||
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
|
||||||
let sequence = 0;
|
|
||||||
let active: AbortController | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
begin() {
|
|
||||||
active?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
const attemptSequence = ++sequence;
|
|
||||||
active = controller;
|
|
||||||
return {
|
|
||||||
signal: controller.signal,
|
|
||||||
isCurrent: () => (
|
|
||||||
!controller.signal.aborted &&
|
|
||||||
active === controller &&
|
|
||||||
sequence === attemptSequence
|
|
||||||
),
|
|
||||||
finish: () => {
|
|
||||||
if (active !== controller || sequence !== attemptSequence) return false;
|
|
||||||
active = null;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
active?.abort();
|
|
||||||
// Keep ownership until the cancelled attempt reaches `finish()`. This
|
|
||||||
// lets its finally block settle a replacement that already passed
|
|
||||||
// onReplayBegin, while `isCurrent()` still fails immediately because the
|
|
||||||
// signal is aborted. A later `begin()` replaces and invalidates it.
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error && error.message.trim()
|
return error instanceof Error && error.message.trim()
|
||||||
? error.message
|
? error.message
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
|
||||||
|
|
||||||
|
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
|
||||||
|
|
||||||
|
export interface RerunPlaybackState {
|
||||||
|
recordingId: string;
|
||||||
|
timeline: string;
|
||||||
|
rangeNs: { min: number; max: number } | null;
|
||||||
|
currentNs: number;
|
||||||
|
playing: boolean;
|
||||||
|
bufferedEndNs: number | null;
|
||||||
|
expectedStartNs: number | null;
|
||||||
|
expectedEndNs: number | null;
|
||||||
|
bufferProgress: number | null;
|
||||||
|
fullyBuffered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RerunPlaybackController {
|
||||||
|
seek: (timeNs: number) => void;
|
||||||
|
setPlaying: (playing: boolean) => void;
|
||||||
|
jumpToEnd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordedPerceptionLayers {
|
||||||
|
enabled: boolean;
|
||||||
|
detections2d: boolean;
|
||||||
|
segmentation: boolean;
|
||||||
|
cuboids3d: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordedRrdArtifactDescriptor {
|
||||||
|
sourceUrl: string;
|
||||||
|
viewerSourceUrl: string;
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native live receiver owns one acquisition lineage and never exposes
|
||||||
|
* recorded transport or autoplay policy.
|
||||||
|
*/
|
||||||
|
export interface LiveAcquisitionRerunProfile {
|
||||||
|
kind: "live-acquisition";
|
||||||
|
clock: "stream_time";
|
||||||
|
sourceUrl: string;
|
||||||
|
liveActivitySequence: number | null;
|
||||||
|
liveStreamId: string | null;
|
||||||
|
liveRecoveryAuthorityIdentity: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sealed session is progressively presentable, while its shared playback
|
||||||
|
* controls remain fenced by the aggregate session admission gate.
|
||||||
|
*/
|
||||||
|
export interface RecordedSessionRerunProfile {
|
||||||
|
kind: "recorded-session";
|
||||||
|
clock: "session_time";
|
||||||
|
sourceUrl: string;
|
||||||
|
artifact: RecordedRrdArtifactDescriptor | null;
|
||||||
|
autoplayWhenReady: boolean;
|
||||||
|
presentationGate: RecordedAdmissionPhase;
|
||||||
|
expectedTimelineStartSeconds?: number;
|
||||||
|
expectedTimelineEndSeconds?: number;
|
||||||
|
initialPlaybackStartSeconds?: number;
|
||||||
|
view: RecordedRerunView;
|
||||||
|
viewResetGeneration: 0 | 1;
|
||||||
|
followTrajectory: boolean;
|
||||||
|
perceptionLayers: RecordedPerceptionLayers;
|
||||||
|
/** Explicit small blueprint endpoint when the viewer source is a merged LAB RRD. */
|
||||||
|
blueprintSourceUrl?: string;
|
||||||
|
/** Optional immutable RRD sidecar for LAB/model evidence on the same recording clock. */
|
||||||
|
perceptionSourceUrl?: string;
|
||||||
|
/** Selects one semantic entity without changing the sealed sidecar. */
|
||||||
|
semanticLayer?: "city" | "vegetation";
|
||||||
|
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||||
|
unifiedPerception?: boolean;
|
||||||
|
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||||
|
planView?: boolean;
|
||||||
|
perceptionRetryGeneration: number;
|
||||||
|
lockPerceptionCameraInteraction: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||||
|
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||||
|
* canonical LAB route or start work in the background.
|
||||||
|
*/
|
||||||
|
export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||||
|
kind: "lab-recorded-evidence";
|
||||||
|
clock: "source-sequence";
|
||||||
|
cameraTransport: "generation-bound-fmp4";
|
||||||
|
spatialTransport: "bounded-sealed-artifacts";
|
||||||
|
loadPolicy: "explicit-legacy-comparison-only";
|
||||||
|
workerRequired: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RerunViewerProfile =
|
||||||
|
| LiveAcquisitionRerunProfile
|
||||||
|
| RecordedSessionRerunProfile;
|
||||||
|
|
||||||
|
export type ObservationViewerProfile =
|
||||||
|
| RerunViewerProfile
|
||||||
|
| LaboratoryRecordedEvidenceViewerProfile;
|
||||||
|
|
||||||
|
export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||||
|
kind: "lab-recorded-evidence",
|
||||||
|
clock: "source-sequence",
|
||||||
|
cameraTransport: "generation-bound-fmp4",
|
||||||
|
spatialTransport: "bounded-sealed-artifacts",
|
||||||
|
loadPolicy: "explicit-legacy-comparison-only",
|
||||||
|
workerRequired: false,
|
||||||
|
} satisfies LaboratoryRecordedEvidenceViewerProfile);
|
||||||
|
|
||||||
|
export function liveAcquisitionRerunProfile(
|
||||||
|
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||||
|
): LiveAcquisitionRerunProfile {
|
||||||
|
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedSessionRerunProfile(
|
||||||
|
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||||
|
): RecordedSessionRerunProfile {
|
||||||
|
return { kind: "recorded-session", clock: "session_time", ...input };
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import {
|
||||||
|
fetchObservationSessionCatalog,
|
||||||
|
type ObservationLabInstance,
|
||||||
|
type ObservationSessionFetch,
|
||||||
|
type ObservationSessionSummary,
|
||||||
|
} from "../observation/sessionArchive";
|
||||||
|
import {
|
||||||
|
observatoryRecordedRunBinding,
|
||||||
|
type ObservatoryRecordedRunBinding,
|
||||||
|
} from "./recordedRun";
|
||||||
|
|
||||||
|
export interface ObservatoryEvidence {
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly status: ObservationSessionSummary["status"];
|
||||||
|
readonly publishedAtUtc: string;
|
||||||
|
readonly lab: ObservationLabInstance;
|
||||||
|
readonly recordedRun: ObservatoryRecordedRunBinding | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatorySession {
|
||||||
|
readonly source: ObservationSessionSummary;
|
||||||
|
readonly evidence: readonly ObservatoryEvidence[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatoryCatalog {
|
||||||
|
readonly items: readonly ObservatorySession[];
|
||||||
|
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
|
||||||
|
readonly window: {
|
||||||
|
readonly limit: number;
|
||||||
|
readonly sourceCount: number;
|
||||||
|
readonly laboratoryCount: number;
|
||||||
|
readonly sourceLimitReached: boolean;
|
||||||
|
readonly laboratoryLimitReached: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservatoryCatalogMutation =
|
||||||
|
| {
|
||||||
|
readonly kind: "rename";
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly revision: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "delete";
|
||||||
|
readonly revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
|
||||||
|
string,
|
||||||
|
ObservatoryCatalogMutation
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ObservatoryCatalogReconciliation {
|
||||||
|
readonly catalog: ObservatoryCatalog;
|
||||||
|
readonly overlay: ObservatoryCatalogMutationOverlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObservatoryCatalogContractError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryCatalogContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findObservatoryEvidence(
|
||||||
|
catalog: ObservatoryCatalog,
|
||||||
|
sessionId: string,
|
||||||
|
): ObservatoryEvidence | null {
|
||||||
|
for (const item of catalog.items) {
|
||||||
|
const evidence = item.evidence.find(
|
||||||
|
(candidate) => candidate.sessionId === sessionId,
|
||||||
|
);
|
||||||
|
if (evidence) return evidence;
|
||||||
|
}
|
||||||
|
return catalog.unresolvedEvidence.find(
|
||||||
|
(candidate) => candidate.sessionId === sessionId,
|
||||||
|
) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observatoryCatalogConfirmsEvidenceDeletion(
|
||||||
|
catalog: ObservatoryCatalog,
|
||||||
|
sessionId: string,
|
||||||
|
): boolean {
|
||||||
|
return !catalog.window.laboratoryLimitReached
|
||||||
|
&& findObservatoryEvidence(catalog, sessionId) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalog: ObservatoryCatalog,
|
||||||
|
overlay: ObservatoryCatalogMutationOverlay,
|
||||||
|
): ObservatoryCatalog {
|
||||||
|
if (overlay.size === 0) return catalog;
|
||||||
|
|
||||||
|
const removedSessionIds = new Set<string>();
|
||||||
|
const projectEvidence = (
|
||||||
|
evidence: ObservatoryEvidence,
|
||||||
|
): ObservatoryEvidence | null => {
|
||||||
|
const mutation = overlay.get(evidence.sessionId);
|
||||||
|
if (!mutation) return evidence;
|
||||||
|
if (mutation.kind === "delete") {
|
||||||
|
removedSessionIds.add(evidence.sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return evidence.label === mutation.displayName
|
||||||
|
? evidence
|
||||||
|
: { ...evidence, label: mutation.displayName };
|
||||||
|
};
|
||||||
|
const projectEvidenceList = (
|
||||||
|
evidence: readonly ObservatoryEvidence[],
|
||||||
|
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
|
||||||
|
const projected = projectEvidence(candidate);
|
||||||
|
return projected ? [projected] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = catalog.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
evidence: projectEvidenceList(item.evidence),
|
||||||
|
}));
|
||||||
|
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
|
||||||
|
return {
|
||||||
|
...catalog,
|
||||||
|
items,
|
||||||
|
unresolvedEvidence,
|
||||||
|
window: {
|
||||||
|
...catalog.window,
|
||||||
|
laboratoryCount: Math.max(
|
||||||
|
0,
|
||||||
|
catalog.window.laboratoryCount - removedSessionIds.size,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
serverCatalog: ObservatoryCatalog,
|
||||||
|
overlay: ObservatoryCatalogMutationOverlay,
|
||||||
|
requestMutationRevision: number,
|
||||||
|
): ObservatoryCatalogReconciliation {
|
||||||
|
const remaining = new Map(overlay);
|
||||||
|
for (const [sessionId, mutation] of overlay) {
|
||||||
|
if (mutation.revision > requestMutationRevision) continue;
|
||||||
|
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
|
||||||
|
const confirmed = mutation.kind === "rename"
|
||||||
|
? evidence?.label === mutation.displayName
|
||||||
|
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
|
||||||
|
if (confirmed) remaining.delete(sessionId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
|
||||||
|
overlay: remaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestFirst(left: string, right: string): number {
|
||||||
|
return Date.parse(right) - Date.parse(left);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedLimit(limit: number): number {
|
||||||
|
return Number.isFinite(limit)
|
||||||
|
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||||
|
: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceFromSession(
|
||||||
|
session: ObservationSessionSummary,
|
||||||
|
): ObservatoryEvidence {
|
||||||
|
if (!session.lab) {
|
||||||
|
throw new ObservatoryCatalogContractError(
|
||||||
|
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
label: session.label,
|
||||||
|
status: session.status,
|
||||||
|
publishedAtUtc: session.lab.publishedAtUtc,
|
||||||
|
lab: session.lab,
|
||||||
|
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortEvidence(
|
||||||
|
evidence: readonly ObservatoryEvidence[],
|
||||||
|
): ObservatoryEvidence[] {
|
||||||
|
return [...evidence].sort((left, right) =>
|
||||||
|
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|
||||||
|
|| left.sessionId.localeCompare(right.sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildObservatoryCatalog(
|
||||||
|
sourceSessions: readonly ObservationSessionSummary[],
|
||||||
|
laboratorySessions: readonly ObservationSessionSummary[],
|
||||||
|
limit = 100,
|
||||||
|
): ObservatoryCatalog {
|
||||||
|
const safeLimit = boundedLimit(limit);
|
||||||
|
const sources = new Map<string, ObservationSessionSummary>();
|
||||||
|
for (const source of sourceSessions) {
|
||||||
|
if (source.lab !== null) {
|
||||||
|
throw new ObservatoryCatalogContractError(
|
||||||
|
`Каталог источников вернул LAB-сессию ${source.id}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sources.set(source.id, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linked = new Map<string, ObservatoryEvidence[]>();
|
||||||
|
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||||
|
for (const laboratorySession of laboratorySessions) {
|
||||||
|
const evidence = evidenceFromSession(laboratorySession);
|
||||||
|
const sourceId = evidence.lab.sourceSessionId;
|
||||||
|
if (!sources.has(sourceId)) {
|
||||||
|
// Both API projections are bounded newest-first windows without a
|
||||||
|
// cursor. Absence from the source window is not proof of a broken
|
||||||
|
// relationship and must not be presented as an integrity fault.
|
||||||
|
unresolvedEvidence.push(evidence);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const current = linked.get(sourceId) ?? [];
|
||||||
|
current.push(evidence);
|
||||||
|
linked.set(sourceId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...sources.values()]
|
||||||
|
.sort((left, right) =>
|
||||||
|
newestFirst(left.startedAtUtc, right.startedAtUtc)
|
||||||
|
|| left.id.localeCompare(right.id))
|
||||||
|
.map((source) => ({
|
||||||
|
source,
|
||||||
|
evidence: sortEvidence(linked.get(source.id) ?? []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
unresolvedEvidence: sortEvidence(unresolvedEvidence),
|
||||||
|
window: {
|
||||||
|
limit: safeLimit,
|
||||||
|
sourceCount: sourceSessions.length,
|
||||||
|
laboratoryCount: laboratorySessions.length,
|
||||||
|
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||||
|
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchObservatoryCatalog({
|
||||||
|
signal,
|
||||||
|
limit = 100,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
limit?: number;
|
||||||
|
fetcher?: ObservationSessionFetch;
|
||||||
|
} = {}): Promise<ObservatoryCatalog> {
|
||||||
|
const safeLimit = boundedLimit(limit);
|
||||||
|
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
|
||||||
|
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
|
||||||
|
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
|
||||||
|
]);
|
||||||
|
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
|
||||||
|
|
||||||
|
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
|
||||||
|
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
|
||||||
|
|
||||||
|
export type ObservatoryCatalogMutationFetch = (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface ObservatoryLabProjectionMutationResult {
|
||||||
|
readonly schemaVersion: typeof PROJECTION_SCHEMA;
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObservatoryCatalogMutationError extends Error {
|
||||||
|
readonly status: number | null;
|
||||||
|
|
||||||
|
constructor(message: string, status: number | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryCatalogMutationError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameObservatoryLabProjection(
|
||||||
|
binding: ObservatoryRecordedRunBinding,
|
||||||
|
displayName: string,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryCatalogMutationFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<ObservatoryLabProjectionMutationResult> {
|
||||||
|
const sessionId = admittedProjectionId(binding);
|
||||||
|
const normalizedName = displayName.trim();
|
||||||
|
if (normalizedName.length < 1 || normalizedName.length > 160) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Название лабораторного результата должно содержать от 1 до 160 символов.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
schema_version: RENAME_SCHEMA,
|
||||||
|
display_name: normalizedName,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
"Не удалось переименовать лабораторный результат.",
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok || response.status !== 200) {
|
||||||
|
throw apiError(
|
||||||
|
body,
|
||||||
|
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const decoded = decodeProjection(body);
|
||||||
|
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер не подтвердил точное переименование выбранного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteObservatoryLabProjection(
|
||||||
|
binding: ObservatoryRecordedRunBinding,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryCatalogMutationFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionId = admittedProjectionId(binding);
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
"Не удалось удалить лабораторный результат из Обсерватории.",
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok || response.status !== 204) {
|
||||||
|
throw apiError(
|
||||||
|
body,
|
||||||
|
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body !== undefined) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|
||||||
|
const sessionId = binding.evidenceSessionId;
|
||||||
|
const admittedViewer = (
|
||||||
|
binding.kind === "canonical-recorded-rerun"
|
||||||
|
&& binding.viewerProfile === "recorded-session"
|
||||||
|
&& binding.timeline === "session_time"
|
||||||
|
) || (
|
||||||
|
binding.kind === "portable-result-review"
|
||||||
|
&& binding.viewerProfile === "portable-result"
|
||||||
|
&& binding.timeline === "result-defined"
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!admittedViewer
|
||||||
|
|| binding.activation !== "explicit"
|
||||||
|
|| binding.resultId !== sessionId
|
||||||
|
|| binding.sourceSessionId === sessionId
|
||||||
|
|| !SAFE_SESSION_ID.test(sessionId)
|
||||||
|
) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Выбранный результат не допущен к изменению каталога Обсерватории.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
fetcher: ObservatoryCatalogMutationFetch,
|
||||||
|
input: string,
|
||||||
|
init: RequestInit,
|
||||||
|
fallback: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetcher(input, init);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||||
|
throw new ObservatoryCatalogMutationError(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseBody(response: Response): Promise<unknown> {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(body: unknown, fallback: string, status: number): Error {
|
||||||
|
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||||
|
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
|
||||||
|
}
|
||||||
|
if (typeof body === "string" && body.trim()) {
|
||||||
|
return new ObservatoryCatalogMutationError(body.trim(), status);
|
||||||
|
}
|
||||||
|
return new ObservatoryCatalogMutationError(fallback, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер вернул некорректное подтверждение лабораторного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
const expected = ["display_name", "schema_version", "session_id"];
|
||||||
|
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Подтверждение лабораторного результата содержит неизвестные поля.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.schema_version !== PROJECTION_SCHEMA
|
||||||
|
|| typeof value.session_id !== "string"
|
||||||
|
|| !SAFE_SESSION_ID.test(value.session_id)
|
||||||
|
|| typeof value.display_name !== "string"
|
||||||
|
|| value.display_name.trim() !== value.display_name
|
||||||
|
|| value.display_name.length < 1
|
||||||
|
|| value.display_name.length > 160
|
||||||
|
) {
|
||||||
|
throw new ObservatoryCatalogMutationError(
|
||||||
|
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schemaVersion: PROJECTION_SCHEMA,
|
||||||
|
sessionId: value.session_id,
|
||||||
|
displayName: value.display_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
export { fetchObservatoryPortableLaboratorySetups } from "./portableLaboratorySetups";
|
||||||
|
|
||||||
|
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
|
||||||
|
const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1";
|
||||||
|
const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1";
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
export type ObservatoryLaboratorySetupOrigin =
|
||||||
|
| "archived-definition"
|
||||||
|
| "existing-result"
|
||||||
|
| "portable-definition";
|
||||||
|
export type ObservatoryLaboratorySetupAction =
|
||||||
|
| "open-existing"
|
||||||
|
| "open-legacy"
|
||||||
|
| "check"
|
||||||
|
| "blocked";
|
||||||
|
|
||||||
|
export interface ObservatoryLaboratoryRunDefinition {
|
||||||
|
readonly definitionId: string;
|
||||||
|
readonly version: number;
|
||||||
|
readonly workId: string | null;
|
||||||
|
readonly definitionSha256: string;
|
||||||
|
readonly resultSchema: string | null;
|
||||||
|
readonly resultKind: string | null;
|
||||||
|
readonly models: readonly {
|
||||||
|
readonly name: string;
|
||||||
|
readonly releaseId: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
readonly architecture: string;
|
||||||
|
}[];
|
||||||
|
readonly configuration: readonly {
|
||||||
|
readonly role: string;
|
||||||
|
readonly sha256: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatoryLaboratoryPreservedResult {
|
||||||
|
readonly resultId: string;
|
||||||
|
readonly resultKind: string;
|
||||||
|
readonly relation: string;
|
||||||
|
readonly access: "legacy-lab" | "observatory" | "evidence-only";
|
||||||
|
readonly createdAtUtc: string;
|
||||||
|
readonly observatoryProjectionAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatoryLaboratorySetup {
|
||||||
|
readonly setupId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly origin: ObservatoryLaboratorySetupOrigin;
|
||||||
|
readonly runDefinition: ObservatoryLaboratoryRunDefinition | null;
|
||||||
|
readonly compatibility: {
|
||||||
|
readonly compatible: boolean;
|
||||||
|
readonly reasons: readonly { readonly code: string; readonly message: string }[];
|
||||||
|
};
|
||||||
|
readonly executor: {
|
||||||
|
readonly contourId: string;
|
||||||
|
readonly state: "not-installed" | "ready";
|
||||||
|
readonly reasonCode: string;
|
||||||
|
readonly reason: string;
|
||||||
|
};
|
||||||
|
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
|
||||||
|
readonly preflight: {
|
||||||
|
readonly outcome: "existing" | "ready" | "blocked";
|
||||||
|
readonly action: ObservatoryLaboratorySetupAction;
|
||||||
|
readonly reason: string;
|
||||||
|
readonly submissionAllowed: boolean;
|
||||||
|
readonly existingResultIds: readonly string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatoryLaboratorySetupCatalog {
|
||||||
|
readonly sourceSessionId: string;
|
||||||
|
readonly setups: readonly ObservatoryLaboratorySetup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache identity is verified by the portable catalog decoder/server, never by a LAB label.
|
||||||
|
export function selectableObservatorySetups(catalog: ObservatoryLaboratorySetupCatalog | null) {
|
||||||
|
return catalog?.setups.filter((setup) => (
|
||||||
|
setup.origin === "portable-definition"
|
||||||
|
&& setup.runDefinition !== null
|
||||||
|
&& setup.compatibility.compatible
|
||||||
|
&& setup.preflight.outcome !== "existing"
|
||||||
|
)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatoryLaboratoryRunPreflight {
|
||||||
|
readonly sourceSessionId: string;
|
||||||
|
readonly setupId: string;
|
||||||
|
readonly definitionSha256: string | null;
|
||||||
|
readonly checkSha256: string | null;
|
||||||
|
readonly outcome: "existing" | "queueable" | "blocked";
|
||||||
|
readonly submissionAllowed: boolean;
|
||||||
|
readonly checks: readonly {
|
||||||
|
readonly checkId: string;
|
||||||
|
readonly outcome: "pass" | "fail" | "not-applicable";
|
||||||
|
readonly reasonCode: string;
|
||||||
|
readonly message: string;
|
||||||
|
}[];
|
||||||
|
readonly existingResultIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservatoryLaboratorySetupFetch = (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
export class ObservatoryLaboratorySetupContractError extends Error {
|
||||||
|
readonly status: number | null;
|
||||||
|
|
||||||
|
constructor(message: string, status: number | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryLaboratorySetupContractError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchObservatoryLaboratorySetups(
|
||||||
|
sourceSessionId: string,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
`/api/v1/observatory/laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
|
const catalog = decodeCatalog(body);
|
||||||
|
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(
|
||||||
|
"Каталог сетапов относится к другой исходной сессии.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflightObservatoryLaboratorySetup(
|
||||||
|
sourceSessionId: string,
|
||||||
|
setup: ObservatoryLaboratorySetup,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<ObservatoryLaboratoryRunPreflight> {
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
"/api/v1/observatory/run-preflights",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
schema_version: PREFLIGHT_REQUEST_SCHEMA,
|
||||||
|
source_session_id: sourceSessionId,
|
||||||
|
setup_id: setup.setupId,
|
||||||
|
definition_sha256: setup.runDefinition?.definitionSha256 ?? null,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
|
const preflight = decodePreflight(body);
|
||||||
|
if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(
|
||||||
|
"Preflight относится к другому источнику или сетапу.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(
|
||||||
|
"Preflight относится к другой версии сетапа.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return preflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||||
|
const row = record(value, "каталог сетапов");
|
||||||
|
exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов");
|
||||||
|
exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
return {
|
||||||
|
sourceSessionId: text(row.source_session_id, "source_session_id"),
|
||||||
|
setups: array(row.setups, "setups").map(decodeSetup),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSetup(value: unknown): ObservatoryLaboratorySetup {
|
||||||
|
const row = record(value, "сетап");
|
||||||
|
exactKeys(row, [
|
||||||
|
"authority", "compatibility", "description", "display_name", "executor", "origin",
|
||||||
|
"preflight", "preserved_results", "run_definition", "setup_id", "source",
|
||||||
|
], "сетап");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin");
|
||||||
|
const compatibility = record(row.compatibility, "compatibility");
|
||||||
|
const executor = record(row.executor, "executor");
|
||||||
|
const preflight = record(row.preflight, "preflight");
|
||||||
|
const source = record(row.source, "source");
|
||||||
|
const runDefinition = row.run_definition === null
|
||||||
|
? null
|
||||||
|
: decodeRunDefinition(row.run_definition);
|
||||||
|
if ((origin === "archived-definition") !== (runDefinition !== null)) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(
|
||||||
|
"Происхождение сетапа не совпадает с RunDefinition.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exactKeys(source, ["label", "required_modalities", "session_id"], "source");
|
||||||
|
text(source.session_id, "source.session_id");
|
||||||
|
text(source.label, "source.label");
|
||||||
|
array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality"));
|
||||||
|
exactKeys(compatibility, ["compatible", "reasons"], "compatibility");
|
||||||
|
exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor");
|
||||||
|
exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight");
|
||||||
|
return {
|
||||||
|
setupId: text(row.setup_id, "setup_id"),
|
||||||
|
displayName: text(row.display_name, "display_name"),
|
||||||
|
description: text(row.description, "description"),
|
||||||
|
origin,
|
||||||
|
runDefinition,
|
||||||
|
compatibility: {
|
||||||
|
compatible: boolean(row.compatibility && compatibility.compatible, "compatible"),
|
||||||
|
reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => {
|
||||||
|
const reason = record(item, "compatibility reason");
|
||||||
|
exactKeys(reason, ["code", "message"], "compatibility reason");
|
||||||
|
return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
executor: {
|
||||||
|
contourId: text(executor.contour_id, "executor.contour_id"),
|
||||||
|
state: exact(executor.state, "not-installed", "executor.state"),
|
||||||
|
reasonCode: text(executor.reason_code, "executor.reason_code"),
|
||||||
|
reason: text(executor.reason, "executor.reason"),
|
||||||
|
},
|
||||||
|
preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult),
|
||||||
|
preflight: {
|
||||||
|
outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"),
|
||||||
|
action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"),
|
||||||
|
reason: text(preflight.reason, "preflight.reason"),
|
||||||
|
submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"),
|
||||||
|
existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||||
|
const row = record(value, "RunDefinition");
|
||||||
|
exactKeys(row, [
|
||||||
|
"authority", "configuration", "definition_id", "definition_sha256", "schema_version",
|
||||||
|
"source", "version", "work_id",
|
||||||
|
], "RunDefinition");
|
||||||
|
exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
const source = record(row.source, "RunDefinition source");
|
||||||
|
exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source");
|
||||||
|
text(source.session_id, "RunDefinition source.session_id");
|
||||||
|
text(source.label, "RunDefinition source.label");
|
||||||
|
array(source.required_modalities, "RunDefinition source.required_modalities")
|
||||||
|
.forEach((item) => text(item, "RunDefinition source modality"));
|
||||||
|
const digest = text(row.definition_sha256, "definition_sha256");
|
||||||
|
if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition.");
|
||||||
|
return {
|
||||||
|
definitionId: text(row.definition_id, "definition_id"),
|
||||||
|
version: positiveInteger(row.version, "definition version"),
|
||||||
|
workId: text(row.work_id, "work_id"),
|
||||||
|
definitionSha256: digest,
|
||||||
|
resultSchema: null,
|
||||||
|
resultKind: null,
|
||||||
|
models: [],
|
||||||
|
configuration: array(row.configuration, "configuration").map((item) => {
|
||||||
|
const reference = record(item, "configuration reference");
|
||||||
|
exactKeys(reference, ["role", "sha256"], "configuration reference");
|
||||||
|
const sha256 = text(reference.sha256, "configuration sha256");
|
||||||
|
if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации.");
|
||||||
|
return { role: text(reference.role, "configuration role"), sha256 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult {
|
||||||
|
const row = record(value, "preserved result");
|
||||||
|
exactKeys(row, [
|
||||||
|
"access", "created_at_utc", "observatory_projection_available", "relation",
|
||||||
|
"result_id", "result_kind",
|
||||||
|
], "preserved result");
|
||||||
|
return {
|
||||||
|
resultId: text(row.result_id, "result_id"),
|
||||||
|
resultKind: text(row.result_kind, "result_kind"),
|
||||||
|
relation: text(row.relation, "relation"),
|
||||||
|
access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"),
|
||||||
|
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||||
|
observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
|
||||||
|
const row = record(value, "preflight");
|
||||||
|
const baseKeys = [
|
||||||
|
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
|
||||||
|
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
|
||||||
|
] as const;
|
||||||
|
const hasPortableCheck = Object.hasOwn(row, "check_sha256");
|
||||||
|
exactKeys(
|
||||||
|
row,
|
||||||
|
hasPortableCheck ? [...baseKeys, "check_sha256"] : baseKeys,
|
||||||
|
"preflight",
|
||||||
|
);
|
||||||
|
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
const digest = row.definition_sha256;
|
||||||
|
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
|
||||||
|
}
|
||||||
|
const checkDigest = hasPortableCheck ? row.check_sha256 : null;
|
||||||
|
if (
|
||||||
|
checkDigest !== null
|
||||||
|
&& (typeof checkDigest !== "string" || !SHA256.test(checkDigest))
|
||||||
|
) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(
|
||||||
|
"Некорректный check digest preflight.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const outcome = oneOf(
|
||||||
|
row.outcome,
|
||||||
|
["existing", "queueable", "blocked"] as const,
|
||||||
|
"preflight outcome",
|
||||||
|
);
|
||||||
|
const submissionAllowed = boolean(
|
||||||
|
row.submission_allowed,
|
||||||
|
"preflight submission_allowed",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
submissionAllowed !== (outcome === "queueable")
|
||||||
|
|| (hasPortableCheck && outcome === "queueable" && checkDigest === null)
|
||||||
|
) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(
|
||||||
|
"Preflight содержит противоречивое разрешение постановки в очередь.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
|
||||||
|
setupId: text(row.setup_id, "preflight setup_id"),
|
||||||
|
definitionSha256: digest,
|
||||||
|
checkSha256: checkDigest,
|
||||||
|
outcome,
|
||||||
|
submissionAllowed,
|
||||||
|
checks: array(row.checks, "preflight checks").map((item) => {
|
||||||
|
const check = record(item, "preflight check");
|
||||||
|
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
|
||||||
|
return {
|
||||||
|
checkId: text(check.check_id, "check_id"),
|
||||||
|
outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"),
|
||||||
|
reasonCode: text(check.reason_code, "check reason_code"),
|
||||||
|
message: text(check.message, "check message"),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function observationAuthority(value: unknown): void {
|
||||||
|
const row = record(value, "authority");
|
||||||
|
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
|
||||||
|
exactKeys(row, keys, "authority");
|
||||||
|
for (const key of keys) {
|
||||||
|
exact(row[key], false, `authority.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(fetcher: ObservatoryLaboratorySetupFetch, input: string, init: RequestInit): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetcher(input, init);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||||
|
throw new ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseBody(response: Response): Promise<unknown> {
|
||||||
|
const textBody = await response.text();
|
||||||
|
if (!textBody) return undefined;
|
||||||
|
try { return JSON.parse(textBody) as unknown; } catch { return textBody; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(body: unknown, status: number): ObservatoryLaboratorySetupContractError {
|
||||||
|
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||||
|
? (body as Record<string, unknown>).detail
|
||||||
|
: null;
|
||||||
|
return new ObservatoryLaboratorySetupContractError(
|
||||||
|
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function array(value: unknown, label: string): unknown[] {
|
||||||
|
if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value: unknown, label: string): boolean {
|
||||||
|
if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown, label: string): number {
|
||||||
|
if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`);
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||||
|
if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${label}: значение изменилось.`);
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
||||||
|
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(`${label}: значение не поддерживается.`);
|
||||||
|
}
|
||||||
|
return value as T[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const sortedExpected = [...expected].sort();
|
||||||
|
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
|
||||||
|
throw new ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import type {
|
||||||
|
ObservatoryLaboratoryPreservedResult,
|
||||||
|
ObservatoryLaboratoryRunDefinition,
|
||||||
|
ObservatoryLaboratorySetup,
|
||||||
|
ObservatoryLaboratorySetupCatalog,
|
||||||
|
} from "./laboratorySetups";
|
||||||
|
|
||||||
|
const PORTABLE_CATALOG_SCHEMA = "missioncore.observatory-portable-setup-catalog/v2";
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
export class ObservatoryPortableSetupDecodeError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryPortableSetupDecodeError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodePortableCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||||
|
const row = record(value, "portable-каталог сетапов");
|
||||||
|
exactKeys(
|
||||||
|
row,
|
||||||
|
["authority", "schema_version", "setups", "source_session_id"],
|
||||||
|
"portable-каталог сетапов",
|
||||||
|
);
|
||||||
|
exact(row.schema_version, PORTABLE_CATALOG_SCHEMA, "schema_version portable-каталога");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
const sourceSessionId = text(row.source_session_id, "portable source_session_id");
|
||||||
|
return {
|
||||||
|
sourceSessionId,
|
||||||
|
setups: array(row.setups, "portable setups").map((item) => decodePortableSetup(item, sourceSessionId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePortableSetup(value: unknown, sourceSessionId: string): ObservatoryLaboratorySetup {
|
||||||
|
const row = record(value, "portable-сетап");
|
||||||
|
exactKeys(row, [
|
||||||
|
"authority", "description", "display_name", "executor", "existing_results",
|
||||||
|
"origin", "preflight", "run_definition", "setup_id", "source_compatibility",
|
||||||
|
"source_requirements",
|
||||||
|
], "portable-сетап");
|
||||||
|
exact(row.origin, "portable-definition", "portable origin");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
decodePortableSourceRequirements(row.source_requirements);
|
||||||
|
const setupId = text(row.setup_id, "portable setup_id");
|
||||||
|
const runDefinition = decodePortableRunDefinition(row.run_definition);
|
||||||
|
|
||||||
|
const compatibility = record(row.source_compatibility, "portable source_compatibility");
|
||||||
|
exactKeys(
|
||||||
|
compatibility,
|
||||||
|
["compatible", "outcome", "reason", "reason_code"],
|
||||||
|
"portable source_compatibility",
|
||||||
|
);
|
||||||
|
const compatible = boolean(compatibility.compatible, "portable compatible");
|
||||||
|
exact(
|
||||||
|
compatibility.outcome,
|
||||||
|
compatible ? "pass" : "blocked",
|
||||||
|
"portable compatibility outcome",
|
||||||
|
);
|
||||||
|
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
|
||||||
|
const compatibilityReasonCode = text(
|
||||||
|
compatibility.reason_code,
|
||||||
|
"portable compatibility reason_code",
|
||||||
|
);
|
||||||
|
|
||||||
|
const executor = record(row.executor, "portable executor");
|
||||||
|
exactKeys(
|
||||||
|
executor,
|
||||||
|
["contour_id", "ready", "reason", "reason_code", "state"],
|
||||||
|
"portable executor",
|
||||||
|
);
|
||||||
|
const executorState = oneOf(
|
||||||
|
executor.state,
|
||||||
|
["not-installed", "ready"] as const,
|
||||||
|
"portable executor state",
|
||||||
|
);
|
||||||
|
const executorReady = boolean(executor.ready, "portable executor ready");
|
||||||
|
if (executorReady !== (executorState === "ready")) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
"Portable executor: состояние готовности противоречиво.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const executorReason = executor.reason === null
|
||||||
|
? "Исполнитель установлен."
|
||||||
|
: text(executor.reason, "portable executor reason");
|
||||||
|
const executorReasonCode = executor.reason_code === null
|
||||||
|
? "portable-executor-ready"
|
||||||
|
: text(executor.reason_code, "portable executor reason_code");
|
||||||
|
if (
|
||||||
|
(executorReady && (executor.reason !== null || executor.reason_code !== null))
|
||||||
|
|| (!executorReady && (executor.reason === null || executor.reason_code === null))
|
||||||
|
) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
"Portable executor: причина недоступности противоречит состоянию.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preflight = record(row.preflight, "portable preflight");
|
||||||
|
exactKeys(preflight, [
|
||||||
|
"action", "existing_result_ids", "outcome", "reason", "submission_allowed",
|
||||||
|
], "portable preflight");
|
||||||
|
const preflightOutcome = oneOf(
|
||||||
|
preflight.outcome,
|
||||||
|
["existing", "ready", "blocked"] as const,
|
||||||
|
"portable preflight outcome",
|
||||||
|
);
|
||||||
|
const preflightAction = oneOf(
|
||||||
|
preflight.action,
|
||||||
|
["open-existing", "check", "blocked"] as const,
|
||||||
|
"portable preflight action",
|
||||||
|
);
|
||||||
|
const submissionAllowed = boolean(
|
||||||
|
preflight.submission_allowed,
|
||||||
|
"portable preflight submission_allowed",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
submissionAllowed !== (preflightOutcome === "ready")
|
||||||
|
|| (preflightOutcome === "ready" && preflightAction !== "check")
|
||||||
|
|| (preflightOutcome === "blocked" && preflightAction !== "blocked")
|
||||||
|
|| (preflightOutcome === "existing" && preflightAction !== "open-existing")
|
||||||
|
|| (submissionAllowed && (!compatible || !executorReady))
|
||||||
|
) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
"Portable preflight: состояние запуска противоречиво.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingResults = array(row.existing_results, "portable existing_results").map(
|
||||||
|
(item) => decodePortableResult(item, sourceSessionId, setupId, runDefinition),
|
||||||
|
);
|
||||||
|
const existingResultIds = array(
|
||||||
|
preflight.existing_result_ids,
|
||||||
|
"portable existing_result_ids",
|
||||||
|
).map((item) => text(item, "portable existing result id"));
|
||||||
|
if (
|
||||||
|
(existingResults.length > 0) !== (preflightOutcome === "existing")
|
||||||
|
|| existingResultIds.length !== existingResults.length
|
||||||
|
|| new Set(existingResultIds).size !== existingResultIds.length
|
||||||
|
|| existingResults.some((result, index) => result.resultId !== existingResultIds[index])
|
||||||
|
) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
"Portable result: готовность не соответствует проверенным результатам.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setupId,
|
||||||
|
displayName: text(row.display_name, "portable display_name"),
|
||||||
|
description: text(row.description, "portable description"),
|
||||||
|
origin: "portable-definition",
|
||||||
|
runDefinition,
|
||||||
|
compatibility: {
|
||||||
|
compatible,
|
||||||
|
reasons: compatible
|
||||||
|
? []
|
||||||
|
: [{ code: compatibilityReasonCode, message: compatibilityReason }],
|
||||||
|
},
|
||||||
|
executor: {
|
||||||
|
contourId: text(executor.contour_id, "portable executor contour_id"),
|
||||||
|
state: executorState,
|
||||||
|
reasonCode: executorReasonCode,
|
||||||
|
reason: executorReason,
|
||||||
|
},
|
||||||
|
preservedResults: existingResults,
|
||||||
|
preflight: {
|
||||||
|
outcome: preflightOutcome,
|
||||||
|
action: preflightAction,
|
||||||
|
reason: text(preflight.reason, "portable preflight reason"),
|
||||||
|
submissionAllowed,
|
||||||
|
existingResultIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePortableResult(
|
||||||
|
value: unknown, sourceSessionId: string, setupId: string,
|
||||||
|
definition: ObservatoryLaboratoryRunDefinition,
|
||||||
|
): ObservatoryLaboratoryPreservedResult {
|
||||||
|
const row = record(value, "portable result");
|
||||||
|
exactKeys(row, [
|
||||||
|
"result_id", "result_kind", "relation", "access", "created_at_utc",
|
||||||
|
"observatory_projection_available", "identity",
|
||||||
|
], "portable result");
|
||||||
|
exact(row.result_kind, definition.resultKind, "portable result kind");
|
||||||
|
exact(row.relation, "exact-recorded-computation", "portable result relation");
|
||||||
|
exact(row.access, "observatory", "portable result access");
|
||||||
|
exact(row.observatory_projection_available, true, "portable result availability");
|
||||||
|
const identity = record(row.identity, "portable result identity");
|
||||||
|
exactKeys(identity, [
|
||||||
|
"job_id", "source_session_id", "source_catalog_sha256", "source_bundle_sha256",
|
||||||
|
"source_capability_manifest_sha256", "setup_id", "definition_sha256",
|
||||||
|
"package_sha256", "artifact_manifest_id",
|
||||||
|
], "portable result identity");
|
||||||
|
exact(identity.source_session_id, sourceSessionId, "portable result source");
|
||||||
|
exact(identity.setup_id, setupId, "portable result setup");
|
||||||
|
exact(identity.definition_sha256, definition.definitionSha256, "portable result definition");
|
||||||
|
text(identity.job_id, "portable result job");
|
||||||
|
for (const key of [
|
||||||
|
"source_catalog_sha256", "source_bundle_sha256", "source_capability_manifest_sha256",
|
||||||
|
"definition_sha256", "package_sha256", "artifact_manifest_id",
|
||||||
|
]) {
|
||||||
|
if (!SHA256.test(text(identity[key], `portable result ${key}`))) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(`Portable result: некорректный ${key}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultId: text(row.result_id, "portable result id"),
|
||||||
|
resultKind: text(row.result_kind, "portable result kind"),
|
||||||
|
relation: "exact-recorded-computation",
|
||||||
|
access: "observatory",
|
||||||
|
createdAtUtc: text(row.created_at_utc, "portable result created_at_utc"),
|
||||||
|
observatoryProjectionAvailable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePortableSourceRequirements(value: unknown): void {
|
||||||
|
const row = record(value, "portable source_requirements");
|
||||||
|
exactKeys(row, [
|
||||||
|
"archive_id", "calibration_identity_sha256", "calibration_slot", "camera_height",
|
||||||
|
"camera_semantic_channel_id", "camera_source_id", "camera_width",
|
||||||
|
"exactly_one_media_epoch", "plugin_id", "recorded_media_init_sha256",
|
||||||
|
"recorded_media_type", "required_modalities", "seekable",
|
||||||
|
], "portable source_requirements");
|
||||||
|
for (const key of [
|
||||||
|
"archive_id", "calibration_slot", "camera_semantic_channel_id", "camera_source_id",
|
||||||
|
"plugin_id", "recorded_media_type",
|
||||||
|
]) text(row[key], `portable source_requirements.${key}`);
|
||||||
|
for (const key of ["calibration_identity_sha256", "recorded_media_init_sha256"]) {
|
||||||
|
const digest = text(row[key], `portable source_requirements.${key}`);
|
||||||
|
if (!SHA256.test(digest)) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`portable source_requirements.${key}: некорректный digest.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positiveInteger(row.camera_width, "portable camera_width");
|
||||||
|
positiveInteger(row.camera_height, "portable camera_height");
|
||||||
|
exact(row.exactly_one_media_epoch, true, "portable exactly_one_media_epoch");
|
||||||
|
exact(row.seekable, true, "portable seekable");
|
||||||
|
array(row.required_modalities, "portable required_modalities")
|
||||||
|
.forEach((item) => text(item, "portable required modality"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePortableRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||||
|
const row = record(value, "portable RunDefinition");
|
||||||
|
exactKeys(row, [
|
||||||
|
"definition_id", "definition_sha256", "models", "result_kind", "result_schema",
|
||||||
|
"version",
|
||||||
|
], "portable RunDefinition");
|
||||||
|
const digest = text(row.definition_sha256, "portable definition_sha256");
|
||||||
|
if (!SHA256.test(digest)) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
"Portable RunDefinition: некорректный digest.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
definitionId: text(row.definition_id, "portable definition_id"),
|
||||||
|
version: positiveInteger(row.version, "portable definition version"),
|
||||||
|
workId: null,
|
||||||
|
definitionSha256: digest,
|
||||||
|
resultSchema: text(row.result_schema, "portable result_schema"),
|
||||||
|
resultKind: text(row.result_kind, "portable result_kind"),
|
||||||
|
models: array(row.models, "portable models").map((item) => {
|
||||||
|
const model = record(item, "portable model");
|
||||||
|
exactKeys(
|
||||||
|
model,
|
||||||
|
["architecture", "model_id", "name", "release_id"],
|
||||||
|
"portable model",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: text(model.name, "portable model name"),
|
||||||
|
releaseId: text(model.release_id, "portable model release_id"),
|
||||||
|
modelId: text(model.model_id, "portable model model_id"),
|
||||||
|
architecture: text(model.architecture, "portable model architecture"),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
configuration: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function observationAuthority(value: unknown): void {
|
||||||
|
const row = record(value, "authority");
|
||||||
|
const keys = [
|
||||||
|
"commands_enabled",
|
||||||
|
"actuation_allowed",
|
||||||
|
"navigation_or_safety_accepted",
|
||||||
|
"production_accepted",
|
||||||
|
];
|
||||||
|
exactKeys(row, keys, "authority");
|
||||||
|
for (const key of keys) exact(row[key], false, `authority.${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: ожидался объект.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function array(value: unknown, label: string): unknown[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: ожидался массив.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: ожидался текст.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value: unknown, label: string): boolean {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: ожидался boolean.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown, label: string): number {
|
||||||
|
if (!Number.isInteger(value) || Number(value) < 1) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: ожидалось положительное целое.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: значение изменилось.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<const T extends readonly string[]>(
|
||||||
|
value: unknown,
|
||||||
|
allowed: T,
|
||||||
|
label: string,
|
||||||
|
): T[number] {
|
||||||
|
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: значение не поддерживается.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value as T[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
expected: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const sortedExpected = [...expected].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== sortedExpected.length
|
||||||
|
|| actual.some((key, index) => key !== sortedExpected[index])
|
||||||
|
) {
|
||||||
|
throw new ObservatoryPortableSetupDecodeError(
|
||||||
|
`${label}: обнаружены неизвестные поля.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { decodePortableCatalog } from "./portableLaboratorySetupDecoder";
|
||||||
|
import type {
|
||||||
|
ObservatoryLaboratorySetupCatalog,
|
||||||
|
} from "./laboratorySetups";
|
||||||
|
|
||||||
|
export type ObservatoryPortableLaboratorySetupFetch = (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
class ObservatoryPortableLaboratorySetupContractError extends Error {
|
||||||
|
readonly status: number | null;
|
||||||
|
|
||||||
|
constructor(message: string, status: number | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryPortableLaboratorySetupContractError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchObservatoryPortableLaboratorySetups(
|
||||||
|
sourceSessionId: string,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryPortableLaboratorySetupFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
`/api/v1/observatory/portable-laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
|
const catalog = decodePortableCatalog(body);
|
||||||
|
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||||
|
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||||
|
"Portable-каталог сетапов относится к другой исходной сессии.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
fetcher: ObservatoryPortableLaboratorySetupFetch,
|
||||||
|
input: string,
|
||||||
|
init: RequestInit,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetcher(input, init);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||||
|
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||||
|
"Portable-каталог сетапов недоступен.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseBody(response: Response): Promise<unknown> {
|
||||||
|
const textBody = await response.text();
|
||||||
|
if (!textBody) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(textBody) as unknown;
|
||||||
|
} catch {
|
||||||
|
return textBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(
|
||||||
|
body: unknown,
|
||||||
|
status: number,
|
||||||
|
): ObservatoryPortableLaboratorySetupContractError {
|
||||||
|
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||||
|
? (body as Record<string, unknown>).detail
|
||||||
|
: null;
|
||||||
|
return new ObservatoryPortableLaboratorySetupContractError(
|
||||||
|
typeof detail === "string" && detail.trim()
|
||||||
|
? detail
|
||||||
|
: `Observatory API вернул HTTP ${status}.`,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1";
|
||||||
|
const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1";
|
||||||
|
const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1";
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
export type ObservatoryRecordedJobState =
|
||||||
|
| "accepted"
|
||||||
|
| "queued"
|
||||||
|
| "claimed"
|
||||||
|
| "running"
|
||||||
|
| "paused"
|
||||||
|
| "preemption-pending"
|
||||||
|
| "succeeded"
|
||||||
|
| "failed"
|
||||||
|
| "reconciliation-required";
|
||||||
|
|
||||||
|
export type ObservatoryRecordedJobPublicationState =
|
||||||
|
| "not-required"
|
||||||
|
| "pending"
|
||||||
|
| "failed"
|
||||||
|
| "published";
|
||||||
|
|
||||||
|
export interface ObservatoryRecordedJob {
|
||||||
|
readonly jobId: string;
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly sourceSessionId: string;
|
||||||
|
readonly setupId: string;
|
||||||
|
readonly definitionSha256: string;
|
||||||
|
readonly state: ObservatoryRecordedJobState;
|
||||||
|
readonly restartFromZero: boolean;
|
||||||
|
readonly resultId: string | null;
|
||||||
|
readonly terminalMessage: string | null;
|
||||||
|
readonly publication: {
|
||||||
|
readonly state: ObservatoryRecordedJobPublicationState;
|
||||||
|
readonly attempts: number;
|
||||||
|
readonly error: string | null;
|
||||||
|
readonly publishedAtUtc: string | null;
|
||||||
|
};
|
||||||
|
readonly createdAtUtc: string;
|
||||||
|
readonly updatedAtUtc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservatoryRecordedJobFetch = (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
export class ObservatoryRecordedJobContractError extends Error {
|
||||||
|
readonly status: number | null;
|
||||||
|
|
||||||
|
constructor(message: string, status: number | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryRecordedJobContractError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchObservatoryRecordedJobs(
|
||||||
|
sourceSessionId: string,
|
||||||
|
setupId: string,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
definitionSha256,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
definitionSha256?: string;
|
||||||
|
fetcher?: ObservatoryRecordedJobFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
source_session_id: sourceSessionId,
|
||||||
|
setup_id: setupId,
|
||||||
|
limit: "20",
|
||||||
|
});
|
||||||
|
if (definitionSha256 !== undefined) query.set("definition_sha256", definitionSha256);
|
||||||
|
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
|
const row = record(body, "список расчётов");
|
||||||
|
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
|
||||||
|
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
return array(row.items, "items").map(decodeJob).filter((job) => (
|
||||||
|
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||||
|
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitObservatoryRecordedJob(
|
||||||
|
sourceSessionId: string,
|
||||||
|
setupId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
portableBinding: {
|
||||||
|
readonly definitionSha256: string;
|
||||||
|
readonly checkSha256: string;
|
||||||
|
} | null,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryRecordedJobFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<ObservatoryRecordedJob> {
|
||||||
|
const response = await request(fetcher, "/api/v1/observatory/runs", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
schema_version: SUBMIT_SCHEMA,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
source_session_id: sourceSessionId,
|
||||||
|
setup_id: setupId,
|
||||||
|
...(portableBinding === null ? {} : {
|
||||||
|
definition_sha256: portableBinding.definitionSha256,
|
||||||
|
check_sha256: portableBinding.checkSha256,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
|
const job = decodeJob(body);
|
||||||
|
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
||||||
|
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
||||||
|
throw new ObservatoryRecordedJobContractError(
|
||||||
|
"Расчёт относится к другой сессии, сетапу или версии профиля.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryObservatoryRecordedJobPublication(
|
||||||
|
jobId: string,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: ObservatoryRecordedJobFetch;
|
||||||
|
} = {},
|
||||||
|
): Promise<ObservatoryRecordedJob> {
|
||||||
|
const response = await request(
|
||||||
|
fetcher,
|
||||||
|
`/api/v1/observatory/runs/${encodeURIComponent(jobId)}/publication/retry`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = await responseBody(response);
|
||||||
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
|
const job = decodeJob(body);
|
||||||
|
if (job.jobId !== jobId) {
|
||||||
|
throw new ObservatoryRecordedJobContractError(
|
||||||
|
"Повтор публикации вернул другой расчёт.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||||
|
const row = record(value, "расчёт");
|
||||||
|
exactKeys(row, [
|
||||||
|
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||||
|
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
|
||||||
|
"preemption_requested", "priority", "publication", "request_sha256", "restart_from_zero", "result",
|
||||||
|
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
|
||||||
|
"updated_at_utc",
|
||||||
|
], "расчёт");
|
||||||
|
exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта");
|
||||||
|
observationAuthority(row.authority);
|
||||||
|
digest(row.request_sha256, "request_sha256");
|
||||||
|
digest(row.identity_sha256, "identity_sha256");
|
||||||
|
digest(row.submission_receipt_sha256, "submission_receipt_sha256");
|
||||||
|
const source = record(row.source, "source");
|
||||||
|
exactKeys(source, [
|
||||||
|
"adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id",
|
||||||
|
], "source");
|
||||||
|
digest(source.catalog_sha256, "source.catalog_sha256");
|
||||||
|
digest(source.bundle_sha256, "source.bundle_sha256");
|
||||||
|
digest(source.capability_manifest_sha256, "source.capability_manifest_sha256");
|
||||||
|
const setup = record(row.setup, "setup");
|
||||||
|
exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup");
|
||||||
|
digest(setup.definition_sha256, "setup.definition_sha256");
|
||||||
|
const state = oneOf(row.state, [
|
||||||
|
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||||
|
"succeeded", "failed", "reconciliation-required",
|
||||||
|
] as const, "state");
|
||||||
|
if (row.claim_lease !== null) {
|
||||||
|
const lease = record(row.claim_lease, "claim_lease");
|
||||||
|
exactKeys(
|
||||||
|
lease,
|
||||||
|
["claimed_at_utc", "expires_at_utc", "heartbeat_at_utc", "renewal_count"],
|
||||||
|
"claim_lease",
|
||||||
|
);
|
||||||
|
text(lease.claimed_at_utc, "claim_lease.claimed_at_utc");
|
||||||
|
text(lease.expires_at_utc, "claim_lease.expires_at_utc");
|
||||||
|
text(lease.heartbeat_at_utc, "claim_lease.heartbeat_at_utc");
|
||||||
|
nonNegativeInteger(lease.renewal_count, "claim_lease.renewal_count");
|
||||||
|
}
|
||||||
|
const result = row.result === null ? null : record(row.result, "result");
|
||||||
|
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
|
||||||
|
const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
|
||||||
|
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
|
||||||
|
const publication = record(row.publication, "publication");
|
||||||
|
exactKeys(
|
||||||
|
publication,
|
||||||
|
["attempts", "error", "published_at_utc", "state"],
|
||||||
|
"publication",
|
||||||
|
);
|
||||||
|
const publicationState = oneOf(publication.state, [
|
||||||
|
"not-required", "pending", "failed", "published",
|
||||||
|
] as const, "publication.state");
|
||||||
|
const publicationError = publication.error === null
|
||||||
|
? null
|
||||||
|
: text(publication.error, "publication.error");
|
||||||
|
const publishedAtUtc = publication.published_at_utc === null
|
||||||
|
? null
|
||||||
|
: text(publication.published_at_utc, "publication.published_at_utc");
|
||||||
|
return {
|
||||||
|
jobId: text(row.job_id, "job_id"),
|
||||||
|
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
|
||||||
|
sourceSessionId: text(source.session_id, "source.session_id"),
|
||||||
|
setupId: text(setup.setup_id, "setup.setup_id"),
|
||||||
|
definitionSha256: String(setup.definition_sha256),
|
||||||
|
state,
|
||||||
|
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
|
||||||
|
resultId: result === null ? null : text(result.result_id, "result.result_id"),
|
||||||
|
terminalMessage: terminal === null || terminal.message === null
|
||||||
|
? null
|
||||||
|
: text(terminal.message, "terminal.message"),
|
||||||
|
publication: {
|
||||||
|
state: publicationState,
|
||||||
|
attempts: nonNegativeInteger(publication.attempts, "publication.attempts"),
|
||||||
|
error: publicationError,
|
||||||
|
publishedAtUtc,
|
||||||
|
},
|
||||||
|
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||||
|
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function observationAuthority(value: unknown): void {
|
||||||
|
const row = record(value, "authority");
|
||||||
|
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
|
||||||
|
exactKeys(row, keys, "authority");
|
||||||
|
keys.forEach((key) => exact(row[key], false, `authority.${key}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetcher(input, init);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||||
|
throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseBody(response: Response): Promise<unknown> {
|
||||||
|
const body = await response.text();
|
||||||
|
if (!body) return undefined;
|
||||||
|
try { return JSON.parse(body) as unknown; } catch { return body; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError {
|
||||||
|
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||||
|
? (body as Record<string, unknown>).detail
|
||||||
|
: null;
|
||||||
|
return new ObservatoryRecordedJobContractError(
|
||||||
|
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function array(value: unknown, label: string): unknown[] {
|
||||||
|
if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value: unknown, label: string): string {
|
||||||
|
const valueText = text(value, label);
|
||||||
|
if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`);
|
||||||
|
return valueText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value: unknown, label: string): boolean {
|
||||||
|
if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeInteger(value: unknown, label: string): number {
|
||||||
|
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||||
|
throw new ObservatoryRecordedJobContractError(`${label}: ожидалось целое число.`);
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||||
|
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
||||||
|
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||||
|
throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`);
|
||||||
|
}
|
||||||
|
return value as T[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const sortedExpected = [...expected].sort();
|
||||||
|
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
|
||||||
|
throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import type { LaboratoryFetch } from "../laboratory/advancedResults";
|
||||||
|
import {
|
||||||
|
fetchVegetationShadowResultMetadata,
|
||||||
|
type VegetationFullRouteReview,
|
||||||
|
type VegetationShadowResult,
|
||||||
|
} from "../laboratory/vegetationShadow";
|
||||||
|
import type { ObservationLabInstance } from "../observation/sessionArchive";
|
||||||
|
import {
|
||||||
|
decodeLabReplayCapability,
|
||||||
|
decodeCanonicalLabReplayCapabilityProvenance,
|
||||||
|
decodePortableLabReplayCapabilityProvenance,
|
||||||
|
ObservationLabReplayCapabilityContractError,
|
||||||
|
} from "../observation/labReplayCapability";
|
||||||
|
|
||||||
|
const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||||
|
const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live";
|
||||||
|
|
||||||
|
interface ObservatoryRecordedRunBindingBase {
|
||||||
|
readonly evidenceSessionId: string;
|
||||||
|
readonly sourceSessionId: string;
|
||||||
|
readonly resultId: string;
|
||||||
|
readonly activation: "explicit";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatoryCanonicalRecordedRunBinding
|
||||||
|
extends ObservatoryRecordedRunBindingBase {
|
||||||
|
readonly kind: "canonical-recorded-rerun";
|
||||||
|
readonly viewerProfile: "recorded-session";
|
||||||
|
readonly timeline: "session_time";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservatoryPortableResultBinding
|
||||||
|
extends ObservatoryRecordedRunBindingBase {
|
||||||
|
readonly kind: "portable-result-review";
|
||||||
|
readonly viewerProfile: "portable-result";
|
||||||
|
readonly timeline: "result-defined";
|
||||||
|
readonly definitionSha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservatoryRecordedRunBinding =
|
||||||
|
| ObservatoryCanonicalRecordedRunBinding
|
||||||
|
| ObservatoryPortableResultBinding;
|
||||||
|
|
||||||
|
export interface ObservatoryPortableResultReview {
|
||||||
|
readonly kind: "portable-result";
|
||||||
|
readonly resultId: string;
|
||||||
|
readonly sourceSessionId: string;
|
||||||
|
readonly resultKind: string;
|
||||||
|
readonly definitionSha256: string;
|
||||||
|
readonly calculationProfile: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly artifactManifestId: string;
|
||||||
|
readonly artifacts: readonly {
|
||||||
|
readonly role: string;
|
||||||
|
readonly mediaType: string;
|
||||||
|
readonly sha256: string;
|
||||||
|
readonly byteLength: number;
|
||||||
|
}[];
|
||||||
|
readonly resultDocument: Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservatoryRecordedRunReview =
|
||||||
|
| { readonly kind: "canonical-recorded-rerun"; readonly review: VegetationFullRouteReview }
|
||||||
|
| ObservatoryPortableResultReview;
|
||||||
|
|
||||||
|
export class ObservatoryRecordedRunContractError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ObservatoryRecordedRunContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observatoryRecordedRunBinding(
|
||||||
|
evidenceSessionId: string,
|
||||||
|
lab: ObservationLabInstance,
|
||||||
|
): ObservatoryRecordedRunBinding | null {
|
||||||
|
const capability = lab.replayCapability;
|
||||||
|
if (!capability) return null;
|
||||||
|
if (capability.kind === "portable-result-review") {
|
||||||
|
if (
|
||||||
|
evidenceSessionId !== lab.resultId
|
||||||
|
|| lab.sourceSessionId === evidenceSessionId
|
||||||
|
|| lab.configSha256 === null
|
||||||
|
|| !/^[a-f0-9]{64}$/.test(lab.configSha256)
|
||||||
|
|| capability.viewerProfile !== "portable-result"
|
||||||
|
|| capability.timeline !== "result-defined"
|
||||||
|
|| capability.activation !== "explicit"
|
||||||
|
|| capability.commandsEnabled !== false
|
||||||
|
) {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
`Portable-результат ${lab.resultId} не допущен к универсальному viewer.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
decodePortableLabReplayCapabilityProvenance(
|
||||||
|
lab.provenance,
|
||||||
|
evidenceSessionId,
|
||||||
|
lab.resultId,
|
||||||
|
lab.sourceSessionId,
|
||||||
|
lab.configSha256,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||||
|
throw new ObservatoryRecordedRunContractError(error.message);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "portable-result-review",
|
||||||
|
evidenceSessionId,
|
||||||
|
sourceSessionId: lab.sourceSessionId,
|
||||||
|
resultId: lab.resultId,
|
||||||
|
viewerProfile: "portable-result",
|
||||||
|
timeline: "result-defined",
|
||||||
|
activation: "explicit",
|
||||||
|
definitionSha256: lab.configSha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
decodeCanonicalLabReplayCapabilityProvenance(
|
||||||
|
lab.provenance,
|
||||||
|
evidenceSessionId,
|
||||||
|
lab.resultId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||||
|
throw new ObservatoryRecordedRunContractError(error.message);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
evidenceSessionId !== lab.resultId
|
||||||
|
|| lab.sourceSessionId !== CANONICAL_SOURCE_SESSION_ID
|
||||||
|
|| lab.labId !== "LAB V1"
|
||||||
|
|| lab.resultKind !== "recorded-perception-qualification"
|
||||||
|
|| !CANONICAL_RESULT_ID.test(lab.resultId)
|
||||||
|
|| lab.configSha256 !== null
|
||||||
|
|| lab.sourceResultId === null
|
||||||
|
|| !CANONICAL_RESULT_ID.test(lab.sourceResultId)
|
||||||
|
|| capability.kind !== "canonical-recorded-rerun"
|
||||||
|
|| capability.viewerProfile !== "recorded-session"
|
||||||
|
|| capability.timeline !== "session_time"
|
||||||
|
|| capability.activation !== "explicit"
|
||||||
|
|| capability.commandsEnabled !== false
|
||||||
|
) {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
`LAB-результат ${lab.resultId} не допущен к каноническому recorded replay.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
evidenceSessionId,
|
||||||
|
sourceSessionId: lab.sourceSessionId,
|
||||||
|
resultId: lab.resultId,
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchObservatoryRecordedRunReview(
|
||||||
|
binding: ObservatoryRecordedRunBinding,
|
||||||
|
{
|
||||||
|
selectedSourceSessionId,
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
selectedSourceSessionId: string;
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
): Promise<ObservatoryRecordedRunReview> {
|
||||||
|
if (binding.sourceSessionId !== selectedSourceSessionId) {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
"Запуск не связан с выбранной исходной сессией.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (binding.kind === "portable-result-review") {
|
||||||
|
return fetchPortableResultReview(binding, { fetcher, signal });
|
||||||
|
}
|
||||||
|
const result = await fetchVegetationShadowResultMetadata(binding.resultId, {
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
review: admitObservatoryRecordedRunReview(
|
||||||
|
binding,
|
||||||
|
selectedSourceSessionId,
|
||||||
|
result,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function admitObservatoryRecordedRunReview(
|
||||||
|
binding: ObservatoryCanonicalRecordedRunBinding,
|
||||||
|
selectedSourceSessionId: string,
|
||||||
|
result: VegetationShadowResult,
|
||||||
|
): VegetationFullRouteReview {
|
||||||
|
if (binding.sourceSessionId !== selectedSourceSessionId) {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
"Запуск не связан с выбранной исходной сессией.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.resultId !== binding.resultId) {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
"Запечатанный результат не совпадает с выбранным запуском.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const review = result.routeFullReview;
|
||||||
|
if (!review || review.sessionId !== binding.sourceSessionId) {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
"Запечатанный результат потерял точную связь с исходной сессией.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPortableResultReview(
|
||||||
|
binding: ObservatoryPortableResultBinding,
|
||||||
|
{
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
fetcher: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
): Promise<ObservatoryPortableResultReview> {
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/observatory/portable-results/${encodeURIComponent(binding.resultId)}`,
|
||||||
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
"Portable viewer получил некорректный ответ сервера.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ObservatoryRecordedRunContractError(
|
||||||
|
isRecord(body) && typeof body.detail === "string"
|
||||||
|
? body.detail
|
||||||
|
: `Portable viewer вернул HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decodePortableResultReview(body, binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePortableResultReview(
|
||||||
|
value: unknown,
|
||||||
|
binding: ObservatoryPortableResultBinding,
|
||||||
|
): ObservatoryPortableResultReview {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new ObservatoryRecordedRunContractError("Portable viewer вернул не объект.");
|
||||||
|
}
|
||||||
|
const expected = new Set([
|
||||||
|
"schema_version",
|
||||||
|
"result_id",
|
||||||
|
"source_session_id",
|
||||||
|
"result_kind",
|
||||||
|
"definition_sha256",
|
||||||
|
"viewer_capability",
|
||||||
|
"calculation_profile",
|
||||||
|
"artifact_manifest_id",
|
||||||
|
"artifacts",
|
||||||
|
"result_document",
|
||||||
|
]);
|
||||||
|
if (Object.keys(value).some((key) => !expected.has(key)) || Object.keys(value).length !== expected.size) {
|
||||||
|
throw new ObservatoryRecordedRunContractError("Portable viewer изменил контракт ответа.");
|
||||||
|
}
|
||||||
|
let viewerCapability;
|
||||||
|
try {
|
||||||
|
viewerCapability = decodeLabReplayCapability(
|
||||||
|
value.viewer_capability,
|
||||||
|
binding.evidenceSessionId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||||
|
throw new ObservatoryRecordedRunContractError(error.message);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.schema_version !== "missioncore.observatory-portable-result-view/v1"
|
||||||
|
|| value.result_id !== binding.resultId
|
||||||
|
|| value.source_session_id !== binding.sourceSessionId
|
||||||
|
|| value.definition_sha256 !== binding.definitionSha256
|
||||||
|
|| typeof value.result_kind !== "string"
|
||||||
|
|| typeof value.artifact_manifest_id !== "string"
|
||||||
|
|| !/^[a-f0-9]{64}$/.test(value.artifact_manifest_id)
|
||||||
|
|| !Array.isArray(value.artifacts)
|
||||||
|
|| !isRecord(value.result_document)
|
||||||
|
|| !isRecord(value.calculation_profile)
|
||||||
|
|| viewerCapability === null
|
||||||
|
|| viewerCapability.kind !== "portable-result-review"
|
||||||
|
|| viewerCapability.viewerProfile !== binding.viewerProfile
|
||||||
|
|| viewerCapability.timeline !== binding.timeline
|
||||||
|
) {
|
||||||
|
throw new ObservatoryRecordedRunContractError("Portable viewer потерял identity результата.");
|
||||||
|
}
|
||||||
|
const artifacts = value.artifacts.map((item) => {
|
||||||
|
if (
|
||||||
|
!isRecord(item)
|
||||||
|
|| Object.keys(item).length !== 4
|
||||||
|
|| !["role", "media_type", "sha256", "byte_length"].every(
|
||||||
|
(key) => Object.prototype.hasOwnProperty.call(item, key),
|
||||||
|
)
|
||||||
|
|| typeof item.role !== "string"
|
||||||
|
|| typeof item.media_type !== "string"
|
||||||
|
|| typeof item.sha256 !== "string"
|
||||||
|
|| !/^[a-f0-9]{64}$/.test(item.sha256)
|
||||||
|
|| typeof item.byte_length !== "number"
|
||||||
|
|| !Number.isSafeInteger(item.byte_length)
|
||||||
|
|| item.byte_length < 0
|
||||||
|
) {
|
||||||
|
throw new ObservatoryRecordedRunContractError("Portable viewer получил неверный artifact.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: item.role,
|
||||||
|
mediaType: item.media_type,
|
||||||
|
sha256: item.sha256,
|
||||||
|
byteLength: item.byte_length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
kind: "portable-result",
|
||||||
|
resultId: binding.resultId,
|
||||||
|
sourceSessionId: binding.sourceSessionId,
|
||||||
|
resultKind: value.result_kind,
|
||||||
|
definitionSha256: binding.definitionSha256,
|
||||||
|
calculationProfile: value.calculation_profile,
|
||||||
|
artifactManifestId: value.artifact_manifest_id,
|
||||||
|
artifacts,
|
||||||
|
resultDocument: value.result_document,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyObservatoryCatalogMutationOverlay,
|
||||||
|
fetchObservatoryCatalog,
|
||||||
|
reconcileObservatoryCatalogMutationOverlay,
|
||||||
|
type ObservatoryCatalog,
|
||||||
|
type ObservatoryCatalogMutationOverlay,
|
||||||
|
} from "./catalog";
|
||||||
|
|
||||||
|
export type ObservatoryCatalogState =
|
||||||
|
| "idle"
|
||||||
|
| "loading"
|
||||||
|
| "ready"
|
||||||
|
| "refreshing"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface ObservatoryCatalogController {
|
||||||
|
readonly catalog: ObservatoryCatalog | null;
|
||||||
|
readonly state: ObservatoryCatalogState;
|
||||||
|
readonly error: string | null;
|
||||||
|
readonly refresh: () => Promise<ObservatoryCatalog | null>;
|
||||||
|
readonly applyEvidenceRename: (
|
||||||
|
sessionId: string,
|
||||||
|
displayName: string,
|
||||||
|
) => void;
|
||||||
|
readonly applyEvidenceDeletion: (sessionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveCatalogRequest {
|
||||||
|
readonly id: number;
|
||||||
|
readonly controller: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObservatoryCatalogMutationDraft =
|
||||||
|
| { readonly kind: "rename"; readonly displayName: string }
|
||||||
|
| { readonly kind: "delete" };
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.message.trim()
|
||||||
|
? error.message
|
||||||
|
: "Каталог Обсерватории недоступен.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException && error.name === "AbortError";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObservatoryCatalog(): ObservatoryCatalogController {
|
||||||
|
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
||||||
|
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
||||||
|
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
|
||||||
|
const mutationRevisionRef = useRef(0);
|
||||||
|
const requestSequenceRef = useRef(0);
|
||||||
|
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
|
||||||
|
|
||||||
|
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
|
||||||
|
const id = requestSequenceRef.current + 1;
|
||||||
|
requestSequenceRef.current = id;
|
||||||
|
activeRequestRef.current?.controller.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeRequestRef.current = { id, controller };
|
||||||
|
const requestMutationRevision = mutationRevisionRef.current;
|
||||||
|
|
||||||
|
setState(catalogRef.current ? "refreshing" : "loading");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const serverCatalog = await fetchObservatoryCatalog({
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (controller.signal.aborted || requestSequenceRef.current !== id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const reconciled = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
serverCatalog,
|
||||||
|
overlayRef.current,
|
||||||
|
requestMutationRevision,
|
||||||
|
);
|
||||||
|
overlayRef.current = reconciled.overlay;
|
||||||
|
catalogRef.current = reconciled.catalog;
|
||||||
|
activeRequestRef.current = null;
|
||||||
|
setCatalog(reconciled.catalog);
|
||||||
|
setState("ready");
|
||||||
|
return reconciled.catalog;
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
if (
|
||||||
|
controller.signal.aborted
|
||||||
|
|| requestSequenceRef.current !== id
|
||||||
|
|| isAbortError(loadError)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
activeRequestRef.current = null;
|
||||||
|
setError(errorMessage(loadError));
|
||||||
|
setState("error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCatalog();
|
||||||
|
return () => {
|
||||||
|
requestSequenceRef.current += 1;
|
||||||
|
activeRequestRef.current?.controller.abort();
|
||||||
|
activeRequestRef.current = null;
|
||||||
|
};
|
||||||
|
}, [loadCatalog]);
|
||||||
|
|
||||||
|
const applyMutation = useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
mutation: ObservatoryCatalogMutationDraft,
|
||||||
|
) => {
|
||||||
|
const revision = mutationRevisionRef.current + 1;
|
||||||
|
mutationRevisionRef.current = revision;
|
||||||
|
const overlay = new Map(overlayRef.current);
|
||||||
|
overlay.set(sessionId, { ...mutation, revision });
|
||||||
|
overlayRef.current = overlay;
|
||||||
|
if (!catalogRef.current) return;
|
||||||
|
const projected = applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalogRef.current,
|
||||||
|
overlay,
|
||||||
|
);
|
||||||
|
catalogRef.current = projected;
|
||||||
|
setCatalog(projected);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyEvidenceRename = useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
displayName: string,
|
||||||
|
) => {
|
||||||
|
applyMutation(sessionId, { kind: "rename", displayName });
|
||||||
|
}, [applyMutation]);
|
||||||
|
|
||||||
|
const applyEvidenceDeletion = useCallback((sessionId: string) => {
|
||||||
|
applyMutation(sessionId, { kind: "delete" });
|
||||||
|
}, [applyMutation]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog,
|
||||||
|
state,
|
||||||
|
error,
|
||||||
|
refresh: loadCatalog,
|
||||||
|
applyEvidenceRename,
|
||||||
|
applyEvidenceDeletion,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchObservatoryPortableLaboratorySetups,
|
||||||
|
preflightObservatoryLaboratorySetup,
|
||||||
|
selectableObservatorySetups,
|
||||||
|
type ObservatoryLaboratoryRunPreflight,
|
||||||
|
type ObservatoryLaboratorySetupCatalog,
|
||||||
|
} from "./laboratorySetups";
|
||||||
|
|
||||||
|
type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error";
|
||||||
|
export type SetupPreflightState =
|
||||||
|
| { readonly kind: "idle" }
|
||||||
|
| { readonly kind: "checking" }
|
||||||
|
| { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight }
|
||||||
|
| { readonly kind: "error"; readonly message: string };
|
||||||
|
|
||||||
|
export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
||||||
|
const [catalog, setCatalog] = useState<ObservatoryLaboratorySetupCatalog | null>(null);
|
||||||
|
const [state, setState] = useState<SetupCatalogState>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedSetupId, setSelectedSetupId] = useState("");
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const [preflight, setPreflight] = useState<SetupPreflightState>({ kind: "idle" });
|
||||||
|
const requestSequence = useRef(0);
|
||||||
|
const preflightRequest = useRef<AbortController | null>(null);
|
||||||
|
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
|
||||||
|
const selectableSetups = useMemo(() => selectableObservatorySetups(activeCatalog), [activeCatalog]);
|
||||||
|
const selectedSetup = selectableSetups.find((setup) => setup.setupId === selectedSetupId) ?? null;
|
||||||
|
const selectedDefinitionSha256 = selectedSetup?.runDefinition?.definitionSha256 ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
preflightRequest.current?.abort();
|
||||||
|
preflightRequest.current = null;
|
||||||
|
if (!sourceSessionId) {
|
||||||
|
setCatalog(null);
|
||||||
|
setState("idle");
|
||||||
|
setError(null);
|
||||||
|
setSelectedSetupId("");
|
||||||
|
setPreflight({ kind: "idle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sequence = ++requestSequence.current;
|
||||||
|
const request = new AbortController();
|
||||||
|
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
||||||
|
setError(null);
|
||||||
|
setPreflight({ kind: "idle" });
|
||||||
|
// Archived definitions belong to evidence, not to the calculation selector.
|
||||||
|
void fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||||
|
.then((next) => {
|
||||||
|
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||||
|
setCatalog(next);
|
||||||
|
const selectable = selectableObservatorySetups(next);
|
||||||
|
setSelectedSetupId((current) => (
|
||||||
|
selectable.some((setup) => setup.setupId === current)
|
||||||
|
? current
|
||||||
|
: selectable[0]?.setupId ?? ""
|
||||||
|
));
|
||||||
|
setState("ready");
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||||
|
setState("error");
|
||||||
|
setError(caught instanceof Error && caught.message.trim()
|
||||||
|
? caught.message
|
||||||
|
: "Каталог профилей недоступен.");
|
||||||
|
});
|
||||||
|
return () => request.abort();
|
||||||
|
}, [revision, sourceSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => () => preflightRequest.current?.abort(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
preflightRequest.current?.abort();
|
||||||
|
preflightRequest.current = null;
|
||||||
|
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
|
||||||
|
}, [selectedSetupId, sourceSessionId, selectedDefinitionSha256]);
|
||||||
|
|
||||||
|
const selectSetup = useCallback((setupId: string) => {
|
||||||
|
preflightRequest.current?.abort();
|
||||||
|
preflightRequest.current = null;
|
||||||
|
setSelectedSetupId(setupId);
|
||||||
|
setPreflight({ kind: "idle" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||||
|
|
||||||
|
const check = useCallback(async () => {
|
||||||
|
if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return;
|
||||||
|
preflightRequest.current?.abort();
|
||||||
|
const request = new AbortController();
|
||||||
|
preflightRequest.current = request;
|
||||||
|
setPreflight({ kind: "checking" });
|
||||||
|
try {
|
||||||
|
const value = await preflightObservatoryLaboratorySetup(
|
||||||
|
sourceSessionId,
|
||||||
|
selectedSetup,
|
||||||
|
{ signal: request.signal },
|
||||||
|
);
|
||||||
|
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||||
|
setPreflight({ kind: "ready", value });
|
||||||
|
} catch (caught) {
|
||||||
|
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||||
|
setPreflight({
|
||||||
|
kind: "error",
|
||||||
|
message: caught instanceof Error && caught.message.trim()
|
||||||
|
? caught.message
|
||||||
|
: "Не удалось проверить возможность запуска.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (preflightRequest.current === request) preflightRequest.current = null;
|
||||||
|
}
|
||||||
|
}, [preflight.kind, selectedSetup, sourceSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return;
|
||||||
|
void check();
|
||||||
|
}, [check, preflight.kind, selectedSetup, state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog: activeCatalog,
|
||||||
|
selectableSetups,
|
||||||
|
state,
|
||||||
|
error,
|
||||||
|
selectedSetupId: selectedSetup?.setupId ?? "",
|
||||||
|
selectedSetup,
|
||||||
|
selectSetup,
|
||||||
|
refresh,
|
||||||
|
preflight,
|
||||||
|
check,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchObservatoryRecordedJobs,
|
||||||
|
retryObservatoryRecordedJobPublication,
|
||||||
|
submitObservatoryRecordedJob,
|
||||||
|
type ObservatoryRecordedJob,
|
||||||
|
} from "./recordedJobs";
|
||||||
|
|
||||||
|
type RecordedJobsState =
|
||||||
|
| "idle"
|
||||||
|
| "loading"
|
||||||
|
| "ready"
|
||||||
|
| "refreshing"
|
||||||
|
| "submitting"
|
||||||
|
| "retrying-publication"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
interface JobSnapshot {
|
||||||
|
readonly selectionKey: string;
|
||||||
|
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||||
|
readonly state: RecordedJobsState;
|
||||||
|
readonly error: string | null;
|
||||||
|
}
|
||||||
|
const EMPTY_JOBS = [] as const;
|
||||||
|
const OPEN_STATES = new Set([
|
||||||
|
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||||
|
"reconciliation-required",
|
||||||
|
]);
|
||||||
|
const POLL_INTERVAL_MS = 1_500;
|
||||||
|
|
||||||
|
export function useObservatoryRecordedJobs(
|
||||||
|
sourceSessionId: string,
|
||||||
|
setupId: string,
|
||||||
|
definitionSha256: string,
|
||||||
|
) {
|
||||||
|
const selectionKey = JSON.stringify([sourceSessionId, setupId, definitionSha256]);
|
||||||
|
const [snapshot, setSnapshot] = useState<JobSnapshot>({
|
||||||
|
selectionKey: "", jobs: EMPTY_JOBS, state: "idle", error: null,
|
||||||
|
});
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const requestRef = useRef<AbortController | null>(null);
|
||||||
|
const requestSequence = useRef(0);
|
||||||
|
const idempotencyKeys = useRef(new Map<string, string>());
|
||||||
|
const observedJobId = useRef<string | null>(null);
|
||||||
|
const hasSelection = Boolean(sourceSessionId && setupId && definitionSha256);
|
||||||
|
// A source/profile change must not display even one render of the previous queue.
|
||||||
|
const current = snapshot.selectionKey === selectionKey ? snapshot : null;
|
||||||
|
const jobs = current?.jobs ?? EMPTY_JOBS;
|
||||||
|
const state = current?.state ?? (hasSelection ? "loading" : "idle");
|
||||||
|
const error = current?.error ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestRef.current?.abort();
|
||||||
|
requestRef.current = null;
|
||||||
|
if (!hasSelection) {
|
||||||
|
setSnapshot({ selectionKey, jobs: EMPTY_JOBS, state: "idle", error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sequence = ++requestSequence.current;
|
||||||
|
const request = new AbortController();
|
||||||
|
requestRef.current = request;
|
||||||
|
setSnapshot({
|
||||||
|
selectionKey, jobs,
|
||||||
|
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
|
||||||
|
});
|
||||||
|
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
|
||||||
|
definitionSha256, signal: request.signal,
|
||||||
|
}).then((next) => {
|
||||||
|
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||||
|
const pending = next.find((job) => OPEN_STATES.has(job.state));
|
||||||
|
if (pending) observedJobId.current = pending.jobId;
|
||||||
|
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||||
|
setSnapshot({
|
||||||
|
selectionKey, jobs, state: "error",
|
||||||
|
error: caught instanceof Error && caught.message.trim()
|
||||||
|
? caught.message
|
||||||
|
: "Очередь расчётов недоступна.",
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
if (requestRef.current === request) requestRef.current = null;
|
||||||
|
});
|
||||||
|
return () => request.abort();
|
||||||
|
}, [revision, selectionKey]);
|
||||||
|
|
||||||
|
useEffect(() => () => requestRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const latestJob = jobs[0] ?? null;
|
||||||
|
const activeJob = jobs.find((job) => OPEN_STATES.has(job.state)) ?? null;
|
||||||
|
const publicationPending = jobs.some((job) => job.publication.state === "pending");
|
||||||
|
// Do not promote historical terminal logs to a new operator action's error.
|
||||||
|
const computationFailed = latestJob?.state === "failed"
|
||||||
|
&& latestJob.jobId === observedJobId.current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "ready" || (!activeJob && !publicationPending)) return;
|
||||||
|
const timer = globalThis.setTimeout(
|
||||||
|
() => setRevision((value) => value + 1),
|
||||||
|
POLL_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [activeJob, publicationPending, revision, state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!latestJob || activeJob) return;
|
||||||
|
idempotencyKeys.current.delete(selectionKey);
|
||||||
|
}, [activeJob, latestJob, selectionKey]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||||
|
|
||||||
|
const submit = useCallback(async (
|
||||||
|
portableBinding: {
|
||||||
|
readonly definitionSha256: string;
|
||||||
|
readonly checkSha256: string;
|
||||||
|
} | null = null,
|
||||||
|
): Promise<ObservatoryRecordedJob | null> => {
|
||||||
|
if (!hasSelection || state !== "ready"
|
||||||
|
|| portableBinding?.definitionSha256 !== definitionSha256
|
||||||
|
|| publicationPending || latestJob?.publication.state === "failed") return null;
|
||||||
|
if (activeJob) return activeJob;
|
||||||
|
requestRef.current?.abort();
|
||||||
|
const request = new AbortController();
|
||||||
|
requestRef.current = request;
|
||||||
|
const key = idempotencyKeys.current.get(selectionKey) ?? createIdempotencyKey();
|
||||||
|
idempotencyKeys.current.set(selectionKey, key);
|
||||||
|
setSnapshot({ selectionKey, jobs, state: "submitting", error: null });
|
||||||
|
try {
|
||||||
|
const job = await submitObservatoryRecordedJob(
|
||||||
|
sourceSessionId, setupId, key, portableBinding, { signal: request.signal },
|
||||||
|
);
|
||||||
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
|
observedJobId.current = job.jobId;
|
||||||
|
setSnapshot({
|
||||||
|
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||||
|
state: "ready", error: null,
|
||||||
|
});
|
||||||
|
return job;
|
||||||
|
} catch (caught) {
|
||||||
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
|
setSnapshot({
|
||||||
|
selectionKey, jobs, state: "error",
|
||||||
|
error: caught instanceof Error && caught.message.trim()
|
||||||
|
? caught.message
|
||||||
|
: "Не удалось поставить расчёт в очередь.",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (requestRef.current === request) requestRef.current = null;
|
||||||
|
}
|
||||||
|
}, [activeJob, definitionSha256, hasSelection, jobs, latestJob,
|
||||||
|
publicationPending, selectionKey, setupId, sourceSessionId, state]);
|
||||||
|
|
||||||
|
const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
|
||||||
|
if (!latestJob || latestJob.publication.state !== "failed"
|
||||||
|
|| state === "retrying-publication") return null;
|
||||||
|
requestRef.current?.abort();
|
||||||
|
const request = new AbortController();
|
||||||
|
requestRef.current = request;
|
||||||
|
setSnapshot({ selectionKey, jobs, state: "retrying-publication", error: null });
|
||||||
|
try {
|
||||||
|
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
|
||||||
|
signal: request.signal,
|
||||||
|
});
|
||||||
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
|
setSnapshot({
|
||||||
|
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||||
|
state: "ready", error: null,
|
||||||
|
});
|
||||||
|
return job;
|
||||||
|
} catch (caught) {
|
||||||
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
|
setSnapshot({
|
||||||
|
selectionKey, jobs, state: "error",
|
||||||
|
error: caught instanceof Error && caught.message.trim()
|
||||||
|
? caught.message
|
||||||
|
: "Не удалось повторить публикацию результата.",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (requestRef.current === request) requestRef.current = null;
|
||||||
|
}
|
||||||
|
}, [jobs, latestJob, selectionKey, state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs, latestJob, activeJob, publicationPending, computationFailed,
|
||||||
|
state, error, refresh, submit, retryPublication,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdempotencyKey(): string {
|
||||||
|
const entropy = typeof globalThis.crypto?.randomUUID === "function"
|
||||||
|
? globalThis.crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
return `observatory-ui:${entropy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservatoryRecordedJobsController = ReturnType<typeof useObservatoryRecordedJobs>;
|
||||||
@@ -38,6 +38,8 @@ export function useWorkerTelemetry(
|
|||||||
setError(null);
|
setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setTelemetry(null);
|
||||||
|
setError(null);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const stopPolling = startSequentialPolling(async () => {
|
const stopPolling = startSequentialPolling(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -48,6 +50,7 @@ export function useWorkerTelemetry(
|
|||||||
setError(null);
|
setError(null);
|
||||||
} catch (reason: unknown) {
|
} catch (reason: unknown) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
setTelemetry(null);
|
||||||
setError(
|
setError(
|
||||||
reason instanceof Error
|
reason instanceof Error
|
||||||
? reason.message
|
? reason.message
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ export interface WorkerTelemetry {
|
|||||||
power_watts?: number;
|
power_watts?: number;
|
||||||
temperature_celsius?: number;
|
temperature_celsius?: number;
|
||||||
memory_used_percent?: number;
|
memory_used_percent?: number;
|
||||||
|
sm_clock_mhz?: number | null;
|
||||||
|
memory_clock_mhz?: number | null;
|
||||||
|
driver_version?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
triton: {
|
triton: {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
@@ -131,6 +134,13 @@ export interface WorkerTelemetry {
|
|||||||
completed_runs: number | null;
|
completed_runs: number | null;
|
||||||
failed_runs: number | null;
|
failed_runs: number | null;
|
||||||
model_load_seconds: number | null;
|
model_load_seconds: number | null;
|
||||||
|
profile_name?: string | null;
|
||||||
|
input_state?: string | null;
|
||||||
|
wait_reason?: string | null;
|
||||||
|
live_children?: number | null;
|
||||||
|
input_pauses?: number | null;
|
||||||
|
pending_bundles?: number | null;
|
||||||
|
buffer_bytes?: number | null;
|
||||||
stages: WorkerPipelineStage[];
|
stages: WorkerPipelineStage[];
|
||||||
};
|
};
|
||||||
network: {
|
network: {
|
||||||
@@ -189,7 +199,9 @@ export async function fetchWorkerTelemetry(
|
|||||||
`/api/v1/system/contours/${encodeURIComponent(contourId)}/telemetry?history=90`,
|
`/api/v1/system/contours/${encodeURIComponent(contourId)}/telemetry?history=90`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal,
|
signal: signal
|
||||||
|
? AbortSignal.any([signal, AbortSignal.timeout(5_000)])
|
||||||
|
: AbortSignal.timeout(5_000),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
"missioncore.worker-telemetry/v1",
|
"missioncore.worker-telemetry/v1",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export type WorkspaceKind =
|
|||||||
| "datasets"
|
| "datasets"
|
||||||
| "artifact-health"
|
| "artifact-health"
|
||||||
| "lab-archive"
|
| "lab-archive"
|
||||||
| "simulations";
|
| "simulations"
|
||||||
|
| "observatory";
|
||||||
|
|
||||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||||
|
|
||||||
@@ -166,6 +167,17 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
kind: "simulations",
|
kind: "simulations",
|
||||||
groups: [],
|
groups: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "observatory",
|
||||||
|
root: "polygon",
|
||||||
|
label: "Обсерватория",
|
||||||
|
title: "Проверка компьютерного зрения",
|
||||||
|
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||||
|
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||||
|
icon: "eye",
|
||||||
|
kind: "observatory",
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "contour-health",
|
id: "contour-health",
|
||||||
root: "fleet",
|
root: "fleet",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
@import "./styles/device.css";
|
@import "./styles/device.css";
|
||||||
@import "./styles/responsive.css";
|
@import "./styles/responsive.css";
|
||||||
@import "./styles/observation.css";
|
@import "./styles/observation.css";
|
||||||
|
@import "./styles/observatory.css";
|
||||||
@import "./styles/environment-settings.css";
|
@import "./styles/environment-settings.css";
|
||||||
@import "./styles/system-telemetry.css";
|
@import "./styles/system-telemetry.css";
|
||||||
@import "./styles/artifact-health.css";
|
@import "./styles/artifact-health.css";
|
||||||
|
|||||||
@@ -27,6 +27,88 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__unified-content {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
inset: 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__unified-content > *,
|
||||||
|
.m4-replay-threat-visual__unified-content .rerun-viewport {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__deck[data-native-rerun="true"] > .nodedc-split-pane {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||||
|
.m4-replay-threat-visual__pane {
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||||
|
.nodedc-split-pane__separator,
|
||||||
|
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||||
|
.m4-replay-threat-visual__pane-toolbar,
|
||||||
|
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||||
|
.m4-replay-threat-visual__pane-toolbar * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||||
|
.m4-replay-threat-visual__pane-toolbar,
|
||||||
|
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||||
|
.laboratory-evidence-viewer__controls,
|
||||||
|
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||||
|
.laboratory-evidence-viewer__transport {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay[data-presentation-state="loading"]
|
||||||
|
.m4-replay-threat-visual__unified-content {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay__viewport-lock {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay__viewport-lock
|
||||||
|
.rerun-viewport__camera-lock {
|
||||||
|
width: var(--canonical-rerun-camera-pane, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
|
||||||
|
.rerun-viewport__camera-lock {
|
||||||
|
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay__loading {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--nodedc-canvas);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -247,6 +329,10 @@
|
|||||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback {
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__overlay {
|
.m4-replay-threat-visual__overlay {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
.observatory-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
align-content: start;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
container-name: observatory-workspace;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-lead,
|
||||||
|
.observatory-catalog-bar,
|
||||||
|
.observatory-catalog-bar__controls,
|
||||||
|
.observatory-notice,
|
||||||
|
.observatory-evidence > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-lead,
|
||||||
|
.observatory-notice {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-lead > div,
|
||||||
|
.observatory-catalog-bar__copy,
|
||||||
|
.observatory-evidence header > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-lead h2,
|
||||||
|
.observatory-catalog-bar h3,
|
||||||
|
.observatory-session-summary h3,
|
||||||
|
.observatory-evidence h3 {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar h3,
|
||||||
|
.observatory-session-summary h3,
|
||||||
|
.observatory-evidence h3 {
|
||||||
|
font-size: var(--nodedc-font-size-lg);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-lead p,
|
||||||
|
.observatory-state p,
|
||||||
|
.observatory-evidence-empty p {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar,
|
||||||
|
.observatory-session-summary {
|
||||||
|
align-self: start;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__copy {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar h3 {
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__controls {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||||
|
flex: 1 1 18rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__controls > .nodedc-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__run {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-state {
|
||||||
|
display: grid;
|
||||||
|
min-height: 19rem;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-state h3 {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-stack {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.9rem;
|
||||||
|
container-name: observatory-session;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__identity {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__identity h3 {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__identity code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: var(--nodedc-font-size-xs);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__facts > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__facts dt {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: var(--nodedc-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__facts dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__end {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-modalities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence > header {
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
min-height: 4.25rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
|
background: var(--nodedc-glass-control-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__icon {
|
||||||
|
display: grid;
|
||||||
|
width: 2.15rem;
|
||||||
|
height: 2.15rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
background: var(--nodedc-panel-icon-bg);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy strong,
|
||||||
|
.observatory-evidence-card__copy span,
|
||||||
|
.observatory-evidence-card__copy small {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy span {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: var(--nodedc-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__actions,
|
||||||
|
.observatory-replay__header,
|
||||||
|
.observatory-replay-state,
|
||||||
|
.observatory-replay-state__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-replay-state {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-replay__header > div,
|
||||||
|
.observatory-replay-state > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__actions:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-replay,
|
||||||
|
.observatory-replay-state {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-replay {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-replay__header {
|
||||||
|
padding: 0.25rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-replay__header h3,
|
||||||
|
.observatory-replay-state h3 {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-replay__header p,
|
||||||
|
.observatory-replay-state p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 13rem;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-notice {
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-notice__copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-rename-form,
|
||||||
|
.observatory-delete-confirmation {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-delete-confirmation p,
|
||||||
|
.observatory-mutation-error {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-mutation-error {
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container observatory-session (max-width: 46rem) {
|
||||||
|
.observatory-session-summary {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-session-summary__end,
|
||||||
|
.observatory-modalities {
|
||||||
|
justify-items: start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container observatory-session (max-width: 48rem) {
|
||||||
|
.observatory-evidence-card {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card > .nodedc-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container observatory-session (max-width: 38rem) {
|
||||||
|
.observatory-session-summary__facts {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-evidence-card__copy small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container observatory-workspace (max-width: 920px) {
|
||||||
|
.observatory-lead,
|
||||||
|
.observatory-catalog-bar,
|
||||||
|
.observatory-catalog-bar__controls,
|
||||||
|
.observatory-notice {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__controls {
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observatory-catalog-bar__run {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.observatory-replay__header,
|
||||||
|
.observatory-replay-state {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
|
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
||||||
panels are overridden: the native top row, recording tab and view tab.
|
panels are overridden: the native top row, recording tab and view tab.
|
||||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
RecordedCameraAdmissionState,
|
RecordedCameraAdmissionState,
|
||||||
} from "../core/observation/recordedSessionAdmission";
|
} from "../core/observation/recordedSessionAdmission";
|
||||||
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
||||||
|
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
|
||||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||||
import {
|
import {
|
||||||
RerunViewport,
|
RerunViewport,
|
||||||
@@ -40,13 +41,13 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
|
|||||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||||
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
||||||
|
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
|
||||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||||
if (status === "active") return "success";
|
if (status === "active") return "success";
|
||||||
if (status === "ready") return "accent";
|
if (status === "ready") return "accent";
|
||||||
if (status === "contract") return "warning";
|
if (status === "contract") return "warning";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||||
return (
|
return (
|
||||||
<div className="feature-inventory">
|
<div className="feature-inventory">
|
||||||
@@ -76,7 +77,6 @@ function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||||
return (
|
return (
|
||||||
<section className="workspace-lead workspace-lead--compact">
|
<section className="workspace-lead workspace-lead--compact">
|
||||||
@@ -89,7 +89,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||||
@@ -181,7 +180,7 @@ function SpatialWorkspace({
|
|||||||
);
|
);
|
||||||
const recordedPerceptionSupported =
|
const recordedPerceptionSupported =
|
||||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||||
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready";
|
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||||
const recordedPerceptionEnabled =
|
const recordedPerceptionEnabled =
|
||||||
showDetections2d || showSegmentation || showCuboids3d;
|
showDetections2d || showSegmentation || showCuboids3d;
|
||||||
// The native recorded camera remains the authoritative original. Only 2D
|
// The native recorded camera remains the authoritative original. Only 2D
|
||||||
@@ -261,7 +260,7 @@ function SpatialWorkspace({
|
|||||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||||
if (!recordedSessionAdmission) return false;
|
if (!recordedSessionAdmission) return false;
|
||||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||||
}, [recordedSessionAdmission]);
|
}, [recordedSessionAdmission]);
|
||||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||||
const onPlaybackChange = useCallback(
|
const onPlaybackChange = useCallback(
|
||||||
@@ -381,6 +380,35 @@ function SpatialWorkspace({
|
|||||||
: presentedViewerStatus === "error"
|
: presentedViewerStatus === "error"
|
||||||
? "danger"
|
? "danger"
|
||||||
: "neutral";
|
: "neutral";
|
||||||
|
const rerunViewerProfile = recordedSource
|
||||||
|
? recordedSessionRerunProfile({
|
||||||
|
sourceUrl,
|
||||||
|
artifact: recordedReplay,
|
||||||
|
autoplayWhenReady: true,
|
||||||
|
presentationGate: recordedSessionGate,
|
||||||
|
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
|
||||||
|
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
|
||||||
|
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
|
||||||
|
view: "spatial",
|
||||||
|
viewResetGeneration: recordedViewResetGeneration,
|
||||||
|
followTrajectory: followRecordedTrajectory,
|
||||||
|
perceptionLayers: {
|
||||||
|
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
||||||
|
detections2d: showDetections2d,
|
||||||
|
segmentation: showSegmentation,
|
||||||
|
cuboids3d: showCuboids3d,
|
||||||
|
},
|
||||||
|
perceptionRetryGeneration,
|
||||||
|
lockPerceptionCameraInteraction: unifiedPerception,
|
||||||
|
})
|
||||||
|
: liveAcquisitionRerunProfile({
|
||||||
|
sourceUrl,
|
||||||
|
liveActivitySequence: livePresentationActivitySequence,
|
||||||
|
liveStreamId: state?.spatialSource?.id ?? null,
|
||||||
|
liveRecoveryAuthorityIdentity: streamActive
|
||||||
|
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -405,7 +433,7 @@ function SpatialWorkspace({
|
|||||||
variant={detections2dActive ? "primary" : "secondary"}
|
variant={detections2dActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="target" />}
|
icon={<Icon name="target" />}
|
||||||
aria-pressed={detections2dActive}
|
aria-pressed={detections2dActive}
|
||||||
disabled={recordedSource && !recordedPerceptionReady}
|
disabled={recordedPerceptionLoading}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowDetections2d((current) => !current)
|
? setShowDetections2d((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
@@ -420,7 +448,7 @@ function SpatialWorkspace({
|
|||||||
variant={segmentationActive ? "primary" : "secondary"}
|
variant={segmentationActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="image" />}
|
icon={<Icon name="image" />}
|
||||||
aria-pressed={segmentationActive}
|
aria-pressed={segmentationActive}
|
||||||
disabled={recordedSource && !recordedPerceptionReady}
|
disabled={recordedPerceptionLoading}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowSegmentation((current) => !current)
|
? setShowSegmentation((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
@@ -435,7 +463,7 @@ function SpatialWorkspace({
|
|||||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="apps" />}
|
icon={<Icon name="apps" />}
|
||||||
aria-pressed={cuboids3dActive}
|
aria-pressed={cuboids3dActive}
|
||||||
disabled={recordedSource && !recordedPerceptionReady}
|
disabled={recordedPerceptionLoading}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowCuboids3d((current) => !current)
|
? setShowCuboids3d((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
@@ -489,35 +517,8 @@ function SpatialWorkspace({
|
|||||||
>
|
>
|
||||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||||
<RerunViewport
|
<RerunViewport
|
||||||
sourceUrl={sourceUrl}
|
profile={rerunViewerProfile}
|
||||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
|
||||||
followLive={liveRerunSource}
|
|
||||||
liveActivitySequence={livePresentationActivitySequence}
|
|
||||||
liveStreamId={state?.spatialSource?.id}
|
|
||||||
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
|
|
||||||
autoplayWhenReady={recordedSource}
|
|
||||||
presentationGate={recordedSessionGate}
|
|
||||||
expectedTimelineStartSeconds={recordedSource
|
|
||||||
? state?.observationTimeline?.range?.startSeconds
|
|
||||||
: undefined}
|
|
||||||
expectedTimelineEndSeconds={recordedSource
|
|
||||||
? state?.observationTimeline?.range?.endSeconds
|
|
||||||
: undefined}
|
|
||||||
initialPlaybackStartSeconds={initialRecordedPlaybackStartSeconds}
|
|
||||||
sceneSettings={sceneSettings}
|
sceneSettings={sceneSettings}
|
||||||
recordedViewResetGeneration={recordedViewResetGeneration}
|
|
||||||
recordedFollowTrajectory={followRecordedTrajectory}
|
|
||||||
recordedPerceptionLayers={{
|
|
||||||
enabled:
|
|
||||||
recordedPerceptionSupported &&
|
|
||||||
recordedPerceptionReady &&
|
|
||||||
recordedPerceptionEnabled,
|
|
||||||
detections2d: showDetections2d,
|
|
||||||
segmentation: showSegmentation,
|
|
||||||
cuboids3d: showCuboids3d,
|
|
||||||
}}
|
|
||||||
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
|
|
||||||
lockPerceptionCameraInteraction={unifiedPerception}
|
|
||||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||||
onPointColorLoadChange={onPointColorLoadChange}
|
onPointColorLoadChange={onPointColorLoadChange}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
@@ -905,7 +906,7 @@ function CamerasWorkspace({
|
|||||||
if (!recordedReplay) return true;
|
if (!recordedReplay) return true;
|
||||||
if (!recordedSessionAdmission) return false;
|
if (!recordedSessionAdmission) return false;
|
||||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||||
}, [recordedReplay, recordedSessionAdmission]);
|
}, [recordedReplay, recordedSessionAdmission]);
|
||||||
return (
|
return (
|
||||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||||
@@ -1191,6 +1192,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
|||||||
);
|
);
|
||||||
case "simulations":
|
case "simulations":
|
||||||
return <SimulationWorkspace />;
|
return <SimulationWorkspace />;
|
||||||
|
case "observatory":
|
||||||
|
return <ObservatoryWorkspace definition={props.definition} />;
|
||||||
case "device":
|
case "device":
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
buildLaboratoryProfiles,
|
buildLaboratoryProfiles,
|
||||||
experimentOptionsForProfile,
|
experimentOptionsForProfile,
|
||||||
freshestLaboratorySelection,
|
freshestLaboratorySelection,
|
||||||
|
isLegacyPublishedLaboratoryWork,
|
||||||
workOptionsForExperiment,
|
workOptionsForExperiment,
|
||||||
} from "./laboratoryArchiveProfiles";
|
} from "./laboratoryArchiveProfiles";
|
||||||
import {
|
import {
|
||||||
@@ -499,12 +500,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
onDeleteBegin: props.sessionArchive.onDeleteBegin,
|
onDeleteBegin: props.sessionArchive.onDeleteBegin,
|
||||||
});
|
});
|
||||||
const publishedWorks = useMemo(
|
const publishedWorks = useMemo(
|
||||||
() => sessions.items.filter((session) => (
|
() => sessions.items.filter(isLegacyPublishedLaboratoryWork).sort((left, right) => (
|
||||||
session.lab !== null
|
|
||||||
&& session.status === "ready"
|
|
||||||
&& session.replayable
|
|
||||||
&& session.modalities.includes("point-cloud")
|
|
||||||
)).sort((left, right) => (
|
|
||||||
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
|
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
|
||||||
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
|
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -215,12 +215,24 @@ export function M49TgsFullShadowEvidence({
|
|||||||
controlLabel: semanticOverride.controlLabel ?? "ПРИРОДА · DDRNet",
|
controlLabel: semanticOverride.controlLabel ?? "ПРИРОДА · DDRNet",
|
||||||
}] : []),
|
}] : []),
|
||||||
], [semantic, semanticOverride]);
|
], [semantic, semanticOverride]);
|
||||||
|
const spatialSemantic = useMemo<M4ReplayThreatSemanticLayer | undefined>(() => (
|
||||||
|
semantic ? {
|
||||||
|
id: "spatial-urban",
|
||||||
|
controlLabel: "SEMANTICS",
|
||||||
|
resultId: semantic.resultId,
|
||||||
|
spatialResultId: semantic.resultId,
|
||||||
|
taxonomy: semantic.taxonomy,
|
||||||
|
label: "EoMT Cityscapes semantic · point-aligned E47",
|
||||||
|
maskAriaLabel: "EoMT urban semantic prediction",
|
||||||
|
} : undefined
|
||||||
|
), [semantic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<M4ReplayThreatVisual
|
<M4ReplayThreatVisual
|
||||||
resultId={result.source.linkedVisualResultId}
|
resultId={result.source.linkedVisualResultId}
|
||||||
semanticLayers={semanticLayers}
|
semanticLayers={semanticLayers}
|
||||||
|
spatialSemantic={spatialSemantic}
|
||||||
initialSemanticLayerId={semanticOverride ? "vegetation" : "urban"}
|
initialSemanticLayerId={semanticOverride ? "vegetation" : "urban"}
|
||||||
showReviewAnchorBoxes={false}
|
showReviewAnchorBoxes={false}
|
||||||
reviewLabel="4 489 source-paced TGS frames"
|
reviewLabel="4 489 source-paced TGS frames"
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Select,
|
Select,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SplitPane,
|
|
||||||
type SplitPaneOrientation,
|
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||||
|
import {
|
||||||
|
CanonicalRecordedLabReplay,
|
||||||
|
useCanonicalRecordedLabReplayState,
|
||||||
|
} from "../../components/laboratory/CanonicalRecordedLabReplay";
|
||||||
import {
|
import {
|
||||||
LaboratoryMetricEvidenceScene,
|
LaboratoryMetricEvidenceScene,
|
||||||
type LaboratoryMetricCellEvidence,
|
type LaboratoryMetricCellEvidence,
|
||||||
@@ -35,6 +45,7 @@ import {
|
|||||||
type E47SemanticClass,
|
type E47SemanticClass,
|
||||||
type E47SemanticTimelineFrame,
|
type E47SemanticTimelineFrame,
|
||||||
} from "../../core/laboratory/e47SemanticSlam";
|
} from "../../core/laboratory/e47SemanticSlam";
|
||||||
|
import { laboratoryRecordedEvidenceDemand } from "../../core/laboratory/recordedEvidenceProfile";
|
||||||
import type {
|
import type {
|
||||||
M4ThreatCameraProposal,
|
M4ThreatCameraProposal,
|
||||||
M4ThreatTimelineFrame,
|
M4ThreatTimelineFrame,
|
||||||
@@ -52,8 +63,6 @@ import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
|||||||
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
|
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
|
||||||
|
|
||||||
type M4ThreatMediaMode = "video" | "camera";
|
type M4ThreatMediaMode = "video" | "camera";
|
||||||
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
|
|
||||||
type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none";
|
|
||||||
|
|
||||||
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||||
if (proposal.threatDecision === "threat") return "danger";
|
if (proposal.threatDecision === "threat") return "danger";
|
||||||
@@ -144,6 +153,7 @@ export interface M4ReplayClassifiedSpatialLayer {
|
|||||||
label: string;
|
label: string;
|
||||||
pointLayerLabel: string;
|
pointLayerLabel: string;
|
||||||
cellLayerLabel: string;
|
cellLayerLabel: string;
|
||||||
|
cellLayerAvailable?: boolean;
|
||||||
expectedAtSequence: boolean;
|
expectedAtSequence: boolean;
|
||||||
frame: M4ReplayClassifiedSpatialFrame | null;
|
frame: M4ReplayClassifiedSpatialFrame | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -158,6 +168,7 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId,
|
resultId,
|
||||||
semantic,
|
semantic,
|
||||||
semanticLayers,
|
semanticLayers,
|
||||||
|
spatialSemantic,
|
||||||
initialSemanticLayerId,
|
initialSemanticLayerId,
|
||||||
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
||||||
showReviewAnchorBoxes = true,
|
showReviewAnchorBoxes = true,
|
||||||
@@ -168,11 +179,15 @@ export function M4ReplayThreatVisual({
|
|||||||
classifiedSpatialLayer,
|
classifiedSpatialLayer,
|
||||||
showReferenceMediaLayers = true,
|
showReferenceMediaLayers = true,
|
||||||
showSpatialOverlaySummary = true,
|
showSpatialOverlaySummary = true,
|
||||||
|
playbackTransport = "epoch-stream",
|
||||||
|
spatialPlaybackTransport = "auto",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
onActiveSequenceChange,
|
onActiveSequenceChange,
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
semantic?: M4ReplayThreatSemanticLayer;
|
semantic?: M4ReplayThreatSemanticLayer;
|
||||||
semanticLayers?: readonly M4ReplayThreatSemanticLayer[];
|
semanticLayers?: readonly M4ReplayThreatSemanticLayer[];
|
||||||
|
spatialSemantic?: M4ReplayThreatSemanticLayer;
|
||||||
initialSemanticLayerId?: string;
|
initialSemanticLayerId?: string;
|
||||||
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
||||||
showReviewAnchorBoxes?: boolean;
|
showReviewAnchorBoxes?: boolean;
|
||||||
@@ -183,12 +198,26 @@ export function M4ReplayThreatVisual({
|
|||||||
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
||||||
showReferenceMediaLayers?: boolean;
|
showReferenceMediaLayers?: boolean;
|
||||||
showSpatialOverlaySummary?: boolean;
|
showSpatialOverlaySummary?: boolean;
|
||||||
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
onActiveSequenceChange?: (sequence: number | null) => void;
|
onActiveSequenceChange?: (sequence: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video");
|
const {
|
||||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>(
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
splitView,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
expanded,
|
||||||
|
onMediaModeChange: handleMediaModeChange,
|
||||||
|
onSpatialModeChange: handleSpatialModeChange,
|
||||||
|
onSplitPrimarySizeChange: setSplitPrimarySize,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
} = useCanonicalRecordedLabReplayState<M4ThreatMediaMode, LaboratoryMetricSceneMode>({
|
||||||
|
initialMediaMode: "video",
|
||||||
initialSpatialMode,
|
initialSpatialMode,
|
||||||
);
|
});
|
||||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||||
@@ -197,13 +226,6 @@ export function M4ReplayThreatVisual({
|
|||||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||||
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
||||||
const [showStaticObstacles, setShowStaticObstacles] = useState(true);
|
const [showStaticObstacles, setShowStaticObstacles] = useState(true);
|
||||||
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
|
||||||
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
|
||||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
|
||||||
? "horizontal"
|
|
||||||
: "vertical"
|
|
||||||
));
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
|
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
|
||||||
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
|
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
|
||||||
() => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [],
|
() => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [],
|
||||||
@@ -234,6 +256,28 @@ export function M4ReplayThreatVisual({
|
|||||||
const activeSemantic = availableSemanticLayers.find(
|
const activeSemantic = availableSemanticLayers.find(
|
||||||
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
|
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
|
||||||
) ?? availableSemanticLayers[0];
|
) ?? availableSemanticLayers[0];
|
||||||
|
const activeSpatialSemantic = spatialSemantic ?? activeSemantic;
|
||||||
|
const evidenceDemand = useMemo(() => laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic,
|
||||||
|
showSpatialSemantic: Boolean(activeSpatialSemantic) && showSpatialSemantic,
|
||||||
|
showMediaPoints,
|
||||||
|
classifiedSpatialMode: !classifiedSpatialLayer || classifiedSpatialLayer.cellLayerAvailable === false
|
||||||
|
? "none"
|
||||||
|
: classifiedSpatialLayer.replacePointCloud
|
||||||
|
? "replace-source"
|
||||||
|
: "overlay",
|
||||||
|
}), [
|
||||||
|
activeSemantic,
|
||||||
|
activeSpatialSemantic,
|
||||||
|
classifiedSpatialLayer,
|
||||||
|
mediaMode,
|
||||||
|
showMediaPoints,
|
||||||
|
showMediaSemantic,
|
||||||
|
showSpatialSemantic,
|
||||||
|
spatialMode,
|
||||||
|
]);
|
||||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||||
const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
|
const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
|
||||||
const playbackRange = useMemo(() => metadata.timeline ? ({
|
const playbackRange = useMemo(() => metadata.timeline ? ({
|
||||||
@@ -241,7 +285,7 @@ export function M4ReplayThreatVisual({
|
|||||||
endSeconds: metadata.timeline.timelineEndSeconds,
|
endSeconds: metadata.timeline.timelineEndSeconds,
|
||||||
}) : null, [metadata.timeline]);
|
}) : null, [metadata.timeline]);
|
||||||
const playbackController = useRecordedEvidencePlayback(playbackRange, {
|
const playbackController = useRecordedEvidencePlayback(playbackRange, {
|
||||||
clock: mediaMode === "video" ? "external" : "animation",
|
clock: "external",
|
||||||
});
|
});
|
||||||
const seekPlayback = playbackController.seek;
|
const seekPlayback = playbackController.seek;
|
||||||
const setPlaybackPlaying = playbackController.setPlaying;
|
const setPlaybackPlaying = playbackController.setPlaying;
|
||||||
@@ -249,7 +293,9 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId,
|
resultId,
|
||||||
timeline: metadata.timeline,
|
timeline: metadata.timeline,
|
||||||
currentSeconds: playbackController.playback.currentSeconds,
|
currentSeconds: playbackController.playback.currentSeconds,
|
||||||
|
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
|
||||||
endpointRoot: timelineEndpointRoot,
|
endpointRoot: timelineEndpointRoot,
|
||||||
|
spatialPlaybackTransport,
|
||||||
});
|
});
|
||||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||||
const [videoLoading, setVideoLoading] = useState(false);
|
const [videoLoading, setVideoLoading] = useState(false);
|
||||||
@@ -260,16 +306,12 @@ export function M4ReplayThreatVisual({
|
|||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
}, [resultId]);
|
}, [resultId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const query = window.matchMedia("(max-width: 900px)");
|
|
||||||
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
|
||||||
update();
|
|
||||||
query.addEventListener("change", update);
|
|
||||||
return () => query.removeEventListener("change", update);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeline = metadata.timeline;
|
const timeline = metadata.timeline;
|
||||||
|
if (!evidenceDemand.recordedVideo) {
|
||||||
|
setVideoLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!timeline || videoSource) return;
|
if (!timeline || videoSource) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setVideoLoading(true);
|
setVideoLoading(true);
|
||||||
@@ -304,7 +346,7 @@ export function M4ReplayThreatVisual({
|
|||||||
if (!controller.signal.aborted) setVideoLoading(false);
|
if (!controller.signal.aborted) setVideoLoading(false);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [metadata.timeline, videoSource]);
|
}, [evidenceDemand.recordedVideo, metadata.timeline, videoSource]);
|
||||||
|
|
||||||
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -319,41 +361,53 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId: string;
|
resultId: string;
|
||||||
frame: M4ThreatTimelineFrame;
|
frame: M4ThreatTimelineFrame;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
if (frame?.spatialAvailable) {
|
useEffect(() => {
|
||||||
lastSpatialFrameRef.current = { resultId, frame };
|
lastSpatialFrameRef.current = null;
|
||||||
|
}, [evidenceDemand.sourceSpatialPoints, resultId]);
|
||||||
|
const latestAvailableSpatialFrame = [...timelineFrame.availableFrames]
|
||||||
|
.reverse()
|
||||||
|
.find((candidate) => (
|
||||||
|
candidate.spatialAvailable
|
||||||
|
&& (timelineFrame.activeSequence === null
|
||||||
|
|| candidate.sequence <= timelineFrame.activeSequence)
|
||||||
|
)) ?? null;
|
||||||
|
const currentSpatialFrame = frame?.spatialAvailable ? frame : latestAvailableSpatialFrame;
|
||||||
|
if (currentSpatialFrame) {
|
||||||
|
lastSpatialFrameRef.current = { resultId, frame: currentSpatialFrame };
|
||||||
}
|
}
|
||||||
const spatialFrame = frame?.spatialAvailable
|
const spatialFrame = currentSpatialFrame
|
||||||
? frame
|
? currentSpatialFrame
|
||||||
: lastSpatialFrameRef.current?.resultId === resultId
|
: lastSpatialFrameRef.current?.resultId === resultId
|
||||||
? lastSpatialFrameRef.current.frame
|
? lastSpatialFrameRef.current.frame
|
||||||
: null;
|
: null;
|
||||||
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
||||||
enabled: showReferenceMediaLayers && showMediaPoints,
|
enabled: showReferenceMediaLayers && evidenceDemand.cameraPointOverlay,
|
||||||
resultId,
|
resultId,
|
||||||
sequence: frame?.sequence ?? null,
|
sequence: frame?.sequence ?? null,
|
||||||
endpointRoot: timelineEndpointRoot,
|
endpointRoot: timelineEndpointRoot,
|
||||||
});
|
});
|
||||||
const semanticSpatialResultId = activeSemantic
|
const semanticSpatialResultId = activeSpatialSemantic
|
||||||
? activeSemantic.spatialResultId === undefined
|
? activeSpatialSemantic.spatialResultId === undefined
|
||||||
? activeSemantic.resultId
|
? activeSpatialSemantic.resultId
|
||||||
: activeSemantic.spatialResultId
|
: activeSpatialSemantic.spatialResultId
|
||||||
: null;
|
: null;
|
||||||
const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>(
|
const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>(
|
||||||
() => semanticSpatialResultId && activeSemantic
|
() => semanticSpatialResultId && activeSpatialSemantic
|
||||||
? activeSemantic.taxonomy.map((item) => ({
|
? activeSpatialSemantic.taxonomy.map((item) => ({
|
||||||
classId: item.classId,
|
classId: item.classId,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled",
|
disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled",
|
||||||
colorRgb: item.colorRgb,
|
colorRgb: item.colorRgb,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
[activeSemantic, semanticSpatialResultId],
|
[activeSpatialSemantic, semanticSpatialResultId],
|
||||||
);
|
);
|
||||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||||
resultId: semanticSpatialResultId,
|
resultId: semanticSpatialResultId,
|
||||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||||
frameCount: metadata.timeline?.frameCount ?? 0,
|
frameCount: metadata.timeline?.frameCount ?? 0,
|
||||||
taxonomy: spatialSemanticTaxonomy,
|
taxonomy: spatialSemanticTaxonomy,
|
||||||
|
enabled: evidenceDemand.selectedSemanticPoints,
|
||||||
});
|
});
|
||||||
const displayingBufferedFrame = Boolean(
|
const displayingBufferedFrame = Boolean(
|
||||||
frame
|
frame
|
||||||
@@ -443,6 +497,27 @@ export function M4ReplayThreatVisual({
|
|||||||
})) ?? [],
|
})) ?? [],
|
||||||
[activeSemantic?.taxonomy],
|
[activeSemantic?.taxonomy],
|
||||||
);
|
);
|
||||||
|
const spatialSemanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||||
|
() => activeSpatialSemantic?.taxonomy.map((item) => ({
|
||||||
|
id: item.classId,
|
||||||
|
label: `semantic: ${item.label}`,
|
||||||
|
})) ?? [],
|
||||||
|
[activeSpatialSemantic?.taxonomy],
|
||||||
|
);
|
||||||
|
const spatialSemanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
|
||||||
|
() => activeSpatialSemantic?.taxonomy.map((item) => ({
|
||||||
|
classId: item.classId,
|
||||||
|
color: item.disposition === "undefined"
|
||||||
|
? { kind: "transparent" as const }
|
||||||
|
: item.disposition === "ambiguous"
|
||||||
|
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||||
|
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||||
|
opacity: item.disposition === "undefined"
|
||||||
|
? 0
|
||||||
|
: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||||
|
})) ?? [],
|
||||||
|
[activeSpatialSemantic?.taxonomy],
|
||||||
|
);
|
||||||
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||||
? semanticTimeline.activeFrame
|
? semanticTimeline.activeFrame
|
||||||
: null;
|
: null;
|
||||||
@@ -459,7 +534,7 @@ export function M4ReplayThreatVisual({
|
|||||||
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
|
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
|
||||||
? lastSpatialSemanticFrameRef.current.frame
|
? lastSpatialSemanticFrameRef.current.frame
|
||||||
: null;
|
: null;
|
||||||
const semanticIntegrityError = activeSemantic && spatialFrame && spatialSemanticFrame && (
|
const semanticIntegrityError = activeSpatialSemantic && spatialFrame && spatialSemanticFrame && (
|
||||||
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|
||||||
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|
||||||
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
|
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
|
||||||
@@ -468,7 +543,7 @@ export function M4ReplayThreatVisual({
|
|||||||
: null;
|
: null;
|
||||||
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||||
if (
|
if (
|
||||||
!activeSemantic
|
!activeSpatialSemantic
|
||||||
|| !showSpatialSemantic
|
|| !showSpatialSemantic
|
||||||
|| !spatialFrame
|
|| !spatialFrame
|
||||||
|| !spatialSemanticFrame
|
|| !spatialSemanticFrame
|
||||||
@@ -478,18 +553,24 @@ export function M4ReplayThreatVisual({
|
|||||||
const status = spatialSemanticFrame.statusCodes[index];
|
const status = spatialSemanticFrame.statusCodes[index];
|
||||||
return status === 2 || status === 3 ? classId : null;
|
return status === 2 || status === 3 ? classId : null;
|
||||||
});
|
});
|
||||||
}, [activeSemantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
}, [activeSpatialSemantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
||||||
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
|
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
|
||||||
? spatialFrame
|
? spatialFrame
|
||||||
: null;
|
: null;
|
||||||
const classifiedSpatialFrame = classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
const hasClassifiedSpatialOutput = Boolean(
|
||||||
|
classifiedSpatialLayer && classifiedSpatialLayer.cellLayerAvailable !== false,
|
||||||
|
);
|
||||||
|
const classifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||||
|
&& classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
||||||
? classifiedSpatialLayer?.frame ?? null
|
? classifiedSpatialLayer?.frame ?? null
|
||||||
: null;
|
: null;
|
||||||
const lastClassifiedSpatialFrameRef = useRef<{
|
const lastClassifiedSpatialFrameRef = useRef<{
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frame: M4ReplayClassifiedSpatialFrame;
|
frame: M4ReplayClassifiedSpatialFrame;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null;
|
const incomingClassifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||||
|
? classifiedSpatialLayer?.frame ?? null
|
||||||
|
: null;
|
||||||
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
||||||
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
||||||
}
|
}
|
||||||
@@ -518,7 +599,9 @@ export function M4ReplayThreatVisual({
|
|||||||
? spatialFrame
|
? spatialFrame
|
||||||
: null)
|
: null)
|
||||||
: null;
|
: null;
|
||||||
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true;
|
const replaceClassifiedPointCloud = hasClassifiedSpatialOutput
|
||||||
|
? classifiedSpatialLayer?.replacePointCloud ?? true
|
||||||
|
: false;
|
||||||
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
||||||
const mapGravityLocalSensorToBodyGround = useCallback((
|
const mapGravityLocalSensorToBodyGround = useCallback((
|
||||||
point: readonly [number, number, number],
|
point: readonly [number, number, number],
|
||||||
@@ -636,7 +719,12 @@ export function M4ReplayThreatVisual({
|
|||||||
.map((item) => item.assessment.closestApproachM)
|
.map((item) => item.assessment.closestApproachM)
|
||||||
.filter((value): value is number => value !== null)
|
.filter((value): value is number => value !== null)
|
||||||
.sort((left, right) => left - right)[0] ?? null;
|
.sort((left, right) => left - right)[0] ?? null;
|
||||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
const localSurface = useMemo(() => spatialFrame?.localSlamBodyXyzM?.length ? ({
|
||||||
|
pointsBodyXyzM: spatialFrame.localSlamBodyXyzM,
|
||||||
|
sourceFrameCount: spatialFrame.localSlamSourceFrameCount ?? 0,
|
||||||
|
sourcePointCount: spatialFrame.localSlamSourcePointCount ?? 0,
|
||||||
|
voxelCount: spatialFrame.localSlamBodyXyzM.length,
|
||||||
|
}) : buildM4LocalSurface(
|
||||||
timelineFrame.availableFrames,
|
timelineFrame.availableFrames,
|
||||||
spatialFrame,
|
spatialFrame,
|
||||||
metadata.timeline?.localSurfaceVisualization ?? {
|
metadata.timeline?.localSurfaceVisualization ?? {
|
||||||
@@ -647,7 +735,7 @@ export function M4ReplayThreatVisual({
|
|||||||
},
|
},
|
||||||
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
||||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||||
activeSemantic && showMediaSemantic && frame
|
activeSemantic && evidenceDemand.selectedSemanticMask && frame
|
||||||
? {
|
? {
|
||||||
src: activeSemantic.maskUrl?.(frame.sequence)
|
src: activeSemantic.maskUrl?.(frame.sequence)
|
||||||
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
|
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
|
||||||
@@ -684,50 +772,15 @@ export function M4ReplayThreatVisual({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
|
|
||||||
if (next === "none") return;
|
|
||||||
setMediaMode((current) => current === next ? null : next);
|
|
||||||
};
|
|
||||||
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
|
|
||||||
if (next === "none") return;
|
|
||||||
setSpatialMode((current) => current === next ? null : next);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playbackController.playback.playing || !frame) return;
|
if (
|
||||||
|
playbackController.playback.playing
|
||||||
|
|| !evidenceDemand.exactCameraFrame
|
||||||
|
|| !frame
|
||||||
|
) return;
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = frame.cameraUrl;
|
image.src = frame.cameraUrl;
|
||||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
}, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]);
|
||||||
|
|
||||||
const splitView = mediaMode !== null && spatialMode !== null;
|
|
||||||
|
|
||||||
const mediaModeControls = (
|
|
||||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
|
||||||
<SegmentedControl
|
|
||||||
value={mediaMode ?? "none"}
|
|
||||||
items={[
|
|
||||||
{ value: "video", label: "VIDEO" },
|
|
||||||
{ value: "camera", label: "CAMERA" },
|
|
||||||
]}
|
|
||||||
label="Видео и камера"
|
|
||||||
onChange={handleMediaModeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const spatialModeControls = (
|
|
||||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
|
||||||
<SegmentedControl
|
|
||||||
value={spatialMode ?? "none"}
|
|
||||||
items={[
|
|
||||||
{ value: "3d", label: "3D" },
|
|
||||||
{ value: "plan", label: "PLAN" },
|
|
||||||
]}
|
|
||||||
label="3D и план"
|
|
||||||
onChange={handleSpatialModeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mediaLayerControls = activeSemantic
|
const mediaLayerControls = activeSemantic
|
||||||
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
|
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
|
||||||
@@ -819,16 +872,24 @@ export function M4ReplayThreatVisual({
|
|||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showRollingMap ? "primary" : "secondary"}
|
variant={showRollingMap ? "primary" : "secondary"}
|
||||||
aria-pressed={showRollingMap}
|
aria-pressed={showRollingMap}
|
||||||
|
disabled={classifiedSpatialLayer.cellLayerAvailable === false}
|
||||||
|
title={classifiedSpatialLayer.cellLayerAvailable === false
|
||||||
|
? `${classifiedSpatialLayer.cellLayerLabel} недоступен: для этой записи нет запечатанного полного результата`
|
||||||
|
: undefined}
|
||||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||||
>
|
>
|
||||||
{classifiedSpatialLayer.cellLayerLabel}
|
{classifiedSpatialLayer.cellLayerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{semanticSpatialResultId ? (
|
{activeSpatialSemantic ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||||
aria-pressed={showSpatialSemantic}
|
aria-pressed={showSpatialSemantic}
|
||||||
|
disabled={!semanticSpatialResultId}
|
||||||
|
title={semanticSpatialResultId
|
||||||
|
? "Point-aligned semantic evidence"
|
||||||
|
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||||
>
|
>
|
||||||
SEMANTICS
|
SEMANTICS
|
||||||
@@ -881,12 +942,16 @@ export function M4ReplayThreatVisual({
|
|||||||
LOW-STEP
|
LOW-STEP
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{semanticSpatialResultId ? (
|
{activeSpatialSemantic ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||||
aria-pressed={showSpatialSemantic}
|
aria-pressed={showSpatialSemantic}
|
||||||
|
disabled={!semanticSpatialResultId}
|
||||||
|
title={semanticSpatialResultId
|
||||||
|
? "Point-aligned semantic evidence"
|
||||||
|
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||||
>
|
>
|
||||||
SEMANTICS
|
SEMANTICS
|
||||||
@@ -990,14 +1055,14 @@ export function M4ReplayThreatVisual({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span>Spatial evidence</span>
|
<span>Spatial evidence</span>
|
||||||
<strong>{classifiedSpatialLayer
|
<strong>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? replaceClassifiedPointCloud
|
? replaceClassifiedPointCloud
|
||||||
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
||||||
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
||||||
: "TGS spatial buffer"
|
: "TGS spatial buffer"
|
||||||
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
||||||
<small>{classifiedSpatialLayer
|
<small>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? classifiedSpatialFrame.sampleAvailable === false
|
? classifiedSpatialFrame.sampleAvailable === false
|
||||||
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
||||||
@@ -1006,9 +1071,9 @@ export function M4ReplayThreatVisual({
|
|||||||
: activeSpatialFrame
|
: activeSpatialFrame
|
||||||
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
||||||
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
||||||
: classifiedSpatialLayer.error
|
: classifiedSpatialLayer?.error
|
||||||
?? classifiedSpatialLayer.loadingLabel
|
?? classifiedSpatialLayer?.loadingLabel
|
||||||
?? `Открываем ${classifiedSpatialLayer.label}`
|
?? `Открываем ${classifiedSpatialLayer?.label ?? "spatial evidence"}`
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
{spatialFrame
|
{spatialFrame
|
||||||
@@ -1033,13 +1098,13 @@ export function M4ReplayThreatVisual({
|
|||||||
)}</small>
|
)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"}</span>
|
<span>{hasClassifiedSpatialOutput ? "TGS fail-closed" : "Virtual corridor"}</span>
|
||||||
<strong>{classifiedSpatialLayer
|
<strong>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
||||||
: classifiedSpatialLayer.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
: classifiedSpatialLayer?.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
||||||
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
|
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
|
||||||
<small>{classifiedSpatialLayer
|
<small>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
||||||
: "visual review only · navigation authority OFF"
|
: "visual review only · navigation authority OFF"
|
||||||
@@ -1051,7 +1116,12 @@ export function M4ReplayThreatVisual({
|
|||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const timeline = metadata.timeline;
|
const timeline = metadata.timeline;
|
||||||
let content;
|
let content: ReactNode = null;
|
||||||
|
let canonicalContent: {
|
||||||
|
mediaContent: ReactNode;
|
||||||
|
spatialContent: ReactNode;
|
||||||
|
deckOverlays: ReactNode;
|
||||||
|
} | null = null;
|
||||||
if (metadata.error) {
|
if (metadata.error) {
|
||||||
content = <SpatialState message={metadata.error} />;
|
content = <SpatialState message={metadata.error} />;
|
||||||
} else if (!timeline) {
|
} else if (!timeline) {
|
||||||
@@ -1062,23 +1132,8 @@ export function M4ReplayThreatVisual({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const mediaPane = (
|
const mediaContent = (
|
||||||
<section
|
<>
|
||||||
className="m4-replay-threat-visual__pane"
|
|
||||||
data-pane="media"
|
|
||||||
aria-label={mediaMode === "camera" ? "Камера" : "Видео"}
|
|
||||||
hidden={!mediaMode}
|
|
||||||
>
|
|
||||||
{splitView ? (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-toolbar"
|
|
||||||
data-pane-toolbar="media"
|
|
||||||
data-multi-semantic={availableSemanticLayers.length > 1 ? "true" : undefined}
|
|
||||||
>
|
|
||||||
{mediaLayerControls}
|
|
||||||
{mediaModeControls}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
<div
|
||||||
className="m4-replay-threat-visual__media-layer"
|
className="m4-replay-threat-visual__media-layer"
|
||||||
data-media="video"
|
data-media="video"
|
||||||
@@ -1091,6 +1146,7 @@ export function M4ReplayThreatVisual({
|
|||||||
imageWidth={timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
|
overlaySeconds={frame?.sessionSeconds}
|
||||||
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
||||||
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
|
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
|
||||||
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||||
@@ -1102,7 +1158,9 @@ export function M4ReplayThreatVisual({
|
|||||||
}
|
}
|
||||||
segmentCount={timeline.frameCount}
|
segmentCount={timeline.frameCount}
|
||||||
onPlaybackChange={playbackController.synchronize}
|
onPlaybackChange={playbackController.synchronize}
|
||||||
onPlayingRejected={() => playbackController.setPlaying(false)}
|
playbackAuthority="media"
|
||||||
|
playbackTransport={playbackTransport}
|
||||||
|
recoverTimestampStalls={recoverTimestampStalls}
|
||||||
/>
|
/>
|
||||||
) : videoError ? (
|
) : videoError ? (
|
||||||
<SpatialState message={videoError} />
|
<SpatialState message={videoError} />
|
||||||
@@ -1124,34 +1182,20 @@ export function M4ReplayThreatVisual({
|
|||||||
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const spatialPane = spatialMode ? (
|
const spatialContent = spatialMode ? (
|
||||||
<section
|
<>
|
||||||
className="m4-replay-threat-visual__pane"
|
|
||||||
data-pane="spatial"
|
|
||||||
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
|
||||||
>
|
|
||||||
{splitView ? (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-toolbar"
|
|
||||||
data-pane-toolbar="spatial"
|
|
||||||
>
|
|
||||||
{resetSpatialView}
|
|
||||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
|
||||||
{spatialLayerControls}
|
|
||||||
{spatialModeControls}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<LaboratoryMetricEvidenceScene
|
<LaboratoryMetricEvidenceScene
|
||||||
ref={metricSceneRef}
|
ref={metricSceneRef}
|
||||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? classifiedPointsBody
|
? classifiedPointsBody
|
||||||
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []}
|
: classifiedContextSpatialFrame?.pointCloudBodyXyzM
|
||||||
|
?? activeSpatialFrame?.pointCloudBodyXyzM
|
||||||
|
?? []}
|
||||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||||
obstacles={classifiedSpatialLayer ? [] : sceneObstacles}
|
obstacles={hasClassifiedSpatialOutput ? [] : sceneObstacles}
|
||||||
rig={timeline.rig}
|
rig={timeline.rig}
|
||||||
corridor={timeline.corridor}
|
corridor={timeline.corridor}
|
||||||
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
||||||
@@ -1160,22 +1204,22 @@ export function M4ReplayThreatVisual({
|
|||||||
showCurrentIncrement={showCurrentIncrement}
|
showCurrentIncrement={showCurrentIncrement}
|
||||||
showLocalSurface={showLocalSurface}
|
showLocalSurface={showLocalSurface}
|
||||||
showRollingMap={showRollingMap}
|
showRollingMap={showRollingMap}
|
||||||
showLowStep={classifiedSpatialLayer ? false : showLowStep}
|
showLowStep={hasClassifiedSpatialOutput ? false : showLowStep}
|
||||||
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? displayedClassifiedSpatialFrame.pointClassIds
|
? displayedClassifiedSpatialFrame.pointClassIds
|
||||||
: alignedSemanticPointIds}
|
: alignedSemanticPointIds}
|
||||||
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? displayedClassifiedSpatialFrame.classes
|
? displayedClassifiedSpatialFrame.classes
|
||||||
: semanticClasses}
|
: spatialSemanticClasses}
|
||||||
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? displayedClassifiedSpatialFrame.palette
|
? displayedClassifiedSpatialFrame.palette
|
||||||
: semanticPalette}
|
: spatialSemanticPalette}
|
||||||
classifiedCells={classifiedCellsBody}
|
classifiedCells={classifiedCellsBody}
|
||||||
classifiedPackedCells={classifiedPackedCellsBody}
|
classifiedPackedCells={classifiedPackedCellsBody}
|
||||||
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
||||||
showClassifiedCells={showRollingMap}
|
showClassifiedCells={showRollingMap}
|
||||||
/>
|
/>
|
||||||
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
{hasClassifiedSpatialOutput && classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
||||||
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
||||||
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||||
? <span className="busy-indicator" aria-hidden="true" />
|
? <span className="busy-indicator" aria-hidden="true" />
|
||||||
@@ -1206,31 +1250,11 @@ export function M4ReplayThreatVisual({
|
|||||||
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
content = (
|
const deckOverlays = (
|
||||||
<div
|
<>
|
||||||
className="m4-replay-threat-visual__deck"
|
|
||||||
data-split={splitView ? "true" : undefined}
|
|
||||||
data-empty={!mediaMode && !spatialMode ? "true" : undefined}
|
|
||||||
>
|
|
||||||
<SplitPane
|
|
||||||
primary={mediaPane}
|
|
||||||
secondary={spatialPane ?? <div />}
|
|
||||||
primarySize={splitView ? splitPrimarySize : mediaMode ? 100 : 0}
|
|
||||||
onPrimarySizeChange={setSplitPrimarySize}
|
|
||||||
orientation={splitOrientation}
|
|
||||||
minPrimarySize={splitView ? 24 : 0}
|
|
||||||
minSecondarySize={splitView ? 24 : 0}
|
|
||||||
resizable={splitView}
|
|
||||||
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
|
|
||||||
/>
|
|
||||||
{!mediaMode && !spatialMode ? (
|
|
||||||
<div className="l3-visual-audit__state" role="status">
|
|
||||||
Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
@@ -1261,8 +1285,9 @@ export function M4ReplayThreatVisual({
|
|||||||
<span>{semanticIntegrityError}</span>
|
<span>{semanticIntegrityError}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
canonicalContent = { mediaContent, spatialContent, deckOverlays };
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = timeline ? (
|
const transport = timeline ? (
|
||||||
@@ -1287,38 +1312,59 @@ export function M4ReplayThreatVisual({
|
|||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
if (!timeline || !canonicalContent) {
|
||||||
return (
|
return (
|
||||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||||
<LaboratoryEvidenceViewer
|
<LaboratoryEvidenceViewer
|
||||||
label={activeSemantic
|
label={`${evidenceLabel} recorded-realtime replay`}
|
||||||
? activeSemantic.label ?? "Semantic diagnostic replay"
|
|
||||||
: `${evidenceLabel} recorded-realtime replay`}
|
|
||||||
className="m4-replay-threat-evidence-viewer"
|
className="m4-replay-threat-evidence-viewer"
|
||||||
mode={mediaMode ?? "none"}
|
mode="video"
|
||||||
modes={[
|
modes={[{ value: "video", label: "VIDEO" }]}
|
||||||
{ value: "video", label: "VIDEO" },
|
|
||||||
{ value: "camera", label: "CAMERA" },
|
|
||||||
]}
|
|
||||||
secondaryMode={{
|
|
||||||
value: spatialMode ?? "none",
|
|
||||||
modes: [
|
|
||||||
{ value: "3d", label: "3D" },
|
|
||||||
{ value: "plan", label: "PLAN" },
|
|
||||||
],
|
|
||||||
label: "3D и план",
|
|
||||||
onChange: handleSpatialModeChange,
|
|
||||||
}}
|
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onModeChange={handleMediaModeChange}
|
onModeChange={() => undefined}
|
||||||
onExpandedChange={setExpanded}
|
onExpandedChange={setExpanded}
|
||||||
modeControlsVisible={!splitView}
|
|
||||||
actions={actions}
|
|
||||||
overlay={overlay}
|
|
||||||
transport={transport}
|
|
||||||
trailingActions={trailingActions}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</LaboratoryEvidenceViewer>
|
</LaboratoryEvidenceViewer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CanonicalRecordedLabReplay
|
||||||
|
label={activeSemantic
|
||||||
|
? activeSemantic.label ?? "Semantic diagnostic replay"
|
||||||
|
: `${evidenceLabel} recorded-realtime replay`}
|
||||||
|
mediaMode={mediaMode ?? "none"}
|
||||||
|
mediaModes={[
|
||||||
|
{ value: "video", label: "VIDEO" },
|
||||||
|
{ value: "camera", label: "CAMERA" },
|
||||||
|
]}
|
||||||
|
spatialMode={spatialMode ?? "none"}
|
||||||
|
spatialModes={[
|
||||||
|
{ value: "3d", label: "3D" },
|
||||||
|
{ value: "plan", label: "PLAN" },
|
||||||
|
]}
|
||||||
|
expanded={expanded}
|
||||||
|
splitPrimarySize={splitPrimarySize}
|
||||||
|
splitOrientation={splitOrientation}
|
||||||
|
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
|
||||||
|
spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||||
|
mediaLayerControls={mediaLayerControls}
|
||||||
|
spatialLayerControls={spatialLayerControls}
|
||||||
|
spatialLeadingControl={resetSpatialView}
|
||||||
|
mediaMultiLayer={availableSemanticLayers.length > 1}
|
||||||
|
mediaContent={canonicalContent.mediaContent}
|
||||||
|
spatialContent={canonicalContent.spatialContent}
|
||||||
|
emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте."
|
||||||
|
deckOverlays={canonicalContent.deckOverlays}
|
||||||
|
actions={actions}
|
||||||
|
overlay={overlay}
|
||||||
|
transport={transport}
|
||||||
|
trailingActions={trailingActions}
|
||||||
|
onMediaModeChange={handleMediaModeChange}
|
||||||
|
onSpatialModeChange={handleSpatialModeChange}
|
||||||
|
onExpandedChange={setExpanded}
|
||||||
|
onSplitPrimarySizeChange={setSplitPrimarySize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
|
||||||
import {
|
import {
|
||||||
LaboratoryEvidence,
|
LaboratoryEvidence,
|
||||||
LaboratoryResultSummary,
|
LaboratoryResultSummary,
|
||||||
@@ -10,135 +8,99 @@ import {
|
|||||||
} from "../../components/laboratory/LaboratoryPresentation";
|
} from "../../components/laboratory/LaboratoryPresentation";
|
||||||
import {
|
import {
|
||||||
vegetationVideoMaskUrl,
|
vegetationVideoMaskUrl,
|
||||||
type VegetationMixedRouteReview,
|
type VegetationFullRouteReview,
|
||||||
type VegetationShadowResult,
|
type VegetationShadowResult,
|
||||||
} from "../../core/laboratory/vegetationShadow";
|
} from "../../core/laboratory/vegetationShadow";
|
||||||
import {
|
import {
|
||||||
fetchM49TgsFullShadowResult,
|
fetchM49TgsFullShadowResult,
|
||||||
type M49TgsFullShadowResult,
|
type M49TgsFullShadowResult,
|
||||||
} from "../../core/laboratory/m49TgsFullShadow";
|
} from "../../core/laboratory/m49TgsFullShadow";
|
||||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
|
||||||
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
||||||
|
import { CanonicalVegetationRerunReplay } from "./CanonicalVegetationRerunReplay";
|
||||||
|
|
||||||
function decimal(value: number, digits = 1): string {
|
function decimal(value: number, digits = 1): string {
|
||||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIXED_ROUTE_MODES = [
|
function FullRouteReviewEvidence({
|
||||||
{ value: "source", label: "SOURCE" },
|
resultId,
|
||||||
{ value: "city", label: "ГОРОД · EoMT" },
|
review,
|
||||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
}: {
|
||||||
{ value: "tgs", label: "TGS" },
|
resultId: string;
|
||||||
] as const;
|
review: VegetationFullRouteReview;
|
||||||
|
}) {
|
||||||
function MixedRouteReviewEvidence({ review }: { review: VegetationMixedRouteReview }) {
|
return <CanonicalVegetationRerunReplay resultId={resultId} review={review} />;
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
const [mode, setMode] = useState<typeof MIXED_ROUTE_MODES[number]["value"]>("vegetation");
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const item = review.cases[index]!;
|
|
||||||
return (
|
|
||||||
<LaboratoryEvidenceViewer
|
|
||||||
label="RAVNOVES004TREE mixed route review"
|
|
||||||
className="m48-atlas-visual"
|
|
||||||
mode={mode}
|
|
||||||
modes={MIXED_ROUTE_MODES}
|
|
||||||
expanded={expanded}
|
|
||||||
onModeChange={setMode}
|
|
||||||
onExpandedChange={setExpanded}
|
|
||||||
chromeLayout="stacked"
|
|
||||||
actions={(
|
|
||||||
<>
|
|
||||||
<IconButton label="Предыдущая сцена" onClick={() => setIndex((index - 1 + review.cases.length) % review.cases.length)}>
|
|
||||||
<Icon name="chevron-left" size={16} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton label="Следующая сцена" onClick={() => setIndex((index + 1) % review.cases.length)}>
|
|
||||||
<Icon name="chevron-right" size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
overlay={(
|
|
||||||
<div className="m48-atlas-visual__case">
|
|
||||||
<StatusBadge tone={item.phase === "urban" ? "accent" : item.phase === "transition" ? "warning" : "neutral"}>
|
|
||||||
{item.phase.toUpperCase()} · {index + 1}/{review.cases.length}
|
|
||||||
</StatusBadge>
|
|
||||||
<strong>sequence {item.sourceSequence} · +{decimal(item.sessionSeconds, 2)} s</strong>
|
|
||||||
<small>
|
|
||||||
TGS: {item.tgs.groundCells} ground · {item.tgs.occupiedCells} occupied · {item.tgs.unobservedCells} unobserved
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="recorded-evidence-image-scene">
|
|
||||||
<img src={item.assets[mode]} alt="" draggable={false} />
|
|
||||||
</div>
|
|
||||||
</LaboratoryEvidenceViewer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MixedRouteReviewResult({
|
function FullRouteReviewResult({
|
||||||
rigLabel,
|
rigLabel,
|
||||||
|
resultId,
|
||||||
review,
|
review,
|
||||||
}: {
|
}: {
|
||||||
rigLabel: string;
|
rigLabel: string;
|
||||||
review: VegetationMixedRouteReview;
|
resultId: string;
|
||||||
|
review: VegetationFullRouteReview;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<LaboratoryWorkTemplate
|
<LaboratoryWorkTemplate
|
||||||
summary={(
|
summary={(
|
||||||
<LaboratorySummary
|
<LaboratorySummary
|
||||||
title="LAB V1 · RAVNOVES004TREE · село → город"
|
title="LAB V1 · RAVNOVES004TREE · полный маршрут"
|
||||||
description="Существующий LAB-шаблон показывает 10 синхронных camera/LiDAR сцен одной записи. EoMT и DDRNet остаются независимыми слоями; TGS показывает отдельную геометрию и не может быть очищен семантической маской."
|
description="Принятый инструмент записанной LAB воспроизводит RAV004 без отдельного viewer: единый таймлайн, правая камера, исходные точки, ограниченный Local SLAM и переключаемые EoMT/DDRNet."
|
||||||
status="BOUNDED RECORDED REVIEW · truth отсутствует · commands OFF"
|
status="ПОЛНЫЙ ПРОСМОТР ЗАПИСИ · эталон отсутствует · команды ВЫКЛ"
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
facts={[
|
facts={[
|
||||||
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount} camera/LiDAR islands` },
|
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} кадров камеры` },
|
||||||
{ label: "Переход", value: "5 rural · 1 transition · 4 urban" },
|
{ label: "3D", value: "1444 приращения исходного облака · стабильная по гравитации RFU → корпус" },
|
||||||
{ label: "Слои", value: "SOURCE · EoMT CITY · DDRNet VEGETATION · causal TGS" },
|
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} кадра/с` },
|
||||||
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
|
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} кадра/с` },
|
||||||
|
{ label: "TGS", value: "существуют 10 контрольных якорей · артефакт полного маршрута отсутствует" },
|
||||||
|
{ label: "Полномочия", value: `${rigLabel} · ТОЛЬКО ВИЗУАЛЬНЫЙ ПРОСМОТР · команды ВЫКЛ` },
|
||||||
]}
|
]}
|
||||||
brief={{
|
brief={{
|
||||||
question: "Сохраняются ли городская семантика, растительность и геометрия при переходе из сельской среды в город?",
|
question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
|
||||||
approach: "Выбраны десять соседних с исходными сцен camera-кадров, каждый синхронизирован с LiDAR в пределах 100 мс. Все три вычислительных слоя прогнаны на Worker 006 и запечатаны локально.",
|
approach: "RAV004 поставляет только data/provider configuration в тот же M4 recorded viewer. Видеодекодер владеет clock; новые source increments проецируются в gravity-stable forward/left/up frame, Local SLAM ограничен пятью секундами.",
|
||||||
principalResult: "Все 10 сцен обработаны EoMT, DDRNet и causal TGS. Слои можно переключать без наложения цветов и без зависимости LAB от воркера.",
|
principalResult: "RAV004 больше не имеет отдельной логики окон, таймера, seek, cache или 3D controls. Модели и подписи меняются конфигурацией, архитектура переключения остаётся общей.",
|
||||||
limitation: "Это bounded islands без ручной truth. DDRNet шумит по подтипам растительности; TGS не доказывает обнаружение кювета или отрицательного препятствия.",
|
limitation: "Full-route TGS, независимый person/vehicle detector, ручной truth и point-aligned 3D semantics пока не запечатаны. Semantic-derived рамки диагностические и не являются STOP-authority.",
|
||||||
}}
|
}}
|
||||||
method={{
|
method={{
|
||||||
completeness: "complete",
|
completeness: "complete",
|
||||||
executionClass: "ai-inference",
|
executionClass: "ai-inference",
|
||||||
pipelineId: "ravnoves004tree-eomt-ddrnet-causal-tgs-review/v1",
|
pipelineId: "canonical-recorded-lab-rav004tree/v3",
|
||||||
components: [
|
components: [
|
||||||
{ kind: "model", name: review.models.city.name, version: "sealed Worker run", role: "urban semantic review", identitySha256: null },
|
{ kind: "algorithm", name: "Canonical recorded replay", version: "media-clock / one viewer", role: "shared camera + spatial transport", identitySha256: null },
|
||||||
{ kind: "model", name: review.models.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
|
{ kind: "algorithm", name: "Recorded source points + bounded Local SLAM", version: "source-paced-ground-v3", role: "gravity-stable spatial evidence", identitySha256: null },
|
||||||
{ kind: "algorithm", name: review.models.tgs.name, version: "TRAVEL compatibility runner", role: "independent local geometry", identitySha256: null },
|
{ kind: "model", name: review.city.name, version: "sealed Worker 006 run", role: "urban semantic review", identitySha256: null },
|
||||||
|
{ kind: "model", name: review.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
evidence={(
|
evidence={(
|
||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="M4.7 TEMPLATE · RAVNOVES004TREE"
|
eyebrow="КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE"
|
||||||
title="SOURCE / ГОРОД / ПРИРОДА / TGS · 10/10 · TRUTH отсутствует"
|
title="КАМЕРА + ИСХОДНЫЕ ТОЧКИ + ЛОКАЛЬНЫЙ SLAM + КАРТА TGS + СЕМАНТИКА · 6830/6830"
|
||||||
kind="diagnostic-model"
|
kind="recorded-replay"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
<MixedRouteReviewEvidence review={review} />
|
<FullRouteReviewEvidence resultId={resultId} review={review} />
|
||||||
</LaboratoryEvidence>
|
</LaboratoryEvidence>
|
||||||
)}
|
)}
|
||||||
result={(
|
result={(
|
||||||
<LaboratoryResultSummary
|
<LaboratoryResultSummary
|
||||||
title="Переход село → город воспроизведён; safety gate не закрыт"
|
title="RAV004 переведён на общий replay-каркас; safety evidence ещё не полно"
|
||||||
status="Review ready · navigation/actuation OFF"
|
status="Записанные доказательства · навигация/управление ВЫКЛ"
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
metrics={[
|
metrics={[
|
||||||
{ label: "Aligned scenes", value: "10/10", hint: "camera + LiDAR + pose · автономный archive" },
|
{ label: "Таймлайн камеры", value: "6830 кадров · ≈9,51 Гц", hint: "единые часы управляют видео, слоями и пространством" },
|
||||||
{ label: "EoMT end-to-end p95", value: `${decimal(review.models.city.endToEndP95Ms, 2)} ms`, hint: `${decimal(review.models.city.inferenceFps, 2)} fps в изолированном прогоне` },
|
{ label: "Исходная геометрия", value: "1444 приращения · ≈2 Гц", hint: "между поступлениями удерживается последний подтверждённый пространственный кадр" },
|
||||||
{ label: "DDRNet inference p95", value: `${decimal(review.models.vegetation.latencyP95Ms, 2)} ms`, hint: "candidate review · не совместный realtime stack" },
|
{ label: "Пропускная способность EoMT", value: `${decimal(review.city.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; не стек реального времени" },
|
||||||
{ label: "TGS p95", value: `${decimal(review.models.tgs.latencyP95Ms, 2)} ms`, hint: `${review.models.tgs.cellSizeM} m cells · ${review.models.tgs.radiusM} m radius` },
|
{ label: "Пропускная способность DDRNet", value: `${decimal(review.vegetation.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; временная стабильность не принята" },
|
||||||
]}
|
]}
|
||||||
conclusion={{
|
conclusion={{
|
||||||
proved: "Оба semantic слоя и causal TGS воспроизводимо работают на сельской, переходной и городской части новой записи.",
|
proved: "Камера, перемотка, пространственные слои и переключение семантики используют один принятый переиспользуемый viewer и единые часы; исходная геометрия RFU больше не наследует крен и тангаж LiDAR.",
|
||||||
notProved: "Не доказаны accuracy без truth, временная стабильность по всему видео, детект кюветов и безопасное совместное realtime-управление ровером.",
|
notProved: "Не доказаны непрерывная TGS, независимый детектор/STOP, точность относительно эталона, временная стабильность DDRNet и ≥10 кадров/с совместного стека реального времени.",
|
||||||
decision: "Оставить navigation/actuation OFF. Следующий короткий gate — непрерывный realtime-load двух моделей плюс независимый person/vehicle STOP; кюветы проверять отдельной записью.",
|
decision: "Продолжать как визуальный аудит. До запечатанной TGS полного маршрута и барьера детектора/нагрузки навигация и управление остаются выключенными.",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -148,6 +110,7 @@ function MixedRouteReviewResult({
|
|||||||
|
|
||||||
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
|
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
|
||||||
const route = result.routeVideo!;
|
const route = result.routeVideo!;
|
||||||
|
const linkedTgsResultId = route.linkedTgsResultId;
|
||||||
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
||||||
const [tgsError, setTgsError] = useState<string | null>(null);
|
const [tgsError, setTgsError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -155,8 +118,8 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setTgs(null);
|
setTgs(null);
|
||||||
setTgsError(null);
|
setTgsError(null);
|
||||||
if (!route.linkedTgsResultId) return () => controller.abort();
|
if (!linkedTgsResultId) return () => controller.abort();
|
||||||
void fetchM49TgsFullShadowResult(route.linkedTgsResultId, {
|
void fetchM49TgsFullShadowResult(linkedTgsResultId, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}).then((next) => {
|
}).then((next) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -170,7 +133,11 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [route.baseM4ResultId, route.linkedTgsResultId]);
|
}, [linkedTgsResultId, route.baseM4ResultId]);
|
||||||
|
|
||||||
|
if (!linkedTgsResultId) {
|
||||||
|
throw new Error("Vegetation LAB result has no linked canonical M4.9 TGS evidence.");
|
||||||
|
}
|
||||||
|
|
||||||
const semantic = {
|
const semantic = {
|
||||||
id: "vegetation",
|
id: "vegetation",
|
||||||
@@ -183,16 +150,14 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
maskAriaLabel: "DDRNet vegetation material prediction",
|
maskAriaLabel: "DDRNet vegetation material prediction",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (route.linkedTgsResultId && tgs) {
|
if (tgsError) {
|
||||||
return (
|
return (
|
||||||
<M49TgsFullShadowEvidence
|
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
||||||
result={tgs}
|
Канонический M4.9 TGS слой недоступен: {tgsError}
|
||||||
semanticOverride={semantic}
|
</div>
|
||||||
evidenceLabel="LAB V1 · EoMT + DDRNet + YOLOX + TGS"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (route.linkedTgsResultId && !tgsError) {
|
if (!tgs) {
|
||||||
return (
|
return (
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||||
Открываем sealed EoMT, TGS и coarse vegetation timeline…
|
Открываем sealed EoMT, TGS и coarse vegetation timeline…
|
||||||
@@ -200,20 +165,11 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<M49TgsFullShadowEvidence
|
||||||
<M4ReplayThreatVisual
|
result={tgs}
|
||||||
resultId={route.baseM4ResultId}
|
semanticOverride={semantic}
|
||||||
evidenceLabel="LAB V1 · DDRNet"
|
evidenceLabel="LAB V1 · EoMT + DDRNet + YOLOX + TGS"
|
||||||
showReferenceMediaLayers
|
|
||||||
showSpatialOverlaySummary={false}
|
|
||||||
semantic={semantic}
|
|
||||||
/>
|
/>
|
||||||
{tgsError ? (
|
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
|
||||||
TGS слой недоступен: {tgsError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,8 +180,19 @@ export function VegetationShadowResultView({
|
|||||||
rigLabel: string;
|
rigLabel: string;
|
||||||
result: VegetationShadowResult;
|
result: VegetationShadowResult;
|
||||||
}) {
|
}) {
|
||||||
if (result.routeReview) {
|
if (result.routeFullReview) {
|
||||||
return <MixedRouteReviewResult rigLabel={rigLabel} review={result.routeReview} />;
|
return (
|
||||||
|
<FullRouteReviewResult
|
||||||
|
rigLabel={rigLabel}
|
||||||
|
resultId={result.resultId}
|
||||||
|
review={result.routeFullReview}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result.routeVideo?.linkedTgsResultId) {
|
||||||
|
throw new Error(
|
||||||
|
"Vegetation LAB result has no canonical M4 source timeline and linked M4.9 TGS evidence.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const route = result.routeVideo;
|
const route = result.routeVideo;
|
||||||
const selected = result.candidates.find(
|
const selected = result.candidates.find(
|
||||||
@@ -238,9 +205,7 @@ export function VegetationShadowResultView({
|
|||||||
<LaboratorySummary
|
<LaboratorySummary
|
||||||
title="LAB V1 · карта ровера · город + растительность"
|
title="LAB V1 · карта ровера · город + растительность"
|
||||||
description="Один recorded-контур RAVNOVES00 синхронно показывает городской EoMT, природный DDRNet, frozen YOLOX detections и causal TGS. Семантические маски переключаются, чтобы их цвета не скрывали друг друга; геометрическое veto остаётся независимым."
|
description="Один recorded-контур RAVNOVES00 синхронно показывает городской EoMT, природный DDRNet, frozen YOLOX detections и causal TGS. Семантические маски переключаются, чтобы их цвета не скрывали друг друга; геометрическое veto остаётся независимым."
|
||||||
status={route
|
status="MULTILAYER RECORDED REVIEW · commands OFF · route truth отсутствует"
|
||||||
? "MULTILAYER RECORDED REVIEW · commands OFF · route truth отсутствует"
|
|
||||||
: "ROUTE EVIDENCE MISSING · commands OFF"}
|
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
facts={[
|
facts={[
|
||||||
{ label: "Источник", value: "RAVNOVES00 · sensor.camera.right · 4489 recorded frames" },
|
{ label: "Источник", value: "RAVNOVES00 · sensor.camera.right · 4489 recorded frames" },
|
||||||
@@ -251,61 +216,31 @@ export function VegetationShadowResultView({
|
|||||||
]}
|
]}
|
||||||
brief={{
|
brief={{
|
||||||
question: "Можно ли одновременно видеть городской и природный semantic stack, не теряя независимую геометрическую защиту?",
|
question: "Можно ли одновременно видеть городской и природный semantic stack, не теряя независимую геометрическую защиту?",
|
||||||
approach: "EoMT и DDRNet сохранены как два независимых sealed слоя на одной M4 timeline. В штатном M4.7 viewer пользователь переключает только отображаемую маску; YOLOX и TGS остаются активными слоями evidence.",
|
approach: "EoMT и DDRNet сохранены как два независимых sealed слоя на одной M4 timeline. В штатном M4.9 viewer пользователь переключает только отображаемую маску; YOLOX и TGS остаются активными слоями evidence.",
|
||||||
principalResult: route
|
principalResult: "Оба semantic archive доступны в одном viewer. Это не пиксельный fusion и не единая новая модель: городской и природный ответы остаются раздельными.",
|
||||||
? "Оба semantic archive доступны в одном viewer. Это не пиксельный fusion и не единая новая модель: городской и природный ответы остаются раздельными."
|
|
||||||
: "Route archive для этой immutable identity отсутствует.",
|
|
||||||
limitation: "RAVNOVES00 не имеет ручной truth. DDRNet заметно прыгает между HIGH GRASS, WOODY и UNKNOWN; поэтому subtype нельзя подавать напрямую в planner. Отсутствие класса никогда не означает свободный путь.",
|
limitation: "RAVNOVES00 не имеет ручной truth. DDRNet заметно прыгает между HIGH GRASS, WOODY и UNKNOWN; поэтому subtype нельзя подавать напрямую в planner. Отсутствие класса никогда не означает свободный путь.",
|
||||||
}}
|
}}
|
||||||
method={{
|
method={{
|
||||||
completeness: route ? "complete" : "legacy-partial",
|
completeness: "complete",
|
||||||
executionClass: "ai-inference",
|
executionClass: "ai-inference",
|
||||||
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
|
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
|
||||||
components: [
|
components: [
|
||||||
{
|
{ kind: "model", name: "EoMT Cityscapes semantic", version: "sealed E47 archive", role: "urban semantic review", identitySha256: null },
|
||||||
kind: "model",
|
{ kind: "model", name: selected.loadedModelName, version: selected.candidate, role: "vegetation material candidate", identitySha256: selected.checkpointSha256 },
|
||||||
name: "EoMT Cityscapes semantic",
|
{ kind: "algorithm", name: "Frozen YOLOX + causal TGS", version: "linked M4/M4.9 archives", role: "independent object and geometry veto", identitySha256: null },
|
||||||
version: "sealed E47 archive",
|
|
||||||
role: "urban semantic review",
|
|
||||||
identitySha256: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "model",
|
|
||||||
name: selected.loadedModelName,
|
|
||||||
version: selected.candidate,
|
|
||||||
role: "vegetation material candidate",
|
|
||||||
identitySha256: selected.checkpointSha256,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "algorithm",
|
|
||||||
name: "Frozen YOLOX + causal TGS",
|
|
||||||
version: "linked M4/M4.9 archives",
|
|
||||||
role: "independent object and geometry veto",
|
|
||||||
identitySha256: null,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
evidence={route ? (
|
evidence={(
|
||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO"
|
eyebrow="M4.9 · RAVNOVES00 FULL VIDEO"
|
||||||
title="EoMT CITY / DDRNet VEGETATION + YOLOX + CAUSAL TGS · 4489/4489 · TRUTH отсутствует"
|
title="EoMT CITY / DDRNet VEGETATION + YOLOX + CAUSAL TGS · 4489/4489 · TRUTH отсутствует"
|
||||||
kind="diagnostic-model"
|
kind="diagnostic-model"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
<VegetationRouteEvidence result={result} />
|
<VegetationRouteEvidence result={result} />
|
||||||
</LaboratoryEvidence>
|
</LaboratoryEvidence>
|
||||||
) : (
|
|
||||||
<LaboratoryEvidence
|
|
||||||
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO"
|
|
||||||
title="ROUTE ARCHIVE отсутствует"
|
|
||||||
kind="diagnostic-model"
|
|
||||||
>
|
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
|
||||||
Для этой immutable identity нет полного route video evidence.
|
|
||||||
</div>
|
|
||||||
</LaboratoryEvidence>
|
|
||||||
)}
|
)}
|
||||||
result={(
|
result={(
|
||||||
<LaboratoryResultSummary
|
<LaboratoryResultSummary
|
||||||
@@ -313,26 +248,10 @@ export function VegetationShadowResultView({
|
|||||||
status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
|
status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
metrics={[
|
metrics={[
|
||||||
{
|
{ label: "Route masks", value: `${route.frameCount}/${route.frameCount}`, hint: "sealed local playback · Worker для открытия не нужен" },
|
||||||
label: "Route masks",
|
{ label: "Semantic sources", value: "2 independent layers", hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse" },
|
||||||
value: route ? `${route.frameCount}/${route.frameCount}` : "0/4489",
|
{ label: "Vegetation worker p95", value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`, hint: "изолированный DDRNet inference; не совместный realtime stack" },
|
||||||
hint: "sealed local playback · Worker для открытия не нужен",
|
{ label: "Vegetation peak VRAM", value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`, hint: "DDRNet candidate на Worker 006" },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Semantic sources",
|
|
||||||
value: route ? "2 independent layers" : "0",
|
|
||||||
hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vegetation worker p95",
|
|
||||||
value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`,
|
|
||||||
hint: "изолированный DDRNet inference; не совместный realtime stack",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vegetation peak VRAM",
|
|
||||||
value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`,
|
|
||||||
hint: "DDRNet candidate на Worker 006",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
conclusion={{
|
conclusion={{
|
||||||
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
|
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
|
||||||
|
|||||||
+4
-2
@@ -10,6 +10,7 @@ interface M48EvidenceModeControlProps {
|
|||||||
mode: M48BlindEvidenceMode;
|
mode: M48BlindEvidenceMode;
|
||||||
cameraVisible: boolean;
|
cameraVisible: boolean;
|
||||||
spatialAvailable: boolean;
|
spatialAvailable: boolean;
|
||||||
|
planAvailable?: boolean;
|
||||||
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
||||||
onCameraVisibleChange: (visible: boolean) => void;
|
onCameraVisibleChange: (visible: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ export function M48EvidenceModeControls({
|
|||||||
mode,
|
mode,
|
||||||
cameraVisible,
|
cameraVisible,
|
||||||
spatialAvailable,
|
spatialAvailable,
|
||||||
|
planAvailable = spatialAvailable,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onCameraVisibleChange,
|
onCameraVisibleChange,
|
||||||
}: M48EvidenceModeControlProps) {
|
}: M48EvidenceModeControlProps) {
|
||||||
@@ -68,9 +70,9 @@ export function M48EvidenceModeControls({
|
|||||||
<IconButton
|
<IconButton
|
||||||
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
||||||
aria-pressed={spatialMode === "plan"}
|
aria-pressed={spatialMode === "plan"}
|
||||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
|
disabled={!planAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!spatialAvailable) return;
|
if (!planAvailable) return;
|
||||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -459,6 +459,17 @@ function pipelineIdForSession(session: ObservationSessionSummary): string {
|
|||||||
return session.lab?.resultKind ?? "legacy-perception";
|
return session.lab?.resultKind ?? "legacy-perception";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep capability projections owned by Observatory out of the legacy LAB surface. */
|
||||||
|
export function isLegacyPublishedLaboratoryWork(
|
||||||
|
session: ObservationSessionSummary,
|
||||||
|
): boolean {
|
||||||
|
return session.lab !== null
|
||||||
|
&& session.lab.replayCapability === null
|
||||||
|
&& session.status === "ready"
|
||||||
|
&& session.replayable
|
||||||
|
&& session.modalities.includes("point-cloud");
|
||||||
|
}
|
||||||
|
|
||||||
export function buildLaboratoryCatalog({
|
export function buildLaboratoryCatalog({
|
||||||
rigLabel,
|
rigLabel,
|
||||||
knownWorks,
|
knownWorks,
|
||||||
|
|||||||
@@ -11,11 +11,30 @@ const CHUNK_SIZE = 24;
|
|||||||
const RETAINED_CHUNK_COUNT = 8;
|
const RETAINED_CHUNK_COUNT = 8;
|
||||||
const PREFETCH_CHUNKS_AHEAD = 2;
|
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||||
|
|
||||||
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] {
|
export function e47SemanticChunkWindowStarts(
|
||||||
return Array.from(
|
activeStart: number,
|
||||||
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
frameCount: number,
|
||||||
(_, index) => activeStart + (index - 1) * CHUNK_SIZE,
|
): readonly number[] {
|
||||||
).filter((start) => start >= 0 && start < frameCount);
|
return [
|
||||||
|
activeStart,
|
||||||
|
...Array.from(
|
||||||
|
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||||
|
(_, index) => activeStart + (index + 1) * CHUNK_SIZE,
|
||||||
|
),
|
||||||
|
activeStart - CHUNK_SIZE,
|
||||||
|
].filter((start) => start >= 0 && start < frameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelE47SemanticRequestsOutsideWindow<T extends { abort(): void }>(
|
||||||
|
inFlight: Map<number, T>,
|
||||||
|
desiredStarts: readonly number[],
|
||||||
|
): void {
|
||||||
|
const desired = new Set(desiredStarts);
|
||||||
|
for (const [start, controller] of inFlight) {
|
||||||
|
if (desired.has(start)) continue;
|
||||||
|
controller.abort();
|
||||||
|
inFlight.delete(start);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
@@ -29,11 +48,13 @@ export function useE47SemanticTimelineFrame({
|
|||||||
activeSequence,
|
activeSequence,
|
||||||
frameCount,
|
frameCount,
|
||||||
taxonomy,
|
taxonomy,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
resultId: string | null;
|
resultId: string | null;
|
||||||
activeSequence: number | null;
|
activeSequence: number | null;
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
taxonomy: readonly E47SemanticClass[];
|
taxonomy: readonly E47SemanticClass[];
|
||||||
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
@@ -55,16 +76,21 @@ export function useE47SemanticTimelineFrame({
|
|||||||
for (const controller of inFlight.current.values()) controller.abort();
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
inFlight.current.clear();
|
inFlight.current.clear();
|
||||||
};
|
};
|
||||||
}, [resultId]);
|
}, [enabled, resultId]);
|
||||||
|
|
||||||
const activeStart = activeSequence === null
|
const activeStart = !enabled || activeSequence === null
|
||||||
? null
|
? null
|
||||||
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
||||||
activeStartRef.current = activeStart;
|
activeStartRef.current = activeStart;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resultId || activeStart === null || frameCount < 1) return;
|
if (!enabled || !resultId || activeStart === null || frameCount < 1) {
|
||||||
for (const start of chunkWindowStarts(activeStart, frameCount)) {
|
cancelE47SemanticRequestsOutsideWindow(inFlight.current, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const starts = e47SemanticChunkWindowStarts(activeStart, frameCount);
|
||||||
|
cancelE47SemanticRequestsOutsideWindow(inFlight.current, starts);
|
||||||
|
for (const start of starts) {
|
||||||
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
inFlight.current.set(start, controller);
|
inFlight.current.set(start, controller);
|
||||||
@@ -95,8 +121,11 @@ export function useE47SemanticTimelineFrame({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
||||||
});
|
});
|
||||||
|
// Semantic point arrays are large JSON payloads. Admit the active chunk
|
||||||
|
// first, then advance the bounded prefetch window one request per render.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, [activeStart, frameCount, resultId, taxonomy]);
|
}, [activeStart, chunks, enabled, frameCount, resultId, taxonomy]);
|
||||||
|
|
||||||
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
|
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
|
||||||
if (activeSequence === null || activeStart === null) return null;
|
if (activeSequence === null || activeStart === null) return null;
|
||||||
@@ -107,7 +136,7 @@ export function useE47SemanticTimelineFrame({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
activeFrame,
|
activeFrame,
|
||||||
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
loading: enabled && Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
|
|
||||||
const REQUESTED_CHUNK_FRAMES = 24;
|
const REQUESTED_CHUNK_FRAMES = 24;
|
||||||
const RETAINED_CHUNK_COUNT = 4;
|
const RETAINED_CHUNK_COUNT = 4;
|
||||||
|
const RETAINED_CHUNKS_BEHIND = 1;
|
||||||
const PREFETCH_CHUNKS_AHEAD = 1;
|
const PREFETCH_CHUNKS_AHEAD = 1;
|
||||||
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
||||||
|
|
||||||
@@ -31,10 +32,17 @@ export function m4ThreatChunkWindowStarts(
|
|||||||
frameCount: number,
|
frameCount: number,
|
||||||
): readonly number[] {
|
): readonly number[] {
|
||||||
if (chunkSize < 1 || frameCount < 1) return [];
|
if (chunkSize < 1 || frameCount < 1) return [];
|
||||||
return Array.from(
|
return [
|
||||||
{ length: PREFETCH_CHUNKS_AHEAD + 1 },
|
activeChunkStart,
|
||||||
(_, index) => activeChunkStart + index * chunkSize,
|
...Array.from(
|
||||||
).filter((start) => start >= 0 && start < frameCount);
|
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||||
|
(_, index) => activeChunkStart + (index + 1) * chunkSize,
|
||||||
|
),
|
||||||
|
...Array.from(
|
||||||
|
{ length: RETAINED_CHUNKS_BEHIND },
|
||||||
|
(_, index) => activeChunkStart - (index + 1) * chunkSize,
|
||||||
|
),
|
||||||
|
].filter((start) => start >= 0 && start < frameCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
|
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
|
||||||
@@ -76,12 +84,16 @@ export function useM4ThreatTimelineFrame({
|
|||||||
resultId,
|
resultId,
|
||||||
timeline,
|
timeline,
|
||||||
currentSeconds,
|
currentSeconds,
|
||||||
|
includeSpatialPoints = true,
|
||||||
endpointRoot,
|
endpointRoot,
|
||||||
|
spatialPlaybackTransport = "auto",
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
timeline: M4ThreatTimeline | null;
|
timeline: M4ThreatTimeline | null;
|
||||||
currentSeconds: number;
|
currentSeconds: number;
|
||||||
|
includeSpatialPoints?: boolean;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
|
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||||
}) {
|
}) {
|
||||||
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
@@ -94,8 +106,10 @@ export function useM4ThreatTimelineFrame({
|
|||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
});
|
});
|
||||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||||
const binaryPlayback = endpointRoot === undefined
|
const binaryPlayback = spatialPlaybackTransport === "sealed-binary"
|
||||||
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT;
|
|| (spatialPlaybackTransport === "auto" && (
|
||||||
|
endpointRoot === undefined || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT
|
||||||
|
));
|
||||||
const inFlight = useRef(new Map<number, AbortController>());
|
const inFlight = useRef(new Map<number, AbortController>());
|
||||||
const chunksRef = useRef(chunks);
|
const chunksRef = useRef(chunks);
|
||||||
const activeChunkStartRef = useRef<number | null>(null);
|
const activeChunkStartRef = useRef<number | null>(null);
|
||||||
@@ -107,7 +121,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
setPlaybackError(null);
|
setPlaybackError(null);
|
||||||
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||||
if (!timeline) return () => controller.abort();
|
if (!timeline) return () => controller.abort();
|
||||||
if (!binaryPlayback) {
|
if (!binaryPlayback || !includeSpatialPoints) {
|
||||||
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
@@ -127,7 +141,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [binaryPlayback, endpointRoot, resultId, timeline]);
|
}, [binaryPlayback, endpointRoot, includeSpatialPoints, resultId, timeline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const controller of inFlight.current.values()) controller.abort();
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
@@ -140,7 +154,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
for (const controller of inFlight.current.values()) controller.abort();
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
inFlight.current.clear();
|
inFlight.current.clear();
|
||||||
};
|
};
|
||||||
}, [resultId, timeline]);
|
}, [includeSpatialPoints, resultId, timeline]);
|
||||||
|
|
||||||
const activeSequence = useMemo(
|
const activeSequence = useMemo(
|
||||||
() => timeline
|
() => timeline
|
||||||
@@ -158,7 +172,11 @@ export function useM4ThreatTimelineFrame({
|
|||||||
activeChunkStartRef.current = activeChunkStart;
|
activeChunkStartRef.current = activeChunkStart;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return;
|
if (
|
||||||
|
!timeline
|
||||||
|
|| activeChunkStart === null
|
||||||
|
|| (binaryPlayback && includeSpatialPoints && !playbackManifest)
|
||||||
|
) return;
|
||||||
const starts = m4ThreatChunkWindowStarts(
|
const starts = m4ThreatChunkWindowStarts(
|
||||||
activeChunkStart,
|
activeChunkStart,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
@@ -170,7 +188,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
inFlight.current.set(start, controller);
|
inFlight.current.set(start, controller);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const playbackPointPack = binaryPlayback && playbackManifest
|
const playbackPointPack = binaryPlayback && includeSpatialPoints && playbackManifest
|
||||||
? await fetchM4ThreatPlaybackPointChunk(
|
? await fetchM4ThreatPlaybackPointChunk(
|
||||||
playbackManifest,
|
playbackManifest,
|
||||||
Math.floor(start / playbackManifest.chunkFrameCount),
|
Math.floor(start / playbackManifest.chunkFrameCount),
|
||||||
@@ -189,6 +207,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
endpointRoot,
|
endpointRoot,
|
||||||
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||||
playbackPointPack,
|
playbackPointPack,
|
||||||
|
includePoints: includeSpatialPoints,
|
||||||
});
|
});
|
||||||
})()
|
})()
|
||||||
.then((chunk) => {
|
.then((chunk) => {
|
||||||
@@ -220,7 +239,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
// loaded first, then the next chunk is prefetched on the following render.
|
// loaded first, then the next chunk is prefetched on the following render.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]);
|
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, includeSpatialPoints, playbackManifest, resultId, timeline]);
|
||||||
|
|
||||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||||
if (activeSequence === null || activeChunkStart === null) return null;
|
if (activeSequence === null || activeChunkStart === null) return null;
|
||||||
|
|||||||
@@ -0,0 +1,853 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Button,
|
||||||
|
ConfirmationModal,
|
||||||
|
GlassSurface,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Select,
|
||||||
|
StatusBadge,
|
||||||
|
TextField,
|
||||||
|
Window,
|
||||||
|
WindowFooterActions,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||||
|
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
||||||
|
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
||||||
|
import {
|
||||||
|
fetchObservatoryRecordedRunReview,
|
||||||
|
type ObservatoryRecordedRunBinding,
|
||||||
|
} from "../../core/observatory/recordedRun";
|
||||||
|
import {
|
||||||
|
findObservatoryEvidence,
|
||||||
|
observatoryCatalogConfirmsEvidenceDeletion,
|
||||||
|
type ObservatoryEvidence,
|
||||||
|
} from "../../core/observatory/catalog";
|
||||||
|
import {
|
||||||
|
deleteObservatoryLabProjection,
|
||||||
|
renameObservatoryLabProjection,
|
||||||
|
} from "../../core/observatory/catalogMutations";
|
||||||
|
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||||
|
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||||
|
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
||||||
|
import type { WorkspaceDefinition } from "../../productModel";
|
||||||
|
|
||||||
|
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||||
|
type ObservatoryRecordedRunReview = Awaited<
|
||||||
|
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ObservatoryMutationReconciliation =
|
||||||
|
| {
|
||||||
|
readonly kind: "rename";
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "delete";
|
||||||
|
readonly sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObservatoryReplayState =
|
||||||
|
| { readonly kind: "closed" }
|
||||||
|
| {
|
||||||
|
readonly kind: "loading";
|
||||||
|
readonly binding: ObservatoryRecordedRunBinding;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "ready";
|
||||||
|
readonly binding: ObservatoryRecordedRunBinding;
|
||||||
|
readonly review: ObservatoryRecordedRunReview;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "error";
|
||||||
|
readonly binding: ObservatoryRecordedRunBinding;
|
||||||
|
readonly message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel: Record<ObservationSessionStatus, string> = {
|
||||||
|
recording: "Запись идёт",
|
||||||
|
ready: "Готова",
|
||||||
|
degraded: "С деградацией",
|
||||||
|
interrupted: "Прервана",
|
||||||
|
failed: "Ошибка",
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalityLabel: Record<string, string> = {
|
||||||
|
"point-cloud": "Облако точек",
|
||||||
|
pose: "Поза",
|
||||||
|
trajectory: "Траектория",
|
||||||
|
video: "Видео",
|
||||||
|
image: "Изображения",
|
||||||
|
depth: "Глубина",
|
||||||
|
telemetry: "Телеметрия",
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusTone(
|
||||||
|
status: ObservationSessionStatus,
|
||||||
|
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||||
|
if (status === "ready") return "success";
|
||||||
|
if (status === "recording") return "accent";
|
||||||
|
if (status === "degraded" || status === "interrupted") return "warning";
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(seconds));
|
||||||
|
const hours = Math.floor(totalSeconds / 3_600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||||
|
const remainingSeconds = totalSeconds % 60;
|
||||||
|
return hours > 0
|
||||||
|
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
|
||||||
|
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutationErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.message.trim()
|
||||||
|
? error.message
|
||||||
|
: "Не удалось изменить лабораторный результат в Обсерватории.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
|
||||||
|
const profileName = evidence.lab.calculationProfile?.displayName;
|
||||||
|
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObservatoryWorkspace({
|
||||||
|
definition,
|
||||||
|
}: {
|
||||||
|
definition: WorkspaceDefinition;
|
||||||
|
}) {
|
||||||
|
const controller = useObservatoryCatalog();
|
||||||
|
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||||
|
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
||||||
|
const recordedJobsController = useObservatoryRecordedJobs(
|
||||||
|
selectedSessionId,
|
||||||
|
setupController.selectedSetupId,
|
||||||
|
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
|
||||||
|
);
|
||||||
|
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||||
|
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
|
||||||
|
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
|
||||||
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||||
|
const [mutationReconciliation, setMutationReconciliation] = useState<
|
||||||
|
ObservatoryMutationReconciliation | null
|
||||||
|
>(null);
|
||||||
|
const overviewRef = useRef<HTMLElement | null>(null);
|
||||||
|
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||||
|
const refreshedPublishedJobRef = useRef<string | null>(null);
|
||||||
|
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||||
|
|
||||||
|
const closeReplay = useCallback(() => {
|
||||||
|
replayCoordinatorRef.current.cancel();
|
||||||
|
setReplay((current) => current.kind === "closed" ? current : { kind: "closed" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const returnToOverview = useCallback(() => {
|
||||||
|
closeReplay();
|
||||||
|
overviewRef.current?.scrollIntoView({ block: "start" });
|
||||||
|
}, [closeReplay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.some((item) => item.source.id === selectedSessionId)) return;
|
||||||
|
closeReplay();
|
||||||
|
setSelectedSessionId(items[0]?.source.id ?? "");
|
||||||
|
}, [closeReplay, items, selectedSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => () => replayCoordinatorRef.current.cancel(), []);
|
||||||
|
|
||||||
|
const selectedSession = items.find(
|
||||||
|
(item) => item.source.id === selectedSessionId,
|
||||||
|
) ?? null;
|
||||||
|
const presentedEvidence = selectedSession?.evidence ?? [];
|
||||||
|
const options = useMemo(() => items.map(({ source, evidence }) => ({
|
||||||
|
value: source.id,
|
||||||
|
label: source.label,
|
||||||
|
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
||||||
|
})), [items]);
|
||||||
|
const setupOptions = useMemo(() => (
|
||||||
|
setupController.selectableSetups.map((setup) => ({
|
||||||
|
value: setup.setupId,
|
||||||
|
label: setup.displayName,
|
||||||
|
description: setup.description,
|
||||||
|
}))
|
||||||
|
), [setupController.selectableSetups]);
|
||||||
|
const preflightCandidate = setupController.preflight.kind === "ready"
|
||||||
|
? setupController.preflight.value
|
||||||
|
: null;
|
||||||
|
const runPreflight = preflightCandidate
|
||||||
|
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
|
||||||
|
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
|
||||||
|
&& preflightCandidate.definitionSha256
|
||||||
|
=== setupController.selectedSetup?.runDefinition?.definitionSha256
|
||||||
|
? preflightCandidate
|
||||||
|
: null;
|
||||||
|
const queueSubmissionAllowed = Boolean(
|
||||||
|
setupController.selectedSetup?.runDefinition
|
||||||
|
&& setupController.selectedSetup.compatibility.compatible
|
||||||
|
&& runPreflight?.outcome === "queueable"
|
||||||
|
&& runPreflight.submissionAllowed,
|
||||||
|
);
|
||||||
|
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
|
||||||
|
const publicationFailed = presentedJob?.publication.state === "failed";
|
||||||
|
const calculationPending = recordedJobsController.activeJob !== null
|
||||||
|
|| recordedJobsController.publicationPending
|
||||||
|
|| recordedJobsController.state === "submitting"
|
||||||
|
|| recordedJobsController.state === "retrying-publication";
|
||||||
|
const showCalculate = setupController.selectedSetup !== null
|
||||||
|
&& !calculationPending && !publicationFailed;
|
||||||
|
const canSubmitRecordedJob = queueSubmissionAllowed
|
||||||
|
&& setupController.state === "ready"
|
||||||
|
&& recordedJobsController.state === "ready"
|
||||||
|
&& !calculationPending && !publicationFailed;
|
||||||
|
const queueStatusError = setupController.preflight.kind === "error"
|
||||||
|
? setupController.preflight.message
|
||||||
|
: recordedJobsController.error
|
||||||
|
?? (publicationFailed
|
||||||
|
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
|
||||||
|
: recordedJobsController.computationFailed
|
||||||
|
? "Не удалось выполнить расчёт. Можно повторить запуск."
|
||||||
|
: runPreflight?.outcome === "blocked"
|
||||||
|
? runPreflight.checks.filter((check) => check.outcome === "fail")
|
||||||
|
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
|
||||||
|
: null);
|
||||||
|
const initialLoading = !controller.catalog
|
||||||
|
&& ["idle", "loading"].includes(controller.state);
|
||||||
|
const unavailable = !controller.catalog && controller.state === "error";
|
||||||
|
const replayEvidenceId = replay.kind === "closed"
|
||||||
|
? null
|
||||||
|
: replay.binding.evidenceSessionId;
|
||||||
|
const replayEvidence = replayEvidenceId === null
|
||||||
|
? null
|
||||||
|
: selectedSession?.evidence.find(
|
||||||
|
(evidence) => evidence.sessionId === replayEvidenceId,
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const publishedJob = recordedJobsController.jobs.find(
|
||||||
|
(job) => job.publication.state === "published" && job.resultId !== null,
|
||||||
|
);
|
||||||
|
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
|
||||||
|
refreshedPublishedJobRef.current = publishedJob.jobId;
|
||||||
|
void controller.refresh();
|
||||||
|
setupController.refresh();
|
||||||
|
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
replayEvidenceId === null
|
||||||
|
|| replayEvidence
|
||||||
|
) return;
|
||||||
|
closeReplay();
|
||||||
|
}, [closeReplay, replayEvidence, replayEvidenceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
mutationPending !== null
|
||||||
|
|| mutationReconciliation === null
|
||||||
|
|| controller.catalog === null
|
||||||
|
) return;
|
||||||
|
const evidence = findObservatoryEvidence(
|
||||||
|
controller.catalog,
|
||||||
|
mutationReconciliation.sessionId,
|
||||||
|
);
|
||||||
|
const confirmed = mutationReconciliation.kind === "rename"
|
||||||
|
? evidence?.label === mutationReconciliation.displayName
|
||||||
|
: observatoryCatalogConfirmsEvidenceDeletion(
|
||||||
|
controller.catalog,
|
||||||
|
mutationReconciliation.sessionId,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
setRenameTarget(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
if (mutationReconciliation.kind === "delete") setupController.refresh();
|
||||||
|
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
|
||||||
|
|
||||||
|
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||||
|
const attempt = replayCoordinatorRef.current.begin();
|
||||||
|
setReplay({ kind: "loading", binding });
|
||||||
|
void fetchObservatoryRecordedRunReview(binding, {
|
||||||
|
selectedSourceSessionId: selectedSessionId,
|
||||||
|
signal: attempt.signal,
|
||||||
|
}).then((review) => {
|
||||||
|
if (!attempt.isCurrent() || !attempt.finish()) return;
|
||||||
|
setReplay({ kind: "ready", binding, review });
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (!attempt.isCurrent() || !attempt.finish()) return;
|
||||||
|
setReplay({
|
||||||
|
kind: "error",
|
||||||
|
binding,
|
||||||
|
message: caught instanceof Error && caught.message.trim()
|
||||||
|
? caught.message
|
||||||
|
: "Канонический визуальный разбор недоступен.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [selectedSessionId]);
|
||||||
|
|
||||||
|
const selectSession = useCallback((sessionId: string) => {
|
||||||
|
closeReplay();
|
||||||
|
setSelectedSessionId(sessionId);
|
||||||
|
}, [closeReplay]);
|
||||||
|
|
||||||
|
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
||||||
|
if (!evidence.recordedRun) return;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
setRenameValue(evidence.label);
|
||||||
|
setRenameTarget(evidence);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
||||||
|
if (!evidence.recordedRun) return;
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
setDeleteTarget(evidence);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitRename = useCallback(async () => {
|
||||||
|
if (!renameTarget?.recordedRun || mutationPending !== null) return;
|
||||||
|
const requestedDisplayName = renameValue.trim();
|
||||||
|
const reconciliation: ObservatoryMutationReconciliation = {
|
||||||
|
kind: "rename",
|
||||||
|
sessionId: renameTarget.sessionId,
|
||||||
|
displayName: requestedDisplayName,
|
||||||
|
};
|
||||||
|
setMutationPending("rename");
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(reconciliation);
|
||||||
|
try {
|
||||||
|
const result = await renameObservatoryLabProjection(
|
||||||
|
renameTarget.recordedRun,
|
||||||
|
renameValue,
|
||||||
|
);
|
||||||
|
controller.applyEvidenceRename(result.sessionId, result.displayName);
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
void controller.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
const reconciled = await controller.refresh();
|
||||||
|
const evidence = reconciled
|
||||||
|
? findObservatoryEvidence(reconciled, reconciliation.sessionId)
|
||||||
|
: null;
|
||||||
|
if (reconciled && evidence?.label === reconciliation.displayName) {
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
} else {
|
||||||
|
setMutationError(mutationErrorMessage(error));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMutationPending(null);
|
||||||
|
}
|
||||||
|
}, [controller, mutationPending, renameTarget, renameValue]);
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
|
||||||
|
const reconciliation: ObservatoryMutationReconciliation = {
|
||||||
|
kind: "delete",
|
||||||
|
sessionId: deleteTarget.sessionId,
|
||||||
|
};
|
||||||
|
setMutationPending("delete");
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(reconciliation);
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
replay.kind !== "closed"
|
||||||
|
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
||||||
|
) {
|
||||||
|
flushSync(() => {
|
||||||
|
closeReplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||||
|
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||||
|
setupController.refresh();
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
void controller.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
const reconciled = await controller.refresh();
|
||||||
|
if (
|
||||||
|
reconciled
|
||||||
|
&& observatoryCatalogConfirmsEvidenceDeletion(
|
||||||
|
reconciled,
|
||||||
|
reconciliation.sessionId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
} else {
|
||||||
|
setMutationError(mutationErrorMessage(error));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMutationPending(null);
|
||||||
|
}
|
||||||
|
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="observatory-workspace"
|
||||||
|
data-observatory-authority="observation-only"
|
||||||
|
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
|
||||||
|
>
|
||||||
|
<section ref={overviewRef} className="observatory-lead">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||||
|
<h2>{definition.title}</h2>
|
||||||
|
<p>
|
||||||
|
Записанные источники и строго связанные результаты без запуска тяжёлого
|
||||||
|
визуализатора и без доступа к управлению аппаратом.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<GlassSurface className="observatory-catalog-bar" padding="sm">
|
||||||
|
<div className="observatory-catalog-bar__copy">
|
||||||
|
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
||||||
|
<h3>Сохранённая сессия</h3>
|
||||||
|
</div>
|
||||||
|
<div className="observatory-catalog-bar__controls">
|
||||||
|
<Select
|
||||||
|
label="Выбрать сохранённую сессию"
|
||||||
|
value={selectedSessionId}
|
||||||
|
options={options}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="Поиск по сессиям"
|
||||||
|
emptyLabel="Сессия не найдена"
|
||||||
|
minMenuWidth={360}
|
||||||
|
menuWidth={460}
|
||||||
|
onChange={selectSession}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Выбрать сетап лаборатории"
|
||||||
|
value={setupController.selectedSetupId}
|
||||||
|
options={setupOptions}
|
||||||
|
disabled={!selectedSessionId || setupOptions.length === 0}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="Поиск по сетапам"
|
||||||
|
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
|
||||||
|
minMenuWidth={360}
|
||||||
|
menuWidth={500}
|
||||||
|
onChange={setupController.selectSetup}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={
|
||||||
|
controller.state === "loading"
|
||||||
|
|| controller.state === "refreshing"
|
||||||
|
|| setupController.state === "loading"
|
||||||
|
|| setupController.state === "refreshing"
|
||||||
|
}
|
||||||
|
icon={<Icon name="refresh" />}
|
||||||
|
onClick={() => {
|
||||||
|
void controller.refresh();
|
||||||
|
setupController.refresh();
|
||||||
|
recordedJobsController.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
|
||||||
|
{calculationPending ? (
|
||||||
|
<ActivityIndicator size="compact" label="Ожидание результата расчёта" />
|
||||||
|
) : null}
|
||||||
|
{showCalculate ? (
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canSubmitRecordedJob}
|
||||||
|
onClick={() => {
|
||||||
|
void recordedJobsController.submit(
|
||||||
|
setupController.selectedSetup?.origin === "portable-definition"
|
||||||
|
&& runPreflight?.definitionSha256
|
||||||
|
&& runPreflight.checkSha256
|
||||||
|
? {
|
||||||
|
definitionSha256: runPreflight.definitionSha256,
|
||||||
|
checkSha256: runPreflight.checkSha256,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Рассчитать
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
|
{queueStatusError ? (
|
||||||
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||||
|
<span className="observatory-notice__copy">{queueStatusError}</span>
|
||||||
|
{publicationFailed ? (
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={recordedJobsController.state === "retrying-publication"}
|
||||||
|
onClick={() => void recordedJobsController.retryPublication()}
|
||||||
|
>
|
||||||
|
Повторить публикацию
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{controller.error && controller.catalog ? (
|
||||||
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||||
|
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
|
||||||
|
<span className="observatory-notice__copy">{controller.error}</span>
|
||||||
|
<Button size="compact" variant="ghost" onClick={controller.refresh}>Повторить</Button>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{setupController.error ? (
|
||||||
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||||
|
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||||
|
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{controller.catalog
|
||||||
|
&& (controller.catalog.window.sourceLimitReached
|
||||||
|
|| controller.catalog.window.laboratoryLimitReached) ? (
|
||||||
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
|
||||||
|
<StatusBadge tone="warning">Срез ограничен</StatusBadge>
|
||||||
|
<span className="observatory-notice__copy">
|
||||||
|
Загружены последние {controller.catalog.window.sourceCount} исходных сессий и{
|
||||||
|
" "
|
||||||
|
}{controller.catalog.window.laboratoryCount} LAB-результатов. Полнота исторических
|
||||||
|
связей не подтверждена.
|
||||||
|
</span>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{initialLoading ? (
|
||||||
|
<GlassSurface className="observatory-state" padding="lg">
|
||||||
|
<ActivityIndicator label="Читаем каталог сессий" />
|
||||||
|
<h3>Читаем источники и связи результатов</h3>
|
||||||
|
<p>Визуализатор и лабораторные сцены при этом не запускаются.</p>
|
||||||
|
</GlassSurface>
|
||||||
|
) : unavailable ? (
|
||||||
|
<GlassSurface className="observatory-state" padding="lg" role="alert">
|
||||||
|
<Icon name="alert" size={20} />
|
||||||
|
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
|
||||||
|
<h3>{controller.error}</h3>
|
||||||
|
<Button size="compact" variant="secondary" onClick={controller.refresh}>Повторить</Button>
|
||||||
|
</GlassSurface>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<GlassSurface className="observatory-state" padding="lg">
|
||||||
|
<Icon name="database" size={20} />
|
||||||
|
<h3>Сохранённых сессий пока нет</h3>
|
||||||
|
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
|
||||||
|
</GlassSurface>
|
||||||
|
) : selectedSession ? (
|
||||||
|
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
|
||||||
|
<GlassSurface className="observatory-session-summary" padding="sm">
|
||||||
|
<div className="observatory-session-summary__identity">
|
||||||
|
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
|
||||||
|
<h3>{selectedSession.source.label}</h3>
|
||||||
|
<code>{selectedSession.source.id}</code>
|
||||||
|
</div>
|
||||||
|
<dl className="observatory-session-summary__facts">
|
||||||
|
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
|
||||||
|
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Чтение</dt>
|
||||||
|
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Оборудование</dt>
|
||||||
|
<dd>
|
||||||
|
{selectedSession.source.captureAttestation?.equipmentDisplayName
|
||||||
|
?? "Не аттестовано"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Профиль записи</dt>
|
||||||
|
<dd title={selectedSession.source.captureAttestation?.captureProfileSha256}>
|
||||||
|
{selectedSession.source.captureAttestation?.captureProfileId
|
||||||
|
?? "Не определён"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="observatory-session-summary__end">
|
||||||
|
<StatusBadge tone={statusTone(selectedSession.source.status)}>
|
||||||
|
{statusLabel[selectedSession.source.status]}
|
||||||
|
</StatusBadge>
|
||||||
|
<div className="observatory-modalities" aria-label="Каналы сессии">
|
||||||
|
{selectedSession.source.modalities.length > 0
|
||||||
|
? selectedSession.source.modalities.map((modality) => (
|
||||||
|
<StatusBadge key={modality} tone="neutral">
|
||||||
|
{modalityLabel[modality] ?? modality}
|
||||||
|
</StatusBadge>
|
||||||
|
))
|
||||||
|
: <span>Каналы не зафиксированы</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
|
<section className="observatory-evidence">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
||||||
|
<h3>Лабораторные доказательства</h3>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
|
||||||
|
{selectedSession.evidence.length}
|
||||||
|
</StatusBadge>
|
||||||
|
</header>
|
||||||
|
{selectedSession.evidence.length > 0 ? (
|
||||||
|
<ol className="observatory-evidence-list">
|
||||||
|
{presentedEvidence.map((evidence) => (
|
||||||
|
<li key={evidence.sessionId}>
|
||||||
|
<div className="observatory-evidence-card">
|
||||||
|
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||||
|
<Icon name="clipboard" size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="observatory-evidence-card__copy">
|
||||||
|
<strong>{evidence.lab.labId}</strong>
|
||||||
|
<span>{evidenceResultSubtitle(evidence)}</span>
|
||||||
|
<small>
|
||||||
|
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="observatory-evidence-card__actions">
|
||||||
|
{evidence.recordedRun ? (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
|
||||||
|
onClick={() => openDelete(evidence)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
label={`Переименовать ${evidence.lab.labId}`}
|
||||||
|
onClick={() => openRename(evidence)}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
label={`Открыть визуальный разбор: ${evidence.lab.labId}`}
|
||||||
|
disabled={
|
||||||
|
replay.kind === "loading"
|
||||||
|
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||||
|
}
|
||||||
|
onClick={() => openReplay(evidence.recordedRun!)}
|
||||||
|
>
|
||||||
|
{replay.kind === "loading"
|
||||||
|
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||||
|
? <ActivityIndicator size="compact" />
|
||||||
|
: <Icon name="eye" size={16} />}
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<div className="observatory-evidence-empty">
|
||||||
|
<Icon name="clipboard" size={18} />
|
||||||
|
<strong>Связанных результатов нет</strong>
|
||||||
|
<p>
|
||||||
|
Наличие исходной записи само по себе не является выводом о качестве
|
||||||
|
компьютерного зрения или безопасности прохождения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{replay.kind === "loading" ? (
|
||||||
|
<GlassSurface className="observatory-replay-state" padding="lg" role="status">
|
||||||
|
<ActivityIndicator label="Проверяем запечатанный результат" />
|
||||||
|
<div>
|
||||||
|
<h3>Проверяем точную связь результата с исходной сессией</h3>
|
||||||
|
<p>Визуализатор и данные маршрута ещё не запущены.</p>
|
||||||
|
</div>
|
||||||
|
<Button size="compact" variant="ghost" onClick={returnToOverview}>Отменить</Button>
|
||||||
|
</GlassSurface>
|
||||||
|
) : replay.kind === "error" ? (
|
||||||
|
<GlassSurface className="observatory-replay-state" padding="lg" role="alert">
|
||||||
|
<Icon name="alert" size={20} />
|
||||||
|
<div>
|
||||||
|
<StatusBadge tone="danger">Просмотр недоступен</StatusBadge>
|
||||||
|
<h3>{replay.message}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="observatory-replay-state__actions">
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => openReplay(replay.binding)}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
) : replay.kind === "ready" ? (
|
||||||
|
<section className="observatory-replay" aria-label="Визуальный разбор результата">
|
||||||
|
<header className="observatory-replay__header">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">
|
||||||
|
{replay.review.kind === "canonical-recorded-rerun"
|
||||||
|
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
|
||||||
|
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
|
||||||
|
</span>
|
||||||
|
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||||
|
<p>
|
||||||
|
{replay.review.kind === "canonical-recorded-rerun"
|
||||||
|
? "Записанный маршрут синхронизирован по общей временной шкале."
|
||||||
|
: "Показан проверенный документ результата и связанные с ним артефакты."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Icon name="close" size={14} />}
|
||||||
|
onClick={returnToOverview}
|
||||||
|
>
|
||||||
|
Закрыть разбор
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
{replay.review.kind === "canonical-recorded-rerun" ? (
|
||||||
|
<CanonicalVegetationRerunReplay
|
||||||
|
resultId={replay.binding.resultId}
|
||||||
|
review={replay.review.review}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GlassSurface className="observatory-replay-state" padding="lg">
|
||||||
|
<div>
|
||||||
|
<StatusBadge tone="success">Проверено</StatusBadge>
|
||||||
|
<h3>{replay.review.resultKind}</h3>
|
||||||
|
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
|
||||||
|
<details>
|
||||||
|
<summary>Документ результата</summary>
|
||||||
|
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
|
||||||
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
|
||||||
|
<StatusBadge tone="warning">Вне среза</StatusBadge>
|
||||||
|
<span className="observatory-notice__copy">
|
||||||
|
{controller.catalog?.unresolvedEvidence.length} результатов ссылаются на источники
|
||||||
|
вне текущего загруженного среза и не приписаны к квалификационным доказательствам.
|
||||||
|
</span>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Window
|
||||||
|
open={renameTarget !== null}
|
||||||
|
title="Переименовать лабораторный результат"
|
||||||
|
subtitle="Меняется только отображаемое название в Обсерватории"
|
||||||
|
size="sm"
|
||||||
|
closeOnBackdrop={mutationPending !== "rename"}
|
||||||
|
closeOnEscape={mutationPending !== "rename"}
|
||||||
|
onClose={() => {
|
||||||
|
if (mutationPending === "rename") return;
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<WindowFooterActions>
|
||||||
|
<Button
|
||||||
|
disabled={mutationPending === "rename"}
|
||||||
|
onClick={() => {
|
||||||
|
setRenameTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="observatory-rename-form"
|
||||||
|
variant="primary"
|
||||||
|
disabled={mutationPending === "rename" || renameValue.trim().length === 0}
|
||||||
|
>
|
||||||
|
{mutationPending === "rename" ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</Button>
|
||||||
|
</WindowFooterActions>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
id="observatory-rename-form"
|
||||||
|
className="observatory-rename-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submitRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Название"
|
||||||
|
value={renameValue}
|
||||||
|
maxLength={160}
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
disabled={mutationPending === "rename"}
|
||||||
|
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{mutationError && renameTarget ? (
|
||||||
|
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</Window>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
title="Удалить результат из Обсерватории?"
|
||||||
|
description={deleteTarget ? (
|
||||||
|
<div className="observatory-delete-confirmation">
|
||||||
|
<p>
|
||||||
|
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
|
||||||
|
{" "}и её отображаемое название в Обсерватории.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
|
||||||
|
останутся неизменными.
|
||||||
|
</p>
|
||||||
|
{mutationError ? (
|
||||||
|
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
confirmLabel="Удалить из Обсерватории"
|
||||||
|
pendingLabel="Удаляем…"
|
||||||
|
danger
|
||||||
|
onClose={() => {
|
||||||
|
if (mutationPending === "delete") return;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setMutationError(null);
|
||||||
|
setMutationReconciliation(null);
|
||||||
|
}}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,9 +17,14 @@ import {
|
|||||||
} from "../../core/system/useWorkerTelemetry";
|
} from "../../core/system/useWorkerTelemetry";
|
||||||
|
|
||||||
function pipelineStateLabel(state: string): string {
|
function pipelineStateLabel(state: string): string {
|
||||||
if (state === "busy") return "Выполняет задачу";
|
if (state === "busy" || state === "running") return "Выполняет задачу";
|
||||||
|
if (state === "waiting") return "Ожидает восстановления данных";
|
||||||
|
if (state === "synchronizing") return "Синхронизирует поток";
|
||||||
|
if (state === "starting") return "Загружает профиль";
|
||||||
|
if (state === "stopped" || state === "cancelled") return "Профиль остановлен";
|
||||||
|
if (state === "failed") return "Ошибка профиля";
|
||||||
if (state === "ready") return "Готов к задаче";
|
if (state === "ready") return "Готов к задаче";
|
||||||
return "Нет live-состояния";
|
return "Нет свежих данных о задаче";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatResourcePair(
|
function formatResourcePair(
|
||||||
@@ -116,7 +121,7 @@ export function ComputeModulesWorkspace() {
|
|||||||
{!legacyDiagnostic && !agentTelemetry ? (
|
{!legacyDiagnostic && !agentTelemetry ? (
|
||||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||||
<StatusBadge tone="warning">
|
<StatusBadge tone="warning">
|
||||||
Агентный data-plane ещё не опубликовал нормализованный срез этого контура.
|
Ожидаем свежую телеметрию выбранного узла.
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -187,6 +192,8 @@ export function ComputeModulesWorkspace() {
|
|||||||
? `${node.gpu.temperature_celsius} °C`
|
? `${node.gpu.temperature_celsius} °C`
|
||||||
: "—"
|
: "—"
|
||||||
}</dd></div>
|
}</dd></div>
|
||||||
|
<div><dt>Частота GPU</dt><dd>{node?.gpu?.sm_clock_mhz == null ? "—" : `${node.gpu.sm_clock_mhz} МГц`}</dd></div>
|
||||||
|
<div><dt>Частота памяти GPU</dt><dd>{node?.gpu?.memory_clock_mhz == null ? "—" : `${node.gpu.memory_clock_mhz} МГц`}</dd></div>
|
||||||
<div><dt>Мощность</dt><dd>{
|
<div><dt>Мощность</dt><dd>{
|
||||||
typeof node?.gpu?.power_watts === "number"
|
typeof node?.gpu?.power_watts === "number"
|
||||||
? `${node.gpu.power_watts.toFixed(1)} Вт`
|
? `${node.gpu.power_watts.toFixed(1)} Вт`
|
||||||
@@ -214,7 +221,7 @@ export function ComputeModulesWorkspace() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">PROCESSING RUNTIME</span>
|
<span className="section-eyebrow">PROCESSING RUNTIME</span>
|
||||||
<h3>Контейнеры Mission Core</h3>
|
<h3>Контейнеры Mission Core</h3>
|
||||||
<p>Два ограниченных runtime: inference server и прикладной perception pipeline.</p>
|
<p>Состояние контейнера и готовность вычислительного профиля проверяются отдельно.</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={node?.triton.ready ? "success" : "danger"}>
|
<StatusBadge tone={node?.triton.ready ? "success" : "danger"}>
|
||||||
{node?.triton.ready ? "Triton ready" : "Triton недоступен"}
|
{node?.triton.ready ? "Triton ready" : "Triton недоступен"}
|
||||||
@@ -233,19 +240,31 @@ export function ComputeModulesWorkspace() {
|
|||||||
<span className="section-eyebrow">ТЕКУЩАЯ ЗАДАЧА</span>
|
<span className="section-eyebrow">ТЕКУЩАЯ ЗАДАЧА</span>
|
||||||
<h3>{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}</h3>
|
<h3>{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}</h3>
|
||||||
<p>
|
<p>
|
||||||
|
{telemetry?.pipeline.profile_name ?? "Perception"}
|
||||||
|
{" · "}
|
||||||
{telemetry?.pipeline.active_request_id
|
{telemetry?.pipeline.active_request_id
|
||||||
? `Run ${telemetry.pipeline.active_request_id}`
|
? `Run ${telemetry.pipeline.active_request_id}`
|
||||||
: "Очередь свободна; модели остаются загруженными в persistent worker."}
|
: telemetry?.pipeline.service_state === "ready"
|
||||||
|
? "Готов к приёму данных."
|
||||||
|
: "Готовность моделей не подтверждена."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={
|
<StatusBadge tone={
|
||||||
telemetry?.pipeline.service_state === "busy" ? "warning"
|
["busy", "running", "waiting", "synchronizing", "starting"].includes(telemetry?.pipeline.service_state ?? "") ? "warning"
|
||||||
: telemetry?.pipeline.service_state === "ready" ? "success"
|
: telemetry?.pipeline.service_state === "ready" ? "success"
|
||||||
: "danger"
|
: "danger"
|
||||||
}>
|
}>
|
||||||
{telemetry?.pipeline.service_state ?? "unavailable"}
|
{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</header>
|
</header>
|
||||||
|
{telemetry?.pipeline.profile_name ? (
|
||||||
|
<div className="worker-pipeline__summary">
|
||||||
|
<div><span>Процессы профиля</span><strong>{telemetry.pipeline.live_children ?? "—"}</strong></div>
|
||||||
|
<div><span>Ожиданий потока</span><strong>{telemetry.pipeline.input_pauses ?? "—"}</strong></div>
|
||||||
|
<div><span>Пакетов в очереди</span><strong>{telemetry.pipeline.pending_bundles ?? "—"}</strong></div>
|
||||||
|
<div><span>Входные буферы</span><strong>{formatBytes(telemetry.pipeline.buffer_bytes)}</strong></div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="worker-pipeline__summary">
|
<div className="worker-pipeline__summary">
|
||||||
<div><span>Завершено запусков</span><strong>{telemetry?.pipeline.completed_runs ?? "—"}</strong></div>
|
<div><span>Завершено запусков</span><strong>{telemetry?.pipeline.completed_runs ?? "—"}</strong></div>
|
||||||
<div><span>Ошибок запусков</span><strong>{telemetry?.pipeline.failed_runs ?? "—"}</strong></div>
|
<div><span>Ошибок запусков</span><strong>{telemetry?.pipeline.failed_runs ?? "—"}</strong></div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ let fetchM49TgsAnchorSpatial;
|
|||||||
let AdvancedLaboratoryContractError;
|
let AdvancedLaboratoryContractError;
|
||||||
let buildLaboratoryCatalog;
|
let buildLaboratoryCatalog;
|
||||||
let buildLaboratoryProfiles;
|
let buildLaboratoryProfiles;
|
||||||
|
let isLegacyPublishedLaboratoryWork;
|
||||||
let experimentOptionsForProfile;
|
let experimentOptionsForProfile;
|
||||||
let freshestLaboratorySelection;
|
let freshestLaboratorySelection;
|
||||||
let workOptionsForExperiment;
|
let workOptionsForExperiment;
|
||||||
@@ -928,6 +929,7 @@ before(async () => {
|
|||||||
({
|
({
|
||||||
buildLaboratoryCatalog,
|
buildLaboratoryCatalog,
|
||||||
buildLaboratoryProfiles,
|
buildLaboratoryProfiles,
|
||||||
|
isLegacyPublishedLaboratoryWork,
|
||||||
experimentOptionsForProfile,
|
experimentOptionsForProfile,
|
||||||
freshestLaboratorySelection,
|
freshestLaboratorySelection,
|
||||||
workOptionsForExperiment,
|
workOptionsForExperiment,
|
||||||
@@ -962,6 +964,31 @@ after(async () => {
|
|||||||
await server?.close();
|
await server?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("capability-owned projections never enter the legacy LAB catalog", () => {
|
||||||
|
const legacy = {
|
||||||
|
status: "ready",
|
||||||
|
replayable: true,
|
||||||
|
modalities: ["point-cloud", "trajectory"],
|
||||||
|
lab: { replayCapability: null },
|
||||||
|
};
|
||||||
|
const capabilityProjection = {
|
||||||
|
...legacy,
|
||||||
|
lab: {
|
||||||
|
replayCapability: {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commandsEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(isLegacyPublishedLaboratoryWork(legacy), true);
|
||||||
|
assert.equal(isLegacyPublishedLaboratoryWork(capabilityProjection), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
|
test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
|
||||||
const catalog = buildLaboratoryCatalog({
|
const catalog = buildLaboratoryCatalog({
|
||||||
rigLabel: "K1",
|
rigLabel: "K1",
|
||||||
|
|||||||
@@ -105,23 +105,45 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
|
|||||||
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("central composition files cannot silently become monoliths again", async () => {
|
test("Observatory owns a bounded explicit-open lifecycle around the shared recorded viewer", async () => {
|
||||||
const ratchets = [
|
const workspaceHub = await read("workspaces/Workspaces.tsx");
|
||||||
["App.tsx", 1_250],
|
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
|
||||||
["workspaces/Workspaces.tsx", 1_200],
|
const observatoryCore = await read("core/observatory/catalog.ts");
|
||||||
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
|
const recordedRun = await read("core/observatory/recordedRun.ts");
|
||||||
["core/laboratory/advancedResults.ts", 1_000],
|
const sharedReplay = await read(
|
||||||
["core/laboratory/e40ProductGate.ts", 500],
|
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
|
||||||
["styles/workspaces.css", 4_350],
|
|
||||||
["styles/laboratory.css", 900],
|
|
||||||
["styles/laboratory-reporting.css", 100],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [relativePath, maximumLines] of ratchets) {
|
|
||||||
const lineCount = (await read(relativePath)).split("\n").length;
|
|
||||||
assert.ok(
|
|
||||||
lineCount <= maximumLines,
|
|
||||||
`${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
|
|
||||||
);
|
);
|
||||||
}
|
const workspaceCss = await read("styles/workspaces.css");
|
||||||
|
const observatoryCss = await read("styles/observatory.css");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
workspaceHub,
|
||||||
|
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
|
||||||
|
);
|
||||||
|
assert.match(observatory, /export function ObservatoryWorkspace/);
|
||||||
|
assert.match(observatory, /useObservatoryCatalog/);
|
||||||
|
assert.match(observatory, /data-observatory-authority="observation-only"/);
|
||||||
|
assert.match(
|
||||||
|
observatory,
|
||||||
|
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
`${observatory}\n${observatoryCore}`,
|
||||||
|
/(?:core|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
|
||||||
|
);
|
||||||
|
assert.match(observatory, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||||
|
assert.match(observatory, /closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
observatory,
|
||||||
|
/replayObservationSession|deleteObservationSession|useObservationSessions|useAdvancedLaboratoryCatalog/,
|
||||||
|
);
|
||||||
|
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
recordedRun,
|
||||||
|
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||||
|
);
|
||||||
|
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||||
|
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||||
|
assert.doesNotMatch(workspaceCss, /\.observatory-/);
|
||||||
|
assert.match(observatoryCss, /\.observatory-workspace/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let canonicalMapGravityLocalPointToBodyGround;
|
||||||
|
let canonicalRecordedLabPackedTgsCells;
|
||||||
|
let canonicalRecordedLabTgsIsCurrent;
|
||||||
|
let resolveCanonicalLabReplay;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
canonicalMapGravityLocalPointToBodyGround,
|
||||||
|
canonicalRecordedLabPackedTgsCells,
|
||||||
|
canonicalRecordedLabTgsIsCurrent,
|
||||||
|
} = await server.ssrLoadModule("/src/core/laboratory/canonicalRecordedLab.ts"));
|
||||||
|
({ resolveCanonicalLabReplay } = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/canonicalLabReplay.ts",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const identity = [
|
||||||
|
[1, 0, 0],
|
||||||
|
[0, 1, 0],
|
||||||
|
[0, 0, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
test("canonical TGS validity never retains a sparse anchor beyond its sealed history", () => {
|
||||||
|
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_000, 1_000_000_000), true);
|
||||||
|
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_001, 1_000_000_000), false);
|
||||||
|
assert.equal(canonicalRecordedLabTgsIsCurrent(999_999_999, 1_000_000_000), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("map-gravity-local TGS uses sensor translation and current ground body exactly once", () => {
|
||||||
|
const anchor = {
|
||||||
|
originMapXyzM: [10, 20, 0.68],
|
||||||
|
sensorOriginMapXyzM: [10, 20, 1],
|
||||||
|
basisMapFromBody: identity,
|
||||||
|
};
|
||||||
|
const current = {
|
||||||
|
originMapXyzM: [8, 20, 0],
|
||||||
|
sensorOriginMapXyzM: [8, 20, 0.32],
|
||||||
|
basisMapFromBody: identity,
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
canonicalMapGravityLocalPointToBodyGround([1, 2, -1], anchor, current),
|
||||||
|
[3, 2, 0],
|
||||||
|
);
|
||||||
|
const packed = canonicalRecordedLabPackedTgsCells({
|
||||||
|
centersXyM: [[1, 2]],
|
||||||
|
stateCodes: [2],
|
||||||
|
zBoundsM: [[-1, 0]],
|
||||||
|
}, anchor, current);
|
||||||
|
assert.deepEqual([...packed.centersBodyXyM], [3, 2]);
|
||||||
|
assert.deepEqual([...packed.zBoundsM], [0, 1]);
|
||||||
|
assert.deepEqual([...packed.stateCodes], [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recorded LAB spatial loading is shared, profile-bound and experiment-neutral", async () => {
|
||||||
|
const [contract, scheduler, vegetation] = await Promise.all([
|
||||||
|
readFile(new URL("../src/core/laboratory/canonicalRecordedLabSpatial.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/components/laboratory/useCanonicalRecordedLabSpatialFrame.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/core/laboratory/vegetationShadow.ts", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(contract, /CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
|
||||||
|
assert.match(contract, /profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
|
||||||
|
assert.match(scheduler, /Shared latest-request-wins scheduler/);
|
||||||
|
assert.match(scheduler, /identityRef\.current !== requestIdentity/);
|
||||||
|
assert.doesNotMatch(scheduler, /RAVNOVES|vegetation|DDRNet/);
|
||||||
|
assert.doesNotMatch(vegetation, /fetchCanonicalRecordedLabSpatialFrame|CanonicalRecordedLabSpatialFrame/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonical LAB resolves one generation-bound merged RRD", async () => {
|
||||||
|
const baseGeneration = "a".repeat(64);
|
||||||
|
const replayGeneration = "b".repeat(64);
|
||||||
|
const resultId = `lab-v1-vegetation-shadow-${"c".repeat(64)}`;
|
||||||
|
let request;
|
||||||
|
const replay = await resolveCanonicalLabReplay(resultId, {
|
||||||
|
kind: "rerun-recording",
|
||||||
|
sessionId: "session-001",
|
||||||
|
sourceUrl: "/api/v1/observation-sessions/session-001/recording.rrd",
|
||||||
|
viewerSourceUrl:
|
||||||
|
`/api/v1/observation-sessions/session-001/recording.rrd?generation=${baseGeneration}`,
|
||||||
|
mediaType: "application/vnd.rerun.rrd",
|
||||||
|
timeline: "session_time",
|
||||||
|
timelineStartSeconds: 0,
|
||||||
|
timelineEndSeconds: 10,
|
||||||
|
seekable: true,
|
||||||
|
byteLength: 100,
|
||||||
|
sha256: baseGeneration,
|
||||||
|
playback: { speed: 1, loop: false },
|
||||||
|
mediaSources: [],
|
||||||
|
}, {
|
||||||
|
origin: "http://mission-core.test",
|
||||||
|
fetcher: async (url, options) => {
|
||||||
|
request = { url, options };
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/vnd.rerun.rrd",
|
||||||
|
"Content-Length": "234567",
|
||||||
|
"ETag": `"${replayGeneration}"`,
|
||||||
|
"X-Rerun-Format": "RRF2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceUrl =
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${resultId}/canonical-replay.rrd`;
|
||||||
|
assert.equal(
|
||||||
|
request.url,
|
||||||
|
`http://mission-core.test${sourceUrl}?base_generation=${baseGeneration}`,
|
||||||
|
);
|
||||||
|
assert.equal(request.options.method, "HEAD");
|
||||||
|
assert.deepEqual(replay, {
|
||||||
|
sourceUrl,
|
||||||
|
viewerSourceUrl: `${sourceUrl}?generation=${replayGeneration}`,
|
||||||
|
byteLength: 234567,
|
||||||
|
sha256: replayGeneration,
|
||||||
|
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let laboratoryRecordedEvidenceDemand;
|
||||||
|
let e47SemanticChunkWindowStarts;
|
||||||
|
let cancelE47SemanticRequestsOutsideWindow;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({ laboratoryRecordedEvidenceDemand } = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/recordedEvidenceProfile.ts",
|
||||||
|
));
|
||||||
|
({
|
||||||
|
e47SemanticChunkWindowStarts,
|
||||||
|
cancelE47SemanticRequestsOutsideWindow,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
|
"/src/workspaces/laboratory/useE47SemanticTimeline.ts",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("camera-only LAB presentation does not acquire hidden spatial or semantic payloads", () => {
|
||||||
|
assert.deepEqual(laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode: "video",
|
||||||
|
spatialMode: null,
|
||||||
|
showMediaSemantic: false,
|
||||||
|
showSpatialSemantic: true,
|
||||||
|
showMediaPoints: false,
|
||||||
|
classifiedSpatialMode: "overlay",
|
||||||
|
}), {
|
||||||
|
sourceTimelineMetadata: true,
|
||||||
|
recordedVideo: true,
|
||||||
|
exactCameraFrame: false,
|
||||||
|
sourceSpatialPoints: false,
|
||||||
|
cameraPointOverlay: false,
|
||||||
|
selectedSemanticMask: false,
|
||||||
|
selectedSemanticPoints: false,
|
||||||
|
classifiedSpatial: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("visible M4 layers acquire only their selected camera and spatial evidence", () => {
|
||||||
|
assert.deepEqual(laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode: "camera",
|
||||||
|
spatialMode: "plan",
|
||||||
|
showMediaSemantic: true,
|
||||||
|
showSpatialSemantic: true,
|
||||||
|
showMediaPoints: true,
|
||||||
|
classifiedSpatialMode: "overlay",
|
||||||
|
}), {
|
||||||
|
sourceTimelineMetadata: true,
|
||||||
|
recordedVideo: false,
|
||||||
|
exactCameraFrame: true,
|
||||||
|
sourceSpatialPoints: true,
|
||||||
|
cameraPointOverlay: true,
|
||||||
|
selectedSemanticMask: true,
|
||||||
|
selectedSemanticPoints: true,
|
||||||
|
classifiedSpatial: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a classified replacement does not also load the hidden source point track", () => {
|
||||||
|
const demand = laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode: null,
|
||||||
|
spatialMode: "3d",
|
||||||
|
showMediaSemantic: false,
|
||||||
|
showSpatialSemantic: false,
|
||||||
|
showMediaPoints: false,
|
||||||
|
classifiedSpatialMode: "replace-source",
|
||||||
|
});
|
||||||
|
assert.equal(demand.sourceSpatialPoints, false);
|
||||||
|
assert.equal(demand.classifiedSpatial, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("semantic point chunks are ordered active-first and stale requests are cancelled", () => {
|
||||||
|
assert.deepEqual(e47SemanticChunkWindowStarts(48, 4_489), [48, 72, 96, 24]);
|
||||||
|
assert.deepEqual(e47SemanticChunkWindowStarts(0, 4_489), [0, 24, 48]);
|
||||||
|
|
||||||
|
const cancelled = [];
|
||||||
|
const requests = new Map([
|
||||||
|
[0, { abort: () => cancelled.push(0) }],
|
||||||
|
[24, { abort: () => cancelled.push(24) }],
|
||||||
|
[240, { abort: () => cancelled.push(240) }],
|
||||||
|
]);
|
||||||
|
cancelE47SemanticRequestsOutsideWindow(requests, [240, 264]);
|
||||||
|
assert.deepEqual(cancelled, [0, 24]);
|
||||||
|
assert.deepEqual([...requests.keys()], [240]);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ let createLiveViewerDiagnosticLifecycle;
|
|||||||
let createAbortFencedBuildVerifier;
|
let createAbortFencedBuildVerifier;
|
||||||
let createUiBuildStaleCoordinator;
|
let createUiBuildStaleCoordinator;
|
||||||
let liveViewerDiagnosticBody;
|
let liveViewerDiagnosticBody;
|
||||||
|
let reloadRecordedViewerAfterStaleModuleFailure;
|
||||||
let server;
|
let server;
|
||||||
let uiBuildIdFromModuleScripts;
|
let uiBuildIdFromModuleScripts;
|
||||||
let verifyLiveViewerClientBuild;
|
let verifyLiveViewerClientBuild;
|
||||||
@@ -22,6 +23,7 @@ before(async () => {
|
|||||||
createLiveViewerDiagnosticLifecycle,
|
createLiveViewerDiagnosticLifecycle,
|
||||||
createUiBuildStaleCoordinator,
|
createUiBuildStaleCoordinator,
|
||||||
liveViewerDiagnosticBody,
|
liveViewerDiagnosticBody,
|
||||||
|
reloadRecordedViewerAfterStaleModuleFailure,
|
||||||
uiBuildIdFromModuleScripts,
|
uiBuildIdFromModuleScripts,
|
||||||
verifyLiveViewerClientBuild,
|
verifyLiveViewerClientBuild,
|
||||||
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
||||||
@@ -204,6 +206,36 @@ test("build drift is reported once with the exact loaded build", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a recorded viewer reloads only when its failed lazy module belongs to a stale build", async () => {
|
||||||
|
const reloads = [];
|
||||||
|
const loadedUiBuildId = "/assets/index-abcdefgh.js";
|
||||||
|
const currentUiBuildId = "/assets/index-ijklmnop.js";
|
||||||
|
const fetcher = async (_url, options) => {
|
||||||
|
assert.equal(options.headers["X-MissionCore-UI-Build"], loadedUiBuildId);
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
|
||||||
|
loadedUiBuildId,
|
||||||
|
fetcher,
|
||||||
|
reload: () => reloads.push("reload"),
|
||||||
|
}), true);
|
||||||
|
assert.deepEqual(reloads, ["reload"]);
|
||||||
|
|
||||||
|
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
|
||||||
|
loadedUiBuildId: currentUiBuildId,
|
||||||
|
fetcher: async () => new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
|
||||||
|
}),
|
||||||
|
reload: () => reloads.push("unexpected"),
|
||||||
|
}), false);
|
||||||
|
assert.deepEqual(reloads, ["reload"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("last unsubscribe fences an already queued build verification callback", () => {
|
test("last unsubscribe fences an already queued build verification callback", () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const observedSignals = [];
|
const observedSignals = [];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ let nextM48ObjectId;
|
|||||||
let laboratoryMetricLegendEntries;
|
let laboratoryMetricLegendEntries;
|
||||||
let nearestLaboratoryRecordedClipFrame;
|
let nearestLaboratoryRecordedClipFrame;
|
||||||
let laboratoryRecordedClipEndExclusiveNs;
|
let laboratoryRecordedClipEndExclusiveNs;
|
||||||
|
let laboratoryRecordedClipClockGate;
|
||||||
let m48SpatialPlaybackWindow;
|
let m48SpatialPlaybackWindow;
|
||||||
let trimM48SpatialPlaybackCache;
|
let trimM48SpatialPlaybackCache;
|
||||||
let nextM48CameraVisibility;
|
let nextM48CameraVisibility;
|
||||||
@@ -45,6 +46,7 @@ before(async () => {
|
|||||||
({
|
({
|
||||||
nearestLaboratoryRecordedClipFrame,
|
nearestLaboratoryRecordedClipFrame,
|
||||||
laboratoryRecordedClipEndExclusiveNs,
|
laboratoryRecordedClipEndExclusiveNs,
|
||||||
|
laboratoryRecordedClipClockGate,
|
||||||
} = await server.ssrLoadModule(
|
} = await server.ssrLoadModule(
|
||||||
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
||||||
));
|
));
|
||||||
@@ -342,6 +344,21 @@ test("shared recorded clip clock selects exact frames and one stable loop bounda
|
|||||||
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
|
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shared recorded clip rejects stale media callbacks until an explicit seek lands", () => {
|
||||||
|
assert.deepEqual(laboratoryRecordedClipClockGate(1, 123), {
|
||||||
|
accept: false,
|
||||||
|
pendingSequence: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(laboratoryRecordedClipClockGate(1, 1), {
|
||||||
|
accept: true,
|
||||||
|
pendingSequence: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(laboratoryRecordedClipClockGate(null, 124), {
|
||||||
|
accept: true,
|
||||||
|
pendingSequence: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
|
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
|
||||||
assert.equal(nextM48CameraVisibility("camera", true), true);
|
assert.equal(nextM48CameraVisibility("camera", true), true);
|
||||||
assert.equal(nextM48CameraVisibility("3d", true), false);
|
assert.equal(nextM48CameraVisibility("3d", true), false);
|
||||||
|
|||||||
@@ -379,14 +379,16 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
||||||
const pointOffsets = [0, ...Array(4489).fill(2)];
|
const playbackResultId = `lab-v1-vegetation-shadow-${"b".repeat(64)}`;
|
||||||
|
const frameCount = 48;
|
||||||
|
const pointOffsets = [0, ...Array(frameCount).fill(2)];
|
||||||
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
||||||
const pointBytes = points.buffer;
|
const pointBytes = points.buffer;
|
||||||
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
||||||
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
||||||
const chunks = Array.from({ length: 188 }, (_, index) => {
|
const chunks = Array.from({ length: Math.ceil(frameCount / 24) }, (_, index) => {
|
||||||
const start = index * 24;
|
const start = index * 24;
|
||||||
const count = Math.min(24, 4489 - start);
|
const count = Math.min(24, frameCount - start);
|
||||||
const pointStart = pointOffsets[start];
|
const pointStart = pointOffsets[start];
|
||||||
const pointStop = pointOffsets[start + count];
|
const pointStop = pointOffsets[start + count];
|
||||||
const pointCount = pointStop - pointStart;
|
const pointCount = pointStop - pointStart;
|
||||||
@@ -396,7 +398,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
count,
|
count,
|
||||||
point_start: pointStart,
|
point_start: pointStart,
|
||||||
point_count: pointCount,
|
point_count: pointCount,
|
||||||
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`,
|
url: `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/${index}`,
|
||||||
media_type: "application/octet-stream",
|
media_type: "application/octet-stream",
|
||||||
dtype: "<f4",
|
dtype: "<f4",
|
||||||
shape: [pointCount, 3],
|
shape: [pointCount, 3],
|
||||||
@@ -406,8 +408,8 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
});
|
});
|
||||||
const manifestPayload = {
|
const manifestPayload = {
|
||||||
schema_version: "missioncore.recorded-spatial-playback/v1",
|
schema_version: "missioncore.recorded-spatial-playback/v1",
|
||||||
result_id: resultId,
|
result_id: playbackResultId,
|
||||||
frame_count: 4489,
|
frame_count: frameCount,
|
||||||
point_count: 2,
|
point_count: 2,
|
||||||
point_offsets: pointOffsets,
|
point_offsets: pointOffsets,
|
||||||
chunk_frame_count: 24,
|
chunk_frame_count: 24,
|
||||||
@@ -416,7 +418,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
chunks,
|
chunks,
|
||||||
track: {
|
track: {
|
||||||
id: "points-map-f32",
|
id: "points-map-f32",
|
||||||
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`,
|
url: `${endpointRoot}/${playbackResultId}/timeline/playback/tracks/points-map-f32`,
|
||||||
media_type: "application/octet-stream",
|
media_type: "application/octet-stream",
|
||||||
dtype: "<f4",
|
dtype: "<f4",
|
||||||
shape: [2, 3],
|
shape: [2, 3],
|
||||||
@@ -434,12 +436,12 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
};
|
};
|
||||||
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher });
|
const manifest = await fetchM4ThreatPlaybackManifest(playbackResultId, { fetcher });
|
||||||
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
||||||
|
|
||||||
assert.deepEqual(requested, [
|
assert.deepEqual(requested, [
|
||||||
`${endpointRoot}/${resultId}/timeline/playback`,
|
`${endpointRoot}/${playbackResultId}/timeline/playback`,
|
||||||
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`,
|
`${endpointRoot}/${playbackResultId}/timeline/playback/chunks/0`,
|
||||||
]);
|
]);
|
||||||
assert.equal(chunk.pointCount, 2);
|
assert.equal(chunk.pointCount, 2);
|
||||||
assert.equal(chunk.pointStart, 0);
|
assert.equal(chunk.pointStart, 0);
|
||||||
@@ -769,7 +771,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72, 24]);
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -786,8 +788,8 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps",
|
|||||||
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.deepEqual([...inFlight.keys()], [4488]);
|
assert.deepEqual([...inFlight.keys()], [4488, 4464]);
|
||||||
assert.deepEqual(aborted, [0, 24, 1488, 1512]);
|
assert.deepEqual(aborted, [0, 24, 1488, 1512, 1464]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
||||||
@@ -847,8 +849,9 @@ test("recorded VIDEO clock cannot reverse an explicit operator pause", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
|
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
|
||||||
const [visual, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
|
const [visual, canonical, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
|
||||||
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
|
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
||||||
@@ -858,10 +861,11 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
||||||
assert.match(visual, /<RecordedEvidenceImageScene/);
|
assert.match(visual, /<RecordedEvidenceImageScene/);
|
||||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||||
assert.match(visual, /m4-replay-threat-visual__deck/);
|
assert.match(visual, /<CanonicalRecordedLabReplay/);
|
||||||
|
assert.match(canonical, /m4-replay-threat-visual__deck/);
|
||||||
assert.match(visual, /lastFrameRef/);
|
assert.match(visual, /lastFrameRef/);
|
||||||
assert.match(visual, /lastSpatialFrameRef/);
|
assert.match(visual, /lastSpatialFrameRef/);
|
||||||
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
|
assert.match(visual, /const spatialFrame = currentSpatialFrame/);
|
||||||
assert.match(visual, /<ObservationTimeline/);
|
assert.match(visual, /<ObservationTimeline/);
|
||||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||||
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
|
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
|
||||||
@@ -876,19 +880,33 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visual, /pointCloudOverlay=/);
|
assert.match(visual, /pointCloudOverlay=/);
|
||||||
assert.match(visual, /mediaMode/);
|
assert.match(visual, /mediaMode/);
|
||||||
assert.match(visual, /spatialMode/);
|
assert.match(visual, /spatialMode/);
|
||||||
assert.match(visual, /current === next \? null : next/);
|
assert.match(canonical, /current === next \? null : next/);
|
||||||
assert.match(visual, /data-split=\{splitView \? "true" : undefined\}/);
|
assert.match(canonical, /data-split=\{splitView \? "true" : undefined\}/);
|
||||||
assert.match(visual, /<SplitPane/);
|
assert.match(canonical, /<SplitPane/);
|
||||||
assert.match(visual, /primarySize=\{splitView \? splitPrimarySize : mediaMode \? 100 : 0\}/);
|
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||||
assert.match(visual, /resizable=\{splitView\}/);
|
assert.match(canonical, /secondary=\{spatialPane/);
|
||||||
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||||
assert.match(visual, /secondaryMode=\{\{/);
|
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
|
||||||
|
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||||
|
assert.match(canonical, /secondaryMode=\{\{/);
|
||||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||||
assert.match(visual, /clock: mediaMode === "video" \? "external" : "animation"/);
|
assert.match(visual, /clock: "external"/);
|
||||||
|
assert.match(visual, /useCanonicalRecordedLabReplayState/);
|
||||||
|
assert.match(canonical, /current === next \? null : next/);
|
||||||
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
|
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
|
||||||
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
|
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
|
||||||
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
|
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
|
||||||
assert.match(visual, /onPlayingRejected=\{\(\) => playbackController\.setPlaying\(false\)\}/);
|
assert.match(
|
||||||
|
await readFile(new URL("../src/components/laboratory/useRecordedEvidencePlayback.ts", import.meta.url), "utf8"),
|
||||||
|
/if \(clock === "animation"\) return;/,
|
||||||
|
);
|
||||||
|
assert.match(visual, /playbackAuthority="media"/);
|
||||||
|
assert.match(visual, /playbackTransport = "epoch-stream"/);
|
||||||
|
assert.match(visual, /playbackTransport=\{playbackTransport\}/);
|
||||||
|
assert.match(
|
||||||
|
await readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
|
||||||
|
/if \(playbackAuthority === "host"\) return;/,
|
||||||
|
);
|
||||||
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
|
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
|
||||||
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
|
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
|
||||||
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
|
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
|
||||||
@@ -912,6 +930,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
||||||
assert.match(visualCss, /bottom: auto/);
|
assert.match(visualCss, /bottom: auto/);
|
||||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||||
|
assert.match(videoScene, /!playback\.playing && Math\.abs\(next\.currentSeconds - playback\.currentSeconds\) > 0\.35/);
|
||||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||||
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
@@ -920,19 +939,20 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(metricScene, /OrbitControls/);
|
assert.match(metricScene, /OrbitControls/);
|
||||||
assert.match(visual, /LOCAL SLAM/);
|
assert.match(visual, /LOCAL SLAM/);
|
||||||
assert.match(visual, /showLocalSurface/);
|
assert.match(visual, /showLocalSurface/);
|
||||||
|
assert.match(visual, /const latestAvailableSpatialFrame = \[\.\.\.timelineFrame\.availableFrames\][\s\S]*candidate\.spatialAvailable[\s\S]*candidate\.sequence <= timelineFrame\.activeSequence/);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? activeSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? \[\]\}/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
/const classifiedSpatialFrame = hasClassifiedSpatialOutput[\s\S]*classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||||
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||||
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
||||||
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
||||||
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
assert.match(visual, /\{activeSemantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
||||||
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
|
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
@@ -951,12 +971,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
|
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
|
||||||
const visual = await readFile(
|
const [visual, canonical] = await Promise.all([
|
||||||
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
),
|
||||||
assert.match(visual, /const mediaPane = \(/);
|
readFile(
|
||||||
assert.match(visual, /hidden=\{!mediaMode\}/);
|
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert.match(canonical, /const mediaPane = \(/);
|
||||||
|
assert.match(canonical, /hidden=\{mediaMode === "none"\}/);
|
||||||
assert.match(visual, /data-media="video"/);
|
assert.match(visual, /data-media="video"/);
|
||||||
assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
|
assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
|
||||||
assert.match(visual, /\{videoSource \? \(/);
|
assert.match(visual, /\{videoSource \? \(/);
|
||||||
|
|||||||
@@ -77,6 +77,70 @@ function session(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalLab(overrides = {}) {
|
||||||
|
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
|
return {
|
||||||
|
lab_id: "LAB V1",
|
||||||
|
source_session_id: "20260828T130511Z_viewer_live",
|
||||||
|
result_kind: "recorded-perception-qualification",
|
||||||
|
result_id: resultId,
|
||||||
|
source_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||||
|
config_sha256: null,
|
||||||
|
run_created_at_utc: "2026-08-29T18:05:11.329061+00:00",
|
||||||
|
published_at_utc: "2026-08-30T12:00:00.000Z",
|
||||||
|
replay_capability: {
|
||||||
|
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewer_profile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commands_enabled: false,
|
||||||
|
},
|
||||||
|
provenance: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyCalculationProfile(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.observatory-calculation-profile/v1",
|
||||||
|
setup_id: "lab-v1-ravnoves004tree-final",
|
||||||
|
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||||
|
origin: "existing-result",
|
||||||
|
definition_id: null,
|
||||||
|
definition_version: null,
|
||||||
|
definition_sha256: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalProjectionProvenance(resultId, replayCapability) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||||
|
evidence_identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||||
|
result_document_sha256: "a".repeat(64),
|
||||||
|
replay_capability: replayCapability,
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
actuation_accepted: false,
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
schema_version: "missioncore.laboratory-method/v1",
|
||||||
|
completeness: "legacy-partial",
|
||||||
|
execution_class: "ai-inference",
|
||||||
|
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||||
|
components: [{
|
||||||
|
kind: "source",
|
||||||
|
name: "sealed full-route LAB result",
|
||||||
|
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||||
|
role: "immutable Session catalog projection",
|
||||||
|
identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function replay(overrides = {}) {
|
function replay(overrides = {}) {
|
||||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||||
const sourceUrl = overrides.source_url ??
|
const sourceUrl = overrides.source_url ??
|
||||||
@@ -134,6 +198,7 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
|||||||
modalities: ["point-cloud", "pose"],
|
modalities: ["point-cloud", "pose"],
|
||||||
durationSeconds: 1_450.744,
|
durationSeconds: 1_450.744,
|
||||||
replayable: true,
|
replayable: true,
|
||||||
|
captureAttestation: null,
|
||||||
preparation: null,
|
preparation: null,
|
||||||
lab: null,
|
lab: null,
|
||||||
}]);
|
}]);
|
||||||
@@ -141,6 +206,137 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
|||||||
assert.equal("path" in catalog.items[0], false);
|
assert.equal("path" in catalog.items[0], false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("session catalog strictly decodes the explicit recorded LAB replay capability", () => {
|
||||||
|
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
|
const decoded = decodeObservationSessionCatalog({
|
||||||
|
items: [session({ id: resultId, lab: canonicalLab() })],
|
||||||
|
}).items[0].lab;
|
||||||
|
|
||||||
|
assert.deepEqual(decoded.replayCapability, {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commandsEnabled: false,
|
||||||
|
});
|
||||||
|
const rollingUpgradeLab = canonicalLab();
|
||||||
|
rollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||||
|
resultId,
|
||||||
|
rollingUpgradeLab.replay_capability,
|
||||||
|
);
|
||||||
|
delete rollingUpgradeLab.replay_capability;
|
||||||
|
assert.deepEqual(
|
||||||
|
decodeObservationSessionCatalog({
|
||||||
|
items: [session({ id: resultId, lab: rollingUpgradeLab })],
|
||||||
|
}).items[0].lab.replayCapability,
|
||||||
|
decoded.replayCapability,
|
||||||
|
);
|
||||||
|
const invalidComponents = [
|
||||||
|
{ kind: "tool" },
|
||||||
|
{ name: "unsealed result" },
|
||||||
|
{ version: "missioncore.lab-v1-vegetation-shadow/v0" },
|
||||||
|
{ role: "mutable projection" },
|
||||||
|
{ identity_sha256: "0".repeat(64) },
|
||||||
|
{ unexpected: true },
|
||||||
|
];
|
||||||
|
for (const componentOverride of invalidComponents) {
|
||||||
|
const invalidRollingUpgradeLab = canonicalLab();
|
||||||
|
invalidRollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||||
|
resultId,
|
||||||
|
invalidRollingUpgradeLab.replay_capability,
|
||||||
|
);
|
||||||
|
invalidRollingUpgradeLab.provenance.method.components = [{
|
||||||
|
...invalidRollingUpgradeLab.provenance.method.components[0],
|
||||||
|
...componentOverride,
|
||||||
|
}];
|
||||||
|
delete invalidRollingUpgradeLab.replay_capability;
|
||||||
|
assert.throws(
|
||||||
|
() => decodeObservationSessionCatalog({
|
||||||
|
items: [session({ id: resultId, lab: invalidRollingUpgradeLab })],
|
||||||
|
}),
|
||||||
|
ObservationSessionContractError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() => decodeObservationSessionCatalog({
|
||||||
|
items: [session({
|
||||||
|
id: resultId,
|
||||||
|
lab: canonicalLab({
|
||||||
|
replay_capability: {
|
||||||
|
...canonicalLab().replay_capability,
|
||||||
|
commands_enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
ObservationSessionContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session catalog decodes a typed calculation profile without inferring unknown results", () => {
|
||||||
|
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
|
const decoded = decodeObservationSessionCatalog({
|
||||||
|
items: [session({
|
||||||
|
id: resultId,
|
||||||
|
lab: canonicalLab({ calculation_profile: legacyCalculationProfile() }),
|
||||||
|
})],
|
||||||
|
}).items[0].lab;
|
||||||
|
|
||||||
|
assert.deepEqual(decoded.calculationProfile, {
|
||||||
|
schemaVersion: "missioncore.observatory-calculation-profile/v1",
|
||||||
|
setupId: "lab-v1-ravnoves004tree-final",
|
||||||
|
displayName: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||||
|
origin: "existing-result",
|
||||||
|
definitionId: null,
|
||||||
|
definitionVersion: null,
|
||||||
|
definitionSha256: null,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
decodeObservationSessionCatalog({
|
||||||
|
items: [session({ id: resultId, lab: canonicalLab() })],
|
||||||
|
}).items[0].lab.calculationProfile,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
decodeObservationSessionCatalog({
|
||||||
|
items: [session({
|
||||||
|
id: resultId,
|
||||||
|
lab: canonicalLab({ calculation_profile: null }),
|
||||||
|
})],
|
||||||
|
}).items[0].lab.calculationProfile,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => decodeObservationSessionCatalog({
|
||||||
|
items: [session({
|
||||||
|
id: resultId,
|
||||||
|
lab: canonicalLab({
|
||||||
|
calculation_profile: legacyCalculationProfile({
|
||||||
|
origin: "archived-definition",
|
||||||
|
definition_id: "lab-v1-portable",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
ObservationSessionContractError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => decodeObservationSessionCatalog({
|
||||||
|
items: [session({
|
||||||
|
id: resultId,
|
||||||
|
lab: canonicalLab({
|
||||||
|
calculation_profile: {
|
||||||
|
...legacyCalculationProfile(),
|
||||||
|
guessed_from_provenance: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
ObservationSessionContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
||||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||||
const styles = await readFile(
|
const styles = await readFile(
|
||||||
@@ -246,7 +442,7 @@ test("source and laboratory catalogs are requested as disjoint backend projectio
|
|||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
"/api/v1/observation-sessions?limit=100&scope=source",
|
"/api/v1/observation-sessions?limit=100&scope=source",
|
||||||
"/api/v1/observation-sessions?limit=100&scope=laboratory",
|
"/api/v1/observation-sessions?limit=100&scope=laboratory&lab_contract=v3",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ let resolveRecordedBlueprintUrl;
|
|||||||
let fetchRecordedBlueprintRrd;
|
let fetchRecordedBlueprintRrd;
|
||||||
let resolveRecordedPerceptionUrl;
|
let resolveRecordedPerceptionUrl;
|
||||||
let fetchRecordedPerceptionRrd;
|
let fetchRecordedPerceptionRrd;
|
||||||
|
let resolveRecordedPerceptionViewerSourceUrl;
|
||||||
|
let probeRecordedPerceptionViewerSource;
|
||||||
let resolveRecordedPointColorsUrl;
|
let resolveRecordedPointColorsUrl;
|
||||||
let fetchRecordedPointColorsRrd;
|
let fetchRecordedPointColorsRrd;
|
||||||
let recordedPointColorKey;
|
let recordedPointColorKey;
|
||||||
@@ -110,6 +112,8 @@ before(async () => {
|
|||||||
fetchRecordedBlueprintRrd,
|
fetchRecordedBlueprintRrd,
|
||||||
resolveRecordedPerceptionUrl,
|
resolveRecordedPerceptionUrl,
|
||||||
fetchRecordedPerceptionRrd,
|
fetchRecordedPerceptionRrd,
|
||||||
|
resolveRecordedPerceptionViewerSourceUrl,
|
||||||
|
probeRecordedPerceptionViewerSource,
|
||||||
resolveRecordedPointColorsUrl,
|
resolveRecordedPointColorsUrl,
|
||||||
fetchRecordedPointColorsRrd,
|
fetchRecordedPointColorsRrd,
|
||||||
recordedPointColorKey,
|
recordedPointColorKey,
|
||||||
@@ -407,6 +411,15 @@ test("recorded blueprint endpoint is derived only from canonical same-origin RRD
|
|||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveRecordedBlueprintUrl(
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"a".repeat(64)}`
|
||||||
|
+ "/canonical-replay.rrd",
|
||||||
|
"http://127.0.0.1:5174",
|
||||||
|
"/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||||
|
),
|
||||||
|
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
||||||
@@ -485,6 +498,8 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||||||
view_reset_generation: 1,
|
view_reset_generation: 1,
|
||||||
follow_trajectory: true,
|
follow_trajectory: true,
|
||||||
unified_perception: true,
|
unified_perception: true,
|
||||||
|
semantic_layer: null,
|
||||||
|
plan_view: false,
|
||||||
show_detections_2d: true,
|
show_detections_2d: true,
|
||||||
show_segmentation: false,
|
show_segmentation: false,
|
||||||
show_cuboids_3d: true,
|
show_cuboids_3d: true,
|
||||||
@@ -664,6 +679,74 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
|
|||||||
assert.equal(absent, null);
|
assert.equal(absent, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("LAB perception sidecar is streamed by native Rerun from one generation-bound URL", async () => {
|
||||||
|
const endpoint =
|
||||||
|
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
|
||||||
|
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
|
||||||
|
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
|
||||||
|
endpoint,
|
||||||
|
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||||
|
"b".repeat(64),
|
||||||
|
"http://127.0.0.1:5174",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sourceUrl,
|
||||||
|
`${endpoint}?application_id=nodedc_mission_core_recorded` +
|
||||||
|
`&recording_id=recording-001&generation=${"b".repeat(64)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlayGeneration = "c".repeat(64);
|
||||||
|
const probe = await probeRecordedPerceptionViewerSource(sourceUrl, {
|
||||||
|
origin: "http://127.0.0.1:5174",
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
assert.equal(String(input), sourceUrl);
|
||||||
|
assert.equal(init.method, "HEAD");
|
||||||
|
assert.equal(new Headers(init.headers).get("Range"), null);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/vnd.rerun.rrd",
|
||||||
|
"Content-Length": "186058411",
|
||||||
|
"ETag": `"${overlayGeneration}"`,
|
||||||
|
"X-Rerun-Format": "RRF2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(probe, {
|
||||||
|
sourceUrl: `${sourceUrl}&overlay_generation=${overlayGeneration}`,
|
||||||
|
byteLength: 186_058_411,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LAB native source rejects an unsealed overlay descriptor", async () => {
|
||||||
|
const endpoint =
|
||||||
|
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
|
||||||
|
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
|
||||||
|
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
|
||||||
|
endpoint,
|
||||||
|
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||||
|
"b".repeat(64),
|
||||||
|
"http://127.0.0.1:5174",
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
probeRecordedPerceptionViewerSource(sourceUrl, {
|
||||||
|
origin: "http://127.0.0.1:5174",
|
||||||
|
fetcher: async () => new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/vnd.rerun.rrd",
|
||||||
|
"Content-Length": "186058411",
|
||||||
|
"ETag": `"${"c".repeat(64)}"`,
|
||||||
|
"X-Rerun-Format": "RRF1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/Invalid recorded perception viewer response/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||||
const sources = recordedObservationSources({
|
const sources = recordedObservationSources({
|
||||||
kind: "rerun-recording",
|
kind: "rerun-recording",
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let applyObservatoryCatalogMutationOverlay;
|
||||||
|
let buildObservatoryCatalog;
|
||||||
|
let fetchObservatoryCatalog;
|
||||||
|
let findObservatoryEvidence;
|
||||||
|
let observatoryCatalogConfirmsEvidenceDeletion;
|
||||||
|
let reconcileObservatoryCatalogMutationOverlay;
|
||||||
|
let ObservatoryCatalogContractError;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
applyObservatoryCatalogMutationOverlay,
|
||||||
|
buildObservatoryCatalog,
|
||||||
|
fetchObservatoryCatalog,
|
||||||
|
findObservatoryEvidence,
|
||||||
|
observatoryCatalogConfirmsEvidenceDeletion,
|
||||||
|
reconcileObservatoryCatalogMutationOverlay,
|
||||||
|
ObservatoryCatalogContractError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function source(id, startedAtUtc) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: `Источник ${id}`,
|
||||||
|
startedAtUtc,
|
||||||
|
completedAtUtc: startedAtUtc,
|
||||||
|
status: "ready",
|
||||||
|
modalities: ["point-cloud", "video"],
|
||||||
|
durationSeconds: 42,
|
||||||
|
replayable: true,
|
||||||
|
preparation: null,
|
||||||
|
lab: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidence(id, sourceSessionId, publishedAtUtc) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: `Результат ${id}`,
|
||||||
|
startedAtUtc: publishedAtUtc,
|
||||||
|
completedAtUtc: publishedAtUtc,
|
||||||
|
status: "ready",
|
||||||
|
modalities: ["video"],
|
||||||
|
durationSeconds: 12,
|
||||||
|
replayable: true,
|
||||||
|
preparation: null,
|
||||||
|
lab: {
|
||||||
|
labId: `LAB-${id}`,
|
||||||
|
sourceSessionId,
|
||||||
|
resultKind: "recorded-evidence",
|
||||||
|
resultId: `result-${id}`,
|
||||||
|
sourceResultId: null,
|
||||||
|
configSha256: "a".repeat(64),
|
||||||
|
runCreatedAtUtc: publishedAtUtc,
|
||||||
|
publishedAtUtc,
|
||||||
|
replayCapability: null,
|
||||||
|
calculationProfile: null,
|
||||||
|
provenance: { verdict: "must-not-be-inferred" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalProvenance(resultId) {
|
||||||
|
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||||
|
evidence_identity_sha256: identitySha256,
|
||||||
|
result_document_sha256: "a".repeat(64),
|
||||||
|
replay_capability: {
|
||||||
|
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewer_profile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commands_enabled: false,
|
||||||
|
},
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
actuation_accepted: false,
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
schema_version: "missioncore.laboratory-method/v1",
|
||||||
|
completeness: "legacy-partial",
|
||||||
|
execution_class: "ai-inference",
|
||||||
|
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||||
|
components: [{
|
||||||
|
kind: "source",
|
||||||
|
name: "sealed full-route LAB result",
|
||||||
|
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||||
|
role: "immutable Session catalog projection",
|
||||||
|
identity_sha256: identitySha256,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
|
||||||
|
const catalog = buildObservatoryCatalog(
|
||||||
|
[
|
||||||
|
source("older", "2026-08-28T10:00:00Z"),
|
||||||
|
source("newer", "2026-08-29T10:00:00Z"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
evidence("old-result", "newer", "2026-08-29T11:00:00Z"),
|
||||||
|
evidence("new-result", "newer", "2026-08-29T12:00:00Z"),
|
||||||
|
evidence("orphan", "missing", "2026-08-29T13:00:00Z"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(catalog.items.map(({ source: item }) => item.id), ["newer", "older"]);
|
||||||
|
assert.deepEqual(
|
||||||
|
catalog.items[0].evidence.map((item) => item.sessionId),
|
||||||
|
["new-result", "old-result"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(catalog.unresolvedEvidence.map((item) => item.sessionId), ["orphan"]);
|
||||||
|
assert.deepEqual(catalog.window, {
|
||||||
|
limit: 100,
|
||||||
|
sourceCount: 2,
|
||||||
|
laboratoryCount: 3,
|
||||||
|
sourceLimitReached: false,
|
||||||
|
laboratoryLimitReached: false,
|
||||||
|
});
|
||||||
|
assert.equal("verdict" in catalog.items[0].evidence[0], false);
|
||||||
|
assert.equal(catalog.items[1].evidence.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory fails visibly when the laboratory projection loses its typed link", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[source("not-lab", "2026-08-29T11:00:00Z")],
|
||||||
|
),
|
||||||
|
ObservatoryCatalogContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory fetches disjoint read-only source and laboratory projections", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fetcher = async (input, init) => {
|
||||||
|
calls.push({ input: String(input), method: init?.method });
|
||||||
|
return new Response(JSON.stringify({ items: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalog = await fetchObservatoryCatalog({ fetcher, limit: 50 });
|
||||||
|
assert.deepEqual(catalog, {
|
||||||
|
items: [],
|
||||||
|
unresolvedEvidence: [],
|
||||||
|
window: {
|
||||||
|
limit: 50,
|
||||||
|
sourceCount: 0,
|
||||||
|
laboratoryCount: 0,
|
||||||
|
sourceLimitReached: false,
|
||||||
|
laboratoryLimitReached: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map(({ input }) => input).sort(),
|
||||||
|
[
|
||||||
|
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
|
||||||
|
"/api/v1/observation-sessions?limit=50&scope=source",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory exposes bounded-window uncertainty without inventing a broken link", () => {
|
||||||
|
const catalog = buildObservatoryCatalog(
|
||||||
|
[
|
||||||
|
source("newer", "2026-08-29T10:00:00Z"),
|
||||||
|
source("older", "2026-08-28T10:00:00Z"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
evidence("linked", "newer", "2026-08-29T11:00:00Z"),
|
||||||
|
evidence("outside-window", "older-than-window", "2026-08-29T12:00:00Z"),
|
||||||
|
],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(catalog.window.sourceLimitReached, true);
|
||||||
|
assert.equal(catalog.window.laboratoryLimitReached, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
catalog.unresolvedEvidence.map((item) => item.sessionId),
|
||||||
|
["outside-window"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
|
||||||
|
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
|
const canonical = evidence(
|
||||||
|
canonicalResultId,
|
||||||
|
"20260828T130511Z_viewer_live",
|
||||||
|
"2026-08-29T18:05:11Z",
|
||||||
|
);
|
||||||
|
canonical.lab = {
|
||||||
|
...canonical.lab,
|
||||||
|
labId: "LAB V1",
|
||||||
|
resultKind: "recorded-perception-qualification",
|
||||||
|
resultId: canonicalResultId,
|
||||||
|
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||||
|
configSha256: null,
|
||||||
|
provenance: canonicalProvenance(canonicalResultId),
|
||||||
|
replayCapability: {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commandsEnabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const catalog = buildObservatoryCatalog(
|
||||||
|
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
|
||||||
|
[canonical],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
evidenceSessionId: canonicalResultId,
|
||||||
|
sourceSessionId: "20260828T130511Z_viewer_live",
|
||||||
|
resultId: canonicalResultId,
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
|
||||||
|
const catalog = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[evidence("result", "source", "2026-08-29T11:00:00Z")],
|
||||||
|
);
|
||||||
|
const original = catalog.items[0].evidence[0];
|
||||||
|
const projected = applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalog,
|
||||||
|
new Map([["result", {
|
||||||
|
kind: "rename",
|
||||||
|
displayName: "Операторское имя",
|
||||||
|
revision: 1,
|
||||||
|
}]]),
|
||||||
|
);
|
||||||
|
const renamed = findObservatoryEvidence(projected, "result");
|
||||||
|
|
||||||
|
assert.equal(renamed.label, "Операторское имя");
|
||||||
|
assert.equal(renamed.sessionId, original.sessionId);
|
||||||
|
assert.equal(renamed.lab, original.lab);
|
||||||
|
assert.equal(renamed.recordedRun, original.recordedRun);
|
||||||
|
assert.equal(projected.items[0].source, catalog.items[0].source);
|
||||||
|
assert.equal(projected.window.laboratoryCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory tombstone removes only its catalog evidence projection", () => {
|
||||||
|
const catalog = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[
|
||||||
|
evidence("remove", "source", "2026-08-29T12:00:00Z"),
|
||||||
|
evidence("keep", "source", "2026-08-29T11:00:00Z"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const projected = applyObservatoryCatalogMutationOverlay(
|
||||||
|
catalog,
|
||||||
|
new Map([["remove", { kind: "delete", revision: 1 }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(projected.items.length, 1);
|
||||||
|
assert.equal(projected.items[0].source, catalog.items[0].source);
|
||||||
|
assert.deepEqual(
|
||||||
|
projected.items[0].evidence.map(({ sessionId }) => sessionId),
|
||||||
|
["keep"],
|
||||||
|
);
|
||||||
|
assert.equal(projected.window.sourceCount, 1);
|
||||||
|
assert.equal(projected.window.laboratoryCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory confirms an ambiguous delete only from a complete LAB window", () => {
|
||||||
|
const bounded = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[evidence("other", "source", "2026-08-29T11:00:00Z")],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const complete = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(bounded, "result"), false);
|
||||||
|
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(complete, "result"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory reconciliation cannot let a stale in-flight fetch undo a local mutation", () => {
|
||||||
|
const oldCatalog = buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[evidence("result", "source", "2026-08-29T11:00:00Z")],
|
||||||
|
);
|
||||||
|
const renameOverlay = new Map([["result", {
|
||||||
|
kind: "rename",
|
||||||
|
displayName: "Новое имя",
|
||||||
|
revision: 1,
|
||||||
|
}]]);
|
||||||
|
|
||||||
|
const staleRename = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
renameOverlay,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(staleRename.catalog, "result").label, "Новое имя");
|
||||||
|
assert.equal(staleRename.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const reconciledOldRename = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
renameOverlay,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(reconciledOldRename.catalog, "result").label, "Новое имя");
|
||||||
|
assert.equal(reconciledOldRename.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const renamedEvidence = evidence("result", "source", "2026-08-29T11:00:00Z");
|
||||||
|
renamedEvidence.label = "Новое имя";
|
||||||
|
const confirmedRename = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[renamedEvidence],
|
||||||
|
),
|
||||||
|
staleRename.overlay,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(confirmedRename.catalog, "result").label, "Новое имя");
|
||||||
|
assert.equal(confirmedRename.overlay.size, 0);
|
||||||
|
|
||||||
|
const deleteOverlay = new Map([["result", { kind: "delete", revision: 2 }]]);
|
||||||
|
const staleDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
deleteOverlay,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(staleDelete.catalog, "result"), null);
|
||||||
|
assert.equal(staleDelete.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const reconciledOldDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
oldCatalog,
|
||||||
|
deleteOverlay,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(reconciledOldDelete.catalog, "result"), null);
|
||||||
|
assert.equal(reconciledOldDelete.overlay.has("result"), true);
|
||||||
|
|
||||||
|
const confirmedDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||||
|
buildObservatoryCatalog(
|
||||||
|
[source("source", "2026-08-29T10:00:00Z")],
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
staleDelete.overlay,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert.equal(findObservatoryEvidence(confirmedDelete.catalog, "result"), null);
|
||||||
|
assert.equal(confirmedDelete.overlay.size, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let deleteObservatoryLabProjection;
|
||||||
|
let renameObservatoryLabProjection;
|
||||||
|
let ObservatoryCatalogMutationError;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
deleteObservatoryLabProjection,
|
||||||
|
renameObservatoryLabProjection,
|
||||||
|
ObservatoryCatalogMutationError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observatory/catalogMutations.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
|
const binding = {
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
evidenceSessionId: sessionId,
|
||||||
|
sourceSessionId: "20260828T130511Z_viewer_live",
|
||||||
|
resultId: sessionId,
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("Observatory rename sends the closed projection-only contract", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await renameObservatoryLabProjection(binding, " Новый разбор ", {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
calls.push({ input, init });
|
||||||
|
return Response.json({
|
||||||
|
schema_version: "missioncore.observatory-lab-projection/v1",
|
||||||
|
session_id: sessionId,
|
||||||
|
display_name: "Новый разбор",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
schemaVersion: "missioncore.observatory-lab-projection/v1",
|
||||||
|
sessionId,
|
||||||
|
displayName: "Новый разбор",
|
||||||
|
});
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
|
||||||
|
assert.equal(calls[0].init.method, "PATCH");
|
||||||
|
assert.equal(calls[0].init.headers.Accept, "application/json");
|
||||||
|
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||||
|
schema_version: "missioncore.observatory-lab-projection-rename/v1",
|
||||||
|
display_name: "Новый разбор",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory delete accepts only an empty 204 response", async () => {
|
||||||
|
const calls = [];
|
||||||
|
await deleteObservatoryLabProjection(binding, {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
calls.push({ input, init });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
|
||||||
|
assert.equal(calls[0].init.method, "DELETE");
|
||||||
|
await assert.rejects(
|
||||||
|
deleteObservatoryLabProjection(binding, {
|
||||||
|
fetcher: async () => new Response("unexpected", { status: 200 }),
|
||||||
|
}),
|
||||||
|
ObservatoryCatalogMutationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory mutations fail closed before network access", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetcher = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
renameObservatoryLabProjection(binding, " ", { fetcher }),
|
||||||
|
/от 1 до 160/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
deleteObservatoryLabProjection({ ...binding, resultId: "different" }, { fetcher }),
|
||||||
|
/не допущен/,
|
||||||
|
);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory mutations reject response drift and preserve API detail", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
renameObservatoryLabProjection(binding, "Разбор", {
|
||||||
|
fetcher: async () => Response.json({
|
||||||
|
schema_version: "missioncore.observatory-lab-projection/v1",
|
||||||
|
session_id: sessionId,
|
||||||
|
display_name: "Разбор",
|
||||||
|
unexpected: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/неизвестные поля/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
deleteObservatoryLabProjection(binding, {
|
||||||
|
fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }),
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ObservatoryCatalogMutationError
|
||||||
|
&& error.status === 409
|
||||||
|
&& error.message === "Проекция не принадлежит Обсерватории.",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import React from "react";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let useObservatoryRecordedJobs;
|
||||||
|
let useObservatoryLaboratorySetups;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom", logLevel: "silent", server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({ useObservatoryRecordedJobs } = await server.ssrLoadModule("/src/core/observatory/useObservatoryRecordedJobs.ts"));
|
||||||
|
({ useObservatoryLaboratorySetups } = await server.ssrLoadModule("/src/core/observatory/useObservatoryLaboratorySetups.ts"));
|
||||||
|
});
|
||||||
|
after(async () => { await server?.close(); });
|
||||||
|
|
||||||
|
// Deliberately inspect the render BEFORE effects/cleanup: stale state must already be hidden.
|
||||||
|
function renderBeforeEffects(hook, args, stateSlots) {
|
||||||
|
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||||
|
const previous = internals.H;
|
||||||
|
let stateIndex = 0;
|
||||||
|
internals.H = {
|
||||||
|
useState: (initial) => [
|
||||||
|
stateIndex < stateSlots.length ? stateSlots[stateIndex++]
|
||||||
|
: typeof initial === "function" ? initial() : initial,
|
||||||
|
() => {},
|
||||||
|
],
|
||||||
|
useRef: (value) => ({ current: value }),
|
||||||
|
useMemo: (factory) => factory(),
|
||||||
|
useCallback: (fn) => fn,
|
||||||
|
useEffect: () => {},
|
||||||
|
};
|
||||||
|
try { return hook(...args); } finally { internals.H = previous; }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("queue data and errors cannot survive a source/setup/version change for one render", () => {
|
||||||
|
const original = ["source-a", "profile-a", "a".repeat(64)];
|
||||||
|
const activeJob = { state: "running", publication: { state: "not-required" } };
|
||||||
|
const snapshot = {
|
||||||
|
selectionKey: JSON.stringify(original), jobs: [activeJob], state: "ready", error: "old error",
|
||||||
|
};
|
||||||
|
assert.equal(renderBeforeEffects(useObservatoryRecordedJobs, original, [snapshot]).activeJob, activeJob);
|
||||||
|
for (const next of [
|
||||||
|
["source-b", original[1], original[2]],
|
||||||
|
[original[0], "profile-b", original[2]],
|
||||||
|
[original[0], original[1], "b".repeat(64)],
|
||||||
|
["", "", ""],
|
||||||
|
]) {
|
||||||
|
const view = renderBeforeEffects(useObservatoryRecordedJobs, next, [snapshot]);
|
||||||
|
assert.deepEqual(view.jobs, []);
|
||||||
|
assert.equal(view.activeJob, null);
|
||||||
|
assert.equal(view.latestJob, null);
|
||||||
|
assert.equal(view.error, null);
|
||||||
|
assert.notEqual(view.state, "ready");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("published, incompatible and previous-source profiles have no effective selection", () => {
|
||||||
|
const profile = {
|
||||||
|
setupId: "profile-a", origin: "portable-definition",
|
||||||
|
compatibility: { compatible: true },
|
||||||
|
runDefinition: { definitionSha256: "a".repeat(64) },
|
||||||
|
preflight: { outcome: "ready" },
|
||||||
|
};
|
||||||
|
function render(sourceId, setups) {
|
||||||
|
return renderBeforeEffects(useObservatoryLaboratorySetups, [sourceId], [
|
||||||
|
{ sourceSessionId: "source-a", setups }, "ready", null, "profile-a", 0, { kind: "idle" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
assert.equal(render("source-a", [profile]).selectedSetupId, "profile-a");
|
||||||
|
for (const view of [
|
||||||
|
render("source-b", [profile]),
|
||||||
|
render("source-a", [{ ...profile, preflight: { outcome: "existing" } }]),
|
||||||
|
render("source-a", [{ ...profile, compatibility: { compatible: false } }]),
|
||||||
|
render("source-a", []),
|
||||||
|
]) {
|
||||||
|
assert.equal(view.selectedSetupId, "");
|
||||||
|
assert.equal(view.selectedSetup, null);
|
||||||
|
assert.deepEqual(view.selectableSetups, []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("historical failures are not presented as errors of a new operator action", () => {
|
||||||
|
const selection = ["source-a", "profile-a", "a".repeat(64)];
|
||||||
|
const snapshot = {
|
||||||
|
selectionKey: JSON.stringify(selection),
|
||||||
|
jobs: [{ jobId: "old-run", state: "failed", publication: { state: "not-required" } }],
|
||||||
|
state: "ready", error: null,
|
||||||
|
};
|
||||||
|
const view = renderBeforeEffects(useObservatoryRecordedJobs, selection, [snapshot]);
|
||||||
|
assert.equal(view.computationFailed, false);
|
||||||
|
assert.equal(view.error, null);
|
||||||
|
assert.equal(view.activeJob, null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let fetchObservatoryLaboratorySetups;
|
||||||
|
let fetchObservatoryPortableLaboratorySetups;
|
||||||
|
let preflightObservatoryLaboratorySetup;
|
||||||
|
let selectableObservatorySetups;
|
||||||
|
let ObservatoryLaboratorySetupContractError;
|
||||||
|
|
||||||
|
const authority = {
|
||||||
|
commands_enabled: false,
|
||||||
|
actuation_allowed: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
production_accepted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function definition() {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.observatory-run-definition/v1",
|
||||||
|
definition_id: "m49-tgs-full-shadow",
|
||||||
|
version: 1,
|
||||||
|
work_id: "m49-tgs-full-shadow",
|
||||||
|
source: {
|
||||||
|
session_id: "source-a",
|
||||||
|
label: "RAVNOVES00",
|
||||||
|
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||||
|
},
|
||||||
|
configuration: [{ role: "primary-profile", sha256: "a".repeat(64) }],
|
||||||
|
authority,
|
||||||
|
definition_sha256: "b".repeat(64),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
return {
|
||||||
|
setup_id: "m49-tgs-full-shadow-v1",
|
||||||
|
display_name: "M4.9T5",
|
||||||
|
description: "Сохранённый полный source-paced shadow.",
|
||||||
|
origin: "archived-definition",
|
||||||
|
source: {
|
||||||
|
session_id: "source-a",
|
||||||
|
label: "RAVNOVES00",
|
||||||
|
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||||
|
},
|
||||||
|
run_definition: definition(),
|
||||||
|
compatibility: { compatible: true, reasons: [] },
|
||||||
|
executor: {
|
||||||
|
contour_id: "worker-006",
|
||||||
|
state: "not-installed",
|
||||||
|
reason_code: "laboratory-runner-adapter-not-installed",
|
||||||
|
reason: "Адаптер не установлен.",
|
||||||
|
},
|
||||||
|
preserved_results: [{
|
||||||
|
result_id: `m49-tgs-full-shadow-${"c".repeat(64)}`,
|
||||||
|
result_kind: "recorded-source-paced-tgs-shadow",
|
||||||
|
relation: "primary-visual",
|
||||||
|
access: "legacy-lab",
|
||||||
|
created_at_utc: "2026-08-26T20:27:19Z",
|
||||||
|
observatory_projection_available: false,
|
||||||
|
}],
|
||||||
|
preflight: {
|
||||||
|
outcome: "blocked",
|
||||||
|
action: "open-legacy",
|
||||||
|
reason: "Точный результат сохранён в legacy LAB.",
|
||||||
|
submission_allowed: false,
|
||||||
|
existing_result_ids: [],
|
||||||
|
},
|
||||||
|
authority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableSetup() {
|
||||||
|
return {
|
||||||
|
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
|
||||||
|
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||||
|
description: "Проверка записанной K1-сессии моделями EoMT и DDRNet; только наблюдение.",
|
||||||
|
origin: "portable-definition",
|
||||||
|
source_requirements: {
|
||||||
|
plugin_id: "nodedc.device.xgrids-lixelkity-k1",
|
||||||
|
archive_id: "xgrids-k1.viewer-live.evidence",
|
||||||
|
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||||
|
camera_source_id: "sensor.camera.right",
|
||||||
|
camera_semantic_channel_id: "camera.video.recorded",
|
||||||
|
recorded_media_type: 'video/mp4; codecs="avc1.641028"',
|
||||||
|
recorded_media_init_sha256: "1".repeat(64),
|
||||||
|
camera_width: 800,
|
||||||
|
camera_height: 600,
|
||||||
|
calibration_slot: "camera_1",
|
||||||
|
calibration_identity_sha256: "2".repeat(64),
|
||||||
|
exactly_one_media_epoch: true,
|
||||||
|
seekable: true,
|
||||||
|
},
|
||||||
|
run_definition: {
|
||||||
|
definition_id: "lab-v1-eomt-ddrnet-portable",
|
||||||
|
version: 1,
|
||||||
|
definition_sha256: "3".repeat(64),
|
||||||
|
result_schema: "missioncore.recorded-eomt-ddrnet-review/v2",
|
||||||
|
result_kind: "recorded-perception-qualification",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: "EoMT Cityscapes Large 1024",
|
||||||
|
release_id: "eomt-cityscapes-large-1024-v1",
|
||||||
|
model_id: "tue-mps/cityscapes_semantic_eomt_large_1024",
|
||||||
|
architecture: "EomtForUniversalSegmentation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DDRNet-39",
|
||||||
|
release_id: "lab-v1-ddrnet-39-goose-fine-64-v1",
|
||||||
|
model_id: "goose-ddrnet-class-512",
|
||||||
|
architecture: "ddrnet_39",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
source_compatibility: {
|
||||||
|
outcome: "pass",
|
||||||
|
compatible: true,
|
||||||
|
reason_code: "source-compatible",
|
||||||
|
reason: "Запись соответствует требованиям EoMT + DDRNet.",
|
||||||
|
},
|
||||||
|
executor: {
|
||||||
|
contour_id: "worker-006",
|
||||||
|
state: "not-installed",
|
||||||
|
ready: false,
|
||||||
|
reason_code: "eomt-executor-release-unsealed",
|
||||||
|
reason: "Immutable executor release не установлен.",
|
||||||
|
},
|
||||||
|
existing_results: [],
|
||||||
|
preflight: {
|
||||||
|
outcome: "blocked",
|
||||||
|
action: "blocked",
|
||||||
|
reason: "Вычислительный контур LAB V1 пока недоступен.",
|
||||||
|
submission_allowed: false,
|
||||||
|
existing_result_ids: [],
|
||||||
|
},
|
||||||
|
authority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableM49Setup() {
|
||||||
|
const profile = portableSetup();
|
||||||
|
profile.setup_id = "m49-tgs-portable-v2";
|
||||||
|
profile.display_name = "M4.9T5 · TRAVEL TGS · CPU-only, без ML";
|
||||||
|
profile.description = "Динамический TGS-разбор записанной K1-сессии без ML; только наблюдение.";
|
||||||
|
profile.run_definition = {
|
||||||
|
definition_id: "m49-tgs-portable",
|
||||||
|
version: 2,
|
||||||
|
definition_sha256: "4".repeat(64),
|
||||||
|
result_schema: "missioncore.recorded-travel-tgs-review/v2",
|
||||||
|
result_kind: "recorded-source-paced-tgs-shadow",
|
||||||
|
models: [],
|
||||||
|
};
|
||||||
|
profile.source_compatibility.reason = "Запись соответствует требованиям TRAVEL TGS.";
|
||||||
|
profile.executor.reason_code = "m49-executor-release-unsealed";
|
||||||
|
profile.executor.reason = "Immutable executor release не установлен.";
|
||||||
|
profile.preflight.reason = "Переносимый вычислительный контур M4.9T5 пока недоступен.";
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
fetchObservatoryLaboratorySetups,
|
||||||
|
fetchObservatoryPortableLaboratorySetups,
|
||||||
|
preflightObservatoryLaboratorySetup,
|
||||||
|
selectableObservatorySetups,
|
||||||
|
ObservatoryLaboratorySetupContractError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory setup catalog keeps definition identity separate from executor availability", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fetcher = async (input, init) => {
|
||||||
|
calls.push({ input: String(input), init });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [setup()],
|
||||||
|
authority,
|
||||||
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalog = await fetchObservatoryLaboratorySetups("source-a", { fetcher });
|
||||||
|
assert.equal(catalog.setups[0].runDefinition.definitionSha256, "b".repeat(64));
|
||||||
|
assert.equal(catalog.setups[0].executor.state, "not-installed");
|
||||||
|
assert.equal(catalog.setups[0].preflight.submissionAllowed, false);
|
||||||
|
assert.equal(catalog.setups[0].preservedResults[0].access, "legacy-lab");
|
||||||
|
assert.equal(
|
||||||
|
calls[0].input,
|
||||||
|
"/api/v1/observatory/laboratory-setups?source_session_id=source-a",
|
||||||
|
);
|
||||||
|
assert.equal(calls[0].init.method, "GET");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable profiles report compatible source separately from unavailable Worker", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const selected = (await fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
calls.push({ input: String(input), init });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [portableSetup(), portableM49Setup()],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 });
|
||||||
|
},
|
||||||
|
})).setups[0];
|
||||||
|
|
||||||
|
assert.equal(selected.origin, "portable-definition");
|
||||||
|
assert.equal(selected.compatibility.compatible, true);
|
||||||
|
assert.equal(selected.executor.state, "not-installed");
|
||||||
|
assert.equal(selected.preflight.submissionAllowed, false);
|
||||||
|
assert.deepEqual(selected.runDefinition.models.map((model) => model.name), [
|
||||||
|
"EoMT Cityscapes Large 1024",
|
||||||
|
"DDRNet-39",
|
||||||
|
]);
|
||||||
|
const m49 = (await fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [portableSetup(), portableM49Setup()],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
})).setups[1];
|
||||||
|
assert.equal(m49.displayName, "M4.9T5 · TRAVEL TGS · CPU-only, без ML");
|
||||||
|
assert.deepEqual(m49.runDefinition.models, []);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].input,
|
||||||
|
"/api/v1/observatory/portable-laboratory-setups?source_session_id=source-a",
|
||||||
|
);
|
||||||
|
|
||||||
|
let preflightRequest;
|
||||||
|
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
preflightRequest = { input: String(input), init };
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setup_id: selected.setupId,
|
||||||
|
definition_sha256: "3".repeat(64),
|
||||||
|
check_sha256: null,
|
||||||
|
outcome: "blocked",
|
||||||
|
submission_allowed: false,
|
||||||
|
checks: [{
|
||||||
|
check_id: "executor",
|
||||||
|
outcome: "fail",
|
||||||
|
reason_code: "eomt-executor-release-unsealed",
|
||||||
|
message: "Immutable executor release не установлен.",
|
||||||
|
}],
|
||||||
|
existing_result_ids: [],
|
||||||
|
executor: portableSetup().executor,
|
||||||
|
authority,
|
||||||
|
}), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(preflightRequest.input, "/api/v1/observatory/run-preflights");
|
||||||
|
assert.equal(preflightRequest.init.method, "POST");
|
||||||
|
assert.equal(preflight.outcome, "blocked");
|
||||||
|
assert.equal(preflight.submissionAllowed, false);
|
||||||
|
assert.equal(preflight.checkSha256, null);
|
||||||
|
assert.equal(preflight.checks[0].outcome, "fail");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable profile accepts a consistent ready projection", async () => {
|
||||||
|
const ready = portableSetup();
|
||||||
|
ready.executor = {
|
||||||
|
contour_id: "worker-006",
|
||||||
|
state: "ready",
|
||||||
|
ready: true,
|
||||||
|
reason_code: null,
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
ready.preflight = {
|
||||||
|
outcome: "ready",
|
||||||
|
action: "check",
|
||||||
|
reason: "Запись готова к постановке в очередь.",
|
||||||
|
submission_allowed: true,
|
||||||
|
existing_result_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalog = await fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [ready],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
});
|
||||||
|
assert.equal(catalog.setups[0].executor.state, "ready");
|
||||||
|
assert.equal(catalog.setups[0].preflight.outcome, "ready");
|
||||||
|
assert.equal(catalog.setups[0].preflight.submissionAllowed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable LAB V1 rejects an unbound existing result projection", async () => {
|
||||||
|
const spoofed = portableSetup();
|
||||||
|
spoofed.existing_results = [{
|
||||||
|
result_id: `legacy-vegetation-${"4".repeat(64)}`,
|
||||||
|
result_kind: "recorded-perception-qualification",
|
||||||
|
created_at_utc: "2026-08-30T18:16:00Z",
|
||||||
|
}];
|
||||||
|
spoofed.preflight = {
|
||||||
|
outcome: "existing",
|
||||||
|
action: "open-existing",
|
||||||
|
reason: "Результат якобы готов.",
|
||||||
|
submission_allowed: false,
|
||||||
|
existing_result_ids: [spoofed.existing_results[0].result_id],
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [spoofed],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
}),
|
||||||
|
/значение изменилось|значение не поддерживается|обнаружены неизвестные поля/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable LAB V1 rejects heavyweight compatibility evidence", async () => {
|
||||||
|
const heavyweight = portableSetup();
|
||||||
|
heavyweight.source_compatibility.evidence = {
|
||||||
|
frame_count: 6830,
|
||||||
|
timeline_start_seconds: 0,
|
||||||
|
timeline_end_seconds: 819,
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [heavyweight],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
}),
|
||||||
|
/неизвестные поля/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function cachedPortableSetup() {
|
||||||
|
const setup = portableSetup();
|
||||||
|
setup.existing_results = [{
|
||||||
|
result_id: "portable-result-001",
|
||||||
|
result_kind: setup.run_definition.result_kind,
|
||||||
|
relation: "exact-recorded-computation",
|
||||||
|
access: "observatory",
|
||||||
|
created_at_utc: "2026-09-03T00:00:00Z",
|
||||||
|
observatory_projection_available: true,
|
||||||
|
identity: {
|
||||||
|
job_id: "observatory-run-001",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
source_catalog_sha256: "a".repeat(64),
|
||||||
|
source_bundle_sha256: "b".repeat(64),
|
||||||
|
source_capability_manifest_sha256: "c".repeat(64),
|
||||||
|
setup_id: setup.setup_id,
|
||||||
|
definition_sha256: setup.run_definition.definition_sha256,
|
||||||
|
package_sha256: "d".repeat(64),
|
||||||
|
artifact_manifest_id: "e".repeat(64),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
setup.preflight = {
|
||||||
|
outcome: "existing", action: "open-existing",
|
||||||
|
reason: "Точный результат проверен.", submission_allowed: false,
|
||||||
|
existing_result_ids: [setup.existing_results[0].result_id],
|
||||||
|
};
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCachedPortable(setup) {
|
||||||
|
return fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||||
|
source_session_id: "source-a", setups: [setup], authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("portable exact cached result is readable without an installed executor", async () => {
|
||||||
|
const catalog = await fetchCachedPortable(cachedPortableSetup());
|
||||||
|
const setup = catalog.setups[0];
|
||||||
|
assert.equal(setup.executor.state, "not-installed");
|
||||||
|
assert.equal(setup.preflight.outcome, "existing");
|
||||||
|
assert.equal(setup.preflight.submissionAllowed, false);
|
||||||
|
assert.deepEqual(setup.preflight.existingResultIds, ["portable-result-001"]);
|
||||||
|
assert.equal(setup.preservedResults[0].access, "observatory");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selector contains only compatible uncalculated portable definitions", async () => {
|
||||||
|
const uncached = (await fetchCachedPortable(portableSetup())).setups[0];
|
||||||
|
const cached = (await fetchCachedPortable(cachedPortableSetup())).setups[0];
|
||||||
|
const currentVersion = {
|
||||||
|
...uncached,
|
||||||
|
runDefinition: { ...uncached.runDefinition, version: 2, definitionSha256: "f".repeat(64) },
|
||||||
|
};
|
||||||
|
const catalog = (setups) => ({ sourceSessionId: "source-a", setups });
|
||||||
|
assert.deepEqual(selectableObservatorySetups(null), []);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([])), []);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([cached])), []);
|
||||||
|
// Same display label and setup ID, but no verified cache hit for the new definition.
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([currentVersion])), [currentVersion]);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([
|
||||||
|
cached,
|
||||||
|
{ ...uncached, setupId: "remaining-profile" },
|
||||||
|
])).map((s) => s.setupId), ["remaining-profile"]);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([
|
||||||
|
{ ...uncached, compatibility: { compatible: false, reasons: [] } },
|
||||||
|
{ ...uncached, origin: "archived-definition" },
|
||||||
|
{ ...uncached, origin: "existing-result" },
|
||||||
|
{ ...uncached, runDefinition: null },
|
||||||
|
])), []);
|
||||||
|
// Worker availability does not masquerade as completed computation.
|
||||||
|
assert.equal(uncached.executor.state, "not-installed");
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([uncached])), [uncached]);
|
||||||
|
// A missing/deleted/unpublished projection is not a completed result.
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([{
|
||||||
|
...uncached, preservedResults: cached.preservedResults,
|
||||||
|
}])).map((s) => s.setupId), [uncached.setupId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, change] of [
|
||||||
|
["another source", (s) => { s.existing_results[0].identity.source_session_id = "source-b"; }],
|
||||||
|
["another setup", (s) => { s.existing_results[0].identity.setup_id = "another-profile"; }],
|
||||||
|
["another version", (s) => { s.existing_results[0].identity.definition_sha256 = "f".repeat(64); }],
|
||||||
|
["bad package digest", (s) => { s.existing_results[0].identity.package_sha256 = "not-a-digest"; }],
|
||||||
|
["unavailable artifact", (s) => { s.existing_results[0].observatory_projection_available = false; }],
|
||||||
|
["missing binding", (s) => { delete s.existing_results[0].identity; }],
|
||||||
|
["unrelated result ID", (s) => { s.preflight.existing_result_ids = ["different-result"]; }],
|
||||||
|
["duplicate result", (s) => {
|
||||||
|
s.existing_results.push(s.existing_results[0]);
|
||||||
|
s.preflight.existing_result_ids.push(s.preflight.existing_result_ids[0]);
|
||||||
|
}],
|
||||||
|
["contradictory action", (s) => { s.preflight.action = "check"; }],
|
||||||
|
["cached but queueable", (s) => { s.preflight.submission_allowed = true; }],
|
||||||
|
]) {
|
||||||
|
test(`portable cached result rejects ${label}`, async () => {
|
||||||
|
const setup = cachedPortableSetup();
|
||||||
|
change(setup);
|
||||||
|
await assert.rejects(fetchCachedPortable(setup));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
|
||||||
|
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [setup()],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
})).setups[0];
|
||||||
|
let request;
|
||||||
|
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
request = { input: String(input), init };
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setup_id: selected.setupId,
|
||||||
|
definition_sha256: "b".repeat(64),
|
||||||
|
check_sha256: null,
|
||||||
|
outcome: "blocked",
|
||||||
|
submission_allowed: false,
|
||||||
|
checks: [{
|
||||||
|
check_id: "executor",
|
||||||
|
outcome: "fail",
|
||||||
|
reason_code: "laboratory-runner-adapter-not-installed",
|
||||||
|
message: "Адаптер не установлен.",
|
||||||
|
}],
|
||||||
|
existing_result_ids: [],
|
||||||
|
executor: setup().executor,
|
||||||
|
authority,
|
||||||
|
}), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(request.input, "/api/v1/observatory/run-preflights");
|
||||||
|
assert.equal(request.init.method, "POST");
|
||||||
|
assert.deepEqual(JSON.parse(request.init.body), {
|
||||||
|
schema_version: "missioncore.observatory-run-preflight-request/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setup_id: "m49-tgs-full-shadow-v1",
|
||||||
|
definition_sha256: "b".repeat(64),
|
||||||
|
});
|
||||||
|
assert.equal(preflight.outcome, "blocked");
|
||||||
|
assert.equal(preflight.submissionAllowed, false);
|
||||||
|
assert.equal(preflight.checkSha256, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory dynamic preflight admits only an explicit queueable response", async () => {
|
||||||
|
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [setup()],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
})).setups[0];
|
||||||
|
|
||||||
|
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setup_id: selected.setupId,
|
||||||
|
definition_sha256: "b".repeat(64),
|
||||||
|
outcome: "queueable",
|
||||||
|
submission_allowed: true,
|
||||||
|
checks: [{
|
||||||
|
check_id: "durable-queue",
|
||||||
|
outcome: "pass",
|
||||||
|
reason_code: "exact-binding-ready",
|
||||||
|
message: "Точный источник и очередь готовы.",
|
||||||
|
}],
|
||||||
|
existing_result_ids: [],
|
||||||
|
executor: setup().executor,
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(preflight.outcome, "queueable");
|
||||||
|
assert.equal(preflight.submissionAllowed, true);
|
||||||
|
assert.equal(preflight.checkSha256, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory setup contract rejects authority escalation and response drift", async () => {
|
||||||
|
const escalated = setup();
|
||||||
|
escalated.authority = { ...authority, commands_enabled: true };
|
||||||
|
await assert.rejects(
|
||||||
|
fetchObservatoryLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [escalated],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
}),
|
||||||
|
ObservatoryLaboratorySetupContractError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const drifted = setup();
|
||||||
|
drifted.unexpected = true;
|
||||||
|
await assert.rejects(
|
||||||
|
fetchObservatoryLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [drifted],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
}),
|
||||||
|
ObservatoryLaboratorySetupContractError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setups: [setup()],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
})).setups[0];
|
||||||
|
await assert.rejects(
|
||||||
|
preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setup_id: selected.setupId,
|
||||||
|
definition_sha256: "c".repeat(64),
|
||||||
|
outcome: "blocked",
|
||||||
|
submission_allowed: false,
|
||||||
|
checks: [],
|
||||||
|
existing_result_ids: [],
|
||||||
|
executor: setup().executor,
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
}),
|
||||||
|
ObservatoryLaboratorySetupContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let fetchObservatoryRecordedJobs;
|
||||||
|
let retryObservatoryRecordedJobPublication;
|
||||||
|
let submitObservatoryRecordedJob;
|
||||||
|
let ObservatoryRecordedJobContractError;
|
||||||
|
|
||||||
|
const authority = {
|
||||||
|
commands_enabled: false,
|
||||||
|
actuation_allowed: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
production_accepted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function job(state = "queued") {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.observatory-recorded-job/v1",
|
||||||
|
job_id: "observatory-run-0123456789abcdef0123456789abcdef",
|
||||||
|
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
|
||||||
|
request_sha256: "1".repeat(64),
|
||||||
|
identity_sha256: "2".repeat(64),
|
||||||
|
submission_receipt_sha256: "3".repeat(64),
|
||||||
|
source: {
|
||||||
|
session_id: "source-a",
|
||||||
|
catalog_sha256: "4".repeat(64),
|
||||||
|
bundle_sha256: "5".repeat(64),
|
||||||
|
capability_manifest_sha256: "6".repeat(64),
|
||||||
|
adapter: {
|
||||||
|
adapter_id: "ravnoves00-m49-source-pack",
|
||||||
|
version: 1,
|
||||||
|
adapter_sha256: "7".repeat(64),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
setup_id: "m49-tgs",
|
||||||
|
definition_id: "m49-tgs",
|
||||||
|
definition_version: 1,
|
||||||
|
definition_sha256: "8".repeat(64),
|
||||||
|
},
|
||||||
|
executor: {
|
||||||
|
release_id: "m49-tgs-release",
|
||||||
|
release_sha256: "9".repeat(64),
|
||||||
|
image_sha256: "a".repeat(64),
|
||||||
|
model_release_ids: [],
|
||||||
|
learned_models: [],
|
||||||
|
model_manifest_sha256: "b".repeat(64),
|
||||||
|
resource_profile_id: "m49-tgs-cpu",
|
||||||
|
resource_profile_sha256: "c".repeat(64),
|
||||||
|
},
|
||||||
|
checkpoint_policy: {
|
||||||
|
mode: "non-checkpointable",
|
||||||
|
allowed_checkpoints: [],
|
||||||
|
last_checkpoint_id: null,
|
||||||
|
},
|
||||||
|
priority: { class: "recorded", rank: 100, server_owned: true },
|
||||||
|
state,
|
||||||
|
preemption_requested: state === "preemption-pending",
|
||||||
|
restart_from_zero: state === "paused",
|
||||||
|
preemption_receipt_sha256: null,
|
||||||
|
claim_generation: 0,
|
||||||
|
claim_lease: null,
|
||||||
|
result: state === "succeeded"
|
||||||
|
? { result_id: "m49-result", sha256: "d".repeat(64) }
|
||||||
|
: null,
|
||||||
|
publication: {
|
||||||
|
state: "not-required",
|
||||||
|
attempts: 0,
|
||||||
|
error: null,
|
||||||
|
published_at_utc: null,
|
||||||
|
},
|
||||||
|
terminal: state === "failed"
|
||||||
|
? { code: "worker-failed", message: "Worker завершил расчёт с ошибкой." }
|
||||||
|
: null,
|
||||||
|
created_at_utc: "2026-08-31T10:00:00Z",
|
||||||
|
updated_at_utc: "2026-08-31T10:00:01Z",
|
||||||
|
authority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
fetchObservatoryRecordedJobs,
|
||||||
|
retryObservatoryRecordedJobPublication,
|
||||||
|
submitObservatoryRecordedJob,
|
||||||
|
ObservatoryRecordedJobContractError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observatory/recordedJobs.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory reads the exact session/setup queue without polling", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
calls.push({ input: String(input), init });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-recorded-job-list/v1",
|
||||||
|
items: [job("running")],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
calls[0].input,
|
||||||
|
"/api/v1/observatory/runs?source_session_id=source-a&setup_id=m49-tgs&limit=20",
|
||||||
|
);
|
||||||
|
assert.equal(calls[0].init.method, "GET");
|
||||||
|
assert.equal(jobs[0].state, "running");
|
||||||
|
assert.equal(jobs[0].definitionSha256, "8".repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory submits only public identities and accepts every durable state", async () => {
|
||||||
|
const states = [
|
||||||
|
"accepted",
|
||||||
|
"queued",
|
||||||
|
"claimed",
|
||||||
|
"running",
|
||||||
|
"paused",
|
||||||
|
"preemption-pending",
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
"reconciliation-required",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const state of states) {
|
||||||
|
let request;
|
||||||
|
const result = await submitObservatoryRecordedJob(
|
||||||
|
"source-a",
|
||||||
|
"m49-tgs",
|
||||||
|
"observatory-ui:source-a:m49-tgs:request-a",
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
request = { input: String(input), init };
|
||||||
|
return new Response(JSON.stringify(job(state)), { status: 200 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.state, state);
|
||||||
|
assert.equal(request.input, "/api/v1/observatory/runs");
|
||||||
|
assert.equal(request.init.method, "POST");
|
||||||
|
assert.deepEqual(JSON.parse(request.init.body), {
|
||||||
|
schema_version: "missioncore.observatory-recorded-run-submit/v1",
|
||||||
|
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setup_id: "m49-tgs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory queue contract rejects authority escalation and response drift", async () => {
|
||||||
|
const escalated = job();
|
||||||
|
escalated.authority = { ...authority, commands_enabled: true };
|
||||||
|
await assert.rejects(
|
||||||
|
fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-recorded-job-list/v1",
|
||||||
|
items: [escalated],
|
||||||
|
authority,
|
||||||
|
}), { status: 200 }),
|
||||||
|
}),
|
||||||
|
ObservatoryRecordedJobContractError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const drifted = job();
|
||||||
|
drifted.worker_command = "forbidden";
|
||||||
|
await assert.rejects(
|
||||||
|
submitObservatoryRecordedJob(
|
||||||
|
"source-a",
|
||||||
|
"m49-tgs",
|
||||||
|
"observatory-ui:source-a:m49-tgs:request-a",
|
||||||
|
null,
|
||||||
|
{ fetcher: async () => new Response(JSON.stringify(drifted), { status: 200 }) },
|
||||||
|
),
|
||||||
|
ObservatoryRecordedJobContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable submission carries the exact definition/check fence", async () => {
|
||||||
|
let request;
|
||||||
|
await submitObservatoryRecordedJob(
|
||||||
|
"source-a",
|
||||||
|
"lab-v1-eomt-ddrnet-portable-v1",
|
||||||
|
"observatory-ui:source-a:lab-v1:request-a",
|
||||||
|
{
|
||||||
|
definitionSha256: "e".repeat(64),
|
||||||
|
checkSha256: "f".repeat(64),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
request = { input: String(input), init };
|
||||||
|
const response = job("queued");
|
||||||
|
response.setup.setup_id = "lab-v1-eomt-ddrnet-portable-v1";
|
||||||
|
response.setup.definition_sha256 = "e".repeat(64);
|
||||||
|
response.source.session_id = "source-a";
|
||||||
|
return new Response(JSON.stringify(response), { status: 202 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(request.init.body), {
|
||||||
|
schema_version: "missioncore.observatory-recorded-run-submit/v1",
|
||||||
|
idempotency_key: "observatory-ui:source-a:lab-v1:request-a",
|
||||||
|
source_session_id: "source-a",
|
||||||
|
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
|
||||||
|
definition_sha256: "e".repeat(64),
|
||||||
|
check_sha256: "f".repeat(64),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publication retry never submits a second compute request", async () => {
|
||||||
|
let request;
|
||||||
|
const response = job("succeeded");
|
||||||
|
response.publication = {
|
||||||
|
state: "published",
|
||||||
|
attempts: 2,
|
||||||
|
error: null,
|
||||||
|
published_at_utc: "2026-09-01T12:00:00Z",
|
||||||
|
};
|
||||||
|
const retried = await retryObservatoryRecordedJobPublication(response.job_id, {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
request = { input: String(input), init };
|
||||||
|
return new Response(JSON.stringify(response), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
request.input,
|
||||||
|
`/api/v1/observatory/runs/${response.job_id}/publication/retry`,
|
||||||
|
);
|
||||||
|
assert.equal(request.init.method, "POST");
|
||||||
|
assert.equal(request.init.body, undefined);
|
||||||
|
assert.equal(retried.publication.state, "published");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queue query and response are fenced to the selected definition", async () => {
|
||||||
|
const oldVersion = job("succeeded");
|
||||||
|
oldVersion.setup.definition_sha256 = "f".repeat(64);
|
||||||
|
const otherSource = job("running");
|
||||||
|
otherSource.source.session_id = "source-b";
|
||||||
|
const otherSetup = job("running");
|
||||||
|
otherSetup.setup.setup_id = "other-setup";
|
||||||
|
let url;
|
||||||
|
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
|
||||||
|
definitionSha256: "8".repeat(64),
|
||||||
|
fetcher: async (input) => {
|
||||||
|
url = new URL(String(input), "http://localhost");
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-recorded-job-list/v1",
|
||||||
|
items: [oldVersion, otherSource, otherSetup, job("queued")], authority,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(url.searchParams.get("definition_sha256"), "8".repeat(64));
|
||||||
|
assert.deepEqual(jobs.map((j) => j.state), ["queued"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable submit rejects a successful response for an old definition", async () => {
|
||||||
|
await assert.rejects(submitObservatoryRecordedJob(
|
||||||
|
"source-a", "m49-tgs", "test-request",
|
||||||
|
{ definitionSha256: "f".repeat(64), checkSha256: "e".repeat(64) },
|
||||||
|
{ fetcher: async () => new Response(JSON.stringify(job("succeeded"))) },
|
||||||
|
), ObservatoryRecordedJobContractError);
|
||||||
|
});
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let admitObservatoryRecordedRunReview;
|
||||||
|
let fetchObservatoryRecordedRunReview;
|
||||||
|
let observatoryRecordedRunBinding;
|
||||||
|
let ObservatoryRecordedRunContractError;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
admitObservatoryRecordedRunReview,
|
||||||
|
fetchObservatoryRecordedRunReview,
|
||||||
|
observatoryRecordedRunBinding,
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observatory/recordedRun.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceSessionId = "20260828T130511Z_viewer_live";
|
||||||
|
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
|
|
||||||
|
function canonicalProvenance() {
|
||||||
|
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||||
|
evidence_identity_sha256: identitySha256,
|
||||||
|
result_document_sha256: "a".repeat(64),
|
||||||
|
replay_capability: {
|
||||||
|
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewer_profile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commands_enabled: false,
|
||||||
|
},
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
actuation_accepted: false,
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
schema_version: "missioncore.laboratory-method/v1",
|
||||||
|
completeness: "legacy-partial",
|
||||||
|
execution_class: "ai-inference",
|
||||||
|
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||||
|
components: [{
|
||||||
|
kind: "source",
|
||||||
|
name: "sealed full-route LAB result",
|
||||||
|
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||||
|
role: "immutable Session catalog projection",
|
||||||
|
identity_sha256: identitySha256,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lab(overrides = {}) {
|
||||||
|
return {
|
||||||
|
labId: "LAB V1",
|
||||||
|
sourceSessionId,
|
||||||
|
resultKind: "recorded-perception-qualification",
|
||||||
|
resultId,
|
||||||
|
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||||
|
configSha256: null,
|
||||||
|
runCreatedAtUtc: "2026-08-29T18:05:11.329061+00:00",
|
||||||
|
publishedAtUtc: "2026-08-30T12:00:00.000Z",
|
||||||
|
replayCapability: {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
commandsEnabled: false,
|
||||||
|
},
|
||||||
|
provenance: canonicalProvenance(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableCapability() {
|
||||||
|
return {
|
||||||
|
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
|
||||||
|
kind: "portable-result-review",
|
||||||
|
viewerProfile: "portable-result",
|
||||||
|
timeline: "result-defined",
|
||||||
|
activation: "explicit",
|
||||||
|
commandsEnabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableCapabilityDocument() {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.observation-lab-replay-capability/v2",
|
||||||
|
kind: "portable-result-review",
|
||||||
|
viewer_profile: "portable-result",
|
||||||
|
timeline: "result-defined",
|
||||||
|
activation: "explicit",
|
||||||
|
commands_enabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableLab(portableResultId, definitionSha256) {
|
||||||
|
return lab({
|
||||||
|
labId: "M4.9",
|
||||||
|
resultId: portableResultId,
|
||||||
|
resultKind: "recorded-route-analysis",
|
||||||
|
sourceResultId: null,
|
||||||
|
configSha256: definitionSha256,
|
||||||
|
replayCapability: portableCapability(),
|
||||||
|
provenance: {
|
||||||
|
schema_version: "missioncore.observatory-portable-result-publication/v1",
|
||||||
|
authority: {},
|
||||||
|
calculation_profile: {},
|
||||||
|
calculation_profile_sha256: "b".repeat(64),
|
||||||
|
job: {},
|
||||||
|
source: { session_id: sourceSessionId },
|
||||||
|
run_definition: { definition_sha256: definitionSha256 },
|
||||||
|
result_package: {
|
||||||
|
manifest_sha256: "c".repeat(64),
|
||||||
|
artifact_manifest_id: "d".repeat(64),
|
||||||
|
},
|
||||||
|
replay_capability: portableCapabilityDocument(),
|
||||||
|
storage: {},
|
||||||
|
method: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Observatory admits only the exact typed canonical recorded run", () => {
|
||||||
|
const binding = observatoryRecordedRunBinding(resultId, lab());
|
||||||
|
assert.deepEqual(binding, {
|
||||||
|
kind: "canonical-recorded-rerun",
|
||||||
|
evidenceSessionId: resultId,
|
||||||
|
sourceSessionId,
|
||||||
|
resultId,
|
||||||
|
viewerProfile: "recorded-session",
|
||||||
|
timeline: "session_time",
|
||||||
|
activation: "explicit",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
observatoryRecordedRunBinding(resultId, lab({ replayCapability: null })),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => observatoryRecordedRunBinding("different-session", lab()),
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => observatoryRecordedRunBinding(
|
||||||
|
resultId,
|
||||||
|
lab({ sourceSessionId: "RAVNOVES004TREE" }),
|
||||||
|
),
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => observatoryRecordedRunBinding(
|
||||||
|
resultId,
|
||||||
|
lab({ configSha256: "a".repeat(64) }),
|
||||||
|
),
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => observatoryRecordedRunBinding(
|
||||||
|
resultId,
|
||||||
|
lab({ sourceResultId: "legacy-result" }),
|
||||||
|
),
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
);
|
||||||
|
const invalidProvenance = canonicalProvenance();
|
||||||
|
invalidProvenance.method.components[0].identity_sha256 = "0".repeat(64);
|
||||||
|
assert.throws(
|
||||||
|
() => observatoryRecordedRunBinding(
|
||||||
|
resultId,
|
||||||
|
lab({ provenance: invalidProvenance }),
|
||||||
|
),
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory rechecks the selected source and sealed review before mounting replay", () => {
|
||||||
|
const binding = observatoryRecordedRunBinding(resultId, lab());
|
||||||
|
const review = { sessionId: sourceSessionId, frameCount: 6830 };
|
||||||
|
assert.equal(
|
||||||
|
admitObservatoryRecordedRunReview(
|
||||||
|
binding,
|
||||||
|
sourceSessionId,
|
||||||
|
{ resultId, routeFullReview: review },
|
||||||
|
),
|
||||||
|
review,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => admitObservatoryRecordedRunReview(
|
||||||
|
binding,
|
||||||
|
"another-session",
|
||||||
|
{ resultId, routeFullReview: review },
|
||||||
|
),
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => admitObservatoryRecordedRunReview(
|
||||||
|
binding,
|
||||||
|
sourceSessionId,
|
||||||
|
{ resultId, routeFullReview: { ...review, sessionId: "another-session" } },
|
||||||
|
),
|
||||||
|
ObservatoryRecordedRunContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory selects and strictly decodes a portable result viewer by capability", async () => {
|
||||||
|
const portableResultId = "portable-result-a";
|
||||||
|
const definitionSha256 = "e".repeat(64);
|
||||||
|
const binding = observatoryRecordedRunBinding(
|
||||||
|
portableResultId,
|
||||||
|
portableLab(portableResultId, definitionSha256),
|
||||||
|
);
|
||||||
|
assert.deepEqual(binding, {
|
||||||
|
kind: "portable-result-review",
|
||||||
|
evidenceSessionId: portableResultId,
|
||||||
|
sourceSessionId,
|
||||||
|
resultId: portableResultId,
|
||||||
|
viewerProfile: "portable-result",
|
||||||
|
timeline: "result-defined",
|
||||||
|
activation: "explicit",
|
||||||
|
definitionSha256,
|
||||||
|
});
|
||||||
|
|
||||||
|
let requestedUrl = null;
|
||||||
|
const review = await fetchObservatoryRecordedRunReview(binding, {
|
||||||
|
selectedSourceSessionId: sourceSessionId,
|
||||||
|
fetcher: async (url) => {
|
||||||
|
requestedUrl = url;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
schema_version: "missioncore.observatory-portable-result-view/v1",
|
||||||
|
result_id: portableResultId,
|
||||||
|
source_session_id: sourceSessionId,
|
||||||
|
result_kind: "recorded-route-analysis",
|
||||||
|
definition_sha256: definitionSha256,
|
||||||
|
viewer_capability: portableCapabilityDocument(),
|
||||||
|
calculation_profile: { name: "M4.9" },
|
||||||
|
artifact_manifest_id: "d".repeat(64),
|
||||||
|
artifacts: [{
|
||||||
|
role: "result-document",
|
||||||
|
media_type: "application/json",
|
||||||
|
sha256: "f".repeat(64),
|
||||||
|
byte_length: 42,
|
||||||
|
}],
|
||||||
|
result_document: { verdict: "reviewed" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
requestedUrl,
|
||||||
|
`/api/v1/observatory/portable-results/${portableResultId}`,
|
||||||
|
);
|
||||||
|
assert.equal(review.kind, "portable-result");
|
||||||
|
assert.deepEqual(review.resultDocument, { verdict: "reviewed" });
|
||||||
|
assert.deepEqual(review.artifacts, [{
|
||||||
|
role: "result-document",
|
||||||
|
mediaType: "application/json",
|
||||||
|
sha256: "f".repeat(64),
|
||||||
|
byteLength: 42,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory run admission remains metadata-only until explicit UI activation", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../src/core/observatory/recordedRun.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /fetchVegetationShadowResultMetadata/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/fetchVegetationShadowResult\(|advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport|recording\.rrd/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let productModel;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
productModel = await server.ssrLoadModule("/src/productModel.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function read(relativePath) {
|
||||||
|
return readFile(new URL(`../src/${relativePath}`, import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Observatory is the third independent Polygon workspace", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||||
|
["lab-archive", "simulations", "observatory"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
productModel.workspaceById("observatory"),
|
||||||
|
{
|
||||||
|
id: "observatory",
|
||||||
|
root: "polygon",
|
||||||
|
label: "Обсерватория",
|
||||||
|
title: "Проверка компьютерного зрения",
|
||||||
|
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||||
|
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||||
|
icon: "eye",
|
||||||
|
kind: "observatory",
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory mounts the one shared canonical replay only after explicit admission", async () => {
|
||||||
|
const [
|
||||||
|
app,
|
||||||
|
workspaceHub,
|
||||||
|
workspace,
|
||||||
|
hook,
|
||||||
|
recordedRun,
|
||||||
|
sharedReplay,
|
||||||
|
legacyReplayWrapper,
|
||||||
|
viewerProfiles,
|
||||||
|
styles,
|
||||||
|
] = await Promise.all([
|
||||||
|
read("App.tsx"),
|
||||||
|
read("workspaces/Workspaces.tsx"),
|
||||||
|
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||||
|
read("core/observatory/useObservatoryCatalog.ts"),
|
||||||
|
read("core/observatory/recordedRun.ts"),
|
||||||
|
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||||
|
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||||
|
read("core/observation/viewerProfile.ts"),
|
||||||
|
read("styles/observatory.css"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(app, /activeDefinition\.kind === "observatory"[\s\S]*Только наблюдение/);
|
||||||
|
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
|
||||||
|
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
|
||||||
|
assert.match(workspace, /useObservatoryCatalog/);
|
||||||
|
assert.match(workspace, /Связанных результатов нет/);
|
||||||
|
assert.match(workspace, /не является выводом о качестве/);
|
||||||
|
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
|
||||||
|
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
|
||||||
|
assert.match(workspace, /Полнота исторических/);
|
||||||
|
assert.match(workspace, /вне текущего загруженного среза/);
|
||||||
|
assert.match(workspace, /observatory-notice__copy/);
|
||||||
|
assert.match(workspace, /observatory-evidence-card/);
|
||||||
|
assert.match(workspace, /observatory-session-stack/);
|
||||||
|
assert.match(workspace, /observatory-session-summary__facts/);
|
||||||
|
assert.match(workspace, /observatory-evidence-card__copy/);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/function evidenceResultSubtitle[\s\S]*calculationProfile\?\.displayName[\s\S]*`\$\{evidence\.label\} · \$\{profileName\}`[\s\S]*: evidence\.label/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
|
||||||
|
assert.match(workspace, /Открыть визуальный разбор/);
|
||||||
|
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||||
|
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
|
||||||
|
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
|
||||||
|
assert.match(workspace, /<summary>Документ результата<\/summary>/);
|
||||||
|
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
||||||
|
assert.match(workspace, /Проверяем точную связь результата/);
|
||||||
|
assert.match(workspace, /role="alert"/);
|
||||||
|
assert.match(workspace, /Повторить/);
|
||||||
|
assert.match(workspace, /Закрыть разбор/);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
|
||||||
|
);
|
||||||
|
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
|
||||||
|
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
|
||||||
|
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||||
|
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
||||||
|
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
`${workspace}\n${hook}`,
|
||||||
|
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
|
||||||
|
);
|
||||||
|
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||||
|
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||||
|
assert.match(sharedReplay, /timelineStartSeconds/);
|
||||||
|
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
|
||||||
|
assert.match(
|
||||||
|
legacyReplayWrapper,
|
||||||
|
/export \{ CanonicalVegetationRerunReplay \} from "\.\.\/\.\.\/components\/laboratory\/CanonicalVegetationRerunReplay"/,
|
||||||
|
);
|
||||||
|
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
|
||||||
|
assert.doesNotMatch(recordedRun, /advanced-index|recording\.rrd|canonical-replay\.rrd/);
|
||||||
|
assert.doesNotMatch(workspace, /workspaces\/laboratory|LaboratoryRecordedClipPlayer|SimulationViewport/);
|
||||||
|
assert.match(viewerProfiles, /kind: "live-acquisition"/);
|
||||||
|
assert.match(viewerProfiles, /kind: "recorded-session"/);
|
||||||
|
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-session-summary \{[\s\S]*grid-template-columns:[\s\S]*\.observatory-evidence-card \{[\s\S]*background: var\(--nodedc-glass-control-bg\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-workspace \{[\s\S]*grid-auto-rows: max-content;[\s\S]*align-content: start;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-session-summary__facts dd \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-catalog-bar h3,[\s\S]*\.observatory-session-summary h3,[\s\S]*\.observatory-evidence h3 \{[\s\S]*font-size: var\(--nodedc-font-size-lg\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container observatory-session \(max-width: 46rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container observatory-session \(max-width: 38rem\) \{[\s\S]*\.observatory-session-summary__facts \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(styles, /\.observatory-evidence-card[\s\S]*background:\s*(?:#0{3,6}|black|rgb\(0[ ,])/i);
|
||||||
|
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory rename and delete use admitted projection mutations and canonical windows", async () => {
|
||||||
|
const [workspace, hook] = await Promise.all([
|
||||||
|
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||||
|
read("core/observatory/useObservatoryCatalog.ts"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/deleteObservatoryLabProjection,[\s\S]*renameObservatoryLabProjection,[\s\S]*from "\.\.\/\.\.\/core\/observatory\/catalogMutations"/,
|
||||||
|
);
|
||||||
|
assert.match(workspace, /<Window[\s\S]*title="Переименовать лабораторный результат"/);
|
||||||
|
assert.match(workspace, /<TextField[\s\S]*label="Название"[\s\S]*maxLength=\{160\}/);
|
||||||
|
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
|
||||||
|
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
|
||||||
|
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*evidence\?\.label === reconciliation\.displayName[\s\S]*setRenameTarget\(null\);/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
|
||||||
|
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
|
||||||
|
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
|
||||||
|
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
|
||||||
|
const teardown = deleteFlow.indexOf("closeReplay();");
|
||||||
|
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
|
||||||
|
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
|
||||||
|
const refresh = deleteFlow.indexOf("void controller.refresh();");
|
||||||
|
assert.ok(teardown >= 0 && teardown < remove, "active replay must unmount before projection delete");
|
||||||
|
assert.ok(remove < tombstone, "local tombstone must follow the exact empty 204");
|
||||||
|
assert.ok(tombstone < refresh, "reconciliation refresh must follow the local tombstone");
|
||||||
|
assert.match(deleteFlow, /flushSync\(\(\) => \{[\s\S]*closeReplay\(\);[\s\S]*\}\);/);
|
||||||
|
assert.match(
|
||||||
|
deleteFlow,
|
||||||
|
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*observatoryCatalogConfirmsEvidenceDeletion\([\s\S]*setDeleteTarget\(null\);/,
|
||||||
|
);
|
||||||
|
assert.match(workspace, /mutationError[\s\S]*role="alert"/);
|
||||||
|
assert.match(hook, /activeRequestRef\.current\?\.controller\.abort\(\)/);
|
||||||
|
assert.match(hook, /requestSequenceRef\.current !== id/);
|
||||||
|
assert.match(hook, /const requestMutationRevision = mutationRevisionRef\.current/);
|
||||||
|
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
|
||||||
|
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
|
||||||
|
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
|
||||||
|
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||||
|
read("styles/observatory.css"),
|
||||||
|
read("core/observatory/useObservatoryLaboratorySetups.ts"),
|
||||||
|
read("core/observatory/useObservatoryRecordedJobs.ts"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
||||||
|
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||||
|
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
||||||
|
assert.match(workspace, /setupController\.error \? \(/);
|
||||||
|
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
||||||
|
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
||||||
|
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
|
||||||
|
assert.match(workspace, /useObservatoryRecordedJobs/);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
|
||||||
|
assert.match(workspace, /setupController\.selectableSetups\.map/);
|
||||||
|
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
|
||||||
|
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
|
||||||
|
assert.match(workspace, /aria-busy=\{calculationPending\}/);
|
||||||
|
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
|
||||||
|
assert.doesNotMatch(runBar, /StatusBadge/);
|
||||||
|
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
|
||||||
|
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
|
||||||
|
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
|
||||||
|
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
|
||||||
|
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
|
||||||
|
assert.match(
|
||||||
|
workspace,
|
||||||
|
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
jobsHook,
|
||||||
|
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
|
||||||
|
);
|
||||||
|
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
|
||||||
|
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
|
||||||
|
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
|
||||||
|
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
|
||||||
|
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
|
||||||
|
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
|
||||||
|
assert.match(jobsHook, /globalThis\.setTimeout/);
|
||||||
|
assert.doesNotMatch(jobsHook, /setInterval/);
|
||||||
|
assert.match(workspace, /job\.publication\.state === "published"/);
|
||||||
|
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
|
||||||
|
);
|
||||||
|
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
|
||||||
|
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
|
||||||
|
assert.match(setupHook, /preflightRequest\.current !== request/);
|
||||||
|
assert.match(
|
||||||
|
setupHook,
|
||||||
|
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
|||||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
workspacesForRoot("polygon").map(({ id }) => id),
|
workspacesForRoot("polygon").map(({ id }) => id),
|
||||||
["lab-archive", "simulations"],
|
["lab-archive", "simulations", "observatory"],
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
|||||||
);
|
);
|
||||||
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
|
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
|
||||||
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||||
|
assert.deepEqual(
|
||||||
|
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||||
|
["lab-archive", "simulations", "observatory"],
|
||||||
|
);
|
||||||
|
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
root: productModel.workspaceById("observatory")?.root,
|
||||||
|
kind: productModel.workspaceById("observatory")?.kind,
|
||||||
|
icon: productModel.workspaceById("observatory")?.icon,
|
||||||
|
},
|
||||||
|
{ root: "polygon", kind: "observatory", icon: "eye" },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every laboratory result uses the shared evidence template", async () => {
|
test("every laboratory result uses the shared evidence template", async () => {
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ let recordedMediaSeekableCoverage;
|
|||||||
let recordedMediaFragmentUrl;
|
let recordedMediaFragmentUrl;
|
||||||
let recordedMediaDecodeStartSequence;
|
let recordedMediaDecodeStartSequence;
|
||||||
let recordedMediaSegmentAppendOrder;
|
let recordedMediaSegmentAppendOrder;
|
||||||
|
let recordedMediaSegmentSequenceAtTime;
|
||||||
let recordedMediaCanRollTarget;
|
let recordedMediaCanRollTarget;
|
||||||
|
let recordedMediaTimestampStallRecoveryTarget;
|
||||||
|
let nextRecordedMediaRandomAccessSequence;
|
||||||
|
let recordedMediaRecoveryTargetSequence;
|
||||||
|
let selectRecordedMediaPreparationEpoch;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -26,7 +31,12 @@ before(async () => {
|
|||||||
recordedMediaFragmentUrl,
|
recordedMediaFragmentUrl,
|
||||||
recordedMediaDecodeStartSequence,
|
recordedMediaDecodeStartSequence,
|
||||||
recordedMediaSegmentAppendOrder,
|
recordedMediaSegmentAppendOrder,
|
||||||
|
recordedMediaSegmentSequenceAtTime,
|
||||||
recordedMediaCanRollTarget,
|
recordedMediaCanRollTarget,
|
||||||
|
recordedMediaTimestampStallRecoveryTarget,
|
||||||
|
nextRecordedMediaRandomAccessSequence,
|
||||||
|
recordedMediaRecoveryTargetSequence,
|
||||||
|
selectRecordedMediaPreparationEpoch,
|
||||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +171,7 @@ test("decoded duration and seekable range cover the complete declared epoch", ()
|
|||||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recorded player keeps full-archive range fallback and uses bounded generation-bound fragments", async () => {
|
test("production replay derives bounded fragments and retains native range fallback", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -176,6 +186,13 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
|||||||
assert.match(source, /hasPresentedFrame/);
|
assert.match(source, /hasPresentedFrame/);
|
||||||
assert.match(source, /retainForwardFrame/);
|
assert.match(source, /retainForwardFrame/);
|
||||||
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
|
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
|
||||||
|
assert.match(source, /effectiveSegmentCount = segmentCount \?\? epoch\?\.segmentCount \?\? null/);
|
||||||
|
assert.match(source, /requestedSegmentSequence = segmentSequence \?\?/);
|
||||||
|
assert.match(source, /waitForRecordedVideoInitialFrame/);
|
||||||
|
assert.match(source, /setSegmentRecoveryGeneration/);
|
||||||
|
assert.match(source, /resumePlaybackIfRequested/);
|
||||||
|
assert.match(source, /!playbackPlayingRef\.current/);
|
||||||
|
assert.match(source, /await video\.play\(\)/);
|
||||||
|
|
||||||
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -209,6 +226,44 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("production replay derives its bounded fragment directly from the source clock", () => {
|
||||||
|
const ends = [0.101, 0.185, 0.286, 0.401];
|
||||||
|
const epochStart = 39.215263458;
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart), 1);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.101), 1);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.103), 2);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.4), 4);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 1), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a corrupt fragment advances recovery to the next random-access frame", () => {
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 1), 11);
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 20), 21);
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 49), null);
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("paused seek displays the recovery keyframe until the source clock leaves the corrupt GOP", () => {
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(120, 120, 149), 149);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(130, 120, 149), 149);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(119, 120, 149), 119);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(149, 120, 149), 149);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(151, 120, 149), 151);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(null, 120, 149), null);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(120, null, null), 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("camera admission selects one bounded epoch while the shared clock is outside video", () => {
|
||||||
|
const epochs = [
|
||||||
|
{ ordinal: 1, timelineStartSeconds: 39, timelineEndSeconds: 100 },
|
||||||
|
{ ordinal: 2, timelineStartSeconds: 120, timelineEndSeconds: 180 },
|
||||||
|
];
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 0).ordinal, 1);
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 70).ordinal, 1);
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 110).ordinal, 2);
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 200).ordinal, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
|
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
|
||||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
|
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
|
||||||
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
|
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
|
||||||
@@ -217,6 +272,12 @@ test("recorded player preserves forward rolling playback but seeks backward clip
|
|||||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recorded player skips only a proven buffered corrupt timestamp interval", () => {
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(11.422, [[0, 16.287]]), 11.602);
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(16.25, [[0, 16.287]]), null);
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(20, [[0, 16.287]]), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||||
const css = await readFile(
|
const css = await readFile(
|
||||||
new URL("../src/styles/observation.css", import.meta.url),
|
new URL("../src/styles/observation.css", import.meta.url),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function camera(phase, byteLength = 1_024) {
|
|||||||
return { phase, byteLength, message: null };
|
return { phase, byteLength, message: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
|
test("recorded session keeps camera and timeline atomic without hiding a ready spatial frame", () => {
|
||||||
const ids = ["camera.left", "camera.right"];
|
const ids = ["camera.left", "camera.right"];
|
||||||
const oneCamera = {
|
const oneCamera = {
|
||||||
"camera.left": camera("ready"),
|
"camera.left": camera("ready"),
|
||||||
@@ -42,7 +42,7 @@ test("recorded session admits RRD and every declared camera as one atomic genera
|
|||||||
};
|
};
|
||||||
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
||||||
assert.equal(partialGate, "loading");
|
assert.equal(partialGate, "loading");
|
||||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
|
assert.equal(rerunPresentationStatus("ready", partialGate, true), "ready");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
||||||
"loading",
|
"loading",
|
||||||
@@ -71,7 +71,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
|
|||||||
...cameras,
|
...cameras,
|
||||||
"camera.right": camera("ready"),
|
"camera.right": camera("ready"),
|
||||||
}), "error");
|
}), "error");
|
||||||
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
|
assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
||||||
"error",
|
"error",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ let liveTimelineNeedsSynchronization;
|
|||||||
let liveRerunReceiverBindingIdentity;
|
let liveRerunReceiverBindingIdentity;
|
||||||
let recordedOpenWatchdogTimeoutMs;
|
let recordedOpenWatchdogTimeoutMs;
|
||||||
let rerunViewerInitialSource;
|
let rerunViewerInitialSource;
|
||||||
let rerunViewerOpenOptions;
|
|
||||||
let resolveRecordedViewerSourceUrl;
|
let resolveRecordedViewerSourceUrl;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
@@ -31,7 +30,6 @@ before(async () => {
|
|||||||
liveRerunReceiverBindingIdentity,
|
liveRerunReceiverBindingIdentity,
|
||||||
recordedOpenWatchdogTimeoutMs,
|
recordedOpenWatchdogTimeoutMs,
|
||||||
rerunViewerInitialSource,
|
rerunViewerInitialSource,
|
||||||
rerunViewerOpenOptions,
|
|
||||||
resolveRecordedViewerSourceUrl,
|
resolveRecordedViewerSourceUrl,
|
||||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||||
});
|
});
|
||||||
@@ -71,9 +69,21 @@ test("only the canonical digest-bound generation URL reaches the native Rerun re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("only the live receiver opens on the native following edge", () => {
|
test("one canonical LAB replay generation reaches the same native receiver", () => {
|
||||||
assert.deepEqual(rerunViewerOpenOptions(true), { follow_if_http: true });
|
const labSource =
|
||||||
assert.equal(rerunViewerOpenOptions(false), null);
|
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"c".repeat(64)}`
|
||||||
|
+ "/canonical-replay.rrd";
|
||||||
|
const descriptor = {
|
||||||
|
sourceUrl: labSource,
|
||||||
|
viewerSourceUrl: `${labSource}?generation=${sha256}`,
|
||||||
|
byteLength: 300_000_000,
|
||||||
|
sha256,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test"),
|
||||||
|
`http://mission-core.test${descriptor.viewerSourceUrl}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
||||||
@@ -216,7 +226,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
|||||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||||
assert.doesNotMatch(source, /recordedChannel/);
|
assert.doesNotMatch(source, /recordedChannel/);
|
||||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||||
assert.match(source, /rerunViewerOpenOptions\(followLive\)/);
|
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
|
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
|
||||||
@@ -257,6 +267,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
|||||||
source,
|
source,
|
||||||
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/reloadRecordedViewerAfterStaleModuleFailure\(\{[\s\S]*loadedUiBuildId: diagnosticLifecycle\.lineage\.uiBuildId/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/!recordedPerceptionUrl \|\| !recordedPerceptionLayers\.enabled/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("one live document owns one native Rerun receiver", async () => {
|
test("one live document owns one native Rerun receiver", async () => {
|
||||||
@@ -287,8 +305,12 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
|||||||
source,
|
source,
|
||||||
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
|
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
|
||||||
);
|
);
|
||||||
assert.match(source, /followLive=\{liveRerunSource\}/);
|
assert.match(
|
||||||
assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/);
|
source,
|
||||||
|
/liveAcquisitionRerunProfile\(\{[\s\S]*liveActivitySequence: livePresentationActivitySequence/,
|
||||||
|
);
|
||||||
|
assert.match(source, /<RerunViewport[\s\S]*profile=\{rerunViewerProfile\}/);
|
||||||
|
assert.doesNotMatch(source, /followLive=\{liveRerunSource\}/);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { createServer } from "vite";
|
|||||||
let server;
|
let server;
|
||||||
let canPublishRecordedPlaybackController;
|
let canPublishRecordedPlaybackController;
|
||||||
let createRecordedAutoplayGate;
|
let createRecordedAutoplayGate;
|
||||||
|
let createRecordedInitialSeekGate;
|
||||||
let isRecordedPlaybackReady;
|
let isRecordedPlaybackReady;
|
||||||
let isRecordedPlaybackPresentationReady;
|
let isRecordedPlaybackPresentationReady;
|
||||||
let isUsableRecordedPlaybackRange;
|
let isUsableRecordedPlaybackRange;
|
||||||
let recordedPlaybackBufferState;
|
let recordedPlaybackBufferState;
|
||||||
let recordedPlaybackRangeWhenReady;
|
let recordedPlaybackRangeWhenReady;
|
||||||
|
let rerunPresentationStatus;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -21,11 +23,13 @@ before(async () => {
|
|||||||
({
|
({
|
||||||
canPublishRecordedPlaybackController,
|
canPublishRecordedPlaybackController,
|
||||||
createRecordedAutoplayGate,
|
createRecordedAutoplayGate,
|
||||||
|
createRecordedInitialSeekGate,
|
||||||
isRecordedPlaybackReady,
|
isRecordedPlaybackReady,
|
||||||
isRecordedPlaybackPresentationReady,
|
isRecordedPlaybackPresentationReady,
|
||||||
isUsableRecordedPlaybackRange,
|
isUsableRecordedPlaybackRange,
|
||||||
recordedPlaybackBufferState,
|
recordedPlaybackBufferState,
|
||||||
recordedPlaybackRangeWhenReady,
|
recordedPlaybackRangeWhenReady,
|
||||||
|
rerunPresentationStatus,
|
||||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +37,7 @@ after(async () => {
|
|||||||
await server?.close();
|
await server?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
|
test("a verified first frame is presentable while full-range controls stay closed", () => {
|
||||||
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
||||||
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
||||||
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
||||||
@@ -47,8 +51,11 @@ test("a first frame reports buffer telemetry but is not ready for presentation",
|
|||||||
bufferProgress: 0,
|
bufferProgress: 0,
|
||||||
fullyBuffered: false,
|
fullyBuffered: false,
|
||||||
});
|
});
|
||||||
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
|
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), true);
|
||||||
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
||||||
|
assert.equal(rerunPresentationStatus("ready", "loading", true), "ready");
|
||||||
|
assert.equal(rerunPresentationStatus("loading", "ready", true), "loading");
|
||||||
|
assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
||||||
@@ -107,7 +114,7 @@ test("buffer progress grows independently and preserves the full-buffer toleranc
|
|||||||
);
|
);
|
||||||
assert.equal(missingDeclaredStart.bufferProgress, 1);
|
assert.equal(missingDeclaredStart.bufferProgress, 1);
|
||||||
assert.equal(missingDeclaredStart.fullyBuffered, false);
|
assert.equal(missingDeclaredStart.fullyBuffered, false);
|
||||||
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), false);
|
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a verified split boundary spill covers and clamps the declared LAB window", () => {
|
test("a verified split boundary spill covers and clamps the declared LAB window", () => {
|
||||||
@@ -154,6 +161,31 @@ test("recorded autoplay waits for the full range and then runs exactly once", ()
|
|||||||
assert.equal(gate.attempted(), true);
|
assert.equal(gate.attempted(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("paused recorded replay seeks once to its first presentable frame", () => {
|
||||||
|
const gate = createRecordedInitialSeekGate();
|
||||||
|
const seeks = [];
|
||||||
|
const range = { min: 0, max: 535_717_620_042 };
|
||||||
|
|
||||||
|
assert.equal(gate.attempt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
range,
|
||||||
|
(value) => seeks.push(value),
|
||||||
|
39_215_263_458,
|
||||||
|
), true);
|
||||||
|
assert.equal(gate.attempt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
range,
|
||||||
|
(value) => seeks.push(value),
|
||||||
|
50_000_000_000,
|
||||||
|
), false);
|
||||||
|
assert.deepEqual(seeks, [39_215_263_458]);
|
||||||
|
assert.equal(gate.attempted(), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
|
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
|
||||||
const gate = createRecordedAutoplayGate();
|
const gate = createRecordedAutoplayGate();
|
||||||
const seeks = [];
|
const seeks = [];
|
||||||
|
|||||||
@@ -1,81 +1,36 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
|
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, "..");
|
const root = resolve(import.meta.dirname, "..");
|
||||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
|
||||||
const sha256 = (path) =>
|
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
|
||||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
const application = readJson(resolve(root, "package.json"));
|
||||||
|
const installed = readJson(resolve(packageRoot, "package.json"));
|
||||||
|
|
||||||
test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => {
|
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
||||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
assert.equal(installed.version, "0.36.3");
|
||||||
assert.equal(manifest.version, "0.34.1");
|
assert.equal(application.scripts.postinstall, undefined);
|
||||||
|
|
||||||
const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
|
|
||||||
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
|
|
||||||
|
|
||||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
|
|
||||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
|
|
||||||
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
|
|
||||||
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("custom JavaScript glue references only exports present in its paired WASM", () => {
|
test("the active application never imports or installs the archived vendor fork", () => {
|
||||||
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm");
|
const application = readFileSync(resolve(root, "package.json"), "utf8");
|
||||||
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js");
|
const viewport = readFileSync(resolve(root, "src/components/RerunViewport.tsx"), "utf8");
|
||||||
const module = new WebAssembly.Module(readFileSync(wasmPath));
|
|
||||||
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
|
|
||||||
const imports = WebAssembly.Module.imports(module);
|
|
||||||
const glue = readFileSync(gluePath, "utf8");
|
|
||||||
const referencedExports = new Set(
|
|
||||||
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
|
|
||||||
);
|
|
||||||
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
|
|
||||||
|
|
||||||
assert.equal(imports.length, 927);
|
assert.doesNotMatch(application, /patch-rerun-web-viewer/);
|
||||||
assert.equal(exports.size, 79);
|
assert.doesNotMatch(application, /vendor\/rerun-web-viewer/);
|
||||||
assert.deepEqual(missingExports, []);
|
assert.doesNotMatch(viewport, /vendor\/rerun-web-viewer/);
|
||||||
assert.match(glue, /export default function\(\)/);
|
assert.doesNotMatch(viewport, /0\.34\.1/);
|
||||||
assert.match(glue, /if \(!wasm\) return;/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("custom Rerun WASM initializes and grows its externref table", () => {
|
test("upstream WebViewer exposes one three-argument start contract", () => {
|
||||||
const runtime = makeRerunRuntime();
|
const declaration = readFileSync(resolve(packageRoot, "index.d.ts"), "utf8");
|
||||||
runtime.initSync({
|
|
||||||
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(typeof runtime.WebHandle, "function");
|
|
||||||
runtime.deinit();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => {
|
|
||||||
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
|
|
||||||
|
|
||||||
assert.match(patch, /fn pointer_ray_direction/);
|
|
||||||
assert.match(patch, /fn zoom_orbit_towards_pointer/);
|
|
||||||
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
|
|
||||||
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
|
|
||||||
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
|
|
||||||
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
|
|
||||||
assert.match(patch, /fn rotate_radians_around_anchor/);
|
|
||||||
assert.match(patch, /orbit_drag_anchor/);
|
|
||||||
assert.match(patch, /minimum_orbital_navigation_speed/);
|
|
||||||
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
|
|
||||||
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
|
|
||||||
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
|
|
||||||
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/);
|
|
||||||
assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
patch,
|
declaration,
|
||||||
/persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/,
|
/start\(rrd: string \| string\[\] \| null, parent: HTMLElement \| null, options: WebViewerOptions \| null\): Promise<void>/,
|
||||||
);
|
);
|
||||||
assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/);
|
|
||||||
assert.match(patch, /previous_picking_result/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,20 +75,29 @@ test("semantic point alignment follows the last qualified spatial increment", as
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4 keeps independent semantic controls in media and spatial panes", async () => {
|
test("M4 keeps independent semantic controls in media and spatial panes", async () => {
|
||||||
const source = await readFile(
|
const [source, canonical] = await Promise.all([
|
||||||
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
]);
|
||||||
assert.match(source, /showMediaSemantic/);
|
assert.match(source, /showMediaSemantic/);
|
||||||
assert.match(source, /showSpatialSemantic/);
|
assert.match(source, /showSpatialSemantic/);
|
||||||
assert.match(source, /semantic && showMediaSemantic && frame/);
|
assert.match(source, /activeSpatialSemantic = spatialSemantic \?\? activeSemantic/);
|
||||||
|
assert.match(source, /spatialSemanticClasses/);
|
||||||
|
assert.match(source, /spatialSemanticPalette/);
|
||||||
|
assert.match(source, /activeSemantic && evidenceDemand\.selectedSemanticMask && frame/);
|
||||||
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
|
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
|
||||||
assert.match(source, /\|\| !showSpatialSemantic/);
|
assert.match(source, /\|\| !showSpatialSemantic/);
|
||||||
assert.match(source, /aria-label="Слои камеры и видео"/);
|
assert.match(source, /aria-label="Слои камеры и видео"/);
|
||||||
assert.match(source, /aria-label="Слои 3D и плана"/);
|
assert.match(source, /aria-label="Слои 3D и плана"/);
|
||||||
assert.match(source, /data-pane-mode="media"/);
|
assert.match(canonical, /data-pane-mode="media"/);
|
||||||
assert.match(source, /data-pane-mode="spatial"/);
|
assert.match(canonical, /data-pane-mode="spatial"/);
|
||||||
assert.match(source, /modeControlsVisible=\{!splitView\}/);
|
assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
|
||||||
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
|||||||
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/);
|
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/);
|
||||||
assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/);
|
assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/);
|
||||||
assert.match(runtime, /app\.autoRender = false/);
|
assert.match(runtime, /app\.autoRender = false/);
|
||||||
|
assert.match(runtime, /app\.systems\.gsplat\?\.on\("frame:request", this\.requestGsplatFrame\)/);
|
||||||
|
assert.match(runtime, /app\.systems\.gsplat\?\.off\("frame:request", this\.requestGsplatFrame\)/);
|
||||||
assert.match(runtime, /app\.on\("update", this\.updateFrame\)/);
|
assert.match(runtime, /app\.on\("update", this\.updateFrame\)/);
|
||||||
assert.match(runtime, /if \(!cameraChanged && this\.controlMode === "free"\) return/);
|
assert.match(runtime, /if \(!cameraChanged && this\.controlMode === "free"\) return/);
|
||||||
assert.doesNotMatch(runtime, /app\.on\("frameupdate"/);
|
assert.doesNotMatch(runtime, /app\.on\("frameupdate"/);
|
||||||
@@ -162,6 +164,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
|||||||
assert.match(ugv, /new this\.ammo\.btRaycastVehicle/);
|
assert.match(ugv, /new this\.ammo\.btRaycastVehicle/);
|
||||||
assert.match(ugv, /type: "mesh"/);
|
assert.match(ugv, /type: "mesh"/);
|
||||||
assert.match(ugv, /MeshoptSimplifier\.simplify/);
|
assert.match(ugv, /MeshoptSimplifier\.simplify/);
|
||||||
|
assert.match(ugv, /MeshoptSimplifier\.simplifySloppy/);
|
||||||
|
assert.match(ugv, /Physics proxy превысил лимит/);
|
||||||
assert.match(ugv, /PHYSICS_PROXY_MAX_TRIANGLES = 240_000/);
|
assert.match(ugv, /PHYSICS_PROXY_MAX_TRIANGLES = 240_000/);
|
||||||
assert.match(ugv, /createPhysicsProxyMesh/);
|
assert.match(ugv, /createPhysicsProxyMesh/);
|
||||||
assert.match(ugv, /findSafeSpawnPosition/);
|
assert.match(ugv, /findSafeSpawnPosition/);
|
||||||
@@ -179,8 +183,33 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
|||||||
assert.match(ugv, /desiredSpeed = forwardInput \* maxSpeed/);
|
assert.match(ugv, /desiredSpeed = forwardInput \* maxSpeed/);
|
||||||
assert.match(ugv, /maximumAcceleration = clamp\(1\.4 \+ maxSpeed \* 0\.35, 1\.8, 4\.2\)/);
|
assert.match(ugv, /maximumAcceleration = clamp\(1\.4 \+ maxSpeed \* 0\.35, 1\.8, 4\.2\)/);
|
||||||
assert.match(ugv, /SERVICE_BRAKE_DECELERATION_MPS2 = 1\.8/);
|
assert.match(ugv, /SERVICE_BRAKE_DECELERATION_MPS2 = 1\.8/);
|
||||||
assert.match(ugv, /horizontalSpeed - deceleration \* Math\.max\(0, deltaSeconds\)/);
|
assert.match(ugv, /PARKING_BRAKE_HOLD_DECELERATION_MPS2 = 6/);
|
||||||
assert.match(ugv, /this\.vehicle\.setBrake\(0, index\)/);
|
assert.match(ugv, /PARKING_BRAKE_ENGAGE_SPEED_MPS = 0\.08/);
|
||||||
|
assert.match(ugv, /TYRE_FRICTION_SLIP = 8\.5/);
|
||||||
|
assert.match(ugv, /TYRE_STATIC_FRICTION_COEFFICIENT = 0\.95/);
|
||||||
|
assert.match(ugv, /TYRE_KINETIC_FRICTION_COEFFICIENT = 0\.78/);
|
||||||
|
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND = 10/);
|
||||||
|
assert.match(ugv, /GRAVITY_METERS_PER_SECOND_SQUARED = 9\.81/);
|
||||||
|
assert.match(ugv, /holding = !braking && forwardInput === 0 && turnInput === 0/);
|
||||||
|
assert.match(ugv, /longitudinalSpeedMetersPerSecond/);
|
||||||
|
assert.match(ugv, /parkingBrakeEngaged = holding/);
|
||||||
|
assert.match(ugv, /this\.settings\.massKg \* brakeDeceleration/);
|
||||||
|
assert.match(ugv, /this\.vehicle\.setBrake\(wheelBrakeForce, index\)/);
|
||||||
|
assert.match(ugv, /set_m_frictionSlip\(\s*parkingBrakeEngaged \? 0 : TYRE_FRICTION_SLIP/);
|
||||||
|
assert.match(ugv, /applyParkingTyreContact/);
|
||||||
|
assert.match(ugv, /wheel\.get_m_wheelsSuspensionForce\(\)/);
|
||||||
|
assert.doesNotMatch(ugv, /get_m_isInContact/);
|
||||||
|
assert.match(ugv, /normalForce < MIN_TYRE_NORMAL_FORCE_NEWTONS/);
|
||||||
|
assert.match(ugv, /lateralGravityAcceleration/);
|
||||||
|
assert.match(ugv, /longitudinalGravityAcceleration/);
|
||||||
|
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND \* lateralSlipSpeed/);
|
||||||
|
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND \* longitudinalSlipSpeed/);
|
||||||
|
assert.match(ugv, /Math\.hypot\(trialLateralForce, trialLongitudinalForce\)/);
|
||||||
|
assert.match(ugv, /body\.applyImpulse\(this\.tyreImpulseNative, this\.tyreRelativePositionNative\)/);
|
||||||
|
assert.doesNotMatch(ugv, /horizontalSpeed - deceleration \* Math\.max\(0, deltaSeconds\)/);
|
||||||
|
assert.match(ugv, /rollingFriction: 0\.12/);
|
||||||
|
assert.match(ugv, /angularDamping: 0\.6/);
|
||||||
|
assert.match(ugv, /wheel\.set_m_frictionSlip\(TYRE_FRICTION_SLIP\)/);
|
||||||
assert.doesNotMatch(ugv, /massKg \* 3/);
|
assert.doesNotMatch(ugv, /massKg \* 3/);
|
||||||
assert.match(ugv, /pureTurn = !braking && forwardInput === 0 && turnInput !== 0/);
|
assert.match(ugv, /pureTurn = !braking && forwardInput === 0 && turnInput !== 0/);
|
||||||
assert.match(ugv, /desiredYawRate = -turnInput \* maxTurnRate/);
|
assert.match(ugv, /desiredYawRate = -turnInput \* maxTurnRate/);
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ test("Compute-contour telemetry remains a bounded system feature slice", async (
|
|||||||
assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/);
|
assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/);
|
||||||
assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/);
|
assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/);
|
||||||
assert.match(telemetryPolling, /startSequentialPolling/);
|
assert.match(telemetryPolling, /startSequentialPolling/);
|
||||||
|
assert.match(telemetryPolling, /catch \(reason: unknown\)[\s\S]*?setTelemetry\(null\)/);
|
||||||
|
assert.match(computeWorkspace, /sm_clock_mhz/);
|
||||||
|
assert.match(computeWorkspace, /memory_clock_mhz/);
|
||||||
|
assert.match(computeWorkspace, /Ожидает восстановления данных/);
|
||||||
|
assert.doesNotMatch(computeWorkspace, /Очередь свободна; модели остаются загруженными/);
|
||||||
assert.doesNotMatch(telemetryPolling, /if\s*\(\s*!enabled\s*\|\|\s*loading\s*\)/);
|
assert.doesNotMatch(telemetryPolling, /if\s*\(\s*!enabled\s*\|\|\s*loading\s*\)/);
|
||||||
assert.match(pollingScheduler, /A transient failure must not stop polling/);
|
assert.match(pollingScheduler, /A transient failure must not stop polling/);
|
||||||
assert.match(styles, /system-telemetry\.css/);
|
assert.match(styles, /system-telemetry\.css/);
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { createServer } from "vite";
|
|||||||
|
|
||||||
let server;
|
let server;
|
||||||
let fetchVegetationBenchmarkResult;
|
let fetchVegetationBenchmarkResult;
|
||||||
|
let fetchCanonicalRecordedLabSpatialFrame;
|
||||||
let fetchVegetationShadowResult;
|
let fetchVegetationShadowResult;
|
||||||
|
let fetchVegetationRouteTgsAnchor;
|
||||||
|
let vegetationFullRouteMaskUrl;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -14,9 +17,17 @@ before(async () => {
|
|||||||
logLevel: "silent",
|
logLevel: "silent",
|
||||||
server: { middlewareMode: true },
|
server: { middlewareMode: true },
|
||||||
});
|
});
|
||||||
({ fetchVegetationBenchmarkResult, fetchVegetationShadowResult } = await server.ssrLoadModule(
|
({
|
||||||
|
fetchVegetationBenchmarkResult,
|
||||||
|
fetchVegetationShadowResult,
|
||||||
|
fetchVegetationRouteTgsAnchor,
|
||||||
|
vegetationFullRouteMaskUrl,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
"/src/core/laboratory/vegetationShadow.ts",
|
"/src/core/laboratory/vegetationShadow.ts",
|
||||||
));
|
));
|
||||||
|
({ fetchCanonicalRecordedLabSpatialFrame } = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/canonicalRecordedLabSpatial.ts",
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@@ -148,6 +159,70 @@ function coarseRouteVideo() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fullRouteReview() {
|
||||||
|
const layer = (kind) => ({
|
||||||
|
name: kind === "city" ? "EoMT Cityscapes" : "ddrnet_39",
|
||||||
|
result_id: kind === "city"
|
||||||
|
? `result-${"2".repeat(64)}`
|
||||||
|
: `lab-v1-ravnoves-video-ddrnet-${"3".repeat(64)}`,
|
||||||
|
frame_count: 6830,
|
||||||
|
taxonomy: {
|
||||||
|
schema_version: kind === "city"
|
||||||
|
? "missioncore.recorded-eomt-taxonomy/v1"
|
||||||
|
: "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||||
|
classes: Array.from({ length: kind === "city" ? 16 : 64 }, (_, classId) => ({
|
||||||
|
class_id: classId,
|
||||||
|
label: classId === 0 ? "undefined" : `${kind}-${classId}`,
|
||||||
|
color_rgb: [classId, classId, classId],
|
||||||
|
disposition: classId === 0 ? "undefined" : "prediction",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
mask_archive: {
|
||||||
|
path: kind === "city"
|
||||||
|
? "video/eomt-semantic-masks.zip"
|
||||||
|
: "video/ddrnet-semantic-masks.zip",
|
||||||
|
sha256: "4".repeat(64),
|
||||||
|
byte_length: 4096,
|
||||||
|
},
|
||||||
|
inference_fps: 9.5,
|
||||||
|
latency_p95_ms: 101.2,
|
||||||
|
peak_reserved_vram_bytes: 3_000_000_000,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
source_id: "RAVNOVES004TREE",
|
||||||
|
session_id: "20260828T130511Z_viewer_live",
|
||||||
|
linked_route_review_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||||
|
source_job_id: "recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
source_job_input_sha256: "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
|
||||||
|
source_stream_sha256: "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
recorded_media_source_id: "recorded.camera.6a3945242828a038",
|
||||||
|
recorded_media_generation_sha256: "b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded",
|
||||||
|
frame_count: 6830,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
timeline_start_seconds: 39.215263458,
|
||||||
|
timeline_end_seconds: 757.260263458,
|
||||||
|
timeline: {
|
||||||
|
path: "video/frame-source-times-ns.bin",
|
||||||
|
sha256: "5".repeat(64),
|
||||||
|
byte_length: 6830 * 8,
|
||||||
|
encoding: "uint64-le-nanoseconds",
|
||||||
|
frame_count: 6830,
|
||||||
|
},
|
||||||
|
ground_truth: false,
|
||||||
|
decode_repair: {
|
||||||
|
repaired_frame_count: 1,
|
||||||
|
sequence: 6092,
|
||||||
|
method: "duplicate-previous-decoded-frame",
|
||||||
|
proofs: {
|
||||||
|
eomt: { path: "proofs/decode_repair.json", sha256: "7".repeat(64) },
|
||||||
|
ddrnet: { path: "proofs/ddrnet_decode_repair.json", sha256: "8".repeat(64) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: { city: layer("city"), vegetation: layer("vegetation") },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function labPayload(route = routeVideo()) {
|
function labPayload(route = routeVideo()) {
|
||||||
return {
|
return {
|
||||||
schema_version: "missioncore.lab-v1-vegetation-shadow/v1",
|
schema_version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||||
@@ -233,6 +308,47 @@ test("vegetation LAB parses coarse material policy and sealed TGS binding", asyn
|
|||||||
assert.equal(result.routeVideo.fusionMode, "synchronised-multilayer-review");
|
assert.equal(result.routeVideo.fusionMode, "synchronised-multilayer-review");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("vegetation LAB parses the full 004 pass inside the existing result contract", async () => {
|
||||||
|
const payload = {
|
||||||
|
...labPayload(null),
|
||||||
|
catalogs: { goose: [], ravnoves: [] },
|
||||||
|
route_full_review: fullRouteReview(),
|
||||||
|
};
|
||||||
|
const timeline = new ArrayBuffer(6830 * 8);
|
||||||
|
const timelineView = new DataView(timeline);
|
||||||
|
for (let index = 0; index < 6830; index += 1) {
|
||||||
|
timelineView.setBigUint64(
|
||||||
|
index * 8,
|
||||||
|
BigInt(39_215_263_458 + index * 100_000_000),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await fetchVegetationShadowResult(resultId, {
|
||||||
|
fetcher: async (url) => String(url).endsWith("/route-timeline")
|
||||||
|
? new Response(timeline, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
ETag: `"${"5".repeat(64)}"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.routeVideo, null);
|
||||||
|
assert.equal(result.routeFullReview.frameCount, 6830);
|
||||||
|
assert.equal(result.routeFullReview.city.taxonomy.length, 16);
|
||||||
|
assert.equal(result.routeFullReview.vegetation.taxonomy.length, 64);
|
||||||
|
assert.equal(result.routeFullReview.decodeRepair.sequence, 6092);
|
||||||
|
assert.equal(result.routeFullReview.frameSourceTimesNs.length, 6830);
|
||||||
|
assert.equal(
|
||||||
|
vegetationFullRouteMaskUrl(resultId, "vegetation", 6829),
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${resultId}/route-masks/vegetation/6829`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("vegetation GOOSE benchmark opens through its separate archival endpoint", async () => {
|
test("vegetation GOOSE benchmark opens through its separate archival endpoint", async () => {
|
||||||
let requestedUrl = "";
|
let requestedUrl = "";
|
||||||
const result = await fetchVegetationBenchmarkResult(benchmarkResultId, {
|
const result = await fetchVegetationBenchmarkResult(benchmarkResultId, {
|
||||||
@@ -255,8 +371,88 @@ test("vegetation GOOSE benchmark opens through its separate archival endpoint",
|
|||||||
assert.equal(result.validationCases.length, 12);
|
assert.equal(result.validationCases.length, 12);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => {
|
test("vegetation route TGS anchor keeps exact sealed metric shapes", async () => {
|
||||||
const [resultSource, benchmarkSource] = await Promise.all([
|
let requestedUrl = "";
|
||||||
|
const anchor = await fetchVegetationRouteTgsAnchor(resultId, 409, {
|
||||||
|
fetcher: async (url) => {
|
||||||
|
requestedUrl = String(url);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.lab-v1-route-tgs-anchor/v1",
|
||||||
|
source_sequence: 409,
|
||||||
|
slot: 1,
|
||||||
|
current_points_xyz_m: [[1, 2, 3], [4, 5, 6]],
|
||||||
|
costmap: {
|
||||||
|
cell_size_m: 0.45,
|
||||||
|
centers_xy_m: Array.from({ length: 2244 }, (_, index) => [index, -index]),
|
||||||
|
state_codes: Array.from({ length: 2244 }, (_, index) => index % 4),
|
||||||
|
z_bounds_m: Array.from({ length: 2244 }, () => [null, null]),
|
||||||
|
},
|
||||||
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
requestedUrl,
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${resultId}/route-tgs-anchor/409`,
|
||||||
|
);
|
||||||
|
assert.equal(anchor.sourceSequence, 409);
|
||||||
|
assert.equal(anchor.currentPointsXyzM.length, 2);
|
||||||
|
assert.equal(anchor.costmap.centersXyM.length, 2244);
|
||||||
|
assert.deepEqual(new Set(anchor.costmap.stateCodes), new Set([0, 1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonical recorded LAB spatial frame keeps source, SLAM and body identity on one clock", async () => {
|
||||||
|
const generation = "e".repeat(64);
|
||||||
|
let requestedUrl = "";
|
||||||
|
const frame = await fetchCanonicalRecordedLabSpatialFrame("session-004", generation, 82_770_000_000, {
|
||||||
|
fetcher: async (url) => {
|
||||||
|
requestedUrl = String(url);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
|
target_time_ns: 82_770_000_000,
|
||||||
|
source_time_ns: 82_769_535_708,
|
||||||
|
pose_time_ns: 82_769_535_708,
|
||||||
|
trajectory_time_ns: 82_700_000_000,
|
||||||
|
coordinate_frame: "body-ground",
|
||||||
|
sensor_height: {
|
||||||
|
meters: 0.32,
|
||||||
|
source: "local-source-cloud-ground-quantile-median",
|
||||||
|
sample_count: 20,
|
||||||
|
mad_m: 0.03,
|
||||||
|
authority: "visual-derived",
|
||||||
|
},
|
||||||
|
spatial_profile: {
|
||||||
|
profile_id: "source-paced-ground-v3",
|
||||||
|
local_slam_history_seconds: 5,
|
||||||
|
local_slam_radius_m: 30,
|
||||||
|
local_slam_voxel_size_m: 0.12,
|
||||||
|
local_slam_point_limit: 27000,
|
||||||
|
},
|
||||||
|
source_point_count: 2,
|
||||||
|
source_points_body_xyz_m: [[1, 2, 3], [4, 5, 6]],
|
||||||
|
local_slam_source_frame_count: 2,
|
||||||
|
local_slam_source_point_count: 4,
|
||||||
|
local_slam_point_count: 2,
|
||||||
|
local_slam_body_xyz_m: [[0, 0, 0], [1, 0, 0]],
|
||||||
|
body_frame: {
|
||||||
|
origin_map_xyz_m: [33, 4, 1],
|
||||||
|
sensor_origin_map_xyz_m: [33, 4, 1.32],
|
||||||
|
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
|
||||||
|
},
|
||||||
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
requestedUrl,
|
||||||
|
`/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000&profile=source-paced-ground-v3`,
|
||||||
|
);
|
||||||
|
assert.equal(frame.sourcePointCount, 2);
|
||||||
|
assert.equal(frame.localSlamBodyXyzM.length, 2);
|
||||||
|
assert.equal(frame.sensorHeight.meters, 0.32);
|
||||||
|
assert.deepEqual(frame.bodyFrame.originMapXyzM, [33, 4, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
|
||||||
|
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -265,14 +461,79 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
|||||||
new URL("../src/workspaces/laboratory/VegetationBenchmarkResult.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/VegetationBenchmarkResult.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/styles/m4-replay-threat.css", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
||||||
assert.match(resultSource, /M4ReplayThreatVisual/);
|
|
||||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||||
assert.match(resultSource, /semanticOverride/);
|
assert.match(resultSource, /semanticOverride/);
|
||||||
assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/);
|
assert.match(m49Source, /spatialSemantic=\{spatialSemantic\}/);
|
||||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 3);
|
assert.match(m49Source, /controlLabel: "SEMANTICS"/);
|
||||||
assert.match(resultSource, /RAVNOVES004TREE mixed route review/);
|
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||||
|
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
|
||||||
|
assert.match(resultSource, /КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE/);
|
||||||
|
assert.match(resultSource, /<CanonicalVegetationRerunReplay/);
|
||||||
|
assert.doesNotMatch(resultSource, /RerunViewport/);
|
||||||
|
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
|
||||||
|
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
|
||||||
|
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
|
||||||
|
assert.match(rerunSource, /<RerunViewport/);
|
||||||
|
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
|
||||||
|
assert.match(rerunSource, /ЛОК\. SLAM/);
|
||||||
|
assert.match(rerunSource, /resolveCanonicalLabReplay/);
|
||||||
|
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
||||||
|
assert.match(rerunSource, /unifiedPerception: splitView/);
|
||||||
|
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
|
||||||
|
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
|
||||||
|
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
|
||||||
|
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
|
||||||
|
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
|
||||||
|
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
|
||||||
|
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
|
||||||
|
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
|
||||||
|
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
|
||||||
|
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
|
||||||
|
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
|
||||||
|
assert.match(rerunSource, /presentationReady && playback && playbackController/);
|
||||||
|
assert.match(
|
||||||
|
replayStyles,
|
||||||
|
/canonical-vegetation-rerun-replay:not\(\[data-presentation-state="ready"\]\)[\s\S]*laboratory-evidence-viewer__controls/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
replayStyles,
|
||||||
|
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
replayStyles,
|
||||||
|
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
replayStyles,
|
||||||
|
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
||||||
|
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||||
|
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
|
||||||
|
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||||
|
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||||
|
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||||
|
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||||
|
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
|
||||||
|
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
|
||||||
assert.match(resultSource, /linkedTgsResultId/);
|
assert.match(resultSource, /linkedTgsResultId/);
|
||||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||||
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
|
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
|
||||||
|
let liveAcquisitionRerunProfile;
|
||||||
|
let recordedSessionRerunProfile;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||||
|
liveAcquisitionRerunProfile,
|
||||||
|
recordedSessionRerunProfile,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("live acquisition profile cannot acquire recorded playback policy", () => {
|
||||||
|
assert.deepEqual(liveAcquisitionRerunProfile({
|
||||||
|
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
|
||||||
|
liveActivitySequence: 12,
|
||||||
|
liveStreamId: "acquisition-1",
|
||||||
|
liveRecoveryAuthorityIdentity: "authority-1",
|
||||||
|
}), {
|
||||||
|
kind: "live-acquisition",
|
||||||
|
clock: "stream_time",
|
||||||
|
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
|
||||||
|
liveActivitySequence: 12,
|
||||||
|
liveStreamId: "acquisition-1",
|
||||||
|
liveRecoveryAuthorityIdentity: "authority-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recorded session profile owns progressive admission and on-demand layers", () => {
|
||||||
|
const artifact = {
|
||||||
|
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
|
||||||
|
viewerSourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd?generation=abc",
|
||||||
|
byteLength: 42,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
};
|
||||||
|
const profile = recordedSessionRerunProfile({
|
||||||
|
sourceUrl: artifact.sourceUrl,
|
||||||
|
artifact,
|
||||||
|
autoplayWhenReady: true,
|
||||||
|
presentationGate: "loading",
|
||||||
|
expectedTimelineStartSeconds: 0,
|
||||||
|
expectedTimelineEndSeconds: 20,
|
||||||
|
initialPlaybackStartSeconds: 1,
|
||||||
|
view: "spatial",
|
||||||
|
viewResetGeneration: 0,
|
||||||
|
followTrajectory: false,
|
||||||
|
perceptionLayers: {
|
||||||
|
enabled: false,
|
||||||
|
detections2d: false,
|
||||||
|
segmentation: false,
|
||||||
|
cuboids3d: false,
|
||||||
|
},
|
||||||
|
perceptionRetryGeneration: 0,
|
||||||
|
lockPerceptionCameraInteraction: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(profile.kind, "recorded-session");
|
||||||
|
assert.equal(profile.clock, "session_time");
|
||||||
|
assert.equal(profile.artifact, artifact);
|
||||||
|
assert.equal(profile.perceptionLayers.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the old LAB transport is fenced as explicit legacy comparison only", () => {
|
||||||
|
assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, {
|
||||||
|
kind: "lab-recorded-evidence",
|
||||||
|
clock: "source-sequence",
|
||||||
|
cameraTransport: "generation-bound-fmp4",
|
||||||
|
spatialTransport: "bounded-sealed-artifacts",
|
||||||
|
loadPolicy: "explicit-legacy-comparison-only",
|
||||||
|
workerRequired: false,
|
||||||
|
});
|
||||||
|
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
||||||
|
});
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Archived viewer comparison source
|
||||||
|
|
||||||
|
`rerun-web-viewer-0.34.1/` is retained only as rollback-era comparison evidence
|
||||||
|
for ADR 0045. The active application neither imports it nor runs the historical
|
||||||
|
patch scripts during install or build. Canonical Control Station routes use the
|
||||||
|
unmodified `@rerun-io/web-viewer` dependency declared in `package.json`.
|
||||||
|
|
||||||
|
Do not update or reactivate this tree. Remove it with the remaining custom
|
||||||
|
fMP4/Three.js replay implementation after the canonical Rerun migration passes
|
||||||
|
operator visual acceptance.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.equipment-model-registry/v1",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.equipment-model/v1",
|
||||||
|
"equipment_model_id": "xgrids.lixelkity-k1",
|
||||||
|
"equipment_model_version": 1,
|
||||||
|
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
|
"vendor": "XGRIDS",
|
||||||
|
"display_name": "XGRIDS LixelKity K1",
|
||||||
|
"category": "mobile-lidar-scanner",
|
||||||
|
"capability_ids": [
|
||||||
|
"recorded.camera.video",
|
||||||
|
"recorded.lidar.point-cloud",
|
||||||
|
"recorded.pose.trajectory"
|
||||||
|
],
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.observatory-laboratory-setup-registry/v1",
|
||||||
|
"setups": [
|
||||||
|
{
|
||||||
|
"setup_id": "m49-tgs-full-shadow-v1",
|
||||||
|
"display_name": "M4.9T5 · TRAVEL TGS · CPU-only, без ML",
|
||||||
|
"description": "Сохранённая конфигурация RAVNOVES00: TRAVEL TGS без ML-моделей, 4 489 кадров, causal rolling 1 s, полный визуальный разбор и отдельное доказательство integrated graph.",
|
||||||
|
"origin": "archived-definition",
|
||||||
|
"source": {
|
||||||
|
"session_id": "20260720T065719Z_viewer_live",
|
||||||
|
"label": "RAVNOVES00",
|
||||||
|
"required_modalities": [
|
||||||
|
"point-cloud",
|
||||||
|
"trajectory",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"run_definition": {
|
||||||
|
"definition_id": "m49-tgs-full-shadow",
|
||||||
|
"version": 1,
|
||||||
|
"work_id": "m49-tgs-full-shadow",
|
||||||
|
"configuration": [
|
||||||
|
{
|
||||||
|
"role": "primary-profile",
|
||||||
|
"path": "config/perception/m49-tgs-full-shadow-v1.json",
|
||||||
|
"sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"contour_id": "worker-006",
|
||||||
|
"state": "not-installed",
|
||||||
|
"reason_code": "laboratory-runner-adapter-not-installed",
|
||||||
|
"reason": "Повторный запуск этого сетапа ещё не подключён к общему контуру расчёта."
|
||||||
|
},
|
||||||
|
"preserved_results": [
|
||||||
|
{
|
||||||
|
"result_id": "m49-tgs-full-shadow-ef98de7db7596d48e8c8c0549ce68e6704ee03e87c8c4bcf1e3e748b7ccb032e",
|
||||||
|
"result_kind": "recorded-source-paced-tgs-shadow",
|
||||||
|
"relation": "primary-visual",
|
||||||
|
"access": "legacy-lab",
|
||||||
|
"created_at_utc": "2026-08-26T20:27:19.076865Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"result_id": "m49-tgs-integrated-graph-shadow-75e48fd8acf0246be16613e087fcf26fe909f7834718d217c74785a57f494b24",
|
||||||
|
"result_kind": "source-paced-integrated-graph-shadow",
|
||||||
|
"relation": "compute-successor",
|
||||||
|
"access": "evidence-only",
|
||||||
|
"created_at_utc": "2026-08-27T08:50:05.5622153+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false,
|
||||||
|
"production_accepted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setup_id": "lab-v1-ravnoves004tree-final",
|
||||||
|
"display_name": "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||||
|
"description": "Точный RAVNOVES004TREE result: 6 830 кадров EoMT Cityscapes Large 1024 + DDRNet-39 и синхронный записанный replay; полный TGS и независимый YOLOX отсутствуют.",
|
||||||
|
"origin": "existing-result",
|
||||||
|
"source": {
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"label": "RAVNOVES004TREE",
|
||||||
|
"required_modalities": [
|
||||||
|
"point-cloud",
|
||||||
|
"trajectory",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"run_definition": null,
|
||||||
|
"executor": {
|
||||||
|
"contour_id": "worker-006",
|
||||||
|
"state": "not-installed",
|
||||||
|
"reason_code": "durable-dispatch-definition-unavailable",
|
||||||
|
"reason": "Для готового результата не сохранён повторно запускаемый сетап; доступен записанный разбор."
|
||||||
|
},
|
||||||
|
"preserved_results": [
|
||||||
|
{
|
||||||
|
"result_id": "lab-v1-vegetation-shadow-8c8f387599955dd79a16ded2c2d7cfd7f9f308d704f52c42fc26bd39d6da2d60",
|
||||||
|
"result_kind": "recorded-perception-qualification",
|
||||||
|
"relation": "canonical-projection",
|
||||||
|
"access": "observatory",
|
||||||
|
"created_at_utc": "2026-08-29T18:05:11.329061Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false,
|
||||||
|
"production_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.observatory-m49-recorded-queue-binding/v1",
|
||||||
|
"binding_id": "m49-ravnoves00-recorded-queue-v1",
|
||||||
|
"source": {
|
||||||
|
"session_id": "20260720T065719Z_viewer_live",
|
||||||
|
"label": "RAVNOVES00",
|
||||||
|
"required_modalities": [
|
||||||
|
"point-cloud",
|
||||||
|
"trajectory",
|
||||||
|
"video"
|
||||||
|
],
|
||||||
|
"source_pack": {
|
||||||
|
"artifact_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||||
|
"sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
|
||||||
|
"byte_length": 72996000,
|
||||||
|
"media_type": "application/vnd.nodedc.lidar-source-pack+npz",
|
||||||
|
"expected_timeline_frames": 4489,
|
||||||
|
"expected_available_lidar_frames": 3928
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"setup_id": "m49-tgs-full-shadow-v1",
|
||||||
|
"definition_id": "m49-tgs-full-shadow",
|
||||||
|
"definition_version": 1,
|
||||||
|
"definition_sha256": "836a66639e69f7ea00de2a9111c1c6c9c3c00f1abd726f1df596aeb4e3a88ae6"
|
||||||
|
},
|
||||||
|
"source_adapter": {
|
||||||
|
"adapter_id": "ravnoves00-m49-source-pack",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"release_id": "m49-tgs-full-shadow-worker-release",
|
||||||
|
"artifact_sha256": "5e0ea16c7a5cc760463836718b0cd8b0006ffc4b202e5f706a20a86ef2f912ab",
|
||||||
|
"code_revision": "40c850b167dda366d8aa45d828520168affaf9fd",
|
||||||
|
"service_installed": false,
|
||||||
|
"image_set": {
|
||||||
|
"schema_version": "missioncore.observatory-executor-image-set/v1",
|
||||||
|
"images": {
|
||||||
|
"travel": "7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f",
|
||||||
|
"parity": "ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"learned_models": []
|
||||||
|
},
|
||||||
|
"resource_profile": {
|
||||||
|
"schema_version": "missioncore.observatory-recorded-resource-profile/v1",
|
||||||
|
"profile_id": "worker006-cpu-single-run-v1",
|
||||||
|
"contour_id": "worker-006",
|
||||||
|
"concurrency": 1,
|
||||||
|
"checkpoint_policy": "non-checkpointable",
|
||||||
|
"restart_from_zero": true,
|
||||||
|
"staging_discard_required": true,
|
||||||
|
"resource_release_receipt_required": true
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false,
|
||||||
|
"production_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.recorded-capture-profile-registry/v1",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.recorded-capture-profile/v1",
|
||||||
|
"capture_profile_id": "xgrids-k1.viewer-live.fw-3.0.2.v1",
|
||||||
|
"capture_profile_version": 1,
|
||||||
|
"equipment_model_id": "xgrids.lixelkity-k1",
|
||||||
|
"equipment_model_sha256": "c95d7183ffd2e425892fd9ddfc4cd8dcbccc0231289127ddc14db6b52bbb92aa",
|
||||||
|
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
|
"archive_id": "xgrids-k1.viewer-live.evidence",
|
||||||
|
"firmware_compatibility_profile_id": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||||
|
"modalities": [
|
||||||
|
"point-cloud",
|
||||||
|
"trajectory",
|
||||||
|
"video"
|
||||||
|
],
|
||||||
|
"semantic_channels": [
|
||||||
|
"camera.video.recorded",
|
||||||
|
"spatial.point-cloud.recorded",
|
||||||
|
"spatial.pose.recorded"
|
||||||
|
],
|
||||||
|
"camera_media": {
|
||||||
|
"source_id": "sensor.camera.right",
|
||||||
|
"media_type": "video/mp4; codecs=\"avc1.641028\"",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"initialization_sha256": "e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38"
|
||||||
|
},
|
||||||
|
"calibration": {
|
||||||
|
"slot_id": "camera_1",
|
||||||
|
"sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||||
|
},
|
||||||
|
"media_epoch_policy": "exactly-one-complete-epoch",
|
||||||
|
"seekable": true,
|
||||||
|
"adapter": {
|
||||||
|
"adapter_id": "xgrids-k1-recorded-observatory-v2",
|
||||||
|
"version": 2,
|
||||||
|
"adapter_sha256": "4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,206 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.perception-profile-candidate/v1",
|
||||||
|
"profile_id": "k1-perception-ddrnet39-rfdetr-tgs-prototype/v1",
|
||||||
|
"display_name": "K1 Perception — DDRNet-39 + RF-DETR + TGS",
|
||||||
|
"status": "stage-1-candidate-not-installed-not-qualified",
|
||||||
|
"experiment_policy": {
|
||||||
|
"bounded_lab_overload_allowed": true,
|
||||||
|
"retain_profiles_that_fail_current_hardware_realtime": true,
|
||||||
|
"realtime_failure_blocks_experimental_packaging": false,
|
||||||
|
"qualification_scope": ["profile-version", "effective-config", "hardware", "source", "transport"],
|
||||||
|
"preserve_original_source_clock": true,
|
||||||
|
"report_drops_and_staleness": true,
|
||||||
|
"overload_is_realtime_pass": false,
|
||||||
|
"future_onboard_placement_requires_new_measurement": true
|
||||||
|
},
|
||||||
|
"packaging": {
|
||||||
|
"image": "ndc-k1-perception-ddrnet39-rfdetr-tgs:prototype-v1",
|
||||||
|
"image_sha256": null,
|
||||||
|
"single_container": true,
|
||||||
|
"external_model_server_required": false,
|
||||||
|
"host_model_cache_required": false,
|
||||||
|
"runtime_model_download_allowed": false,
|
||||||
|
"model_environment_isolation": "supervised-processes-one-gpu-scheduler",
|
||||||
|
"common_interpreter_required": false
|
||||||
|
},
|
||||||
|
"hardware_baseline": {
|
||||||
|
"gpu": "NVIDIA GeForce RTX 4090",
|
||||||
|
"active_profiles_per_worker": 1,
|
||||||
|
"sources_per_worker": 1,
|
||||||
|
"gpu_inference_policy": "serialized",
|
||||||
|
"other_gpu_workloads_allowed": false
|
||||||
|
},
|
||||||
|
"cpu_execution_policy": {
|
||||||
|
"OPENBLAS_NUM_THREADS": 1,
|
||||||
|
"OMP_NUM_THREADS": 1,
|
||||||
|
"MKL_NUM_THREADS": 1,
|
||||||
|
"reason": "bounded-single-thread-numeric-libraries-before-whole-graph-qualification"
|
||||||
|
},
|
||||||
|
"operating_envelope": {
|
||||||
|
"status": "bounded-latency-reference-not-full-qualification",
|
||||||
|
"checker": "k1link.perception.worker_operating_envelope.operating_envelope_failures",
|
||||||
|
"reference_conditions": {
|
||||||
|
"envelope_id": "worker006-4090-610.47-stock-clock-reference/v1",
|
||||||
|
"gpu_name": "NVIDIA GeForce RTX 4090",
|
||||||
|
"driver_version": "610.47",
|
||||||
|
"cpu_limit_millicores": 8000,
|
||||||
|
"memory_limit_mib": 8192,
|
||||||
|
"minimum_sm_clock_mhz": 2610,
|
||||||
|
"minimum_memory_clock_mhz": 10251,
|
||||||
|
"maximum_snapshot_age_ms": 1000
|
||||||
|
},
|
||||||
|
"check_phase": "post-warmup-before-timed-window-plus-runtime-monitoring",
|
||||||
|
"container_may_set_host_clocks": false,
|
||||||
|
"persistent_host_clock_policy_installed": false,
|
||||||
|
"readiness_is_realtime_qualification": false,
|
||||||
|
"unknown_ownership_or_telemetry_is_pass": false,
|
||||||
|
"failed_envelope_allows_labelled_experiment": true
|
||||||
|
},
|
||||||
|
"output_freshness_contract": {
|
||||||
|
"schema_version": "missioncore.perception-scene-freshness/v1",
|
||||||
|
"required_layers": ["segmentation", "objects", "geometry", "motion", "costmap", "policy"],
|
||||||
|
"retain_original_layer_identity": true,
|
||||||
|
"propagate_required_input_age": true,
|
||||||
|
"recheck_at_publication_receipt_and_use": true,
|
||||||
|
"clock_uncertainty_adds_to_age": true,
|
||||||
|
"unavailable_or_stale_required_layer": "no-go-advisory-only",
|
||||||
|
"history_can_independently_authorize_terrain": false,
|
||||||
|
"transport_streamstart_and_lease_fencing_required_separately": true
|
||||||
|
},
|
||||||
|
"input_contract": {
|
||||||
|
"schema_version": "missioncore.perception-stream-start/v1",
|
||||||
|
"channels": ["camera", "point-cloud", "pose"],
|
||||||
|
"camera": {
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"projection": "KB4",
|
||||||
|
"decoded_pixel_format": "bgr8",
|
||||||
|
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||||
|
"valid_fov_sha256": "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
|
||||||
|
},
|
||||||
|
"point_cloud": {
|
||||||
|
"representation": "vendor-registered-current-increment",
|
||||||
|
"coordinate_frame": "map",
|
||||||
|
"units": "metres",
|
||||||
|
"scalar_type": "float32-little-endian",
|
||||||
|
"raw_fields_retained": true
|
||||||
|
},
|
||||||
|
"pose": {"parent_frame": "map", "child_frame": "sensor"},
|
||||||
|
"clock_mapping_required": true,
|
||||||
|
"source_end_or_duration_required": false,
|
||||||
|
"full_source_transfer_required": false,
|
||||||
|
"full_source_hash_pass_required": false,
|
||||||
|
"full_decode_or_png_cache_required": false,
|
||||||
|
"previous_perception_artifacts_allowed": false
|
||||||
|
},
|
||||||
|
"stream_policy": {
|
||||||
|
"replay_speed": 1.0,
|
||||||
|
"release_clock": "original-source-timestamps",
|
||||||
|
"inference_stride": 1,
|
||||||
|
"sampling_status": "strict-every-frame-initial-baseline-no-silent-multirate",
|
||||||
|
"first_result_requires_eof": false,
|
||||||
|
"lookahead_allowed": false,
|
||||||
|
"reconnect": "new-epoch-drop-old-backlog-reacquire-codec-keyframe",
|
||||||
|
"overflow": "bounded-drop-with-ledger-degraded-and-failed-runtime-gate",
|
||||||
|
"archive_export_on_critical_path": false,
|
||||||
|
"transport_candidate": "grpc-bidirectional-protobuf-binary-payloads",
|
||||||
|
"transport_qualified": false
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"segmentation": {
|
||||||
|
"model": "ddrnet_39-goose-fine-64",
|
||||||
|
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
|
||||||
|
"precision": "existing-fp32-baseline",
|
||||||
|
"preprocessing": "center-600-square-to-512-rgb-tensor-0-1",
|
||||||
|
"outside_crop": "undefined-not-free",
|
||||||
|
"class_count": 64,
|
||||||
|
"runtime": "python-3.9-pytorch-1.13.1-cu117-super-gradients-3.2.0"
|
||||||
|
},
|
||||||
|
"objects": {
|
||||||
|
"model": "rf_detr_large_native_kb4",
|
||||||
|
"checkpoint_sha256": "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38",
|
||||||
|
"engine_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
|
||||||
|
"runtime": "tensorrt-11-fp16-native-uint8",
|
||||||
|
"minimum_score": 0.25,
|
||||||
|
"minimum_box_area_pixels": 64,
|
||||||
|
"maximum_box_area_fraction": 0.5,
|
||||||
|
"minimum_valid_fov_fraction": 0.5,
|
||||||
|
"require_center_inside_valid_fov": true,
|
||||||
|
"required_existing_classes": ["person", "cat", "dog"],
|
||||||
|
"fine_static_object_classifier_required": false,
|
||||||
|
"filter_quality_status": "existing-unchanged-near-large-and-small-object-risk-unqualified"
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"online_surface": "K1LocalSurfaceShadowEstimator",
|
||||||
|
"prepared_local_surface_allowed": false,
|
||||||
|
"generic_static_class": "static.unknown",
|
||||||
|
"detected_object_range": "median-camera-z-of-owned-current-points/v1",
|
||||||
|
"geometry_only_range": "nearest-euclidean-sensor-distance/v1",
|
||||||
|
"vehicle_clearance_claim": false,
|
||||||
|
"missing_support": "unavailable-not-zero-not-free"
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"reuse": "BoundedSpatialTemporalProvider+ClassIndependentMotionEstimator",
|
||||||
|
"semantic_class_implies_motion": false
|
||||||
|
},
|
||||||
|
"costmap": {
|
||||||
|
"algorithm": "TRAVEL-TGS",
|
||||||
|
"revision": "95dc2fbd66a343efd9060c45a5711b6307a950a4",
|
||||||
|
"execution": "cpu",
|
||||||
|
"rolling_history_seconds": 1.0,
|
||||||
|
"states": ["UNOBSERVED", "GROUND_SUPPORT", "NONGROUND_OCCUPIED", "UNKNOWN_REJECTED"],
|
||||||
|
"policy_input": true
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"scope": "rural-prototype-advisory-only",
|
||||||
|
"hard_surface": "ALLOW-candidate-subject-to-geometry-and-freshness",
|
||||||
|
"fine_road_sidewalk_bikeway_distinction_required": false,
|
||||||
|
"effective_material_rules_required": true,
|
||||||
|
"unknown_or_occupied_overrides_material_allowance": true,
|
||||||
|
"sensor_to_vehicle_mount": "simulation-only-until-measured"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reference_files": [
|
||||||
|
{"path": "config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json", "sha256": "dbf4da5dbad6c3c22b1280b46ffcad81719bd183c81c263a4859847d829019b6"},
|
||||||
|
{"path": "config/perception/lab-v1-goose-vegetation-benchmark-v1.json", "sha256": "96a427a8baae387b827ec9c0bf7ca42e3fb9114b8fa9a8671bbc9d10877670b9"},
|
||||||
|
{"path": "config/perception/lab-v1-vegetation-provider-label-map-v1.json", "sha256": "f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"},
|
||||||
|
{"path": "config/perception/lab-v1-vegetation-mission-policy-v1.json", "sha256": "b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},
|
||||||
|
{"path": "config/perception/m49-tgs-full-shadow-v1.json", "sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"},
|
||||||
|
{"path": "config/perception/m4-geometry-association-v1.json", "sha256": "cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"}
|
||||||
|
],
|
||||||
|
"reference_usage": "reuse-model-parameters-and-algorithms-only-not-recording-ids-or-precomputed-results",
|
||||||
|
"realtime_contract": {
|
||||||
|
"schema_version": "missioncore.perception-realtime-contract/v1",
|
||||||
|
"budget_status": "engineering-targets-worker-pilot-measured-not-whole-path-qualified-not-safety-approved",
|
||||||
|
"budgets": {
|
||||||
|
"maximum_start_metadata_bytes": 65536,
|
||||||
|
"maximum_chunk_bytes": 1048576,
|
||||||
|
"maximum_inflight_bytes": 16777216,
|
||||||
|
"maximum_pending_camera_frames": 2,
|
||||||
|
"maximum_codec_preroll_ms": 500.0,
|
||||||
|
"maximum_pose_age_ms": 100.0,
|
||||||
|
"maximum_clock_uncertainty_ms": 5.0,
|
||||||
|
"maximum_release_lag_ms": 25.0,
|
||||||
|
"maximum_output_age_p95_ms": 125.0,
|
||||||
|
"maximum_output_age_p99_ms": 125.0,
|
||||||
|
"maximum_layer_age_ms": 250.0,
|
||||||
|
"maximum_first_result_ms": 1000.0,
|
||||||
|
"maximum_warmup_seconds": 120.0,
|
||||||
|
"maximum_stop_seconds": 5.0,
|
||||||
|
"maximum_vram_mib": 22000,
|
||||||
|
"maximum_rss_mib": 8192,
|
||||||
|
"maximum_backlog_growth_ms": 25.0
|
||||||
|
},
|
||||||
|
"budget_origin": "125ms-historical-envelope-retained-as-target-now-applied-to-whole-path; other-limits-explicit-initial-engineering-guards",
|
||||||
|
"capacity_drop_count_max": 0,
|
||||||
|
"unaccounted_input_count_max": 0,
|
||||||
|
"full_input_transfer_before_first_result_allowed": false,
|
||||||
|
"independent_quality_and_physical_live_status": "pending"
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false,
|
||||||
|
"production_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2",
|
||||||
|
"profile_id": "lab-v1-eomt-ddrnet-portable-v2",
|
||||||
|
"lab_id": "LAB-V1",
|
||||||
|
"worker_id": "worker-006",
|
||||||
|
"source_binding": {
|
||||||
|
"mode": "admitted-k1-recording",
|
||||||
|
"camera_source_id": "sensor.camera.right",
|
||||||
|
"camera_timeline": "dynamic",
|
||||||
|
"filesystem_paths": "executor-resolved",
|
||||||
|
"expected_width": 800,
|
||||||
|
"expected_height": 600,
|
||||||
|
"expected_frame_count": "source-derived",
|
||||||
|
"frame_indices": "source-derived-representative",
|
||||||
|
"crop_contract": "center-600-square-to-512; outside-crop-is-undefined"
|
||||||
|
},
|
||||||
|
"effective_config_contract": {
|
||||||
|
"schema_version": "missioncore.lab-v1-goose-vegetation-benchmark/v1",
|
||||||
|
"dynamic_field": "ravnoves",
|
||||||
|
"source_id": "sealed-source-derived",
|
||||||
|
"source_sha256": "sealed-camera-input-derived",
|
||||||
|
"base_m4_result_id": null
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"super_gradients_version": "3.2.0",
|
||||||
|
"super_gradients_revision": "54d062ecb1081944a672ce447cf3e96a36708ff9",
|
||||||
|
"python_version": "3.9",
|
||||||
|
"numpy_version": "1.23.0",
|
||||||
|
"cmake_version": "3.31.6",
|
||||||
|
"onnxsim_version": "0.4.36",
|
||||||
|
"opencv_python_version": "4.8.1.78",
|
||||||
|
"pytorch_version": "1.13.1",
|
||||||
|
"torchvision_version": "0.14.1",
|
||||||
|
"pytorch_cuda_version": "11.7",
|
||||||
|
"container_base": "nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04@sha256:ad6d59a3bbf3e82c1c849c9ac09cfc2a3e0bbb8655042fd899be6681b3fe2a85",
|
||||||
|
"miniconda_installer": "Miniconda3-py39_24.11.1-0-Linux-x86_64.sh",
|
||||||
|
"miniconda_installer_sha256": "3ea8373098d72140e08aac9217822b047ec094eb457e7f73945af7c6f68bf6f5"
|
||||||
|
},
|
||||||
|
"dataset": {
|
||||||
|
"dataset_id": "goose-2d-validation-visible-rgb",
|
||||||
|
"relative_root": "goose-2d/validation",
|
||||||
|
"mapping_relative_path": "goose_label_mapping.csv",
|
||||||
|
"mapping_sha256": "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f",
|
||||||
|
"image_glob": "images/val/**/*_windshield_vis.png",
|
||||||
|
"expected_pair_count": 962,
|
||||||
|
"input_size": [
|
||||||
|
512,
|
||||||
|
512
|
||||||
|
],
|
||||||
|
"preprocessing": [
|
||||||
|
"center-square-crop",
|
||||||
|
"nearest-neighbor-resize",
|
||||||
|
"rgb-to-tensor-0-1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"candidates": {
|
||||||
|
"ddrnet": {
|
||||||
|
"candidate_id": "goose-ddrnet-class-512",
|
||||||
|
"model_names": [
|
||||||
|
"ddrnet_39"
|
||||||
|
],
|
||||||
|
"checkpoint_relative_path": "models/goose/ddrnet_class_512.pth",
|
||||||
|
"checkpoint_size_bytes": 259419077,
|
||||||
|
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
|
||||||
|
"published_validation_miou_percent": 46.53
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vegetation_class_names": [
|
||||||
|
"leaves",
|
||||||
|
"forest",
|
||||||
|
"bush",
|
||||||
|
"moss",
|
||||||
|
"tree_crown",
|
||||||
|
"tree_trunk",
|
||||||
|
"crops",
|
||||||
|
"low_grass",
|
||||||
|
"high_grass",
|
||||||
|
"scenery_vegetation",
|
||||||
|
"hedge",
|
||||||
|
"tree_root"
|
||||||
|
],
|
||||||
|
"visual_case_contract": {
|
||||||
|
"selection_basis": "ground-truth-class-support-only",
|
||||||
|
"case_count": 12,
|
||||||
|
"minimum_focus_pixels": 2048,
|
||||||
|
"strata": [
|
||||||
|
{
|
||||||
|
"class_name": "high_grass",
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"class_name": "low_grass",
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"class_name": "bush",
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"class_name": "tree_trunk",
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"class_name": "tree_crown",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"class_name": "hedge",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"class_name": "forest",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"class_name": "crops",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"error_overlay": {
|
||||||
|
"correct_material_rgba": [
|
||||||
|
34,
|
||||||
|
197,
|
||||||
|
94,
|
||||||
|
72
|
||||||
|
],
|
||||||
|
"missed_vegetation_rgba": [
|
||||||
|
239,
|
||||||
|
68,
|
||||||
|
68,
|
||||||
|
220
|
||||||
|
],
|
||||||
|
"false_vegetation_rgba": [
|
||||||
|
245,
|
||||||
|
158,
|
||||||
|
11,
|
||||||
|
220
|
||||||
|
],
|
||||||
|
"wrong_vegetation_material_rgba": [
|
||||||
|
168,
|
||||||
|
85,
|
||||||
|
247,
|
||||||
|
220
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policy_action_colors": {
|
||||||
|
"ALLOW": "#22c55e",
|
||||||
|
"HIGH_COST": "#f59e0b",
|
||||||
|
"NO_GO": "#ef4444"
|
||||||
|
},
|
||||||
|
"invariants": {
|
||||||
|
"one_heavy_candidate_at_a_time": true,
|
||||||
|
"raw_fisheye_is_immutable": true,
|
||||||
|
"outside_center_crop_is_free": false,
|
||||||
|
"missing_or_unknown_is_free": false,
|
||||||
|
"camera_semantics_can_clear_rigid_geometry": false,
|
||||||
|
"navigation_authority": false,
|
||||||
|
"actuation_authority": false,
|
||||||
|
"canonical_triton_mutation_allowed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.lab-v1-ravnoves-source/v1",
|
||||||
|
"profile_id": "ravnoves004tree-full-video-source/v1",
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES004TREE/right-e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
"source_sha256": "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
"source_job_id": "recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
"source_job_input_sha256": "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"base_m4_result_id": null,
|
||||||
|
"expected_width": 800,
|
||||||
|
"expected_height": 600,
|
||||||
|
"expected_frame_count": 6830,
|
||||||
|
"timeline_start_seconds": 39.215263458,
|
||||||
|
"timeline_end_seconds": 757.260263458,
|
||||||
|
"frame_indices": [],
|
||||||
|
"crop_contract": "center-600-square-to-512; outside-crop-is-undefined"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.m49-tgs-portable-profile/v2",
|
||||||
|
"profile_id": "m49-tgs-portable-v2",
|
||||||
|
"source_binding": {
|
||||||
|
"mode": "admitted-k1-recording",
|
||||||
|
"camera_timeline": "dynamic",
|
||||||
|
"lidar_replay": "dynamic",
|
||||||
|
"trajectory": "dynamic",
|
||||||
|
"frame_counts": "source-derived",
|
||||||
|
"filesystem_paths": "executor-resolved"
|
||||||
|
},
|
||||||
|
"alignment": {
|
||||||
|
"timeline": "recorded-camera-host-arrival",
|
||||||
|
"lidar_selection": "latest-not-newer-than-camera-frame",
|
||||||
|
"maximum_lidar_age_seconds": 1.0,
|
||||||
|
"future_frames_allowed": false
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"max_range_m": 80.0,
|
||||||
|
"min_range_m": 1.0,
|
||||||
|
"resolution_m": 8.0,
|
||||||
|
"num_iterations": 3,
|
||||||
|
"num_lowest_representative_points": 5,
|
||||||
|
"minimum_points": 10,
|
||||||
|
"seed_threshold_m": 0.5,
|
||||||
|
"distance_threshold_m": 0.125,
|
||||||
|
"outlier_threshold_m": 0.3,
|
||||||
|
"normal_threshold": 0.94,
|
||||||
|
"weight_threshold": 200.0,
|
||||||
|
"lcc_normal_similarity": 0.03,
|
||||||
|
"lcc_planar_distance_m": 0.1,
|
||||||
|
"obstacle_height_m": 1.0,
|
||||||
|
"refine_mode": true
|
||||||
|
},
|
||||||
|
"rolling_profile": {
|
||||||
|
"history_seconds": 1.0,
|
||||||
|
"local_radius_m": 12.0,
|
||||||
|
"missing_lidar_policy": "all-cells-unobserved"
|
||||||
|
},
|
||||||
|
"costmap": {
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"cell_size_m": 0.45,
|
||||||
|
"radius_m": 12.0,
|
||||||
|
"state_priority": [
|
||||||
|
"NONGROUND_OCCUPIED",
|
||||||
|
"UNKNOWN_REJECTED",
|
||||||
|
"GROUND_SUPPORT",
|
||||||
|
"UNOBSERVED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"invariants": {
|
||||||
|
"aos_allowed": false,
|
||||||
|
"gpu_allowed": false,
|
||||||
|
"missing_support_means_free": false,
|
||||||
|
"missing_lidar_means_unobserved": true,
|
||||||
|
"unobserved_cells_are_emitted": true,
|
||||||
|
"camera_projection_is_authoritative": false,
|
||||||
|
"navigation_or_actuation_allowed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,23 @@ The normalizer exposes a read-only normalized telemetry adapter on
|
|||||||
Timescale credentials, and Timescale is not published on a host port.
|
Timescale credentials, and Timescale is not published on a host port.
|
||||||
The normalizer uses a separate `missioncore_normalizer` database role with only
|
The normalizer uses a separate `missioncore_normalizer` database role with only
|
||||||
`SELECT`, `INSERT`, `UPDATE`, and bounded retention `DELETE` on the telemetry
|
`SELECT`, `INSERT`, `UPDATE`, and bounded retention `DELETE` on the telemetry
|
||||||
hypertable. Raw telemetry older than 30 days is removed at most once per day by the
|
hypertable. Raw telemetry older than 30 days is removed in batches of at most
|
||||||
normalizer. This stays compatible with the Apache-licensed Timescale image without
|
1000 rows per minute, on a separate connection/thread (2s statement / 250ms lock
|
||||||
|
deadline). Retention never runs inside the MQTT callback. This stays compatible
|
||||||
|
with the Apache-licensed Timescale image without
|
||||||
depending on the Timescale License retention scheduler.
|
depending on the Timescale License retention scheduler.
|
||||||
|
|
||||||
|
The 1 GiB database container explicitly sets 128 MiB shared buffers and 64 MiB
|
||||||
|
maintenance memory. The HA image's automatic tuning sees the Docker VM instead
|
||||||
|
of this container limit; do not inherit its multi-GiB defaults.
|
||||||
|
|
||||||
|
For a memory-constrained local repair, `compose.source.yaml` mounts the reviewed
|
||||||
|
normalizer source read-only into the **existing** image. Set
|
||||||
|
`MISSIONCORE_NORMALIZER_SOURCE` to the absolute `normalizer.py` path, pass both
|
||||||
|
Compose files and the existing private `--env-file` / `--project-directory`, and
|
||||||
|
use `up -d --no-deps --no-build normalizer`. This is an explicit local source
|
||||||
|
override, not a rebuilt portable image. Normal restart/reboot retains the mount.
|
||||||
|
|
||||||
The stack is one deployment contour, not one multi-process container. Keeping broker,
|
The stack is one deployment contour, not one multi-process container. Keeping broker,
|
||||||
normalizer and database in separate containers preserves independent health checks,
|
normalizer and database in separate containers preserves independent health checks,
|
||||||
least-privilege boundaries and rollback while Compose provides one operator lifecycle:
|
least-privilege boundaries and rollback while Compose provides one operator lifecycle:
|
||||||
@@ -136,6 +149,24 @@ globally unique because Mosquitto ACL ownership is username-based.
|
|||||||
|
|
||||||
## Worker agent
|
## Worker agent
|
||||||
|
|
||||||
|
MQTT outputs use `startup_error_behavior="retry"`, with a 2000-metric buffer and
|
||||||
|
the configured flush interval. A missing broker at agent startup must not end
|
||||||
|
the service. See [Telegraf's startup policy](https://docs.influxdata.com/telegraf/v1/configuration/plugin-options/).
|
||||||
|
If Docker lost a published listener while the saved LAN address is unchanged,
|
||||||
|
the explicit broker Apply action reconciles that listener; a telemetry GET never
|
||||||
|
restarts infrastructure. Node connectivity does not prove a profile is ready.
|
||||||
|
|
||||||
|
The streaming profile can export one bounded (8 KiB) current observation using
|
||||||
|
`--telemetry-snapshot /telemetry/current.json`, with `/telemetry` mounted to
|
||||||
|
`C:\ProgramData\NDC\MissionCore\telemetry-agent\perception` on Worker. The existing
|
||||||
|
collector reads it with a 5s freshness limit and forwards it over the same MQTT
|
||||||
|
pipeline topic. It overrides the legacy service only when explicitly present;
|
||||||
|
an expired/broken file reports unavailable rather than relabeling a legacy model.
|
||||||
|
The publisher runs independently from heartbeat/inference and never renews a
|
||||||
|
lease, grants GPU ownership, or qualifies real-time. Queue memory includes active
|
||||||
|
input and decoder scratch. Stage timings are unavailable until actually measured.
|
||||||
|
No camera, point cloud, model result, MQTT secret, or motor command goes here.
|
||||||
|
|
||||||
Worker 006 uses the official Windows Telegraf distribution as the host service
|
Worker 006 uses the official Windows Telegraf distribution as the host service
|
||||||
`NDC Mission Core Telemetry Agent`. Install or update it with:
|
`NDC Mission Core Telemetry Agent`. Install or update it with:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Local, read-only source override for repairing the existing normalizer without
|
||||||
|
# rebuilding an image on a memory-constrained operator machine. No extra service.
|
||||||
|
services:
|
||||||
|
normalizer:
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${MISSIONCORE_NORMALIZER_SOURCE:?Set the absolute normalizer.py path}
|
||||||
|
target: /app/normalizer.py
|
||||||
|
read_only: true
|
||||||
@@ -49,6 +49,8 @@ services:
|
|||||||
|
|
||||||
timescale:
|
timescale:
|
||||||
image: timescale/timescaledb-ha:pg16.14-ts2.28.2-all-oss
|
image: timescale/timescaledb-ha:pg16.14-ts2.28.2-all-oss
|
||||||
|
# The HA image auto-tunes for the Docker VM, not this 1 GiB container.
|
||||||
|
command: ["postgres", "-c", "shared_buffers=128MB", "-c", "work_mem=4MB", "-c", "maintenance_work_mem=64MB", "-c", "effective_cache_size=512MB", "-c", "max_parallel_workers_per_gather=0", "-c", "max_connections=32"]
|
||||||
container_name: ndc-mission-core-telemetry-timescaledb
|
container_name: ndc-mission-core-telemetry-timescaledb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user