feat(observatory): ship modular AI inference labs
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Синхронизированная запись</title>
|
||||
<style>
|
||||
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer"></div>
|
||||
<script type="module" src="/src/components/rerun/isolatedRerunEntry.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createIsolatedRerunHost } from "./rerun/isolatedRerunHost";
|
||||
import { keepRecordedBlueprintSession } from "../core/observation/recordedBlueprintLifecycle";
|
||||
import {
|
||||
RECORDED_RERUN_ORBITAL_EYE,
|
||||
RECORDED_RERUN_PLAN_EYE,
|
||||
type RecordedRerunCameraEye,
|
||||
} from "./rerun/recordedRerunCameraJournal";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {
|
||||
@@ -138,6 +145,15 @@ export interface RerunViewportProps {
|
||||
|
||||
interface RerunBlueprintChannel {
|
||||
endpointUrl: string;
|
||||
cameraContract?: string | null;
|
||||
configureCameraJournal?: (
|
||||
eye: RecordedRerunCameraEye,
|
||||
spatialViewportStart: number,
|
||||
) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye;
|
||||
setCameraViewportStart?: (spatialViewportStart: number) => void;
|
||||
getCurrentTimeNs?: () => number | null;
|
||||
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
channel: {
|
||||
readonly ready: boolean;
|
||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||
@@ -166,11 +182,12 @@ interface RecordedRerunIdentity {
|
||||
|
||||
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
||||
const LAB_RECORDED_REPLAY_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-replay\.rrd$/;
|
||||
const PORTABLE_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/portable-results\/m49-tgs-portable-review-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
|
||||
const PORTABLE_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/portable-results\/(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
|
||||
const COMPOSITION_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/ai-composition-runs\/ai-composition-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
|
||||
|
||||
export function isRecordedRrdSource(path: string): boolean {
|
||||
return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path)
|
||||
|| PORTABLE_RECORDED_REPLAY_PATH.test(path);
|
||||
|| PORTABLE_RECORDED_REPLAY_PATH.test(path) || COMPOSITION_RECORDED_REPLAY_PATH.test(path);
|
||||
}
|
||||
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
|
||||
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
|
||||
@@ -267,7 +284,9 @@ export function resolveRecordedBlueprintUrl(
|
||||
if (explicitSourceUrl !== undefined) {
|
||||
const explicit = explicitSourceUrl.trim();
|
||||
if (
|
||||
!(LAB_RECORDED_REPLAY_PATH.test(normalized) || PORTABLE_RECORDED_REPLAY_PATH.test(normalized))
|
||||
!(LAB_RECORDED_REPLAY_PATH.test(normalized)
|
||||
|| PORTABLE_RECORDED_REPLAY_PATH.test(normalized)
|
||||
|| COMPOSITION_RECORDED_REPLAY_PATH.test(normalized))
|
||||
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
|
||||
) return null;
|
||||
const endpoint = new URL(explicit, `${base.origin}/`);
|
||||
@@ -393,7 +412,11 @@ export async function fetchRecordedBlueprintRrd(
|
||||
followTrajectory = false,
|
||||
semanticLayer,
|
||||
unifiedPerception,
|
||||
unifiedCameraShare = 0.46,
|
||||
planView = false,
|
||||
cameraEye,
|
||||
currentTimeNs,
|
||||
onCameraMaxOrbitalRadius,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
@@ -410,7 +433,11 @@ export async function fetchRecordedBlueprintRrd(
|
||||
followTrajectory?: boolean;
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
unifiedPerception?: boolean;
|
||||
unifiedCameraShare?: number;
|
||||
planView?: boolean;
|
||||
cameraEye?: RecordedRerunCameraEye;
|
||||
currentTimeNs?: number | null;
|
||||
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
@@ -426,10 +453,8 @@ export async function fetchRecordedBlueprintRrd(
|
||||
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
|
||||
!Number.isFinite(settings.accumulationSeconds) ||
|
||||
settings.accumulationSeconds < 0 ||
|
||||
settings.accumulationSeconds > 3600 ||
|
||||
!Number.isFinite(settings.pointSize) ||
|
||||
settings.pointSize < 0.1 ||
|
||||
settings.pointSize > 32 ||
|
||||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
|
||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
@@ -437,9 +462,21 @@ export async function fetchRecordedBlueprintRrd(
|
||||
![0, 1].includes(viewResetGeneration) ||
|
||||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
|
||||
typeof resolvedUnifiedPerception !== "boolean" ||
|
||||
!Number.isFinite(unifiedCameraShare) ||
|
||||
unifiedCameraShare < 0.1 ||
|
||||
unifiedCameraShare > 0.9 ||
|
||||
typeof planView !== "boolean" ||
|
||||
(cameraEye !== undefined && [
|
||||
...cameraEye.position,
|
||||
...cameraEye.lookTarget,
|
||||
...cameraEye.eyeUp,
|
||||
].some((value) => !Number.isFinite(value))) ||
|
||||
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
||||
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
|
||||
)) ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
perceptionLayers.cameraImage ?? true,
|
||||
perceptionLayers.detections2d,
|
||||
perceptionLayers.segmentation,
|
||||
perceptionLayers.cuboids3d,
|
||||
@@ -474,9 +511,17 @@ export async function fetchRecordedBlueprintRrd(
|
||||
view_reset_generation: viewResetGeneration,
|
||||
follow_trajectory: followTrajectory,
|
||||
unified_perception: resolvedUnifiedPerception,
|
||||
unified_camera_share: unifiedCameraShare,
|
||||
semantic_layer: semanticLayer ?? null,
|
||||
plan_view: planView,
|
||||
eye_position: cameraEye?.position ?? null,
|
||||
eye_look_target: cameraEye?.lookTarget ?? null,
|
||||
eye_up: cameraEye?.eyeUp ?? null,
|
||||
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
||||
current_time_ns: currentTimeNs,
|
||||
}),
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||
show_segmentation: perceptionLayers.segmentation,
|
||||
show_cuboids_3d: perceptionLayers.cuboids3d,
|
||||
...(perceptionLayers.costmap === undefined ? {} : {
|
||||
@@ -506,9 +551,34 @@ export async function fetchRecordedBlueprintRrd(
|
||||
) {
|
||||
throw new Error("Invalid recorded blueprint RRD");
|
||||
}
|
||||
const maxOrbitalRadius = Number(
|
||||
response.headers.get("X-MissionCore-Camera-Max-Orbital-Radius"),
|
||||
);
|
||||
if (Number.isFinite(maxOrbitalRadius) && maxOrbitalRadius >= 0.02) {
|
||||
onCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function recordedCameraJournalContract(
|
||||
{
|
||||
activeView,
|
||||
viewResetGeneration,
|
||||
planView,
|
||||
}: {
|
||||
activeView: RecordedRerunView;
|
||||
viewResetGeneration: 0 | 1;
|
||||
planView: boolean;
|
||||
followTrajectory: boolean;
|
||||
},
|
||||
): string {
|
||||
// Following is a tracking property of the current native eye. It must never
|
||||
// initialize the browser journal again: doing so replaces the operator's
|
||||
// current pose with the startup preset immediately before the blueprint is
|
||||
// sent. Plan/3D and explicit reset are the only preset transitions.
|
||||
return [activeView, viewResetGeneration, planView].join(":");
|
||||
}
|
||||
|
||||
export async function fetchRecordedPerceptionRrd(
|
||||
endpointUrl: string,
|
||||
identity: RecordedRerunIdentity,
|
||||
@@ -717,6 +787,7 @@ export function RerunViewport({
|
||||
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
|
||||
const recordedSemanticLayer = recordedProfile?.semanticLayer;
|
||||
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
|
||||
const recordedUnifiedCameraShare = recordedProfile?.unifiedCameraShare ?? 0.46;
|
||||
const recordedPlanView = recordedProfile?.planView ?? false;
|
||||
const recordedPerceptionRetryGeneration =
|
||||
recordedProfile?.perceptionRetryGeneration ?? 0;
|
||||
@@ -762,9 +833,9 @@ export function RerunViewport({
|
||||
: sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPointColorsUrl = sourceUrl
|
||||
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
||||
const presentationStatus = rerunPresentationStatus(
|
||||
status,
|
||||
presentationGate,
|
||||
@@ -836,6 +907,9 @@ export function RerunViewport({
|
||||
diagnosticLifecycle.verifyBuild();
|
||||
|
||||
let disposed = false;
|
||||
const blueprintOwnerId = crypto.randomUUID().replaceAll("-", "");
|
||||
blueprintSessionIdRef.current = blueprintOwnerId;
|
||||
let releaseBlueprintSession = () => {};
|
||||
let disposeViewer: (() => void) | undefined;
|
||||
let recordedOpenWatchdog: {
|
||||
arm: () => void;
|
||||
@@ -1082,6 +1156,11 @@ export function RerunViewport({
|
||||
);
|
||||
}
|
||||
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
|
||||
const restoreAfterPageCache = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) setRetryNonce((nonce) => nonce + 1);
|
||||
};
|
||||
window.addEventListener("pagehide", disposeActiveViewerLifecycle);
|
||||
window.addEventListener("pageshow", restoreAfterPageCache);
|
||||
|
||||
host.replaceChildren();
|
||||
appliedPointColorKeyRef.current = null;
|
||||
@@ -1089,15 +1168,22 @@ export function RerunViewport({
|
||||
setRecordingBufferProgress(null);
|
||||
onStatusChange?.("loading");
|
||||
|
||||
void import("@rerun-io/web-viewer")
|
||||
.then(async ({ WebViewer }) => {
|
||||
const isolatedHost = isRecordedSource ? createIsolatedRerunHost(host) : null;
|
||||
disposeViewer = () => isolatedHost?.dispose();
|
||||
const nativeViewer = isolatedHost?.ready ?? import("@rerun-io/web-viewer")
|
||||
.then(({ WebViewer }) => ({ viewer: new WebViewer(), mount: host }));
|
||||
void nativeViewer
|
||||
.then(async ({ viewer, mount }) => {
|
||||
// React StrictMode intentionally disposes the first effect while the
|
||||
// dynamic import is still pending. Never start that stale viewer (the
|
||||
// vendor API treats a missing host as document.body).
|
||||
if (disposed) return;
|
||||
|
||||
const viewer = new WebViewer();
|
||||
if (disposed) {
|
||||
viewer.stop();
|
||||
isolatedHost?.dispose();
|
||||
return;
|
||||
}
|
||||
disposeViewer = createReentrantViewerDisposer(() => {
|
||||
releaseBlueprintSession();
|
||||
clearRecordingTimers();
|
||||
clearPlaybackRangeTimer();
|
||||
playbackTimeUpdates.cancel();
|
||||
@@ -1155,6 +1241,7 @@ export function RerunViewport({
|
||||
// A partially initialized WASM handle can already be gone after a
|
||||
// startup failure.
|
||||
}
|
||||
isolatedHost?.dispose();
|
||||
host.replaceChildren();
|
||||
});
|
||||
if (isRecordedSource && recordedArtifact) {
|
||||
@@ -1191,6 +1278,13 @@ export function RerunViewport({
|
||||
event.application_id === "nodedc_mission_core_recorded" &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id)
|
||||
) {
|
||||
releaseBlueprintSession = keepRecordedBlueprintSession({
|
||||
endpointUrl: recordedBlueprintUrl,
|
||||
origin: window.location.origin,
|
||||
applicationId: event.application_id,
|
||||
recordingId: event.recording_id,
|
||||
ownerId: blueprintOwnerId,
|
||||
});
|
||||
const current = recordedIdentityRef.current;
|
||||
if (
|
||||
current?.applicationId !== event.application_id ||
|
||||
@@ -1500,7 +1594,7 @@ export function RerunViewport({
|
||||
};
|
||||
await viewer.start(
|
||||
rerunViewerInitialSource(resolvedSource),
|
||||
host,
|
||||
mount,
|
||||
viewerOptions,
|
||||
);
|
||||
if (disposed) {
|
||||
@@ -1518,7 +1612,34 @@ export function RerunViewport({
|
||||
|
||||
if (recordedBlueprintUrl) {
|
||||
const channel = viewer.open_channel("missioncore/recorded-blueprint");
|
||||
blueprintChannel = { endpointUrl: recordedBlueprintUrl, channel };
|
||||
blueprintChannel = {
|
||||
endpointUrl: recordedBlueprintUrl,
|
||||
cameraContract: null,
|
||||
configureCameraJournal: (eye, spatialViewportStart) => {
|
||||
if ("configure_camera_journal" in viewer) {
|
||||
viewer.configure_camera_journal(eye, spatialViewportStart);
|
||||
}
|
||||
},
|
||||
getCameraEye: () => "get_camera_eye" in viewer
|
||||
? viewer.get_camera_eye()
|
||||
: RECORDED_RERUN_ORBITAL_EYE,
|
||||
setCameraViewportStart: (spatialViewportStart) => {
|
||||
if ("set_camera_viewport_start" in viewer) {
|
||||
viewer.set_camera_viewport_start(spatialViewportStart);
|
||||
}
|
||||
},
|
||||
getCurrentTimeNs: () => {
|
||||
const currentIdentity = recordedIdentityRef.current;
|
||||
if (!currentIdentity) return null;
|
||||
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
|
||||
},
|
||||
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if ("set_camera_max_orbital_radius" in viewer) {
|
||||
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
channel,
|
||||
};
|
||||
blueprintChannelRef.current = blueprintChannel;
|
||||
setBlueprintChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
@@ -1578,6 +1699,8 @@ export function RerunViewport({
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", disposeActiveViewerLifecycle);
|
||||
window.removeEventListener("pageshow", restoreAfterPageCache);
|
||||
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
|
||||
activeViewerLifecycleRef.current = null;
|
||||
}
|
||||
@@ -1959,6 +2082,22 @@ export function RerunViewport({
|
||||
!active.channel.ready
|
||||
) return;
|
||||
const abort = new AbortController();
|
||||
const cameraContract = recordedCameraJournalContract(
|
||||
{
|
||||
activeView: recordedView,
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
planView: recordedPlanView,
|
||||
followTrajectory: recordedFollowTrajectory,
|
||||
},
|
||||
);
|
||||
if (active.cameraContract !== cameraContract) {
|
||||
active.configureCameraJournal?.(
|
||||
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
active.cameraContract = cameraContract;
|
||||
}
|
||||
const cameraEye = active.getCameraEye?.();
|
||||
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||
origin: window.location.origin,
|
||||
blueprintSessionId: blueprintSessionIdRef.current,
|
||||
@@ -1969,7 +2108,15 @@ export function RerunViewport({
|
||||
perceptionLayers: recordedPerceptionLayers,
|
||||
semanticLayer: recordedSemanticLayer,
|
||||
unifiedPerception: recordedUnifiedPerception,
|
||||
unifiedCameraShare: recordedUnifiedCameraShare,
|
||||
planView: recordedPlanView,
|
||||
cameraEye,
|
||||
currentTimeNs: active.getCurrentTimeNs?.(),
|
||||
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if (blueprintChannelRef.current === active) {
|
||||
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
}).then((payload) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
@@ -1980,6 +2127,9 @@ export function RerunViewport({
|
||||
return;
|
||||
}
|
||||
active.channel.send_rrd(payload);
|
||||
active.setCameraViewportStart?.(
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
}).catch(() => {
|
||||
// The recording remains usable with its embedded default blueprint.
|
||||
// A later settings change retries through the same small channel.
|
||||
@@ -1993,8 +2143,10 @@ export function RerunViewport({
|
||||
recordedFollowTrajectory,
|
||||
recordedSemanticLayer,
|
||||
recordedUnifiedPerception,
|
||||
recordedUnifiedCameraShare,
|
||||
recordedPlanView,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionLayers.cameraImage,
|
||||
recordedPerceptionLayers.detections2d,
|
||||
recordedPerceptionLayers.segmentation,
|
||||
recordedPerceptionLayers.cuboids3d,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||
import { resolveAICompositionReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import type { AICompositionRun } from "../../core/observatory/aiComposition";
|
||||
|
||||
function resolveComposition(
|
||||
runId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal },
|
||||
) {
|
||||
return resolveAICompositionReplay(runId, launch, options);
|
||||
}
|
||||
|
||||
export function AICompositionReplay({ run }: { run: AICompositionRun }) {
|
||||
const semantics = run.moduleIds.includes("ddrnet") || run.moduleIds.includes("eomt");
|
||||
const detections = run.moduleIds.includes("rf-detr")
|
||||
|| run.moduleIds.includes("object-distance");
|
||||
const costmap = run.moduleIds.includes("tgs");
|
||||
return (
|
||||
<CanonicalResultRerunReplay
|
||||
key={run.runId}
|
||||
resultId={run.runId}
|
||||
sessionId={run.sourceSessionId}
|
||||
resolveReplay={resolveComposition}
|
||||
semantics={semantics}
|
||||
detections={detections}
|
||||
costmap={costmap}
|
||||
moduleIds={run.moduleIds}
|
||||
viewerLayers={run.presentation.viewerLayers}
|
||||
label={run.configurationLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,9 @@ export function CanonicalRecordedLabReplay<
|
||||
mediaLayerControls,
|
||||
spatialLayerControls,
|
||||
spatialLeadingControl,
|
||||
spatialTrailingControl,
|
||||
mediaModeControlsVisible = true,
|
||||
paneToolbarsAlwaysVisible = false,
|
||||
mediaMultiLayer = false,
|
||||
mediaContent,
|
||||
spatialContent,
|
||||
@@ -116,10 +119,14 @@ export function CanonicalRecordedLabReplay<
|
||||
mediaLayerControls?: ReactNode;
|
||||
spatialLayerControls?: ReactNode;
|
||||
spatialLeadingControl?: ReactNode;
|
||||
spatialTrailingControl?: ReactNode;
|
||||
mediaModeControlsVisible?: boolean;
|
||||
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
|
||||
paneToolbarsAlwaysVisible?: boolean;
|
||||
mediaMultiLayer?: boolean;
|
||||
mediaContent?: ReactNode;
|
||||
spatialContent?: ReactNode;
|
||||
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
|
||||
/** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
|
||||
unifiedContent?: ReactNode;
|
||||
emptyMessage: string;
|
||||
deckOverlays?: ReactNode;
|
||||
@@ -162,14 +169,14 @@ export function CanonicalRecordedLabReplay<
|
||||
aria-label={mediaAriaLabel}
|
||||
hidden={mediaMode === "none"}
|
||||
>
|
||||
{splitView ? (
|
||||
{splitView || paneToolbarsAlwaysVisible ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="media"
|
||||
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
|
||||
>
|
||||
{mediaLayerControls}
|
||||
{mediaModeControls}
|
||||
{mediaModeControlsVisible ? mediaModeControls : null}
|
||||
</div>
|
||||
) : null}
|
||||
{mediaContent}
|
||||
@@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay<
|
||||
data-pane="spatial"
|
||||
aria-label={spatialAriaLabel}
|
||||
>
|
||||
{splitView ? (
|
||||
{splitView || paneToolbarsAlwaysVisible ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="spatial"
|
||||
@@ -190,6 +197,7 @@ export function CanonicalRecordedLabReplay<
|
||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||
{spatialLayerControls}
|
||||
{spatialModeControls}
|
||||
{spatialTrailingControl}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -216,7 +224,7 @@ export function CanonicalRecordedLabReplay<
|
||||
expanded={expanded}
|
||||
onModeChange={onMediaModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
modeControlsVisible={!splitView}
|
||||
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
@@ -241,7 +249,7 @@ export function CanonicalRecordedLabReplay<
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView && !unifiedContent}
|
||||
resizable={splitView}
|
||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||
/>
|
||||
{mediaMode === "none" && spatialMode === "none" ? (
|
||||
|
||||
@@ -4,14 +4,20 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Checker,
|
||||
ControlRow,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
IconButton,
|
||||
Inspector,
|
||||
InspectorSelectField,
|
||||
RangeControl,
|
||||
ToastStack,
|
||||
Window,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
@@ -26,18 +32,24 @@ import {
|
||||
CanonicalRecordedLabReplay,
|
||||
useCanonicalRecordedLabReplayState,
|
||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||
import {
|
||||
type CanonicalLabReplayDescriptor,
|
||||
} from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import { defaultSceneSettings } from "../../sceneSettings";
|
||||
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
||||
import {
|
||||
fetchLabViewProfile,
|
||||
saveLabViewProfile,
|
||||
} from "../../core/observatory/labViewProfile";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "../../sceneSettings";
|
||||
|
||||
type MediaMode = "video" | "camera";
|
||||
type MediaMode = "camera";
|
||||
type SpatialMode = "3d" | "plan";
|
||||
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
||||
type SemanticLayer = "city" | "vegetation";
|
||||
|
||||
interface CanonicalReplayLaunch {
|
||||
base: ObservationSessionReplayLaunch;
|
||||
@@ -45,7 +57,80 @@ interface CanonicalReplayLaunch {
|
||||
}
|
||||
|
||||
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
||||
const LAB_SCENE_SETTINGS_STORAGE_PREFIX = "missioncore.observatory.lab-scene-settings.v1:";
|
||||
const LAB_VIEW_PROFILE_ERROR_TOAST = "observatory-lab-view-profile-error";
|
||||
|
||||
const COLOR_OPTIONS: readonly { value: PointColorMode; label: string }[] = [
|
||||
{ value: "intensity", label: "Интенсивность" },
|
||||
{ value: "height", label: "Высота Z" },
|
||||
{ value: "distance", label: "Дистанция" },
|
||||
{ value: "rgb", label: "RGB" },
|
||||
{ value: "class", label: "Класс" },
|
||||
];
|
||||
|
||||
const PALETTE_OPTIONS: readonly { value: PointPalette; label: string }[] = [
|
||||
{ value: "turbo", label: "Turbo" },
|
||||
{ value: "viridis", label: "Viridis" },
|
||||
{ value: "plasma", label: "Plasma" },
|
||||
{ value: "grayscale", label: "Серый" },
|
||||
];
|
||||
|
||||
const LAB_SCENE_DEFAULTS: SceneSettings = {
|
||||
...defaultSceneSettings,
|
||||
pointSize: 3.8,
|
||||
accumulationSeconds: 5,
|
||||
};
|
||||
|
||||
export function normalizeLabSceneSettings(settings: SceneSettings): SceneSettings {
|
||||
const pointSize = Number.isFinite(settings.pointSize) ? settings.pointSize : LAB_SCENE_DEFAULTS.pointSize;
|
||||
const accumulationSeconds = Number.isFinite(settings.accumulationSeconds)
|
||||
? settings.accumulationSeconds : LAB_SCENE_DEFAULTS.accumulationSeconds;
|
||||
return {
|
||||
...settings,
|
||||
pointSize: Math.round(Math.max(0.1, pointSize) * 10) / 10,
|
||||
accumulationSeconds: Math.round(Math.max(0, accumulationSeconds)),
|
||||
};
|
||||
}
|
||||
|
||||
function loadLabSceneSettings(resultId: string): SceneSettings {
|
||||
if (typeof window === "undefined") return LAB_SCENE_DEFAULTS;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`);
|
||||
if (raw === null) return LAB_SCENE_DEFAULTS;
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return LAB_SCENE_DEFAULTS;
|
||||
const row = value as Record<string, unknown>;
|
||||
const pointSize = row.pointSize;
|
||||
const accumulationSeconds = row.accumulationSeconds;
|
||||
const colorMode = row.colorMode;
|
||||
const palette = row.palette;
|
||||
const showGrid = row.showGrid;
|
||||
const showLabels = row.showLabels;
|
||||
const showCameraFrustums = row.showCameraFrustums;
|
||||
if (
|
||||
typeof pointSize !== "number" || !Number.isFinite(pointSize) || pointSize < 0.1
|
||||
|| typeof accumulationSeconds !== "number" || !Number.isFinite(accumulationSeconds)
|
||||
|| accumulationSeconds < 0
|
||||
|| !COLOR_OPTIONS.some((option) => option.value === colorMode)
|
||||
|| !PALETTE_OPTIONS.some((option) => option.value === palette)
|
||||
|| typeof showGrid !== "boolean"
|
||||
|| typeof showLabels !== "boolean"
|
||||
|| typeof showCameraFrustums !== "boolean"
|
||||
) return LAB_SCENE_DEFAULTS;
|
||||
return {
|
||||
...LAB_SCENE_DEFAULTS,
|
||||
pointSize,
|
||||
accumulationSeconds,
|
||||
colorMode: colorMode as PointColorMode,
|
||||
palette: palette as PointPalette,
|
||||
showGrid,
|
||||
showLabels,
|
||||
showCameraFrustums,
|
||||
};
|
||||
} catch {
|
||||
return LAB_SCENE_DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
export function CanonicalResultRerunReplay({
|
||||
resultId,
|
||||
@@ -53,15 +138,22 @@ export function CanonicalResultRerunReplay({
|
||||
initialPlaybackStartSeconds,
|
||||
resolveReplay,
|
||||
semantics = false,
|
||||
detections = false,
|
||||
costmap = false,
|
||||
label = "Сохранённый результат · синхронизированный повтор",
|
||||
moduleIds = [],
|
||||
viewerLayers = [],
|
||||
label = "Сохранённый результат",
|
||||
}: {
|
||||
resultId: string;
|
||||
sessionId: string;
|
||||
initialPlaybackStartSeconds?: number;
|
||||
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
|
||||
semantics?: boolean;
|
||||
detections?: boolean;
|
||||
costmap?: boolean;
|
||||
portable?: boolean;
|
||||
moduleIds?: readonly string[];
|
||||
viewerLayers?: readonly AIViewerLayer[];
|
||||
label?: string;
|
||||
}) {
|
||||
const {
|
||||
@@ -75,96 +167,106 @@ export function CanonicalResultRerunReplay({
|
||||
onSplitPrimarySizeChange,
|
||||
onExpandedChange,
|
||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "video",
|
||||
initialMediaMode: "camera",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>(costmap ? "tgs" : "source");
|
||||
// Give the portable composition its own initial native view identity.
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(costmap ? 1 : 0);
|
||||
const layerIds = useMemo(
|
||||
() => new Set(viewerLayers.map((layer) => layer.layerId)),
|
||||
[viewerLayers],
|
||||
);
|
||||
const hasProjectedLayers = viewerLayers.length > 0;
|
||||
const hasDDRNet = layerIds.has("camera.ddrnet")
|
||||
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
|
||||
const hasEoMT = layerIds.has("camera.eomt")
|
||||
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
|
||||
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
|
||||
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
|
||||
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
|
||||
const legacyUnclassified = !hasProjectedLayers
|
||||
&& moduleIds.length === 0
|
||||
&& !semantics
|
||||
&& !detections
|
||||
&& !costmap;
|
||||
const hasCameraPane = layerIds.has("camera.source")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
|
||||
const hasSourcePoints = layerIds.has("spatial.source-points")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
|
||||
const hasLocalSlam = layerIds.has("spatial.local-slam")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
|
||||
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
|
||||
const splitView = hasCameraPane && hasSpatialPane;
|
||||
|
||||
const [showCamera, setShowCamera] = useState(true);
|
||||
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
|
||||
const [showEoMT, setShowEoMT] = useState(hasEoMT);
|
||||
const [showDetections, setShowDetections] = useState(hasDetections);
|
||||
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
|
||||
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
|
||||
const [showTgs, setShowTgs] = useState(hasTgs);
|
||||
const [followTarget, setFollowTarget] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
|
||||
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
|
||||
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
|
||||
);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [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 sceneDraftRef = useRef(sceneDraft);
|
||||
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;
|
||||
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
|
||||
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
}
|
||||
trackedLaunchSha256Ref.current = launchSha256;
|
||||
previousSplitViewRef.current = splitView;
|
||||
const cameraPanePercent = mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100;
|
||||
viewerFrameRef.current?.style.setProperty(
|
||||
"--canonical-rerun-camera-pane",
|
||||
`${cameraPanePercent}%`,
|
||||
);
|
||||
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
||||
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) return;
|
||||
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [splitPrimarySize, splitView]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
|
||||
JSON.stringify({
|
||||
pointSize: sceneDraft.pointSize,
|
||||
accumulationSeconds: sceneDraft.accumulationSeconds,
|
||||
colorMode: sceneDraft.colorMode,
|
||||
palette: sceneDraft.palette,
|
||||
showGrid: sceneDraft.showGrid,
|
||||
showLabels: sceneDraft.showLabels,
|
||||
showCameraFrustums: sceneDraft.showCameraFrustums,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// The visual remains usable when the browser refuses local persistence.
|
||||
}
|
||||
}, [resultId, sceneDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
|
||||
if (!controller.signal.aborted && settings) {
|
||||
sceneDraftRef.current = settings;
|
||||
setSceneDraft(settings);
|
||||
}
|
||||
}).catch(() => {
|
||||
// The per-browser copy remains a usable fallback while the local service recovers.
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -179,16 +281,12 @@ export function CanonicalResultRerunReplay({
|
||||
onUpdate: () => undefined,
|
||||
}).then(async (value) => ({
|
||||
base: value,
|
||||
replay: await resolveReplay(resultId, value, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
|
||||
})).then((value) => {
|
||||
if (!controller.signal.aborted) setLaunch(value);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLaunchError(
|
||||
caught instanceof Error ? caught.message : "Сохранённая запись недоступна.",
|
||||
);
|
||||
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
@@ -198,17 +296,15 @@ export function CanonicalResultRerunReplay({
|
||||
&& 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]);
|
||||
: presentationReady ? "ready" : "loading";
|
||||
const sceneSettings = useMemo<SceneSettings>(() => ({
|
||||
...sceneDraft,
|
||||
projection: spatialMode === "plan" ? "2d" : "3d",
|
||||
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
|
||||
showTrajectory: spatialMode !== null && showLocalSlam,
|
||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||
const semanticVisible = showDDRNet || showEoMT;
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
@@ -222,88 +318,99 @@ export function CanonicalResultRerunReplay({
|
||||
presentationGate: "ready",
|
||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
||||
// Seek just inside the first portable sample, not onto the exact latest-at
|
||||
// boundary. This changes only the initial cursor, never source timestamps.
|
||||
initialPlaybackStartSeconds: initialPlaybackStartSeconds
|
||||
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
|
||||
+ (costmap ? 0.000001 : 0)),
|
||||
view: mediaMode !== null ? "perception" : "spatial",
|
||||
+ (hasTgs ? 0.000001 : 0)),
|
||||
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
|
||||
viewResetGeneration,
|
||||
followTrajectory: true,
|
||||
semanticLayer,
|
||||
followTrajectory: followTarget,
|
||||
semanticLayer: showEoMT ? "city" : "vegetation",
|
||||
unifiedPerception: splitView,
|
||||
unifiedCameraShare: blueprintSplitPrimarySize / 100,
|
||||
planView: spatialMode === "plan",
|
||||
perceptionLayers: {
|
||||
enabled: mediaMode !== null,
|
||||
detections2d: semantics && mediaMode === "video",
|
||||
segmentation: semantics && mediaMode === "video" && showSemantics,
|
||||
enabled: true,
|
||||
cameraImage: showCamera,
|
||||
detections2d: hasDetections && showDetections,
|
||||
segmentation: semantics && semanticVisible,
|
||||
cuboids3d: false,
|
||||
...(costmap ? { costmap: spatialMode !== null && spatialLayer === "tgs" } : {}),
|
||||
costmap: hasTgs && showTgs,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: mediaMode !== null,
|
||||
lockPerceptionCameraInteraction: false,
|
||||
}) : null;
|
||||
|
||||
const mediaLayerControls = semantics ? (
|
||||
<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>
|
||||
) : undefined;
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Пространственные слои результата"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={spatialLayer}
|
||||
items={[
|
||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
||||
{ value: "local", label: "ЛОК. SLAM" },
|
||||
{ value: "tgs", label: "TGS", disabled: !costmap },
|
||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
||||
]}
|
||||
label="Пространственные слои"
|
||||
size="dense"
|
||||
onChange={setSpatialLayer}
|
||||
/>
|
||||
const toggle = (caption: string, active: boolean, onClick: () => void) => (
|
||||
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
|
||||
aria-pressed={active} onClick={onClick}>
|
||||
{caption}
|
||||
</Button>
|
||||
);
|
||||
const openSlamSettings = useCallback(() => {
|
||||
if (expanded) onExpandedChange(false);
|
||||
setSettingsOpen(true);
|
||||
}, [expanded, onExpandedChange]);
|
||||
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
|
||||
const next = { ...sceneDraftRef.current, ...patch };
|
||||
sceneDraftRef.current = next;
|
||||
setSceneDraft(next);
|
||||
}, []);
|
||||
const closeSlamSettings = useCallback(() => {
|
||||
const next = normalizeLabSceneSettings(sceneDraftRef.current);
|
||||
sceneDraftRef.current = next;
|
||||
setSceneDraft(next);
|
||||
setSettingsOpen(false);
|
||||
void saveLabViewProfile(resultId, next).then((settings) => {
|
||||
sceneDraftRef.current = settings;
|
||||
setSceneDraft(settings);
|
||||
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
|
||||
}).catch(() => {
|
||||
setToasts((current) => [
|
||||
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
|
||||
{
|
||||
id: LAB_VIEW_PROFILE_ERROR_TOAST,
|
||||
tone: "error",
|
||||
title: "Настройки LAB не сохранились",
|
||||
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [resultId]);
|
||||
const mediaLayerControls = (
|
||||
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
|
||||
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
|
||||
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
|
||||
{hasDDRNet || hasFallbackSemantics
|
||||
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
|
||||
{hasDetections
|
||||
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
|
||||
{hasSourcePoints
|
||||
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
|
||||
{hasLocalSlam ? (
|
||||
<div className="m4-replay-threat-visual__layer-with-settings">
|
||||
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
|
||||
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
|
||||
<Icon name="settings" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
|
||||
</div>
|
||||
);
|
||||
const followTargetControl = toggle(
|
||||
"СЛЕДОВАТЬ",
|
||||
followTarget,
|
||||
() => setFollowTarget((value) => !value),
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<Button
|
||||
size="dense"
|
||||
variant="ghost"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
aria-label="Сбросить положение 3D камеры"
|
||||
title="Сбросить положение 3D камеры"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||
>
|
||||
</Button>
|
||||
<IconButton label="Сбросить положение видов"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
|
||||
<Icon name="refresh" size={16} />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const transport = presentationReady && playback && playbackController ? (
|
||||
@@ -322,65 +429,39 @@ export function CanonicalResultRerunReplay({
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="canonical-vegetation-rerun-replay"
|
||||
data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}
|
||||
>
|
||||
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}>
|
||||
<CanonicalRecordedLabReplay
|
||||
label={label}
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
|
||||
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
|
||||
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
|
||||
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
splitOrientation={splitView ? "vertical" : splitOrientation}
|
||||
mediaAriaLabel="Камера и рассчитанные слои"
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
spatialTrailingControl={followTargetControl}
|
||||
mediaModeControlsVisible={false}
|
||||
paneToolbarsAlwaysVisible
|
||||
mediaMultiLayer
|
||||
actions={resetSpatialView}
|
||||
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 className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||
data-split-view={splitView ? "true" : undefined}>
|
||||
<RerunViewport profile={profile} sceneSettings={sceneSettings}
|
||||
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController} />
|
||||
</div>
|
||||
) : launchError ? (
|
||||
<div className="l3-visual-audit__state" role="alert">
|
||||
{launchError}
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" />
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
|
||||
) : <div aria-hidden="true" />}
|
||||
emptyMessage="Слои результата недоступны."
|
||||
deckOverlays={presentationState === "loading" ? (
|
||||
<div className="canonical-vegetation-rerun-replay__loading">
|
||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||
@@ -392,6 +473,52 @@ export function CanonicalResultRerunReplay({
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
|
||||
placement="end" size="sm" onClose={closeSlamSettings}>
|
||||
<Inspector defaultOpen={["points"]} sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: <>
|
||||
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
|
||||
exactValueBounds={{ min: 0.1 }}
|
||||
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
|
||||
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
|
||||
options={[...COLOR_OPTIONS]}
|
||||
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
|
||||
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
|
||||
options={[...PALETTE_OPTIONS]}
|
||||
onChange={(palette) => patchSceneDraft({ palette })} />
|
||||
</>,
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
content: <RangeControl label="Накопление" min={0} max={500} step={1}
|
||||
exactValueBounds={{ min: 0 }}
|
||||
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
|
||||
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
|
||||
},
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: <>
|
||||
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
|
||||
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
|
||||
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
|
||||
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
|
||||
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
|
||||
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
|
||||
</>,
|
||||
},
|
||||
]} />
|
||||
</Window>
|
||||
<ToastStack items={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
|
||||
import { GlassSurface } from "@nodedc/ui-react";
|
||||
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
|
||||
function resolvePortableReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
|
||||
}
|
||||
|
||||
export function PortableResultReplay({ review }: { review: ObservatoryPortableResultReview }) {
|
||||
const tgs = review.resultDocument.schema_version === "missioncore.recorded-tgs-costmap-review/v2";
|
||||
if (!tgs) return (
|
||||
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
|
||||
}
|
||||
|
||||
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
|
||||
}
|
||||
|
||||
export function PortableResultReplay({
|
||||
review,
|
||||
label,
|
||||
}: {
|
||||
review: ObservatoryPortableResultReview;
|
||||
label?: string;
|
||||
}) {
|
||||
const schema = review.resultDocument.schema_version;
|
||||
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
|
||||
const module = review.resultDocument.module;
|
||||
const moduleId = module && typeof module === "object" && "module_id" in module
|
||||
? module.module_id : undefined;
|
||||
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|
||||
|| (schema === "missioncore.recorded-ai-layer-review/v1"
|
||||
&& (moduleId === "ddrnet" || moduleId === "eomt"));
|
||||
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
|
||||
&& (moduleId === "rf-detr" || moduleId === "object-distance");
|
||||
if (!tgs && !semantics && !objects) return (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg">
|
||||
<div><StatusBadge tone="success">Проверено</StatusBadge>
|
||||
<h3>{review.resultKind}</h3><p>Связанных артефактов: {review.artifacts.length}</p>
|
||||
<details><summary>Документ результата</summary>
|
||||
<pre>{JSON.stringify(review.resultDocument, null, 2)}</pre></details>
|
||||
</div>
|
||||
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
|
||||
</GlassSurface>
|
||||
);
|
||||
return <>
|
||||
<p>Камера, исходное облако и TGS · общая шкала времени. Зелёный — опора,
|
||||
красный — занятое пространство, жёлтый — отклонённые точки.
|
||||
Отсутствие ячеек не означает свободный путь. Сегментация и детекция в этот профиль не входят.</p>
|
||||
<CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
||||
sessionId={review.sourceSessionId} costmap resolveReplay={resolvePortableReplay} />
|
||||
</>;
|
||||
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
||||
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
|
||||
detections={objects}
|
||||
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
|
||||
label={label}
|
||||
resolveReplay={tgs ? resolvePortableTgsReplay
|
||||
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
FieldFrame,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
aiModuleIdForSetup,
|
||||
fetchAIModuleCatalog,
|
||||
fetchAICompositionRuns,
|
||||
fetchAICompositionJobs,
|
||||
saveAIComposition,
|
||||
type AICompositionReceipt,
|
||||
type AICompositionRun,
|
||||
type AIGroupId,
|
||||
type AIModuleCatalog,
|
||||
} from "../../core/observatory/aiComposition";
|
||||
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
|
||||
|
||||
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
|
||||
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
|
||||
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
|
||||
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
|
||||
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
|
||||
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
|
||||
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
|
||||
];
|
||||
|
||||
export function AIConfigurationWindow({
|
||||
open, sourceSessionId, sourceLabel, onClose, onCalculated,
|
||||
}: {
|
||||
readonly open: boolean;
|
||||
readonly sourceSessionId: string;
|
||||
readonly sourceLabel: string;
|
||||
readonly onClose: () => void;
|
||||
readonly onCalculated: (receipt: AICompositionReceipt) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
|
||||
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
|
||||
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
|
||||
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
|
||||
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const request = new AbortController();
|
||||
setState("loading");
|
||||
setMessage(null);
|
||||
void Promise.all([
|
||||
fetchAIModuleCatalog(request.signal),
|
||||
fetchAICompositionJobs(sourceSessionId, request.signal),
|
||||
fetchAICompositionRuns(sourceSessionId, request.signal),
|
||||
]).then(([value, jobs, runs]) => {
|
||||
if (request.signal.aborted) return;
|
||||
setCatalog(value);
|
||||
setExistingJobs(jobs);
|
||||
setExistingRuns(runs);
|
||||
setSelected({});
|
||||
setState("ready");
|
||||
}).catch((error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
|
||||
setState("error");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [open, sourceSessionId]);
|
||||
|
||||
const existingByModule = useMemo(() => {
|
||||
const result = new Map<string, ObservatoryRecordedJob>();
|
||||
for (const job of existingJobs) {
|
||||
if (job.state === "failed" || job.publication.state === "failed") continue;
|
||||
const moduleId = aiModuleIdForSetup(job.setupId);
|
||||
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
|
||||
}
|
||||
return result;
|
||||
}, [existingJobs]);
|
||||
|
||||
const selections = useMemo(() => groups.flatMap(({ id }) => {
|
||||
const moduleId = selected[id];
|
||||
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
|
||||
(item) => item.moduleId === moduleId,
|
||||
);
|
||||
return module ? [{ group: id, module }] : [];
|
||||
}), [catalog, selected]);
|
||||
|
||||
const selectedModuleIds = useMemo(
|
||||
() => selections.map(({ module }) => module.moduleId).sort(),
|
||||
[selections],
|
||||
);
|
||||
const matchingRun = useMemo(() => existingRuns.find((run) => (
|
||||
run.state !== "failed"
|
||||
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
|
||||
)), [existingRuns, selectedModuleIds]);
|
||||
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
|
||||
const configurationIsRunning = matchingRun?.state === "running";
|
||||
|
||||
function selectModule(group: AIGroupId, moduleId: string): void {
|
||||
if (!catalog) return;
|
||||
setSelected((current) => {
|
||||
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
|
||||
const modules = catalog.groups.flatMap((item) => item.modules);
|
||||
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
|
||||
if (moduleId !== "none") {
|
||||
const module = modules.find((item) => item.moduleId === moduleId);
|
||||
for (const requirement of module?.requires ?? []) {
|
||||
if (requirement.startsWith("source.")) continue;
|
||||
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
|
||||
continue;
|
||||
}
|
||||
const providers = modules.filter((item) => item.provides.includes(requirement));
|
||||
if (providers.length === 1) {
|
||||
const providerGroup = catalog.groups.find((item) => item.modules.some(
|
||||
(candidate) => candidate.moduleId === providers[0]?.moduleId,
|
||||
))?.group;
|
||||
if (providerGroup) {
|
||||
next[providerGroup] = providers[0]?.moduleId;
|
||||
selectedIds.add(providers[0]?.moduleId ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
|
||||
.flatMap((item) => item.provides));
|
||||
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
|
||||
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
|
||||
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
|
||||
(module) => module.moduleId === item.moduleId,
|
||||
))?.group;
|
||||
if (dependentGroup) {
|
||||
next[dependentGroup] = undefined;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setMessage(null);
|
||||
if (state === "error") setState("ready");
|
||||
}
|
||||
|
||||
async function calculate(): Promise<void> {
|
||||
if (selections.length === 0 || state === "saving") return;
|
||||
if (configurationAlreadyExists) {
|
||||
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
|
||||
return;
|
||||
}
|
||||
setState("saving");
|
||||
setMessage(null);
|
||||
try {
|
||||
const receipt = await saveAIComposition(sourceSessionId, selections);
|
||||
onCalculated(receipt);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
return <Window
|
||||
open={open}
|
||||
className="observatory-ai-config-window"
|
||||
title="Сконфигурировать AI-слой"
|
||||
subtitle={sourceLabel}
|
||||
size="lg"
|
||||
closeOnBackdrop={state !== "saving"}
|
||||
closeOnEscape={state !== "saving"}
|
||||
onClose={onClose}
|
||||
footer={<WindowFooterActions>
|
||||
{configurationAlreadyExists ? (
|
||||
<span className="observatory-ai-config__duplicate" role="status">
|
||||
{configurationIsRunning
|
||||
? "Текущая конфигурация уже рассчитывается."
|
||||
: "Текущая конфигурация уже рассчитана."}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
|
||||
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
|
||||
aria-busy={state === "saving"}
|
||||
onClick={() => void calculate()}
|
||||
>
|
||||
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
|
||||
</Button>
|
||||
</WindowFooterActions>}
|
||||
>
|
||||
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
|
||||
<p className="observatory-ai-config__lead">
|
||||
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
|
||||
</p>
|
||||
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
|
||||
{catalog ? groups.map(({ id, label, description }) => {
|
||||
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
|
||||
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
|
||||
const selectedExisting = selectedModule
|
||||
? existingByModule.get(selectedModule.moduleId)
|
||||
: undefined;
|
||||
const allCalculated = modules.length > 0 && modules.every(
|
||||
(module) => existingByModule.has(module.moduleId),
|
||||
);
|
||||
return <section className="observatory-ai-module-group" key={id}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
|
||||
{selectedExisting
|
||||
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
|
||||
: selectedModule?.label ?? (allCalculated
|
||||
? "Результат доступен"
|
||||
: modules.length ? "Не используется" : "Не установлен")}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="observatory-ai-module-group__body">
|
||||
<FieldFrame
|
||||
label={`Модуль: ${label}`}
|
||||
description={modules.length
|
||||
? allCalculated
|
||||
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
|
||||
: id === "segmentation"
|
||||
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
|
||||
: id === "range"
|
||||
? "Дистанция использует рамки детектора и синхронное облако точек."
|
||||
: id === "geometry"
|
||||
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
|
||||
: "Связи с другими блоками определяются входами выбранного модуля."
|
||||
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
|
||||
>
|
||||
<Select
|
||||
label={`Выбрать модуль: ${label}`}
|
||||
value={selected[id] ?? "none"}
|
||||
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
|
||||
value: module.moduleId,
|
||||
label: module.label,
|
||||
description: existingByModule.has(module.moduleId)
|
||||
? "Готовый результат будет использован без повторного расчёта"
|
||||
: module.dockerName,
|
||||
}))]}
|
||||
disabled={modules.length === 0 || state === "saving"}
|
||||
onChange={(value) => selectModule(id, value)}
|
||||
/>
|
||||
</FieldFrame>
|
||||
</div>
|
||||
</section>;
|
||||
}) : null}
|
||||
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
|
||||
</div>
|
||||
</Window>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebViewer } from "@rerun-io/web-viewer";
|
||||
|
||||
import type { RerunFrameWindow } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunOwner } from "./recordedRerunOwner";
|
||||
|
||||
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
|
||||
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
|
||||
const mount = document.getElementById("viewer");
|
||||
if (!mount) throw new Error("Recorded viewer mount is missing");
|
||||
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
|
||||
import type { RerunFrameWindow } from "./recordedRerunProtocol";
|
||||
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
|
||||
|
||||
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
|
||||
export function createIsolatedRerunHost(host: HTMLElement) {
|
||||
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
|
||||
frame.title = "Синхронизированная запись";
|
||||
frame.className = "rerun-viewport__runtime";
|
||||
frame.src = "/rerun-runtime.html";
|
||||
let disposed = false;
|
||||
let releaseFacade: (() => void) | null = null;
|
||||
let releaseInput: (() => void) | null = null;
|
||||
let frameWindow: RerunFrameWindow | null = null;
|
||||
let rejectReady: ((reason: Error) => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
frame!.onload = null;
|
||||
frame!.onerror = null;
|
||||
releaseInput?.();
|
||||
releaseInput = null;
|
||||
try {
|
||||
releaseFacade?.();
|
||||
} catch {
|
||||
// Realm teardown is required even if a partial native start failed.
|
||||
} finally {
|
||||
releaseFacade = null;
|
||||
frameWindow = null;
|
||||
frame!.remove();
|
||||
frame = null;
|
||||
rejectReady?.(new Error("Recorded viewer was disposed"));
|
||||
rejectReady = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
|
||||
rejectReady = reject;
|
||||
frame!.onload = () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
|
||||
const api = frameWindow?.missionCoreRerun;
|
||||
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
|
||||
const owner = createRecordedRerunFacade((request, bytes) => {
|
||||
const current = frameWindow?.missionCoreRerun;
|
||||
if (!current) throw new Error("Recorded viewer was disposed");
|
||||
return current.invoke(request, bytes);
|
||||
});
|
||||
releaseFacade = owner.dispose;
|
||||
api.connect(owner.notify);
|
||||
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
|
||||
clearTimeout(timer);
|
||||
rejectReady = null;
|
||||
frame!.onload = null;
|
||||
frame!.onerror = null;
|
||||
resolve({ viewer: owner.facade, mount: host });
|
||||
} catch (error) {
|
||||
reject(new Error(String(error)));
|
||||
dispose();
|
||||
}
|
||||
};
|
||||
frame!.onerror = () => {
|
||||
reject(new Error("Recorded viewer host failed to load"));
|
||||
dispose();
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error("Recorded viewer host timed out"));
|
||||
dispose();
|
||||
}, 30_000);
|
||||
});
|
||||
host.append(frame);
|
||||
return { ready, dispose };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Relay Escape from the isolated viewer realm to the owning application. */
|
||||
export function relayIsolatedRerunInput(
|
||||
host: HTMLElement,
|
||||
frameWindow: Window | null,
|
||||
_frame: HTMLIFrameElement | null,
|
||||
) {
|
||||
const parent = host.ownerDocument.defaultView;
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
|
||||
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
};
|
||||
frameWindow?.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
frameWindow?.removeEventListener("keydown", escape);
|
||||
frameWindow = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
export type RecordedRerunCameraEye = {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly lookTarget: readonly [number, number, number];
|
||||
readonly eyeUp: readonly [number, number, number];
|
||||
};
|
||||
|
||||
type Vector3 = [number, number, number];
|
||||
type DragMode = "rotate" | "pan" | "roll";
|
||||
|
||||
const ROTATION_RADIANS_PER_POINT = 0.004;
|
||||
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
|
||||
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
|
||||
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
|
||||
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
|
||||
const DOM_WHEEL_DELTA_LINE = 1;
|
||||
const DOM_WHEEL_DELTA_PAGE = 2;
|
||||
|
||||
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||
position: [16, -16, 18],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 0, 1],
|
||||
};
|
||||
|
||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||
position: [0, 0, 30],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 1, 0],
|
||||
};
|
||||
|
||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
const length = (value: Vector3) => Math.hypot(...value);
|
||||
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
|
||||
const magnitude = length(value);
|
||||
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
|
||||
};
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
|
||||
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
|
||||
const axis = normalize(rawAxis, [0, 0, 1]);
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
return add(
|
||||
add(scale(value, cosine), scale(cross(axis, value), sine)),
|
||||
scale(axis, dot(axis, value) * (1 - cosine)),
|
||||
);
|
||||
};
|
||||
|
||||
function cloneEye(value: RecordedRerunCameraEye): {
|
||||
position: Vector3;
|
||||
lookTarget: Vector3;
|
||||
eyeUp: Vector3;
|
||||
} {
|
||||
return {
|
||||
position: vector(value.position),
|
||||
lookTarget: vector(value.lookTarget),
|
||||
eyeUp: vector(value.eyeUp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
|
||||
* renderer. Rerun writes operator navigation into its active blueprint clone;
|
||||
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
|
||||
* here lets a later layer blueprint activate with the exact operator pose.
|
||||
*/
|
||||
export function createRecordedRerunCameraJournal(
|
||||
canvas: HTMLCanvasElement,
|
||||
scope: Window & typeof globalThis,
|
||||
) {
|
||||
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
|
||||
let spatialViewportStart = 0;
|
||||
let pointerId: number | null = null;
|
||||
let dragMode: DragMode | null = null;
|
||||
let previousPointer: readonly [number, number] | null = null;
|
||||
let latestPointer: readonly [number, number] | null = null;
|
||||
let maxOrbitalRadius = 1.0e17;
|
||||
|
||||
const isSpatialPoint = (event: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
|
||||
};
|
||||
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
|
||||
const usableUp = () => {
|
||||
const fwd = forward();
|
||||
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
|
||||
? [1, 0, 0]
|
||||
: cross(fwd, [0, 0, 1]);
|
||||
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
|
||||
const candidate = normalize(eye.eyeUp, fallback);
|
||||
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
|
||||
};
|
||||
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
|
||||
|
||||
const rotate = (deltaX: number, deltaY: number) => {
|
||||
const radius = orbitRadius();
|
||||
const up = usableUp();
|
||||
let fwd = forward();
|
||||
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
|
||||
fwd = normalize(
|
||||
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
|
||||
fwd,
|
||||
);
|
||||
const right = normalize(cross(fwd, up), [1, 0, 0]);
|
||||
const maxPitch = 0.99 * Math.PI / 2;
|
||||
const nextPitch = clamp(
|
||||
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
|
||||
-maxPitch,
|
||||
maxPitch,
|
||||
);
|
||||
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
|
||||
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
|
||||
};
|
||||
|
||||
const pan = (deltaX: number, deltaY: number) => {
|
||||
const fwd = forward();
|
||||
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
|
||||
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
|
||||
const speed = 0.001 * orbitRadius();
|
||||
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
|
||||
eye.position = add(eye.position, translation);
|
||||
eye.lookTarget = add(eye.lookTarget, translation);
|
||||
};
|
||||
|
||||
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const left = rect.left + rect.width * spatialViewportStart;
|
||||
const centerX = left + (rect.right - left) / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const relativeX = event.clientX - centerX;
|
||||
const relativeY = event.clientY - centerY;
|
||||
const divisor = relativeX * relativeX + relativeY * relativeY;
|
||||
if (divisor <= Number.EPSILON) return;
|
||||
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
|
||||
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
|
||||
};
|
||||
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (!isSpatialPoint(event)) return;
|
||||
pointerId = event.pointerId;
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
|
||||
const deltaX = event.clientX - previousPointer[0];
|
||||
const deltaY = event.clientY - previousPointer[1];
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
if (dragMode === "rotate") rotate(deltaX, deltaY);
|
||||
else if (dragMode === "pan") pan(deltaX, deltaY);
|
||||
else roll(event, deltaX, deltaY);
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerMove(event);
|
||||
pointerId = null;
|
||||
previousPointer = null;
|
||||
dragMode = null;
|
||||
};
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
|
||||
? event.clientX
|
||||
: latestPointer?.[0];
|
||||
if (wheelX === undefined || rect.width <= 0) return;
|
||||
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
|
||||
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
|
||||
? RERUN_WEB_LINE_SCROLL_POINTS
|
||||
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
|
||||
? rect.height
|
||||
: 1;
|
||||
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
|
||||
// eframe negates the DOM delta before Rerun applies
|
||||
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
|
||||
// exponent products collapse to the raw accumulated DOM delta below.
|
||||
const divisor = event.ctrlKey
|
||||
? RERUN_WEB_PINCH_SCROLL_DIVISOR
|
||||
: RERUN_ORBITAL_SCROLL_DIVISOR;
|
||||
const radiusFactor = Math.exp(scrollPoints / divisor);
|
||||
const offset = subtract(eye.position, eye.lookTarget);
|
||||
const radius = length(offset);
|
||||
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
|
||||
// the scene-derived cap is allowed to stay there and is never snapped in.
|
||||
const nextRadius = clamp(
|
||||
radius * radiusFactor,
|
||||
RERUN_MIN_ORBIT_DISTANCE,
|
||||
Math.max(radius, maxOrbitalRadius),
|
||||
);
|
||||
eye.position = add(
|
||||
eye.lookTarget,
|
||||
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", pointerDown, true);
|
||||
scope.addEventListener("pointermove", pointerMove, true);
|
||||
scope.addEventListener("pointerup", pointerEnd, true);
|
||||
scope.addEventListener("pointercancel", pointerEnd, true);
|
||||
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
|
||||
|
||||
return {
|
||||
current(): RecordedRerunCameraEye {
|
||||
return {
|
||||
position: [...eye.position],
|
||||
lookTarget: [...eye.lookTarget],
|
||||
eyeUp: [...eye.eyeUp],
|
||||
};
|
||||
},
|
||||
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
|
||||
eye = cloneEye(nextEye);
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
pointerId = null;
|
||||
dragMode = null;
|
||||
previousPointer = null;
|
||||
},
|
||||
setSpatialViewportStart(nextSpatialViewportStart: number) {
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
},
|
||||
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
|
||||
if (
|
||||
Number.isFinite(nextMaxOrbitalRadius) &&
|
||||
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
|
||||
) {
|
||||
maxOrbitalRadius = nextMaxOrbitalRadius;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointerdown", pointerDown, true);
|
||||
scope.removeEventListener("pointermove", pointerMove, true);
|
||||
scope.removeEventListener("pointerup", pointerEnd, true);
|
||||
scope.removeEventListener("pointercancel", pointerEnd, true);
|
||||
scope.removeEventListener("wheel", wheel, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunInvoke } from "./recordedRerunProtocol";
|
||||
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||
|
||||
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
|
||||
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
|
||||
export type RecordedRerunViewer = Pick<WebViewer,
|
||||
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
|
||||
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
|
||||
"get_playing" | "get_time_range" | "set_active_timeline" |
|
||||
"set_current_time" | "set_playing"
|
||||
> & {
|
||||
open_channel: (name?: string) => Channel;
|
||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye;
|
||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
||||
};
|
||||
|
||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
||||
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
|
||||
let nextId = 0;
|
||||
const callbacks = new Map<number, (...args: unknown[]) => void>();
|
||||
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
|
||||
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
|
||||
if (!invoke) throw new Error("Recorded viewer was disposed");
|
||||
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.result;
|
||||
};
|
||||
const dispose = () => {
|
||||
callbacks.clear();
|
||||
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
|
||||
starts.clear();
|
||||
try { if (invoke) command("stop"); } finally { invoke = null; }
|
||||
};
|
||||
const notify = (message: string) => {
|
||||
if (!invoke) return;
|
||||
const event = JSON.parse(message);
|
||||
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
|
||||
else {
|
||||
const pending = starts.get(event.id);
|
||||
starts.delete(event.id);
|
||||
if (event.type === "started") pending?.resolve();
|
||||
else pending?.reject(new Error(event.message));
|
||||
}
|
||||
};
|
||||
const facade: RecordedRerunViewer = {
|
||||
get ready() { return invoke ? command("ready") : false; },
|
||||
start(source, _parent, options) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
starts.set(id, { resolve, reject });
|
||||
try { command("start", [source, options], id); }
|
||||
catch (error) { starts.delete(id); reject(error); }
|
||||
});
|
||||
},
|
||||
stop: dispose,
|
||||
on: ((event: string, callback: (...args: unknown[]) => void) => {
|
||||
const id = ++nextId;
|
||||
callbacks.set(id, callback);
|
||||
try { command("on", [event], id); }
|
||||
catch (error) { callbacks.delete(id); throw error; }
|
||||
return () => {
|
||||
if (callbacks.delete(id) && invoke) command("off", [], id);
|
||||
};
|
||||
}) as WebViewer["on"],
|
||||
open_channel(name) {
|
||||
const id = ++nextId;
|
||||
command("channel-open", name === undefined ? [] : [name], id);
|
||||
let closed = false;
|
||||
return {
|
||||
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
|
||||
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (invoke) command("channel-close", [], id);
|
||||
},
|
||||
};
|
||||
},
|
||||
open: (...args) => command("open", args),
|
||||
close: (...args) => { if (invoke) command("close", args); },
|
||||
override_panel_state: (...args) => command("override_panel_state", args),
|
||||
get_active_recording_id: () => command("get_active_recording_id"),
|
||||
get_active_timeline: (...args) => command("get_active_timeline", args),
|
||||
get_current_time: (...args) => command("get_current_time", args),
|
||||
get_playing: (...args) => command("get_playing", args),
|
||||
get_time_range: (...args) => command("get_time_range", args),
|
||||
set_active_timeline: (...args) => command("set_active_timeline", args),
|
||||
set_current_time: (...args) => command("set_current_time", args),
|
||||
set_playing: (...args) => command("set_playing", args),
|
||||
configure_camera_journal: (eye, spatialViewportStart) => command(
|
||||
"configure-camera-journal", [eye, spatialViewportStart],
|
||||
),
|
||||
get_camera_eye: () => command("get-camera-eye"),
|
||||
set_camera_viewport_start: (spatialViewportStart) => command(
|
||||
"set-camera-viewport-start", [spatialViewportStart],
|
||||
),
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
|
||||
"set-camera-max-orbital-radius", [maxOrbitalRadius],
|
||||
),
|
||||
};
|
||||
return { facade, dispose, notify };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
|
||||
|
||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||
let native: WebViewer | null = null;
|
||||
let notify: RerunNotify | null = null;
|
||||
const subscriptions = new Map<number, () => void>();
|
||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
||||
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
|
||||
const send = (message: object) => notify?.(JSON.stringify(message));
|
||||
const stop = () => {
|
||||
notify = null;
|
||||
for (const unsubscribe of subscriptions.values()) {
|
||||
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
|
||||
}
|
||||
subscriptions.clear();
|
||||
for (const channel of channels.values()) {
|
||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||
}
|
||||
channels.clear();
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = null;
|
||||
const current = native;
|
||||
native = null;
|
||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||
};
|
||||
const required = () => {
|
||||
if (!native) throw new Error("Recorded viewer was disposed");
|
||||
return native;
|
||||
};
|
||||
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
|
||||
const starting = required();
|
||||
try {
|
||||
await starting.start(args[0], mount, args[1]);
|
||||
if (native === starting && starting.canvas) {
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = createRecordedRerunCameraJournal(
|
||||
starting.canvas,
|
||||
window as Window & typeof globalThis,
|
||||
);
|
||||
}
|
||||
if (native === starting) send({ type: "started", id });
|
||||
} catch (error) {
|
||||
if (native === starting) send({ type: "start-failed", id, message: String(error) });
|
||||
} finally {
|
||||
if (native !== starting) {
|
||||
try { starting.stop(); } catch { /* Late completion after disposal. */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
version: 2,
|
||||
connect(callback) {
|
||||
if (native) throw new Error("Recorded viewer already has an owner");
|
||||
native = create();
|
||||
notify = callback;
|
||||
},
|
||||
invoke(request, bytes) {
|
||||
try {
|
||||
const { method, args = [], id } = JSON.parse(request);
|
||||
let result: unknown;
|
||||
switch (method) {
|
||||
case "stop": stop(); break;
|
||||
case "start": required(); void start(id, args); break;
|
||||
case "ready": result = native?.ready ?? false; break;
|
||||
case "on": {
|
||||
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
|
||||
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
|
||||
break;
|
||||
}
|
||||
case "off":
|
||||
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
|
||||
break;
|
||||
case "channel-open": {
|
||||
const name = args[0] as string | undefined;
|
||||
channels.set(id, required().open_channel(name));
|
||||
break;
|
||||
}
|
||||
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
|
||||
case "channel-send": if (bytes) {
|
||||
channels.get(id)?.send_rrd(bytes);
|
||||
} break;
|
||||
case "channel-close":
|
||||
try { channels.get(id)?.close(); } finally {
|
||||
channels.delete(id);
|
||||
}
|
||||
break;
|
||||
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
|
||||
case "open": required().open(args[0]); break;
|
||||
case "close": required().close(args[0]); break;
|
||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
|
||||
case "get_active_recording_id": result = required().get_active_recording_id(); break;
|
||||
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
|
||||
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
|
||||
case "get_playing": result = required().get_playing(args[0]); break;
|
||||
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
|
||||
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
|
||||
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
|
||||
case "set_playing": required().set_playing(args[0], args[1]); break;
|
||||
default: throw new Error("Unsupported recorded viewer command");
|
||||
}
|
||||
return JSON.stringify({ result });
|
||||
} catch (error) {
|
||||
// A native Error also has a foreign prototype. Return text, never throw
|
||||
// that object into a parent Promise/callback retained after disposal.
|
||||
return JSON.stringify({ error: String(error) });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Only parent-owned inputs and primitive strings cross the recorded realm.
|
||||
* An SDK instance, Promise, DOM node or native callback never becomes a value
|
||||
* retained by React. This is not a second playback clock.
|
||||
*/
|
||||
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
|
||||
export type RerunNotify = (message: string) => void;
|
||||
export interface RerunFrameApi {
|
||||
version: 2;
|
||||
connect: (notify: RerunNotify) => void;
|
||||
invoke: RerunInvoke;
|
||||
}
|
||||
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
|
||||
@@ -28,19 +28,25 @@ export async function resolveCanonicalLabReplay(
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs";
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!(sourceKind === "portable-tgs" ? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId) : SAFE_RESULT_ID.test(resultId))
|
||||
!(sourceKind === "portable-tgs"
|
||||
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
|
||||
: sourceKind === "portable-semantic"
|
||||
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
|
||||
: sourceKind === "portable-objects"
|
||||
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
|
||||
: SAFE_RESULT_ID.test(resultId))
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl = sourceKind === "portable-tgs"
|
||||
const sourceUrl = sourceKind !== "legacy-vegetation"
|
||||
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
|
||||
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
@@ -76,3 +82,53 @@ export async function resolveCanonicalLabReplay(
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAICompositionReplay(
|
||||
runId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
{
|
||||
origin = window.location.origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
const response = await fetcher(descriptorUrl.href, {
|
||||
method: "HEAD",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/vnd.rerun.rrd" },
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const byteLength = Number(response.headers.get("Content-Length"));
|
||||
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||
if (
|
||||
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|
||||
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|
||||
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|
||||
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
|
||||
) {
|
||||
throw new Error("Составной replay LAB не прошёл проверку.");
|
||||
}
|
||||
return {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
|
||||
byteLength,
|
||||
sha256,
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Small lease for the server's ephemeral blueprint, never for the recording. */
|
||||
export function keepRecordedBlueprintSession({
|
||||
endpointUrl,
|
||||
origin,
|
||||
applicationId,
|
||||
recordingId,
|
||||
ownerId,
|
||||
fetcher = fetch,
|
||||
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
|
||||
cancel = (handle: number) => window.clearInterval(handle),
|
||||
}: {
|
||||
endpointUrl: string;
|
||||
origin: string;
|
||||
applicationId: string;
|
||||
recordingId: string;
|
||||
ownerId: string;
|
||||
fetcher?: typeof fetch;
|
||||
schedule?: (callback: () => void) => number;
|
||||
cancel?: (handle: number) => void;
|
||||
}): () => void {
|
||||
const endpoint = new URL(endpointUrl, origin);
|
||||
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
|
||||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
|
||||
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
|
||||
}
|
||||
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
|
||||
const identity = { application_id: applicationId, recording_id: recordingId,
|
||||
blueprint_session_id: ownerId };
|
||||
let closed = false;
|
||||
let pending: AbortController | null = null;
|
||||
const renew = () => {
|
||||
if (closed) return;
|
||||
pending?.abort();
|
||||
pending = new AbortController();
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
|
||||
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
|
||||
};
|
||||
const timer = schedule(renew);
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
cancel(timer);
|
||||
pending?.abort();
|
||||
pending = null;
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "release" }),
|
||||
keepalive: true, signal: AbortSignal.timeout(5_000),
|
||||
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
decodeObservationSessionCatalog,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
type ObservationSessionCatalog,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionScope,
|
||||
} from "./sessionArchive";
|
||||
|
||||
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
|
||||
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
|
||||
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
|
||||
readonly nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
|
||||
export async function fetchObservationSessionCatalogPage({
|
||||
scope,
|
||||
limit = 100,
|
||||
cursor = null,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
scope: ObservationSessionScope;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
}): Promise<ObservationSessionCatalogPage> {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|
||||
|| (cursor !== null && !CURSOR.test(cursor))) {
|
||||
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const query = new URLSearchParams({ limit: String(limit), scope });
|
||||
if (scope === "laboratory") query.set("lab_contract", "v3");
|
||||
query.set("pagination", "cursor-v1");
|
||||
if (cursor !== null) query.set("cursor", cursor);
|
||||
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
|
||||
method: "GET", headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ObservationSessionApiError(
|
||||
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
|
||||
}
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
|
||||
}
|
||||
const page = body as Record<string, unknown>;
|
||||
if (Object.keys(page).length !== 3
|
||||
|| page.schema_version !== PAGE_SCHEMA
|
||||
|| !Object.hasOwn(page, "items")
|
||||
|| !(page.next_cursor === null
|
||||
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
|
||||
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
|
||||
}
|
||||
const catalog = decodeObservationSessionCatalog({ items: page.items });
|
||||
if (catalog.items.length > limit) {
|
||||
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { ...catalog, nextCursor: page.next_cursor };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface RerunPlaybackController {
|
||||
|
||||
export interface RecordedPerceptionLayers {
|
||||
enabled: boolean;
|
||||
cameraImage?: boolean;
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
@@ -76,6 +77,8 @@ export interface RecordedSessionRerunProfile {
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||
unifiedPerception?: boolean;
|
||||
/** Controlled camera-column share for the native horizontal Rerun container. */
|
||||
unifiedCameraShare?: number;
|
||||
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||
planView?: boolean;
|
||||
perceptionRetryGeneration: number;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
decodeObservatoryRecordedJob,
|
||||
type ObservatoryRecordedJob,
|
||||
} from "./recordedJobs";
|
||||
|
||||
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
|
||||
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
|
||||
|
||||
export interface AIModule {
|
||||
readonly moduleId: string;
|
||||
readonly moduleSha256: string;
|
||||
readonly label: string;
|
||||
readonly dockerName: string;
|
||||
readonly requires: readonly string[];
|
||||
readonly provides: readonly string[];
|
||||
readonly defaults: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface AIModuleGroup {
|
||||
readonly group: AIGroupId;
|
||||
readonly modules: readonly AIModule[];
|
||||
}
|
||||
|
||||
export interface AIModuleCatalog {
|
||||
readonly groups: readonly AIModuleGroup[];
|
||||
}
|
||||
|
||||
export interface AICompositionReceipt {
|
||||
readonly compositionSha256: string;
|
||||
readonly created: boolean;
|
||||
readonly dispatchReady: boolean;
|
||||
readonly dispatchReason: string;
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly run: AICompositionRun;
|
||||
}
|
||||
|
||||
export interface AIViewerLayer {
|
||||
readonly layerId: string;
|
||||
readonly paneId: "camera" | "spatial";
|
||||
readonly label: string;
|
||||
readonly control: "toggle" | "toggle-with-settings";
|
||||
readonly order: number;
|
||||
}
|
||||
|
||||
export interface AICompositionPresentation {
|
||||
readonly configurationLabel: string;
|
||||
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
|
||||
readonly viewerLayers: readonly AIViewerLayer[];
|
||||
}
|
||||
|
||||
export interface AICompositionRun {
|
||||
readonly runId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly compositionSha256: string;
|
||||
readonly moduleIds: readonly string[];
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobIds: readonly string[];
|
||||
readonly resultIds: readonly string[];
|
||||
readonly createdAtUtc: string;
|
||||
readonly state: "running" | "ready" | "failed";
|
||||
readonly configurationLabel: string;
|
||||
readonly displayName: string | null;
|
||||
readonly presentation: AICompositionPresentation;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
}
|
||||
|
||||
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
ddrnet: "ai-segmentation-ddrnet-v1",
|
||||
eomt: "ai-segmentation-eomt-v1",
|
||||
tgs: "m49-tgs-portable-v2",
|
||||
"rf-detr": "ai-detection-rf-detr-v1",
|
||||
"object-distance": "ai-range-object-distance-v1",
|
||||
});
|
||||
|
||||
export function aiModuleIdForSetup(setupId: string): string | null {
|
||||
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
|
||||
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "каталог AI-модулей");
|
||||
exactKeys(root, ["authority", "groups", "schema_version"]);
|
||||
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
|
||||
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
|
||||
const groups = array(root.groups).map((value): AIModuleGroup => {
|
||||
const group = record(value, "группа AI-модулей");
|
||||
exactKeys(group, ["group", "modules"]);
|
||||
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
|
||||
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
|
||||
}
|
||||
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
|
||||
});
|
||||
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
|
||||
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
|
||||
}
|
||||
return { groups };
|
||||
}
|
||||
|
||||
export async function saveAIComposition(
|
||||
sourceSessionId: string,
|
||||
selections: readonly { group: AIGroupId; module: AIModule }[],
|
||||
): Promise<AICompositionReceipt> {
|
||||
const response = await fetch("/api/v1/observatory/ai-compositions", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: COMPOSITION_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
selections: selections.map(({ group, module }) => ({
|
||||
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
|
||||
parameters: module.defaults,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "конфигурация AI-слоя");
|
||||
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
|
||||
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
|
||||
throw new Error("Сервер вернул конфигурацию для другой записи.");
|
||||
}
|
||||
const digest = text(root.composition_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
|
||||
const dispatch = record(root.dispatch, "готовность композиции");
|
||||
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
|
||||
if (dispatch.ready !== true) {
|
||||
throw new Error(text(dispatch.reason));
|
||||
}
|
||||
const setupIds = array(dispatch.setup_ids).map(text);
|
||||
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
|
||||
if (!jobs.length || jobs.length !== setupIds.length
|
||||
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
|
||||
throw new Error("Сервер вернул неполную очередь композиции.");
|
||||
}
|
||||
const run = compositionRunOf(root.run);
|
||||
return {
|
||||
compositionSha256: digest,
|
||||
created: boolean(dispatch && root.created),
|
||||
dispatchReady: boolean(dispatch.ready),
|
||||
dispatchReason: text(dispatch.reason),
|
||||
setupIds,
|
||||
jobs,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAICompositionRuns(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly AICompositionRun[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "запуски композиций");
|
||||
exactKeys(root, ["items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
|
||||
throw new Error("Версия запусков AI-композиций изменилась.");
|
||||
}
|
||||
return array(root.items).map(compositionRunOf);
|
||||
}
|
||||
|
||||
export async function renameAICompositionRunProjection(
|
||||
runId: string,
|
||||
displayName: string,
|
||||
): Promise<string> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI inference.");
|
||||
}
|
||||
const normalized = displayName.trim();
|
||||
if (!normalized || normalized.length > 160) {
|
||||
throw new Error("Название результата должно содержать от 1 до 160 символов.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
|
||||
display_name: normalized,
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const row = record(body, "результат AI inference");
|
||||
exactKeys(row, ["display_name", "run_id", "schema_version"]);
|
||||
if (
|
||||
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|
||||
|| row.run_id !== runId
|
||||
|| row.display_name !== normalized
|
||||
) throw new Error("Сервер не подтвердил переименование результата AI inference.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI inference.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(await bodyOf(response), response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function compositionRunOf(value: unknown): AICompositionRun {
|
||||
const row = record(value, "запуск AI-композиции");
|
||||
const expected = [
|
||||
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
|
||||
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
|
||||
"source_session_id", "state",
|
||||
];
|
||||
// The receipt embeds the immutable run before its live state projection.
|
||||
const receiptShape = [
|
||||
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
|
||||
"run_id", "schema_version", "setup_ids", "source_session_id",
|
||||
];
|
||||
const keys = Object.keys(row);
|
||||
const projected = keys.length === expected.length;
|
||||
exactKeys(row, projected ? expected : receiptShape);
|
||||
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
|
||||
throw new Error("Версия запуска AI-композиции изменилась.");
|
||||
}
|
||||
const sourceSessionId = text(row.source_session_id);
|
||||
const compositionSha256 = text(row.composition_sha256);
|
||||
const runId = text(row.run_id);
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
|
||||
throw new Error("Некорректная идентичность запуска AI-композиции.");
|
||||
}
|
||||
const presentation = presentationOf(row.presentation);
|
||||
const state = projected ? text(row.state) : "running";
|
||||
if (!new Set(["running", "ready", "failed"]).has(state)) {
|
||||
throw new Error("Неизвестное состояние запуска AI-композиции.");
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
sourceSessionId,
|
||||
compositionSha256,
|
||||
moduleIds: array(row.module_ids).map(text),
|
||||
setupIds: array(row.setup_ids).map(text),
|
||||
jobIds: array(row.job_ids).map(text),
|
||||
resultIds: projected ? array(row.result_ids).map(text) : [],
|
||||
createdAtUtc: text(row.created_at_utc),
|
||||
state: state as AICompositionRun["state"],
|
||||
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
|
||||
displayName: projected
|
||||
? row.display_name === null ? null : text(row.display_name)
|
||||
: null,
|
||||
presentation,
|
||||
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function presentationOf(value: unknown): AICompositionPresentation {
|
||||
const row = record(value, "проекция AI-композиции");
|
||||
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
|
||||
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
|
||||
throw new Error("Версия проекции AI-композиции изменилась.");
|
||||
}
|
||||
const modules = array(row.modules).map((value) => {
|
||||
const module = record(value, "модуль проекции");
|
||||
exactKeys(module, ["label", "module_id"]);
|
||||
return { moduleId: text(module.module_id), label: text(module.label) };
|
||||
});
|
||||
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
|
||||
const layer = record(value, "слой viewer");
|
||||
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
|
||||
const paneId = text(layer.pane_id);
|
||||
const control = text(layer.control);
|
||||
if (!new Set(["camera", "spatial"]).has(paneId)
|
||||
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|
||||
|| typeof layer.order !== "number") {
|
||||
throw new Error("Некорректная проекция слоя viewer.");
|
||||
}
|
||||
return {
|
||||
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
|
||||
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
|
||||
};
|
||||
});
|
||||
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
|
||||
}
|
||||
|
||||
export async function fetchAICompositionJobs(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "очередь AI-слоёв");
|
||||
exactKeys(root, ["authority", "items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
|
||||
throw new Error("Версия очереди AI-слоёв изменилась.");
|
||||
}
|
||||
return array(root.items).map(decodeObservatoryRecordedJob);
|
||||
}
|
||||
|
||||
function moduleOf(value: unknown): AIModule {
|
||||
const row = record(value, "AI-модуль");
|
||||
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
|
||||
const digest = text(row.module_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
|
||||
return {
|
||||
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
|
||||
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
|
||||
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
|
||||
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
|
||||
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
|
||||
}
|
||||
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
|
||||
function apiError(body: unknown, status: number): Error {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
|
||||
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const entropy = typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `ai-layer:${entropy}`;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
fetchObservationSessionCatalog,
|
||||
type ObservationLabInstance,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../observation/sessionArchive";
|
||||
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
|
||||
import {
|
||||
observatoryRecordedRunBinding,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
@@ -164,19 +164,23 @@ function boundedLimit(limit: number): number {
|
||||
|
||||
function evidenceFromSession(
|
||||
session: ObservationSessionSummary,
|
||||
): ObservatoryEvidence {
|
||||
): ObservatoryEvidence | null {
|
||||
if (!session.lab) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
||||
);
|
||||
}
|
||||
// Historical LABs retain their archive and replay adapters. Observatory is
|
||||
// the portable-profile product; a familiar LAB label is not admission.
|
||||
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
|
||||
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
|
||||
return {
|
||||
sessionId: session.id,
|
||||
label: session.label,
|
||||
status: session.status,
|
||||
publishedAtUtc: session.lab.publishedAtUtc,
|
||||
lab: session.lab,
|
||||
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
|
||||
recordedRun,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,11 +212,11 @@ export function buildObservatoryCatalog(
|
||||
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||
for (const laboratorySession of laboratorySessions) {
|
||||
const evidence = evidenceFromSession(laboratorySession);
|
||||
if (!evidence) continue;
|
||||
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.
|
||||
// A bounded traversal may stop before this source. Absence alone is
|
||||
// not proof of a broken relationship or permission to remove data.
|
||||
unresolvedEvidence.push(evidence);
|
||||
continue;
|
||||
}
|
||||
@@ -236,7 +240,8 @@ export function buildObservatoryCatalog(
|
||||
window: {
|
||||
limit: safeLimit,
|
||||
sourceCount: sourceSessions.length,
|
||||
laboratoryCount: laboratorySessions.length,
|
||||
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
|
||||
+ unresolvedEvidence.length,
|
||||
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||
},
|
||||
@@ -253,9 +258,64 @@ export async function fetchObservatoryCatalog({
|
||||
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);
|
||||
const request = new AbortController();
|
||||
const abort = () => request.abort(signal?.reason);
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
const [sources, laboratories] = await Promise.all([
|
||||
readCatalogPages("source", safeLimit, fetcher, request.signal),
|
||||
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
|
||||
]);
|
||||
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
|
||||
return {
|
||||
...catalog,
|
||||
window: {
|
||||
...catalog.window,
|
||||
sourceLimitReached: sources.hasMore,
|
||||
laboratoryLimitReached: laboratories.hasMore,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
request.abort();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata only, never recordings or result artifacts. The cap is an explicit
|
||||
// partial window, not silent completeness or an unbounded background crawler.
|
||||
const MAX_CATALOG_PAGES = 256;
|
||||
|
||||
async function readCatalogPages(
|
||||
scope: "source" | "laboratory",
|
||||
limit: number,
|
||||
fetcher: ObservationSessionFetch,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
|
||||
const items: ObservationSessionSummary[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const cursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
|
||||
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
|
||||
for (const item of page.items) {
|
||||
if (seenIds.has(item.id)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
|
||||
}
|
||||
seenIds.add(item.id);
|
||||
// Do not accumulate legacy provenance while walking the shared catalog.
|
||||
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
|
||||
items.push(item);
|
||||
} else if (!item.lab) {
|
||||
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
|
||||
}
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (cursor === null) return { items, hasMore: false };
|
||||
if (cursors.has(cursor)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
|
||||
}
|
||||
cursors.add(cursor);
|
||||
}
|
||||
return { items, hasMore: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
const PROFILE_SCHEMA = "missioncore.observatory-lab-view-profile/v1";
|
||||
|
||||
export async function fetchLabViewProfile(
|
||||
resultId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings | null> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Профиль отображения LAB не загрузился.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
export async function saveLabViewProfile(
|
||||
resultId: string,
|
||||
settings: SceneSettings,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
method: "PUT",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PROFILE_SCHEMA,
|
||||
result_id: resultId,
|
||||
scene_settings: {
|
||||
point_size: settings.pointSize,
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
show_grid: settings.showGrid,
|
||||
show_labels: settings.showLabels,
|
||||
show_camera_frustums: settings.showCameraFrustums,
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Настройки LAB не сохранились. Повторите закрытие окна.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
function decodeProfile(value: unknown, expectedResultId: string): SceneSettings {
|
||||
const row = record(value);
|
||||
exactKeys(row, ["result_id", "scene_settings", "schema_version", "updated_at_utc"]);
|
||||
if (row.schema_version !== PROFILE_SCHEMA || row.result_id !== expectedResultId) {
|
||||
throw new Error("Сервер вернул профиль другой LAB.");
|
||||
}
|
||||
const settings = record(row.scene_settings);
|
||||
exactKeys(settings, [
|
||||
"accumulation_seconds", "color_mode", "palette", "point_size",
|
||||
"show_camera_frustums", "show_grid", "show_labels",
|
||||
]);
|
||||
const pointSize = finite(settings.point_size);
|
||||
const accumulationSeconds = finite(settings.accumulation_seconds);
|
||||
const colorMode = settings.color_mode;
|
||||
const palette = settings.palette;
|
||||
if (
|
||||
pointSize < 0.1
|
||||
|| accumulationSeconds < 0
|
||||
|| !new Set(["intensity", "height", "distance", "rgb", "class"]).has(String(colorMode))
|
||||
|| !new Set(["turbo", "viridis", "plasma", "grayscale"]).has(String(palette))
|
||||
|| typeof settings.show_grid !== "boolean"
|
||||
|| typeof settings.show_labels !== "boolean"
|
||||
|| typeof settings.show_camera_frustums !== "boolean"
|
||||
) throw new Error("Сервер вернул повреждённый профиль LAB.");
|
||||
return {
|
||||
projection: "3d",
|
||||
pointSize,
|
||||
colorMode: colorMode as SceneSettings["colorMode"],
|
||||
palette: palette as SceneSettings["palette"],
|
||||
customColor: "#35d7c1",
|
||||
accumulationSeconds,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showGrid: settings.show_grid,
|
||||
showLabels: settings.show_labels,
|
||||
showCameraFrustums: settings.show_camera_frustums,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Некорректный профиль отображения LAB.");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error("Некорректное числовое значение профиля LAB.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) {
|
||||
throw new Error("Контракт профиля отображения LAB изменился.");
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export async function fetchObservatoryRecordedJobs(
|
||||
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) => (
|
||||
return array(row.items, "items").map(decodeObservatoryRecordedJob).filter((job) => (
|
||||
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||
));
|
||||
@@ -125,7 +125,7 @@ export async function submitObservatoryRecordedJob(
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
||||
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
@@ -156,7 +156,7 @@ export async function retryObservatoryRecordedJobPublication(
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.jobId !== jobId) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
"Повтор публикации вернул другой расчёт.",
|
||||
@@ -165,7 +165,7 @@ export async function retryObservatoryRecordedJobPublication(
|
||||
return job;
|
||||
}
|
||||
|
||||
function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
|
||||
const row = record(value, "расчёт");
|
||||
exactKeys(row, [
|
||||
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface RecordedProgressView {
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly ageSeconds: number;
|
||||
readonly elapsedSeconds: number;
|
||||
readonly phaseElapsedSeconds: number;
|
||||
}
|
||||
|
||||
export async function fetchRecordedProgress(
|
||||
@@ -71,7 +73,8 @@ export function decodeRecordedProgress(
|
||||
const completed = integer(progress.completed);
|
||||
const total = progress.total === null ? null : integer(progress.total, 1);
|
||||
const elapsed = finite(progress.elapsed_seconds);
|
||||
if ((total !== null && completed > total) || finite(progress.phase_elapsed_seconds) > elapsed) {
|
||||
const phaseElapsed = finite(progress.phase_elapsed_seconds);
|
||||
if ((total !== null && completed > total) || phaseElapsed > elapsed) {
|
||||
throw new Error("Некорректные счётчики прогресса.");
|
||||
}
|
||||
integer(progress.phase_index);
|
||||
@@ -79,6 +82,7 @@ export function decodeRecordedProgress(
|
||||
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
|
||||
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
|
||||
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
|
||||
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +91,8 @@ export function recordedProgressLabel(
|
||||
): string {
|
||||
if (job?.publication.state === "pending") return "Сохраняем результат";
|
||||
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
|
||||
if (job.state === "failed") return "Ошибка расчёта";
|
||||
if (job.state === "succeeded") return "Расчёт завершён";
|
||||
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
|
||||
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
|
||||
if (!progress || progress.jobId !== job.jobId
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
AI_MODULE_SETUP_IDS,
|
||||
fetchAIModuleCatalog,
|
||||
fetchAICompositionJobs,
|
||||
fetchAICompositionRuns,
|
||||
type AICompositionRun,
|
||||
} from "./aiComposition";
|
||||
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
|
||||
import type { ObservatoryRecordedJob } from "./recordedJobs";
|
||||
|
||||
const OPEN = new Set(["accepted", "queued", "claimed", "running", "paused",
|
||||
"preemption-pending", "reconciliation-required"]);
|
||||
|
||||
interface Snapshot {
|
||||
readonly sourceSessionId: string;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly runs: readonly AICompositionRun[];
|
||||
readonly moduleLabelsBySetup: Readonly<Record<string, string>>;
|
||||
readonly progress: Readonly<Record<string, RecordedProgressView>>;
|
||||
readonly error: string | null;
|
||||
}
|
||||
|
||||
export function useAICompositionJobs(sourceSessionId: string) {
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<Snapshot>({
|
||||
sourceSessionId: "", jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
|
||||
});
|
||||
const current = snapshot.sourceSessionId === sourceSessionId ? snapshot : {
|
||||
sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceSessionId) {
|
||||
setSnapshot({ sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null });
|
||||
return;
|
||||
}
|
||||
const request = new AbortController();
|
||||
void Promise.all([
|
||||
fetchAICompositionJobs(sourceSessionId, request.signal),
|
||||
fetchAICompositionRuns(sourceSessionId, request.signal),
|
||||
fetchAIModuleCatalog(request.signal),
|
||||
]).then(async ([jobs, runs, catalog]) => {
|
||||
const active = jobs.filter((job) => OPEN.has(job.state));
|
||||
const samples = await Promise.all(active.map(async (job) => [
|
||||
job.jobId, await fetchRecordedProgress(job, { signal: request.signal }).catch(() => null),
|
||||
] as const));
|
||||
if (request.signal.aborted) return;
|
||||
const catalogLabels = catalog.groups.flatMap(({ modules }) => (
|
||||
modules.flatMap((module) => {
|
||||
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
|
||||
return setupId ? [[setupId, module.label] as const] : [];
|
||||
})
|
||||
));
|
||||
const projectedLabels = runs.flatMap((run) => run.presentation.modules.flatMap((module) => {
|
||||
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
|
||||
return setupId ? [[setupId, module.label] as const] : [];
|
||||
}));
|
||||
setSnapshot({
|
||||
sourceSessionId, jobs, runs,
|
||||
moduleLabelsBySetup: Object.fromEntries([...catalogLabels, ...projectedLabels]),
|
||||
progress: Object.fromEntries(samples.filter(
|
||||
(sample): sample is readonly [string, RecordedProgressView] => sample[1] !== null,
|
||||
)),
|
||||
error: null,
|
||||
});
|
||||
}).catch((error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
setSnapshot((value) => ({
|
||||
sourceSessionId, jobs: value.sourceSessionId === sourceSessionId ? value.jobs : [],
|
||||
runs: value.sourceSessionId === sourceSessionId ? value.runs : [],
|
||||
moduleLabelsBySetup: value.sourceSessionId === sourceSessionId
|
||||
? value.moduleLabelsBySetup : {},
|
||||
progress: value.sourceSessionId === sourceSessionId ? value.progress : {},
|
||||
error: error instanceof Error ? error.message : "Очередь AI-слоёв недоступна.",
|
||||
}));
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
|
||||
const active = current.runs.some((run) => run.state === "running")
|
||||
|| current.jobs.some((job) => OPEN.has(job.state) || job.publication.state === "pending");
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = globalThis.setTimeout(() => setRevision((value) => value + 1), 1_500);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [active, revision]);
|
||||
|
||||
return {
|
||||
jobs: current.jobs,
|
||||
runs: current.runs,
|
||||
moduleLabelsBySetup: current.moduleLabelsBySetup,
|
||||
progress: current.progress,
|
||||
error: current.error,
|
||||
refresh: useCallback(() => setRevision((value) => value + 1), []),
|
||||
};
|
||||
}
|
||||
@@ -171,9 +171,9 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Проверка компьютерного зрения",
|
||||
title: "AI inference",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
description: "Сессии и квалификация без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
|
||||
@@ -89,16 +89,6 @@
|
||||
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;
|
||||
@@ -155,29 +145,16 @@
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
|
||||
> .m4-replay-threat-visual__pane-layer-controls {
|
||||
flex: 1 0 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
|
||||
> .m4-replay-threat-visual__pane-mode-controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]:has(
|
||||
.m4-replay-threat-visual__review-controls
|
||||
) .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
@@ -185,12 +162,17 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
||||
right: 3.8rem;
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"],
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.m4-replay-threat-visual__deck:not([data-split="true"])
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
/* The two viewer-wide actions stay at the deck's right edge. Keep their
|
||||
complete hit area out of this viewport-owned toolbar at every split. */
|
||||
right: 7rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar > *,
|
||||
@@ -199,18 +181,15 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: none;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
|
||||
display: none;
|
||||
.m4-replay-threat-visual__pane-toolbar > .m4-replay-threat-visual__pane-layer-controls,
|
||||
.m4-replay-threat-visual__spatial-toolbar-end > .m4-replay-threat-visual__pane-layer-controls {
|
||||
/* A viewport toolbar is one adaptive flex sequence. `display: contents`
|
||||
keeps the semantic layer group while allowing each control to move to the
|
||||
next row only when the viewport itself runs out of width. */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-mode-controls {
|
||||
@@ -259,7 +238,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--nodedc-glass-outline);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.42rem 0.58rem;
|
||||
@@ -280,6 +260,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.laboratory-evidence-viewer__controls {
|
||||
right: 0.6rem;
|
||||
left: auto;
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -304,6 +293,30 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.18rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
padding: 0.12rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button:focus-visible {
|
||||
background: var(--nodedc-focus-surface);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__review-controls,
|
||||
.m4-replay-threat-visual__review-controls > *,
|
||||
.m4-replay-threat-visual__single-pane-controls,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice,
|
||||
@@ -21,18 +20,15 @@
|
||||
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 {
|
||||
@@ -47,7 +43,6 @@
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.observatory-lead p,
|
||||
.observatory-state p,
|
||||
.observatory-evidence-empty p {
|
||||
max-width: 52rem;
|
||||
@@ -197,6 +192,50 @@
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-evidence__ordering {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort {
|
||||
display: grid;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort:hover,
|
||||
.observatory-evidence__sort:focus-visible {
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observatory-evidence__sort svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort path {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort[data-direction="oldest"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.observatory-evidence-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
@@ -241,6 +280,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy strong {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
@@ -251,6 +298,28 @@
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-evidence-card--progress {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__progress {
|
||||
height: 0.28rem;
|
||||
margin: 0.18rem 0 0.08rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--nodedc-accent-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
transition: width 180ms ease-out;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__actions,
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state,
|
||||
@@ -282,6 +351,7 @@
|
||||
.observatory-replay {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observatory-replay__header {
|
||||
@@ -293,10 +363,37 @@
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-replay__header h3 {
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.observatory-replay__header p,
|
||||
.observatory-replay-state p {
|
||||
.observatory-replay-state p,
|
||||
.observatory-replay__description {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-family: var(--nodedc-font-family);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-regular);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.observatory-replay__description {
|
||||
margin-right: 0.25rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.observatory-replay .m4-replay-threat-visual__buffering {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-replay .laboratory-evidence-viewer {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.observatory-evidence-empty {
|
||||
@@ -369,7 +466,6 @@
|
||||
}
|
||||
|
||||
@container observatory-workspace (max-width: 920px) {
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice {
|
||||
@@ -399,3 +495,146 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.observatory-ai-config {
|
||||
display: grid;
|
||||
gap: var(--nodedc-space-4);
|
||||
}
|
||||
|
||||
.observatory-ai-config__lead,
|
||||
.observatory-ai-config__error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observatory-ai-config__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observatory-ai-config__duplicate {
|
||||
margin-right: auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes observatory-ai-calculate-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background-color: var(--nodedc-primary-action-bg);
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--nodedc-primary-action-bg) 88%,
|
||||
var(--nodedc-canvas-soft)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.observatory-ai-config__calculate--saving:disabled {
|
||||
animation: observatory-ai-calculate-pulse 1.8s ease-in-out infinite;
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: var(--nodedc-shadow-window);
|
||||
}
|
||||
|
||||
.observatory-ai-config-window::before,
|
||||
.observatory-ai-config-window::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--nodedc-space-3);
|
||||
padding: var(--nodedc-space-3) var(--nodedc-space-4);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header .nodedc-status {
|
||||
min-width: 0;
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-ai-module-group__body {
|
||||
padding: var(--nodedc-space-4);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group__body .nodedc-select-anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window .nodedc-select-trigger,
|
||||
.observatory-ai-config-window .nodedc-window__close {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window .nodedc-select-trigger:focus-visible,
|
||||
.observatory-ai-config-window .nodedc-window__close:focus-visible {
|
||||
background-color: var(--nodedc-focus-surface);
|
||||
box-shadow:
|
||||
var(--nodedc-glass-control-shadow),
|
||||
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.observatory-ai-config-window .nodedc-window__title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header .nodedc-status {
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.observatory-ai-config__calculate--saving:disabled {
|
||||
animation: none;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--nodedc-primary-action-bg) 94%,
|
||||
var(--nodedc-canvas-soft)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__runtime {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
import { AICompositionReplay } from "../../components/laboratory/AICompositionReplay";
|
||||
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
|
||||
import { AIConfigurationWindow } from "../../components/observatory/AIConfigurationWindow";
|
||||
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
||||
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
||||
import {
|
||||
@@ -32,9 +34,14 @@ import {
|
||||
renameObservatoryLabProjection,
|
||||
} from "../../core/observatory/catalogMutations";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
||||
import { recordedProgressLabel } from "../../core/observatory/recordedProgress";
|
||||
import { useAICompositionJobs } from "../../core/observatory/useAICompositionJobs";
|
||||
import {
|
||||
deleteAICompositionRunProjection,
|
||||
renameAICompositionRunProjection,
|
||||
type AICompositionRun,
|
||||
} from "../../core/observatory/aiComposition";
|
||||
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
|
||||
import type { RecordedProgressView } from "../../core/observatory/recordedProgress";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
@@ -53,8 +60,31 @@ type ObservatoryMutationReconciliation =
|
||||
readonly sessionId: string;
|
||||
};
|
||||
|
||||
type EvidenceSortDirection = "newest" | "oldest";
|
||||
|
||||
type ObservatoryEvidenceRow =
|
||||
| {
|
||||
readonly kind: "composition";
|
||||
readonly id: string;
|
||||
readonly timestamp: string;
|
||||
readonly run: AICompositionRun;
|
||||
}
|
||||
| {
|
||||
readonly kind: "job";
|
||||
readonly id: string;
|
||||
readonly timestamp: string;
|
||||
readonly job: ObservatoryRecordedJob;
|
||||
}
|
||||
| {
|
||||
readonly kind: "evidence";
|
||||
readonly id: string;
|
||||
readonly timestamp: string;
|
||||
readonly evidence: ObservatoryEvidence;
|
||||
};
|
||||
|
||||
type ObservatoryReplayState =
|
||||
| { readonly kind: "closed" }
|
||||
| { readonly kind: "composition"; readonly run: AICompositionRun }
|
||||
| {
|
||||
readonly kind: "loading";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
@@ -114,15 +144,198 @@ function formatDuration(seconds: number): string {
|
||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function compositionEventTimestamp(run: AICompositionRun): string {
|
||||
if (run.state === "running" || run.jobs.length === 0) return run.createdAtUtc;
|
||||
return run.jobs.reduce((latest, job) => (
|
||||
Date.parse(job.updatedAtUtc) > Date.parse(latest) ? job.updatedAtUtc : latest
|
||||
), run.createdAtUtc);
|
||||
}
|
||||
|
||||
function compositionProducedEvidence(
|
||||
run: AICompositionRun,
|
||||
evidence: ObservatoryEvidence,
|
||||
): boolean {
|
||||
if (!run.resultIds.includes(evidence.sessionId)) return false;
|
||||
const publishedAt = Date.parse(evidence.publishedAtUtc);
|
||||
const compositionCreatedAt = Date.parse(run.createdAtUtc);
|
||||
return Number.isFinite(publishedAt) && Number.isFinite(compositionCreatedAt)
|
||||
&& publishedAt >= compositionCreatedAt;
|
||||
}
|
||||
|
||||
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;
|
||||
function evidenceConfigurationLabel(
|
||||
evidence: ObservatoryEvidence,
|
||||
moduleLabelsBySetup: Readonly<Record<string, string>>,
|
||||
jobs: readonly ObservatoryRecordedJob[],
|
||||
): string {
|
||||
const setupId = evidence.lab.calculationProfile?.setupId
|
||||
?? jobs.find((job) => job.resultId === evidence.sessionId)?.setupId;
|
||||
const profileName = setupId
|
||||
? moduleLabelsBySetup[setupId] ?? evidence.lab.calculationProfile?.displayName
|
||||
: evidence.lab.calculationProfile?.displayName;
|
||||
return profileName ?? evidence.label;
|
||||
}
|
||||
|
||||
function aiJobTone(job: ObservatoryRecordedJob): "accent" | "success" | "danger" | "warning" {
|
||||
if (job.state === "failed" || job.publication.state === "failed") return "danger";
|
||||
if (job.state === "paused" || job.state === "preemption-pending") return "warning";
|
||||
if (job.state === "succeeded") return "success";
|
||||
return "accent";
|
||||
}
|
||||
|
||||
const AI_PROGRESS_PHASE_RANGES = {
|
||||
"source-transfer": [0, 5],
|
||||
"source-preparation": [5, 25],
|
||||
computing: [25, 90],
|
||||
"result-assembly": [90, 95],
|
||||
"result-transfer": [95, 99],
|
||||
} as const;
|
||||
|
||||
function aiJobProgressPercent(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView | undefined,
|
||||
): number {
|
||||
if (job.state === "succeeded") return 100;
|
||||
if (!progress) return 0;
|
||||
const [start, end] = AI_PROGRESS_PHASE_RANGES[progress.phase];
|
||||
if (progress.total === null || progress.total === 0) return start;
|
||||
const phaseFraction = Math.max(0, Math.min(1, progress.completed / progress.total));
|
||||
return start + ((end - start) * phaseFraction);
|
||||
}
|
||||
|
||||
function aiJobStage(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView | undefined,
|
||||
): string {
|
||||
if (job.state === "failed" || job.publication.state === "failed") return "Ошибка";
|
||||
if (job.state === "accepted" || job.state === "queued" || job.state === "claimed" || !progress) {
|
||||
return "Инициализация";
|
||||
}
|
||||
if (progress.phase === "source-transfer" || progress.phase === "source-preparation") {
|
||||
return "Передача на сервер";
|
||||
}
|
||||
return "Просчёт";
|
||||
}
|
||||
|
||||
function computingStageDetail(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView,
|
||||
recordingDurationSeconds: number,
|
||||
): string {
|
||||
if (job.setupId === "ai-range-object-distance-v1") {
|
||||
if (progress.completed === 0) {
|
||||
return "Подготавливаем камеру и LiDAR";
|
||||
}
|
||||
if (progress.completed === 1) {
|
||||
return "Собираем видеоряд для модели";
|
||||
}
|
||||
if (progress.completed === 2) {
|
||||
return "RF-DETR обрабатывает запись · измеренная скорость Worker 006: 17–18 FPS";
|
||||
}
|
||||
if (progress.completed === 3) return "Сопоставляем рамки с LiDAR · осталось несколько минут";
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
if (job.setupId === "ai-segmentation-ddrnet-v1") {
|
||||
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
|
||||
if (progress.completed === 1) return "Собираем видеоряд для модели";
|
||||
if (progress.completed === 2) {
|
||||
return `DDRNet обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
|
||||
}
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
if (job.setupId === "ai-segmentation-eomt-v1") {
|
||||
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
|
||||
if (progress.completed === 1) return "Собираем видеоряд для модели";
|
||||
if (progress.completed === 2) {
|
||||
return `EoMT обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
|
||||
}
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
if (job.setupId === "ai-detection-rf-detr-v1") {
|
||||
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
|
||||
if (progress.completed === 1) return "Собираем видеоряд для модели";
|
||||
if (progress.completed === 2) {
|
||||
return `RF-DETR обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
|
||||
}
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
return "Обрабатываем запись выбранным модулем";
|
||||
}
|
||||
|
||||
function aiJobStageDetail(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView | undefined,
|
||||
recordingDurationSeconds: number,
|
||||
): string {
|
||||
if (job.state === "failed") {
|
||||
return "Не удалось запустить модель. Конфигурацию можно отправить повторно";
|
||||
}
|
||||
if (job.publication.state === "failed") {
|
||||
return "Результат рассчитан, но не сохранён в Обсерватории";
|
||||
}
|
||||
if (job.state === "succeeded") {
|
||||
if (job.publication.state === "pending") return "Завершаем сохранение результата";
|
||||
if (job.publication.state === "not-required") return "Публикация результата не требуется";
|
||||
return "Результат сохранён в Обсерватории";
|
||||
}
|
||||
if (!progress) {
|
||||
return "Готовим конфигурацию и место в очереди";
|
||||
}
|
||||
if (progress.phase === "source-transfer") {
|
||||
if (progress.total !== null && progress.total > 0) {
|
||||
return `${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} файлов`;
|
||||
}
|
||||
return "Копируем исходную запись на Worker 006";
|
||||
}
|
||||
if (progress.phase === "source-preparation") return "Проверяем целостность переданных данных";
|
||||
if (progress.phase === "computing") {
|
||||
return computingStageDetail(job, progress, recordingDurationSeconds);
|
||||
}
|
||||
return "Собираем, передаём и сохраняем результат";
|
||||
}
|
||||
|
||||
function presentAIJob(job: ObservatoryRecordedJob): boolean {
|
||||
if (job.state === "failed") return true;
|
||||
if (job.publication.state === "pending" || job.publication.state === "failed") return true;
|
||||
return job.state !== "succeeded";
|
||||
}
|
||||
|
||||
function presentLatestAIJobs(
|
||||
jobs: readonly ObservatoryRecordedJob[],
|
||||
): readonly ObservatoryRecordedJob[] {
|
||||
const seenSetups = new Set<string>();
|
||||
return jobs.filter((job) => {
|
||||
if (seenSetups.has(job.setupId)) return false;
|
||||
seenSetups.add(job.setupId);
|
||||
return presentAIJob(job);
|
||||
});
|
||||
}
|
||||
|
||||
function compositionProgressPercent(
|
||||
run: AICompositionRun,
|
||||
progress: Readonly<Record<string, RecordedProgressView>>,
|
||||
): number {
|
||||
if (run.state === "ready") return 100;
|
||||
if (run.jobs.length === 0) return 0;
|
||||
return run.jobs.reduce(
|
||||
(total, job) => total + aiJobProgressPercent(job, progress[job.jobId]),
|
||||
0,
|
||||
) / run.jobs.length;
|
||||
}
|
||||
|
||||
function compositionStage(
|
||||
run: AICompositionRun,
|
||||
progress: Readonly<Record<string, RecordedProgressView>>,
|
||||
): string {
|
||||
if (run.state === "ready") return "Расчёт завершён";
|
||||
if (run.state === "failed") return "Ошибка расчёта";
|
||||
const current = run.jobs.find((job) => job.state !== "succeeded") ?? run.jobs[0];
|
||||
return current ? aiJobStage(current, progress[current.jobId]) : "Инициализация";
|
||||
}
|
||||
|
||||
export function ObservatoryWorkspace({
|
||||
@@ -132,22 +345,21 @@ export function ObservatoryWorkspace({
|
||||
}) {
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
||||
const recordedJobsController = useObservatoryRecordedJobs(
|
||||
selectedSessionId,
|
||||
setupController.selectedSetupId,
|
||||
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
|
||||
);
|
||||
const [aiConfigurationOpen, setAIConfigurationOpen] = useState(false);
|
||||
const [evidenceSortDirection, setEvidenceSortDirection] = useState<EvidenceSortDirection>("newest");
|
||||
const aiJobsController = useAICompositionJobs(selectedSessionId);
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [compositionRenameTarget, setCompositionRenameTarget] = useState<AICompositionRun | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [compositionDeleteTarget, setCompositionDeleteTarget] = useState<AICompositionRun | 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 overviewRef = useRef<HTMLDivElement | null>(null);
|
||||
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||
const refreshedPublishedJobRef = useRef<string | null>(null);
|
||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||
@@ -165,6 +377,7 @@ export function ObservatoryWorkspace({
|
||||
useEffect(() => {
|
||||
if (items.some((item) => item.source.id === selectedSessionId)) return;
|
||||
closeReplay();
|
||||
setAIConfigurationOpen(false);
|
||||
setSelectedSessionId(items[0]?.source.id ?? "");
|
||||
}, [closeReplay, items, selectedSessionId]);
|
||||
|
||||
@@ -173,62 +386,54 @@ export function ObservatoryWorkspace({
|
||||
const selectedSession = items.find(
|
||||
(item) => item.source.id === selectedSessionId,
|
||||
) ?? null;
|
||||
const presentedEvidence = selectedSession?.evidence ?? [];
|
||||
const presentedCompositionRuns = aiJobsController.runs;
|
||||
const presentedEvidence = (selectedSession?.evidence ?? []).filter(
|
||||
(evidence) => !presentedCompositionRuns.some(
|
||||
(run) => compositionProducedEvidence(run, 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 compositionJobIds = useMemo(() => new Set(
|
||||
presentedCompositionRuns.flatMap((run) => run.jobIds),
|
||||
), [presentedCompositionRuns]);
|
||||
const presentedAIJobs = presentLatestAIJobs(aiJobsController.jobs).filter(
|
||||
(job) => !compositionJobIds.has(job.jobId),
|
||||
);
|
||||
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 presentedRows = useMemo<readonly ObservatoryEvidenceRow[]>(() => {
|
||||
const rows: ObservatoryEvidenceRow[] = [
|
||||
...presentedCompositionRuns.map((run): ObservatoryEvidenceRow => ({
|
||||
kind: "composition",
|
||||
id: run.runId,
|
||||
timestamp: compositionEventTimestamp(run),
|
||||
run,
|
||||
})),
|
||||
...presentedAIJobs.map((job): ObservatoryEvidenceRow => ({
|
||||
kind: "job",
|
||||
id: job.jobId,
|
||||
timestamp: job.updatedAtUtc,
|
||||
job,
|
||||
})),
|
||||
...presentedEvidence.map((evidence): ObservatoryEvidenceRow => ({
|
||||
kind: "evidence",
|
||||
id: evidence.sessionId,
|
||||
timestamp: evidence.publishedAtUtc,
|
||||
evidence,
|
||||
})),
|
||||
];
|
||||
const direction = evidenceSortDirection === "newest" ? -1 : 1;
|
||||
return rows.sort((left, right) => (
|
||||
direction * (Date.parse(left.timestamp) - Date.parse(right.timestamp))
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
}, [evidenceSortDirection, presentedAIJobs, presentedCompositionRuns, presentedEvidence]);
|
||||
const initialLoading = !controller.catalog
|
||||
&& ["idle", "loading"].includes(controller.state);
|
||||
const unavailable = !controller.catalog && controller.state === "error";
|
||||
const replayEvidenceId = replay.kind === "closed"
|
||||
const replayEvidenceId = replay.kind === "closed" || replay.kind === "composition"
|
||||
? null
|
||||
: replay.binding.evidenceSessionId;
|
||||
const replayEvidence = replayEvidenceId === null
|
||||
@@ -238,14 +443,13 @@ export function ObservatoryWorkspace({
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const publishedJob = recordedJobsController.jobs.find(
|
||||
const publishedJob = aiJobsController.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]);
|
||||
}, [aiJobsController.jobs, controller.refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -276,8 +480,7 @@ export function ObservatoryWorkspace({
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
if (mutationReconciliation.kind === "delete") setupController.refresh();
|
||||
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
|
||||
}, [controller.catalog, mutationPending, mutationReconciliation]);
|
||||
|
||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||
const attempt = replayCoordinatorRef.current.begin();
|
||||
@@ -307,6 +510,8 @@ export function ObservatoryWorkspace({
|
||||
|
||||
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setCompositionRenameTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
@@ -316,13 +521,52 @@ export function ObservatoryWorkspace({
|
||||
|
||||
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setCompositionRenameTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setDeleteTarget(evidence);
|
||||
}, []);
|
||||
|
||||
const openCompositionRename = useCallback((run: AICompositionRun) => {
|
||||
setRenameTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setRenameValue(run.displayName ?? run.presentation.configurationLabel);
|
||||
setCompositionRenameTarget(run);
|
||||
}, []);
|
||||
|
||||
const openCompositionDelete = useCallback((run: AICompositionRun) => {
|
||||
setRenameTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setCompositionRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setCompositionDeleteTarget(run);
|
||||
}, []);
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
if (compositionRenameTarget) {
|
||||
if (mutationPending !== null) return;
|
||||
setMutationPending("rename");
|
||||
setMutationError(null);
|
||||
try {
|
||||
await renameAICompositionRunProjection(
|
||||
compositionRenameTarget.runId,
|
||||
renameValue,
|
||||
);
|
||||
setCompositionRenameTarget(null);
|
||||
aiJobsController.refresh();
|
||||
} catch (error) {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!renameTarget?.recordedRun || mutationPending !== null) return;
|
||||
const requestedDisplayName = renameValue.trim();
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
@@ -356,9 +600,27 @@ export function ObservatoryWorkspace({
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [controller, mutationPending, renameTarget, renameValue]);
|
||||
}, [aiJobsController, compositionRenameTarget, controller, mutationPending, renameTarget, renameValue]);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (compositionDeleteTarget) {
|
||||
if (mutationPending !== null) return;
|
||||
setMutationPending("delete");
|
||||
setMutationError(null);
|
||||
try {
|
||||
if (replay.kind === "composition" && replay.run.runId === compositionDeleteTarget.runId) {
|
||||
flushSync(() => closeReplay());
|
||||
}
|
||||
await deleteAICompositionRunProjection(compositionDeleteTarget.runId);
|
||||
setCompositionDeleteTarget(null);
|
||||
aiJobsController.refresh();
|
||||
} catch (error) {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
kind: "delete",
|
||||
@@ -370,6 +632,7 @@ export function ObservatoryWorkspace({
|
||||
try {
|
||||
if (
|
||||
replay.kind !== "closed"
|
||||
&& replay.kind !== "composition"
|
||||
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
||||
) {
|
||||
flushSync(() => {
|
||||
@@ -378,7 +641,6 @@ export function ObservatoryWorkspace({
|
||||
}
|
||||
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||
setupController.refresh();
|
||||
setDeleteTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
void controller.refresh();
|
||||
@@ -399,25 +661,16 @@ export function ObservatoryWorkspace({
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
|
||||
}, [aiJobsController, closeReplay, compositionDeleteTarget, controller, deleteTarget, mutationPending, replay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overviewRef}
|
||||
className="observatory-workspace"
|
||||
data-workspace-id={definition.id}
|
||||
data-observatory-authority="observation-only"
|
||||
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
|
||||
data-observatory-viewer={replay.kind === "ready" || replay.kind === "composition" ? "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>
|
||||
@@ -436,83 +689,23 @@ export function ObservatoryWorkspace({
|
||||
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();
|
||||
}}
|
||||
variant="primary"
|
||||
disabled={!selectedSession}
|
||||
onClick={() => setAIConfigurationOpen(true)}
|
||||
>
|
||||
Обновить
|
||||
Сконфигурировать AI-слой
|
||||
</Button>
|
||||
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
|
||||
{calculationPending ? (
|
||||
<>
|
||||
<ActivityIndicator size="compact" />
|
||||
<span role="status">
|
||||
{recordedProgressLabel(presentedJob, recordedJobsController.progress)}
|
||||
</span>
|
||||
</>
|
||||
) : 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 ? (
|
||||
{aiJobsController.error ? (
|
||||
<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}
|
||||
<span className="observatory-notice__copy">{aiJobsController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={aiJobsController.refresh}>
|
||||
Повторить
|
||||
</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
@@ -524,13 +717,6 @@ export function ObservatoryWorkspace({
|
||||
</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) ? (
|
||||
@@ -616,23 +802,150 @@ export function ObservatoryWorkspace({
|
||||
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
||||
<h3>Лабораторные доказательства</h3>
|
||||
</div>
|
||||
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
|
||||
{selectedSession.evidence.length}
|
||||
</StatusBadge>
|
||||
<div className="observatory-evidence__ordering">
|
||||
<StatusBadge tone={presentedRows.length > 0 ? "accent" : "neutral"}>
|
||||
{presentedRows.length}
|
||||
</StatusBadge>
|
||||
<button
|
||||
type="button"
|
||||
className="observatory-evidence__sort"
|
||||
data-direction={evidenceSortDirection}
|
||||
aria-label={evidenceSortDirection === "newest"
|
||||
? "Показать сначала старые результаты"
|
||||
: "Показать сначала новые результаты"}
|
||||
title={evidenceSortDirection === "newest"
|
||||
? "Сначала новые"
|
||||
: "Сначала старые"}
|
||||
onClick={() => setEvidenceSortDirection((current) => (
|
||||
current === "newest" ? "oldest" : "newest"
|
||||
))}
|
||||
>
|
||||
<svg viewBox="0 0 18 18" aria-hidden="true">
|
||||
<path d="M3 5h12M5 9h10M7 13h8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{selectedSession.evidence.length > 0 ? (
|
||||
{presentedRows.length > 0 ? (
|
||||
<ol className="observatory-evidence-list">
|
||||
{presentedEvidence.map((evidence) => (
|
||||
{presentedRows.map((row) => {
|
||||
if (row.kind === "composition") {
|
||||
const { run } = row;
|
||||
const failed = run.state === "failed";
|
||||
const active = run.state === "running";
|
||||
const progressPercent = compositionProgressPercent(run, aiJobsController.progress);
|
||||
return (
|
||||
<li key={run.runId}>
|
||||
<div className={`observatory-evidence-card${active
|
||||
? " observatory-evidence-card--progress" : ""}`}
|
||||
aria-busy={active}>
|
||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||
{failed ? <Icon name="alert" size={16} />
|
||||
: run.state === "ready" ? <Icon name="clipboard" size={18} />
|
||||
: <ActivityIndicator size="compact" />}
|
||||
</span>
|
||||
<div className="observatory-evidence-card__copy">
|
||||
<strong>{run.displayName ?? run.presentation.configurationLabel}</strong>
|
||||
{active ? (
|
||||
<div className="observatory-evidence-card__progress" aria-hidden="true">
|
||||
<span style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
<small>{run.state === "ready"
|
||||
? formatTimestamp(row.timestamp)
|
||||
: `${compositionStage(run, aiJobsController.progress)} · ${formatTimestamp(row.timestamp)}`}</small>
|
||||
</div>
|
||||
{active ? (
|
||||
<StatusBadge tone="accent">{Math.round(progressPercent)}%</StatusBadge>
|
||||
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
|
||||
<div className="observatory-evidence-card__actions">
|
||||
{run.state === "ready" ? (
|
||||
<>
|
||||
<IconButton
|
||||
label={`Удалить ${run.displayName ?? run.presentation.configurationLabel} из Обсерватории`}
|
||||
onClick={() => openCompositionDelete(run)}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`Переименовать ${run.displayName ?? run.presentation.configurationLabel}`}
|
||||
onClick={() => openCompositionRename(run)}
|
||||
>
|
||||
<Icon name="edit" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label={`Открыть визуальный разбор: ${run.configurationLabel}`}
|
||||
onClick={() => setReplay({ kind: "composition", run })}>
|
||||
<Icon name="eye" size={16} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (row.kind === "job") {
|
||||
const { job } = row;
|
||||
const progress = aiJobsController.progress[job.jobId];
|
||||
const progressPercent = aiJobProgressPercent(job, progress);
|
||||
const moduleLabel = aiJobsController.moduleLabelsBySetup[job.setupId] ?? job.setupId;
|
||||
const failed = job.state === "failed" || job.publication.state === "failed";
|
||||
const active = !failed && (
|
||||
job.state !== "succeeded" || job.publication.state === "pending"
|
||||
);
|
||||
return (
|
||||
<li key={job.jobId}>
|
||||
<div
|
||||
className={`observatory-evidence-card${active
|
||||
? " observatory-evidence-card--progress" : ""}`}
|
||||
aria-busy={active}
|
||||
>
|
||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||
{failed
|
||||
? <Icon name="alert" size={16} />
|
||||
: job.state === "succeeded"
|
||||
? <Icon name="check" size={16} />
|
||||
: <ActivityIndicator size="compact" />}
|
||||
</span>
|
||||
<div className="observatory-evidence-card__copy">
|
||||
<strong>{moduleLabel}</strong>
|
||||
{active ? (
|
||||
<div className="observatory-evidence-card__progress" aria-hidden="true">
|
||||
<span style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
<small>
|
||||
{aiJobStage(job, progress)} · {aiJobStageDetail(
|
||||
job,
|
||||
progress,
|
||||
selectedSession.source.durationSeconds,
|
||||
)}{progress && !failed && job.state !== "succeeded"
|
||||
? ` · прошло ${formatDuration(progress.elapsedSeconds)}`
|
||||
: ""} · {formatTimestamp(row.timestamp)}
|
||||
</small>
|
||||
</div>
|
||||
{active ? (
|
||||
<StatusBadge tone={aiJobTone(job)}>{Math.round(progressPercent)}%</StatusBadge>
|
||||
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const { evidence } = row;
|
||||
return (
|
||||
<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>
|
||||
<strong>{evidenceConfigurationLabel(
|
||||
evidence,
|
||||
aiJobsController.moduleLabelsBySetup,
|
||||
aiJobsController.jobs,
|
||||
)}</strong>
|
||||
<small>
|
||||
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
||||
{formatTimestamp(row.timestamp)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="observatory-evidence-card__actions">
|
||||
@@ -668,7 +981,8 @@ export function ObservatoryWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="observatory-evidence-empty">
|
||||
@@ -711,6 +1025,20 @@ export function ObservatoryWorkspace({
|
||||
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "composition" ? (
|
||||
<section className="observatory-replay" aria-label="Визуальный разбор конфигурации">
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">СОХРАНЁННАЯ КОНФИГУРАЦИЯ</span>
|
||||
<h3>{replay.run.configurationLabel}</h3>
|
||||
</div>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="close" size={14} />}
|
||||
onClick={returnToOverview}>
|
||||
Закрыть разбор
|
||||
</Button>
|
||||
</header>
|
||||
<AICompositionReplay run={replay.run} />
|
||||
</section>
|
||||
) : replay.kind === "ready" ? (
|
||||
<section className="observatory-replay" aria-label="Визуальный разбор результата">
|
||||
<header className="observatory-replay__header">
|
||||
@@ -718,12 +1046,13 @@ export function ObservatoryWorkspace({
|
||||
<span className="section-eyebrow">
|
||||
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
|
||||
</span>
|
||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||
<p>
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "Записанный маршрут синхронизирован по общей временной шкале."
|
||||
: "Просмотр сохранённых данных результата без повторного расчёта."}
|
||||
</p>
|
||||
<h3>{replayEvidence && selectedSession
|
||||
? evidenceConfigurationLabel(
|
||||
replayEvidence,
|
||||
aiJobsController.moduleLabelsBySetup,
|
||||
aiJobsController.jobs,
|
||||
)
|
||||
: replay.binding.resultId}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -740,7 +1069,14 @@ export function ObservatoryWorkspace({
|
||||
review={replay.review.review}
|
||||
/>
|
||||
) : (
|
||||
<PortableResultReplay review={replay.review} />
|
||||
<PortableResultReplay review={replay.review}
|
||||
label={replayEvidence && selectedSession
|
||||
? evidenceConfigurationLabel(
|
||||
replayEvidence,
|
||||
aiJobsController.moduleLabelsBySetup,
|
||||
aiJobsController.jobs,
|
||||
)
|
||||
: replay.binding.resultId} />
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
@@ -756,7 +1092,7 @@ export function ObservatoryWorkspace({
|
||||
) : null}
|
||||
|
||||
<Window
|
||||
open={renameTarget !== null}
|
||||
open={renameTarget !== null || compositionRenameTarget !== null}
|
||||
title="Переименовать лабораторный результат"
|
||||
subtitle="Меняется только отображаемое название в Обсерватории"
|
||||
size="sm"
|
||||
@@ -765,6 +1101,7 @@ export function ObservatoryWorkspace({
|
||||
onClose={() => {
|
||||
if (mutationPending === "rename") return;
|
||||
setRenameTarget(null);
|
||||
setCompositionRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
@@ -774,6 +1111,7 @@ export function ObservatoryWorkspace({
|
||||
disabled={mutationPending === "rename"}
|
||||
onClick={() => {
|
||||
setRenameTarget(null);
|
||||
setCompositionRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
@@ -808,19 +1146,26 @@ export function ObservatoryWorkspace({
|
||||
disabled={mutationPending === "rename"}
|
||||
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
||||
/>
|
||||
{mutationError && renameTarget ? (
|
||||
{mutationError && (renameTarget || compositionRenameTarget) ? (
|
||||
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Window>
|
||||
<AIConfigurationWindow
|
||||
open={aiConfigurationOpen && selectedSession !== null}
|
||||
sourceSessionId={selectedSession?.source.id ?? ""}
|
||||
sourceLabel={selectedSession?.source.label ?? ""}
|
||||
onClose={() => setAIConfigurationOpen(false)}
|
||||
onCalculated={() => aiJobsController.refresh()}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
open={deleteTarget !== null || compositionDeleteTarget !== null}
|
||||
title="Удалить результат из Обсерватории?"
|
||||
description={deleteTarget ? (
|
||||
description={deleteTarget || compositionDeleteTarget ? (
|
||||
<div className="observatory-delete-confirmation">
|
||||
<p>
|
||||
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
|
||||
Будут удалены только каталожная проекция <strong>{deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}</strong>
|
||||
{" "}и её отображаемое название в Обсерватории.
|
||||
</p>
|
||||
<p>
|
||||
@@ -838,6 +1183,7 @@ export function ObservatoryWorkspace({
|
||||
onClose={() => {
|
||||
if (mutationPending === "delete") return;
|
||||
setDeleteTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
|
||||
@@ -125,7 +125,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
||||
assert.match(observatory, /data-observatory-authority="observation-only"/);
|
||||
assert.match(
|
||||
observatory,
|
||||
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
|
||||
/data-observatory-viewer={replay\.kind === "ready" \|\| replay\.kind === "composition" \? "attached" : "detached"}/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
`${observatory}\n${observatoryCore}`,
|
||||
@@ -144,8 +144,8 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(costmap \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /costmap \? 0\.000001 : 0/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||
const source = await read(`components/laboratory/${adapter}.tsx`);
|
||||
assert.match(source, /<CanonicalResultRerunReplay/);
|
||||
|
||||
@@ -133,9 +133,12 @@ test("canonical LAB resolves one generation-bound merged RRD", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("portable TGS resolves Core cache for the exact result and base, never a legacy LAB", async () => {
|
||||
for (const [prefix, sourceKind] of [
|
||||
["m49-tgs-portable-review", "portable-tgs"],
|
||||
["lab-v1-eomt-ddrnet", "portable-semantic"],
|
||||
]) test(`${sourceKind} resolves Core cache for the exact result and base, never a legacy LAB`, async () => {
|
||||
const base = "a".repeat(64), generation = "b".repeat(64);
|
||||
const resultId = `m49-tgs-portable-review-${"c".repeat(64)}`;
|
||||
const resultId = `${prefix}-${"c".repeat(64)}`;
|
||||
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
|
||||
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
|
||||
sha256: base };
|
||||
@@ -147,11 +150,14 @@ test("portable TGS resolves Core cache for the exact result and base, never a le
|
||||
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
|
||||
};
|
||||
const options = { sourceKind: "portable-tgs", origin: "http://mission-core.test", fetcher };
|
||||
const options = { sourceKind, origin: "http://mission-core.test", fetcher };
|
||||
const replay = await resolveCanonicalLabReplay(resultId, launch, options);
|
||||
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
|
||||
assert.equal(replay.blueprintSourceUrl, "/api/v1/observation-sessions/source/blueprint.rrd");
|
||||
await assert.rejects(resolveCanonicalLabReplay(`lab-v1-vegetation-shadow-${"c".repeat(64)}`, launch, options));
|
||||
await assert.rejects(resolveCanonicalLabReplay(resultId, launch, {
|
||||
...options, sourceKind: sourceKind === "portable-tgs" ? "portable-semantic" : "portable-tgs",
|
||||
}));
|
||||
await assert.rejects(resolveCanonicalLabReplay(resultId, { ...launch, sha256: "d".repeat(64) }, options));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, fetchLabViewProfile, saveLabViewProfile, normalizeLabSceneSettings;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
({ fetchLabViewProfile, saveLabViewProfile } = await server.ssrLoadModule(
|
||||
"/src/core/observatory/labViewProfile.ts"));
|
||||
({ normalizeLabSceneSettings } = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/CanonicalResultRerunReplay.tsx"));
|
||||
});
|
||||
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
const serverProfile = {
|
||||
schema_version: "missioncore.observatory-lab-view-profile/v1",
|
||||
result_id: "result-1",
|
||||
scene_settings: {
|
||||
point_size: 4.7,
|
||||
accumulation_seconds: 8,
|
||||
color_mode: "height",
|
||||
palette: "viridis",
|
||||
show_grid: false,
|
||||
show_labels: true,
|
||||
show_camera_frustums: false,
|
||||
},
|
||||
updated_at_utc: "2026-09-04T09:30:00.000Z",
|
||||
};
|
||||
|
||||
test("LAB view profile round-trips through the result-scoped server endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({ url, init });
|
||||
return new Response(JSON.stringify(serverProfile), {
|
||||
status: 200, headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const settings = await fetchLabViewProfile("result-1");
|
||||
assert.equal(settings.pointSize, 4.7);
|
||||
assert.equal(settings.palette, "viridis");
|
||||
const saved = await saveLabViewProfile("result-1", settings);
|
||||
assert.equal(saved.accumulationSeconds, 8);
|
||||
assert.equal(requests[0].url, "/api/v1/observatory/lab-view-profiles/result-1");
|
||||
assert.equal(requests[1].init.method, "PUT");
|
||||
assert.deepEqual(JSON.parse(requests[1].init.body).scene_settings, serverProfile.scene_settings);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("LAB view profile fails closed on a foreign result identity", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
...serverProfile, result_id: "foreign-result",
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
try {
|
||||
await assert.rejects(fetchLabViewProfile("result-1"), /другой LAB/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("LAB scene settings normalize typed values before persistence", () => {
|
||||
assert.deepEqual(
|
||||
normalizeLabSceneSettings({
|
||||
pointSize: 28,
|
||||
accumulationSeconds: 305,
|
||||
colorMode: "height",
|
||||
palette: "viridis",
|
||||
customColor: "#ffffff",
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showLabels: true,
|
||||
showCameraFrustums: false,
|
||||
projection: "3d",
|
||||
}),
|
||||
{
|
||||
pointSize: 28,
|
||||
accumulationSeconds: 305,
|
||||
colorMode: "height",
|
||||
palette: "viridis",
|
||||
customColor: "#ffffff",
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showLabels: true,
|
||||
showCameraFrustums: false,
|
||||
projection: "3d",
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -886,7 +886,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||
assert.match(canonical, /secondary=\{spatialPane/);
|
||||
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
|
||||
assert.match(canonical, /resizable=\{splitView\}/);
|
||||
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||
assert.match(canonical, /secondaryMode=\{\{/);
|
||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||
|
||||
@@ -40,6 +40,7 @@ let formatAccumulationDuration;
|
||||
let resolveRerunSourceUrl;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let recordedCameraJournalContract;
|
||||
let resolveRecordedPerceptionUrl;
|
||||
let fetchRecordedPerceptionRrd;
|
||||
let resolveRecordedPerceptionViewerSourceUrl;
|
||||
@@ -110,6 +111,7 @@ before(async () => {
|
||||
resolveRerunSourceUrl,
|
||||
resolveRecordedBlueprintUrl,
|
||||
fetchRecordedBlueprintRrd,
|
||||
recordedCameraJournalContract,
|
||||
resolveRecordedPerceptionUrl,
|
||||
fetchRecordedPerceptionRrd,
|
||||
resolveRecordedPerceptionViewerSourceUrl,
|
||||
@@ -118,7 +120,7 @@ before(async () => {
|
||||
fetchRecordedPointColorsRrd,
|
||||
recordedPointColorKey,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
||||
@@ -129,6 +131,25 @@ before(async () => {
|
||||
));
|
||||
});
|
||||
|
||||
test("follow toggles retain the current recorded camera journal", () => {
|
||||
const before = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: false,
|
||||
});
|
||||
const afterFollowToggle = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: true,
|
||||
});
|
||||
const afterPlanToggle = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: true, followTrajectory: true,
|
||||
});
|
||||
const afterReset = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 1, planView: false, followTrajectory: false,
|
||||
});
|
||||
|
||||
assert.equal(afterFollowToggle, before);
|
||||
assert.notEqual(afterPlanToggle, before);
|
||||
assert.notEqual(afterReset, before);
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
const bounds = { width: 1280, height: 720 };
|
||||
const left = initialObservationWindowRect(0, 2, bounds);
|
||||
@@ -444,6 +465,7 @@ test("recorded replay becomes ready only after the complete declared timeline is
|
||||
|
||||
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||
const calls = [];
|
||||
const cameraLimits = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
@@ -464,6 +486,9 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
activeView: "perception3d",
|
||||
viewResetGeneration: 1,
|
||||
followTrajectory: true,
|
||||
currentTimeNs: 39_215_263_458,
|
||||
onCameraMaxOrbitalRadius: value => cameraLimits.push(value),
|
||||
unifiedCameraShare: 0.73,
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
detections2d: true,
|
||||
@@ -474,7 +499,10 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"X-MissionCore-Camera-Max-Orbital-Radius": "686.024231",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -497,13 +525,20 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
active_view: "perception3d",
|
||||
view_reset_generation: 1,
|
||||
follow_trajectory: true,
|
||||
current_time_ns: 39_215_263_458,
|
||||
unified_perception: true,
|
||||
unified_camera_share: 0.73,
|
||||
semantic_layer: null,
|
||||
plan_view: false,
|
||||
eye_position: null,
|
||||
eye_look_target: null,
|
||||
eye_up: null,
|
||||
show_camera_image: true,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
show_cuboids_3d: true,
|
||||
});
|
||||
assert.deepEqual(cameraLimits, [686.024231]);
|
||||
|
||||
await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
@@ -537,6 +572,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
},
|
||||
);
|
||||
assert.equal(calls[1].body.unified_perception, false);
|
||||
assert.equal(calls[1].body.unified_camera_share, 0.46);
|
||||
assert.equal(calls[1].body.show_cuboids_3d, true);
|
||||
|
||||
await assert.rejects(
|
||||
|
||||
@@ -63,14 +63,31 @@ function evidence(id, sourceSessionId, publishedAtUtc) {
|
||||
labId: `LAB-${id}`,
|
||||
sourceSessionId,
|
||||
resultKind: "recorded-evidence",
|
||||
resultId: `result-${id}`,
|
||||
resultId: id,
|
||||
sourceResultId: null,
|
||||
configSha256: "a".repeat(64),
|
||||
runCreatedAtUtc: publishedAtUtc,
|
||||
publishedAtUtc,
|
||||
replayCapability: null,
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review", viewerProfile: "portable-result",
|
||||
timeline: "result-defined", activation: "explicit", commandsEnabled: false,
|
||||
},
|
||||
calculationProfile: null,
|
||||
provenance: { verdict: "must-not-be-inferred" },
|
||||
provenance: {
|
||||
schema_version: "missioncore.observatory-portable-result-publication/v1",
|
||||
authority: { commands_enabled: false },
|
||||
calculation_profile: null, calculation_profile_sha256: "1".repeat(64),
|
||||
job: {}, method: {}, storage: {},
|
||||
source: { session_id: sourceSessionId },
|
||||
run_definition: { definition_sha256: "a".repeat(64) },
|
||||
result_package: { manifest_sha256: "b".repeat(64), artifact_manifest_id: "c".repeat(64) },
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review", viewer_profile: "portable-result",
|
||||
timeline: "result-defined", activation: "explicit", commands_enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -154,7 +171,9 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify({ items: [] }), {
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observation-session-page/v1", items: [], next_cursor: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -175,8 +194,8 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
|
||||
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",
|
||||
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3&pagination=cursor-v1",
|
||||
"/api/v1/observation-sessions?limit=50&scope=source&pagination=cursor-v1",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
|
||||
@@ -203,7 +222,7 @@ test("Observatory exposes bounded-window uncertainty without inventing a broken
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
|
||||
test("historical canonical replay stays out of Observatory without modifying its archive record", () => {
|
||||
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const canonical = evidence(
|
||||
canonicalResultId,
|
||||
@@ -227,20 +246,144 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
|
||||
commandsEnabled: false,
|
||||
},
|
||||
};
|
||||
const before = structuredClone(canonical);
|
||||
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",
|
||||
assert.deepEqual(catalog.items[0].evidence, []);
|
||||
assert.equal(catalog.window.laboratoryCount, 0);
|
||||
assert.deepEqual(canonical, before);
|
||||
});
|
||||
|
||||
test("only admitted portable evidence is visible regardless of LAB label, age or installed version", () => {
|
||||
const legacy = evidence("legacy", "source", "2026-09-03T13:00:00Z");
|
||||
legacy.lab.labId = "LAB M4.9T5";
|
||||
legacy.lab.replayCapability = null;
|
||||
const historical = evidence("old-portable", "source", "2026-07-20T10:00:00Z");
|
||||
historical.lab.labId = "LAB E24"; // Labels must never become an allow/deny list.
|
||||
const current = evidence("new-portable", "source", "2026-09-03T12:00:00Z");
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[source("source", "2026-07-19T10:00:00Z")], [legacy, historical, current],
|
||||
);
|
||||
assert.deepEqual(catalog.items[0].evidence.map((item) => item.sessionId), ["new-portable", "old-portable"]);
|
||||
assert.equal(catalog.window.laboratoryCount, 2);
|
||||
assert.equal(findObservatoryEvidence(catalog, "legacy"), null);
|
||||
});
|
||||
|
||||
test("a malformed portable binding errors instead of silently disappearing as legacy", () => {
|
||||
const broken = evidence("broken", "source", "2026-09-03T12:00:00Z");
|
||||
broken.lab.provenance.source.session_id = "another-source";
|
||||
assert.throws(() => buildObservatoryCatalog(
|
||||
[source("source", "2026-07-19T10:00:00Z")], [broken],
|
||||
), /identity/);
|
||||
});
|
||||
|
||||
function wire(item) {
|
||||
const lab = item.lab;
|
||||
return {
|
||||
id: item.id, label: item.label, started_at_utc: item.startedAtUtc,
|
||||
completed_at_utc: item.completedAtUtc, status: item.status,
|
||||
modalities: item.modalities, duration_seconds: item.durationSeconds, replayable: item.replayable,
|
||||
...(lab ? { lab: {
|
||||
lab_id: "LAB M4.9T5", source_session_id: lab.sourceSessionId, result_kind: lab.resultKind,
|
||||
result_id: lab.resultId, source_result_id: lab.sourceResultId, config_sha256: lab.configSha256,
|
||||
run_created_at_utc: lab.runCreatedAtUtc, published_at_utc: lab.publishedAtUtc,
|
||||
provenance: lab.provenance, replay_capability: lab.replayCapability ? lab.provenance.replay_capability : null,
|
||||
calculation_profile: null,
|
||||
} } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function pageResponse(items, nextCursor = null) {
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observation-session-page/v1",
|
||||
items: items.map(wire), next_cursor: nextCursor,
|
||||
}), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
test("500 sources and later-page results are searchable metadata, with no archive or viewer requests", async () => {
|
||||
const sources = Array.from({ length: 500 }, (_, index) => source(`source-${index}`, "2026-08-29T10:00:00Z"));
|
||||
const labs = Array.from({ length: 101 }, (_, index) => {
|
||||
const item = evidence(`legacy-${index}`, sources[0].id, "2026-08-29T11:00:00Z");
|
||||
item.lab.replayCapability = null;
|
||||
return item;
|
||||
});
|
||||
labs.push(evidence("portable-after-legacy", sources[499].id, "2026-08-29T12:00:00Z"));
|
||||
const calls = [];
|
||||
const catalog = await fetchObservatoryCatalog({ fetcher: async (input, init) => {
|
||||
const url = new URL(String(input), "http://localhost");
|
||||
calls.push(url);
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(url.pathname, "/api/v1/observation-sessions");
|
||||
const rows = url.searchParams.get("scope") === "source" ? sources : labs;
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const start = cursor === null ? 0 : rows.findIndex((item) => item.id === cursor) + 1;
|
||||
const page = rows.slice(start, start + 100);
|
||||
return pageResponse(page, start + page.length < rows.length ? page.at(-1).id : null);
|
||||
} });
|
||||
assert.equal(calls.length, 7);
|
||||
assert.equal(catalog.items.length, 500);
|
||||
assert.equal(catalog.window.laboratoryCount, 1);
|
||||
assert.equal(catalog.window.sourceLimitReached, false);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, false);
|
||||
assert.equal(catalog.items.find((item) => item.source.id === "source-499").evidence[0].sessionId, "portable-after-legacy");
|
||||
assert.deepEqual(catalog.unresolvedEvidence, []);
|
||||
});
|
||||
|
||||
test("exactly one full page with no cursor is complete, not a guessed truncated window", async () => {
|
||||
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) =>
|
||||
String(url).includes("scope=source")
|
||||
? pageResponse([source("source", "2026-08-29T10:00:00Z")])
|
||||
: pageResponse([evidence("result", "source", "2026-08-29T11:00:00Z")]),
|
||||
});
|
||||
assert.equal(catalog.window.sourceLimitReached, false);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, false);
|
||||
});
|
||||
|
||||
test("paging rejects repeated records, cursor cycles, unsafe cursors and unversioned responses", async () => {
|
||||
for (const failure of ["duplicate", "cycle", "unsafe", "unversioned", "too-large"]) {
|
||||
let index = 0;
|
||||
await assert.rejects(fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
|
||||
if (String(url).includes("scope=laboratory")) return pageResponse([]);
|
||||
index += 1;
|
||||
if (failure === "unversioned") return new Response(JSON.stringify({ items: [] }));
|
||||
if (failure === "unsafe") return pageResponse([], "../../private");
|
||||
const id = failure === "duplicate" ? "same" : `source-${index}`;
|
||||
const rows = [source(id, "2026-08-29T10:00:00Z")];
|
||||
if (failure === "too-large") rows.push(source("extra", "2026-08-29T10:00:00Z"));
|
||||
return pageResponse(rows, failure === "cycle" ? "cycle" : id);
|
||||
} }));
|
||||
assert.ok(index <= 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("the traversal cap is explicit and cannot confirm absence of a result", async () => {
|
||||
let count = 0;
|
||||
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
|
||||
if (String(url).includes("scope=source")) return pageResponse([]);
|
||||
count += 1;
|
||||
return pageResponse([], `cursor-${count}`);
|
||||
} });
|
||||
assert.equal(count, 256);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, true);
|
||||
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(catalog, "missing"), false);
|
||||
});
|
||||
|
||||
test("failure aborts the sibling traversal and an already aborted caller starts no requests", async () => {
|
||||
const signals = [];
|
||||
await assert.rejects(fetchObservatoryCatalog({ fetcher: async (url, init) => {
|
||||
signals.push(init.signal);
|
||||
if (String(url).includes("scope=source")) return new Response("unavailable", { status: 503 });
|
||||
return pageResponse([]);
|
||||
} }), /503/);
|
||||
assert.ok(signals.every((signal) => signal.aborted));
|
||||
const request = new AbortController();
|
||||
request.abort();
|
||||
await assert.rejects(fetchObservatoryCatalog({ signal: request.signal, fetcher: async () => {
|
||||
assert.fail("aborted catalog cannot start a request");
|
||||
} }), { name: "AbortError" });
|
||||
});
|
||||
|
||||
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
|
||||
|
||||
@@ -68,6 +68,10 @@ test("missing and stale telemetry show no fabricated advancement", () => {
|
||||
assert.equal(label(job, null), "Ожидаем данные расчёта");
|
||||
assert.equal(label(job, decode({ ...view(), age_seconds: 30 }, job)), "Ожидаем обновление прогресса");
|
||||
});
|
||||
test("terminal jobs keep their real state when progress telemetry is absent", () => {
|
||||
assert.equal(label({ ...job, state: "failed" }, null), "Ошибка расчёта");
|
||||
assert.equal(label({ ...job, state: "succeeded" }, null), "Расчёт завершён");
|
||||
});
|
||||
test("progress is a read-only request bound to the active job", async () => {
|
||||
const calls = [];
|
||||
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
|
||||
|
||||
@@ -35,9 +35,9 @@ test("Observatory is the third independent Polygon workspace", () => {
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Проверка компьютерного зрения",
|
||||
title: "AI inference",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
description: "Сессии и квалификация без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
@@ -73,9 +73,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
|
||||
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
|
||||
assert.match(workspace, /useObservatoryCatalog/);
|
||||
assert.doesNotMatch(workspace, /observatory-lead/);
|
||||
assert.doesNotMatch(workspace, /Расчёт записанных маршрутов выбранными профилями/);
|
||||
assert.match(workspace, /Связанных результатов нет/);
|
||||
assert.match(workspace, /не является выводом о качестве/);
|
||||
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
|
||||
assert.match(workspace, /function compositionProducedEvidence\(/);
|
||||
assert.match(workspace, /publishedAt >= compositionCreatedAt/);
|
||||
assert.match(workspace, /const presentedCompositionRuns = aiJobsController\.runs/);
|
||||
assert.match(workspace, /presentedEvidence = \(selectedSession\?\.evidence \?\? \[\]\)\.filter/);
|
||||
assert.match(workspace, /!presentedCompositionRuns\.some\([\s\S]*compositionProducedEvidence\(run, evidence\)/);
|
||||
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
|
||||
assert.match(workspace, /Полнота исторических/);
|
||||
assert.match(workspace, /вне текущего загруженного среза/);
|
||||
@@ -86,12 +92,13 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
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/,
|
||||
/function evidenceConfigurationLabel[\s\S]*calculationProfile\?\.displayName[\s\S]*return profileName \?\? evidence\.label/,
|
||||
);
|
||||
assert.match(
|
||||
workspace,
|
||||
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
|
||||
/<strong>\{evidenceConfigurationLabel\([\s\S]*evidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)\}<\/strong>/,
|
||||
);
|
||||
assert.doesNotMatch(workspace, /evidenceConfigurationLabel\([^)]*,\s*selectedSession\.source\.label/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
|
||||
@@ -101,9 +108,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||
assert.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
|
||||
const portable = await read("components/laboratory/PortableResultReplay.tsx");
|
||||
assert.match(portable, /<summary>Документ результата<\/summary>/);
|
||||
assert.doesNotMatch(portable, /Документ результата|JSON.stringify|StatusBadge/);
|
||||
assert.match(portable, /missioncore.recorded-eomt-ddrnet-review\/v2/);
|
||||
assert.match(portable, /sourceKind: "portable-semantic"/);
|
||||
assert.match(portable, /sourceKind: "portable-objects"/);
|
||||
assert.match(portable, /costmap=\{tgs\} semantics=\{semantics\}/);
|
||||
assert.match(portable, /detections=\{objects\}/);
|
||||
assert.match(portable, /<CanonicalResultRerunReplay/);
|
||||
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
||||
assert.doesNotMatch(workspace, /evidence\.lab\.resultKind|без запуска тяжёлого/);
|
||||
assert.match(workspace, /Проверяем точную связь результата/);
|
||||
assert.match(workspace, /role="alert"/);
|
||||
assert.match(workspace, /Повторить/);
|
||||
@@ -112,7 +125,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
workspace,
|
||||
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
|
||||
);
|
||||
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/<h3>\{replayEvidence && selectedSession[\s\S]*evidenceConfigurationLabel\([\s\S]*replayEvidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)[\s\S]*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"/);
|
||||
@@ -186,6 +202,10 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
|
||||
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
|
||||
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
|
||||
assert.match(workspace, /renameAICompositionRunProjection/);
|
||||
assert.match(workspace, /deleteAICompositionRunProjection/);
|
||||
assert.match(workspace, /openCompositionRename\(run\)/);
|
||||
assert.match(workspace, /openCompositionDelete\(run\)/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
|
||||
@@ -196,9 +216,12 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
);
|
||||
|
||||
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
|
||||
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
|
||||
const deleteFlowEnd = workspace.indexOf("}, [aiJobsController, closeReplay", deleteFlowStart);
|
||||
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
|
||||
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
|
||||
const wholeDeleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
|
||||
const evidenceFlowStart = wholeDeleteFlow.indexOf("if (!deleteTarget?.recordedRun");
|
||||
assert.ok(evidenceFlowStart >= 0);
|
||||
const deleteFlow = wholeDeleteFlow.slice(evidenceFlowStart);
|
||||
const teardown = deleteFlow.indexOf("closeReplay();");
|
||||
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
|
||||
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
|
||||
@@ -219,77 +242,128 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
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([
|
||||
test("Observatory configures independent AI modules and shows queued evidence progress", async () => {
|
||||
const [workspace, styles, configWindow, jobsHook, sharedReplay] = await Promise.all([
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("styles/observatory.css"),
|
||||
read("core/observatory/useObservatoryLaboratorySetups.ts"),
|
||||
read("core/observatory/useObservatoryRecordedJobs.ts"),
|
||||
read("components/observatory/AIConfigurationWindow.tsx"),
|
||||
read("core/observatory/useAICompositionJobs.ts"),
|
||||
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
|
||||
]);
|
||||
|
||||
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
||||
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
||||
assert.match(workspace, /setupController\.error \? \(/);
|
||||
assert.match(workspace, />\s*Сконфигурировать AI-слой\s*<\/Button>/);
|
||||
assert.doesNotMatch(workspace, /label="Выбрать сетап лаборатории"/);
|
||||
assert.doesNotMatch(workspace, />\s*Обновить\s*<\/Button>/);
|
||||
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
||||
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
||||
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
|
||||
assert.match(workspace, /useObservatoryRecordedJobs/);
|
||||
assert.doesNotMatch(workspace, /useObservatoryRecordedJobs|useObservatoryLaboratorySetups/);
|
||||
assert.match(configWindow, /title="Сконфигурировать AI-слой"/);
|
||||
assert.match(configWindow, /label: "Сегментация"/);
|
||||
assert.match(configWindow, /Зависимые блоки подключаются явно/);
|
||||
assert.match(configWindow, /label: "Облако точек \/ TGS"/);
|
||||
assert.match(configWindow, /label: "Дистанция"/);
|
||||
assert.match(configWindow, /Дистанция использует рамки детектора и синхронное облако точек/);
|
||||
assert.match(configWindow, /TGS обрабатывает LiDAR независимо от сегментации и детектора/);
|
||||
assert.match(configWindow, /providers\.length === 1/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
|
||||
configWindow,
|
||||
/<WindowFooterActions>[\s\S]*"Отправляем на Worker…" : "Рассчитать"/,
|
||||
);
|
||||
assert.match(configWindow, /className=\{state === "saving" \? "observatory-ai-config__calculate--saving"/);
|
||||
assert.match(configWindow, /aria-busy=\{state === "saving"\}/);
|
||||
assert.match(styles, /@keyframes observatory-ai-calculate-pulse/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
|
||||
styles,
|
||||
/\.observatory-ai-config__calculate--saving:disabled \{[\s\S]*animation: observatory-ai-calculate-pulse 1\.8s ease-in-out infinite;[\s\S]*opacity: 1;/,
|
||||
);
|
||||
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, /useAICompositionJobs\(selectedSessionId\)/);
|
||||
assert.match(workspace, /observatory-evidence-card--progress/);
|
||||
assert.match(workspace, /const active = run\.state === "running"/);
|
||||
assert.match(workspace, /\{active \? \([\s\S]*observatory-evidence-card__progress/);
|
||||
assert.match(workspace, /run\.state === "ready" \? <Icon name="clipboard"/);
|
||||
assert.match(workspace, /function aiJobStage\(/);
|
||||
assert.match(workspace, /return "Инициализация"/);
|
||||
assert.match(workspace, /return "Передача на сервер"/);
|
||||
assert.match(workspace, /return "Просчёт"/);
|
||||
assert.match(workspace, /Собираем видеоряд для модели/);
|
||||
assert.match(workspace, /измеренная скорость Worker 006: 17–18 FPS/);
|
||||
assert.doesNotMatch(workspace, /phaseElapsedSeconds \/ progress\.completed/);
|
||||
assert.match(workspace, /AI_PROGRESS_PHASE_RANGES/);
|
||||
assert.match(workspace, /"source-preparation": \[5, 25\]/);
|
||||
assert.match(workspace, /computing: \[25, 90\]/);
|
||||
assert.match(workspace, /Подготавливаем камеру и LiDAR/);
|
||||
assert.match(workspace, /прошло.*formatDuration\(progress\.elapsedSeconds\)/s);
|
||||
assert.match(workspace, /Не удалось запустить модель/);
|
||||
assert.doesNotMatch(workspace, /job\.terminalMessage|job\.publication\.error/);
|
||||
assert.doesNotMatch(workspace, /function aiModuleLabel\(/);
|
||||
assert.match(workspace, /aiJobsController\.moduleLabelsBySetup\[job\.setupId\] \?\? job\.setupId/);
|
||||
assert.match(jobsHook, /fetchAIModuleCatalog\(request\.signal\)/);
|
||||
assert.match(jobsHook, /AI_MODULE_SETUP_IDS\[module\.moduleId\]/);
|
||||
assert.match(jobsHook, /run\.presentation\.modules\.flatMap/);
|
||||
assert.match(jobsHook, /Object\.fromEntries\(\[\.\.\.catalogLabels, \.\.\.projectedLabels\]\)/);
|
||||
assert.match(workspace, /<strong>\{moduleLabel\}<\/strong>/);
|
||||
assert.match(workspace, /<strong>\{run\.displayName \?\? run\.presentation\.configurationLabel\}<\/strong>/);
|
||||
assert.doesNotMatch(workspace, /<strong>\{selectedSession\.source\.label\} ·/);
|
||||
assert.match(workspace, /type EvidenceSortDirection = "newest" \| "oldest"/);
|
||||
assert.match(workspace, /const presentedRows = useMemo<readonly ObservatoryEvidenceRow\[]>/);
|
||||
assert.match(workspace, /direction \* \(Date\.parse\(left\.timestamp\) - Date\.parse\(right\.timestamp\)\)/);
|
||||
assert.match(workspace, /className="observatory-evidence__sort"/);
|
||||
assert.match(workspace, /data-direction=\{evidenceSortDirection\}/);
|
||||
assert.match(workspace, /formatTimestamp\(row\.timestamp\)/);
|
||||
assert.match(styles, /\.observatory-evidence-card\s*\{[\s\S]*grid-template-columns:\s*auto minmax\(0, 1fr\) auto auto/);
|
||||
assert.match(styles, /\.observatory-evidence__sort\s*\{[\s\S]*border:\s*0;[\s\S]*outline:\s*0/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
|
||||
styles,
|
||||
/\.observatory-evidence-card__copy strong \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
|
||||
);
|
||||
assert.match(sharedReplay, /LAB_SCENE_SETTINGS_STORAGE_PREFIX/);
|
||||
assert.match(sharedReplay, /window\.localStorage\.getItem/);
|
||||
assert.match(sharedReplay, /window\.localStorage\.setItem/);
|
||||
assert.match(sharedReplay, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
|
||||
assert.match(sharedReplay, /setSettingsOpen\(false\);[\s\S]*void saveLabViewProfile/);
|
||||
assert.match(
|
||||
jobsHook,
|
||||
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
|
||||
sharedReplay,
|
||||
/showPoints: spatialMode !== null && \(showSourcePoints \|\| showLocalSlam\)/,
|
||||
);
|
||||
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(sharedReplay, /showTrajectory: spatialMode !== null && showLocalSlam/);
|
||||
assert.match(
|
||||
sharedReplay,
|
||||
/accumulationSeconds: showLocalSlam \? sceneDraft\.accumulationSeconds : 0/,
|
||||
);
|
||||
assert.match(workspace, /<AICompositionReplay run=\{replay\.run\} \/>/);
|
||||
assert.match(workspace, /function presentAIJob\(job: ObservatoryRecordedJob\)/);
|
||||
assert.match(workspace, /if \(job\.state === "failed"\) return true/);
|
||||
assert.match(workspace, /return job\.state !== "succeeded"/);
|
||||
assert.match(workspace, /function presentLatestAIJobs/);
|
||||
assert.match(workspace, /seenSetups\.has\(job\.setupId\)/);
|
||||
assert.match(configWindow, /fetchAICompositionJobs\(sourceSessionId, request\.signal\)/);
|
||||
assert.match(
|
||||
configWindow,
|
||||
/if \(job\.state === "failed" \|\| job\.publication\.state === "failed"\) continue;/,
|
||||
);
|
||||
assert.match(configWindow, /Текущая конфигурация уже рассчитана/);
|
||||
assert.match(configWindow, /configurationAlreadyExists/);
|
||||
assert.match(configWindow, /Готовый результат будет использован без повторного расчёта/);
|
||||
assert.doesNotMatch(configWindow, /disabled: existingByModule\.has\(module\.moduleId\)/);
|
||||
assert.doesNotMatch(configWindow, /selections\.some\(\(\{ module \}\) => existingByModule/);
|
||||
assert.match(jobsHook, /const OPEN = new Set/);
|
||||
assert.match(jobsHook, /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(workspace, /onCalculated=\{\(\) => aiJobsController\.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, /\.observatory-evidence-card__progress \{/);
|
||||
assert.match(styles, /\.observatory-ai-module-group > header \{/);
|
||||
assert.match(styles, /\.observatory-ai-module-group \{[\s\S]*border: 0;[\s\S]*outline: 0;/);
|
||||
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\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, createRecordedRerunCameraJournal, initialEye;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
const module = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts");
|
||||
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
|
||||
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
|
||||
}
|
||||
|
||||
class FakeTarget {
|
||||
listeners = new Map();
|
||||
emitted = [];
|
||||
addEventListener(type, listener) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener); this.listeners.set(type, listeners);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
this.emitted.push(event);
|
||||
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const radius = (eye) => Math.hypot(
|
||||
eye.position[0] - eye.lookTarget[0],
|
||||
eye.position[1] - eye.lookTarget[1],
|
||||
eye.position[2] - eye.lookTarget[2],
|
||||
);
|
||||
|
||||
test("recorded orbital eye tracks only 3D viewport navigation", () => {
|
||||
const scope = new FakeTarget();
|
||||
const timers = [];
|
||||
Object.assign(scope, {
|
||||
WheelEvent: FakeEvent,
|
||||
requestAnimationFrame(callback) { callback(); return 1; },
|
||||
setTimeout(callback) { timers.push(callback); return timers.length; },
|
||||
});
|
||||
const canvas = new FakeTarget();
|
||||
Object.assign(canvas, {
|
||||
clientHeight: 200,
|
||||
isConnected: true,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
|
||||
}),
|
||||
});
|
||||
const journal = createRecordedRerunCameraJournal(canvas, scope);
|
||||
journal.configure(initialEye, 0.46);
|
||||
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
|
||||
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
|
||||
assert.deepEqual(journal.current(), initialEye);
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
|
||||
const rotated = journal.current();
|
||||
assert.notDeepEqual(rotated.position, initialEye.position);
|
||||
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
|
||||
|
||||
journal.setMaxOrbitalRadius(40);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
|
||||
|
||||
journal.configure(rotated, 0.46);
|
||||
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
const zoomed = journal.current();
|
||||
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
|
||||
|
||||
const snapshot = journal.current();
|
||||
journal.setSpatialViewportStart(0.8);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
}));
|
||||
assert.deepEqual(journal.current(), snapshot);
|
||||
journal.dispose();
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, createIsolatedRerunHost, keepRecordedBlueprintSession, createRecordedRerunFacade, createRecordedRerunOwner, relayIsolatedRerunInput;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
({ createIsolatedRerunHost } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/isolatedRerunHost.ts"));
|
||||
({ keepRecordedBlueprintSession } = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedBlueprintLifecycle.ts"));
|
||||
({ createRecordedRerunFacade } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunFacade.ts"));
|
||||
({ createRecordedRerunOwner } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunOwner.ts"));
|
||||
({ relayIsolatedRerunInput } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/isolatedRerunInput.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
function fixture(t, { stopFails = false } = {}) {
|
||||
const timers = new Map();
|
||||
let timerId = 0, creates = 0, stops = 0, removals = 0;
|
||||
t.mock.method(globalThis, "setTimeout", (fn) => {
|
||||
timers.set(++timerId, fn); return timerId;
|
||||
});
|
||||
t.mock.method(globalThis, "clearTimeout", (id) => timers.delete(id));
|
||||
const listeners = new Map();
|
||||
const viewer = { stop() { stops++; if (stopFails) throw new Error("partial start"); } };
|
||||
const mount = {};
|
||||
const frame = {
|
||||
contentWindow: {
|
||||
missionCoreRerun: createRecordedRerunOwner(() => { creates++; return viewer; }, mount),
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: (name) => listeners.delete(name),
|
||||
},
|
||||
remove() { removals++; },
|
||||
};
|
||||
const host = { dataset: {}, ownerDocument: { createElement(name) {
|
||||
assert.equal(name, "iframe"); return frame;
|
||||
} }, append(child) { assert.equal(child, frame); } };
|
||||
return { host, frame, viewer, mount, timers, listeners,
|
||||
counts: () => ({ creates, stops, removals }) };
|
||||
}
|
||||
|
||||
function bridge(native, mount = {}) {
|
||||
const child = createRecordedRerunOwner(() => native, mount);
|
||||
const parent = createRecordedRerunFacade((request, bytes) => {
|
||||
assert.equal(typeof request, "string");
|
||||
const result = child.invoke(request, bytes);
|
||||
assert.equal(typeof result, "string");
|
||||
return result;
|
||||
});
|
||||
child.connect(message => {
|
||||
assert.equal(typeof message, "string");
|
||||
parent.notify(message);
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
|
||||
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
|
||||
const f = fixture(t, { stopFails: true });
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
assert.equal(f.frame.src, "/rerun-runtime.html");
|
||||
f.frame.onload();
|
||||
const ready = await scope.ready;
|
||||
assert.notEqual(ready.viewer, f.viewer);
|
||||
assert.equal(ready.mount, f.host);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.listeners.size, 1);
|
||||
scope.dispose(); scope.dispose();
|
||||
assert.deepEqual(f.counts(), { creates: 1, stops: 1, removals: 1 });
|
||||
assert.equal(f.listeners.size, 0);
|
||||
assert.equal(f.frame.onload, null);
|
||||
assert.equal(ready.viewer.ready, false);
|
||||
});
|
||||
|
||||
test("native frame input relays Escape and leaves pointer ownership inside Rerun", () => {
|
||||
const listeners = new Map(), events = [];
|
||||
class ParentEvent { constructor(type, init) { this.type = type; Object.assign(this, init); } }
|
||||
const host = { dataset: {}, ownerDocument: { defaultView: {
|
||||
KeyboardEvent: ParentEvent, PointerEvent: ParentEvent,
|
||||
} }, dispatchEvent: event => events.push(event) };
|
||||
const child = {
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: name => listeners.delete(name),
|
||||
};
|
||||
const release = relayIsolatedRerunInput(host, child, null);
|
||||
assert.equal(listeners.has("pointerdown"), false);
|
||||
listeners.get("keydown")({ key: "Escape", defaultPrevented: false });
|
||||
assert.equal(events[0].key, "Escape");
|
||||
release(); release();
|
||||
assert.equal(listeners.size, 0); assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test("disposed facade, channel and unsubscribe reject native use and copy event values", async () => {
|
||||
let eventCallback, unsubscribed = 0, sends = 0, starts = 0;
|
||||
const range = { min: 1, max: 2 }, mount = {};
|
||||
const native = {
|
||||
ready: true, stop() {},
|
||||
async start(source, parent) { assert.equal(parent, mount); starts++; },
|
||||
get_time_range: () => range,
|
||||
on(event, callback) { eventCallback = callback; return () => { unsubscribed++; }; },
|
||||
open_channel: () => ({ ready: true, send_rrd() { sends++; }, close() {} }),
|
||||
};
|
||||
const { facade, dispose } = bridge(native, mount);
|
||||
await facade.start(null, {}, null);
|
||||
assert.equal(starts, 1);
|
||||
let event;
|
||||
const unsubscribe = facade.on("time_update", value => { event = value; });
|
||||
eventCallback(range);
|
||||
assert.deepEqual(event, range); assert.notEqual(event, range);
|
||||
assert.notEqual(facade.get_time_range(), range);
|
||||
const channel = facade.open_channel();
|
||||
channel.send_rrd(new Uint8Array());
|
||||
dispose(); dispose(); unsubscribe();
|
||||
assert.equal(unsubscribed, 1);
|
||||
assert.equal(channel.ready, false);
|
||||
channel.send_rrd(new Uint8Array());
|
||||
eventCallback({ min: 3, max: 4 });
|
||||
assert.deepEqual(event, range);
|
||||
assert.equal(sends, 1);
|
||||
await assert.rejects(facade.start(null, {}, null), /disposed/);
|
||||
});
|
||||
|
||||
test("close before frame load cancels startup and cannot reopen", async (t) => {
|
||||
const f = fixture(t);
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
const lateLoad = f.frame.onload;
|
||||
const rejected = assert.rejects(scope.ready, /disposed/);
|
||||
scope.dispose(); lateLoad();
|
||||
await rejected;
|
||||
assert.deepEqual(f.counts(), { creates: 0, stops: 0, removals: 1 });
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test("closing during SDK startup also releases a late successful start", async () => {
|
||||
let finish, stops = 0;
|
||||
const owner = bridge({
|
||||
start: () => new Promise(resolve => { finish = resolve; }),
|
||||
stop() { stops++; },
|
||||
}, {});
|
||||
const pending = owner.facade.start(null, {}, null);
|
||||
const rejected = assert.rejects(pending, /disposed/);
|
||||
owner.dispose();
|
||||
assert.equal(stops, 1);
|
||||
finish();
|
||||
await rejected;
|
||||
await Promise.resolve();
|
||||
assert.equal(stops, 2);
|
||||
assert.equal(owner.facade.ready, false);
|
||||
});
|
||||
|
||||
test("late failed startup cannot revive a disposed parent and still finishes cleanup", async () => {
|
||||
let fail, stops = 0;
|
||||
const owner = bridge({
|
||||
start: () => new Promise((_resolve, reject) => { fail = reject; }),
|
||||
stop() { stops++; if (stops > 1) throw new Error("already gone"); },
|
||||
}, {});
|
||||
const pending = owner.facade.start(null, {}, null);
|
||||
const rejected = assert.rejects(pending, /disposed/);
|
||||
owner.dispose();
|
||||
fail(new Error("startup failed"));
|
||||
await rejected;
|
||||
await Promise.resolve();
|
||||
assert.equal(stops, 2);
|
||||
assert.equal(owner.facade.ready, false);
|
||||
});
|
||||
|
||||
test("primitive bridge preserves all public clock/control arguments and errors", async () => {
|
||||
const calls = [];
|
||||
const methods = {
|
||||
open: [["one.rrd", "two.rrd"]], close: ["one.rrd"],
|
||||
override_panel_state: ["time", "hidden"],
|
||||
get_active_recording_id: [], get_active_timeline: ["recording"],
|
||||
get_current_time: ["recording", "session_time"], get_playing: ["recording"],
|
||||
get_time_range: ["recording", "session_time"],
|
||||
set_active_timeline: ["recording", "session_time"],
|
||||
set_current_time: ["recording", "session_time", 123.456],
|
||||
set_playing: ["recording", false],
|
||||
};
|
||||
const native = { stop() {}, start: async () => { throw new Error("native startup failure"); } };
|
||||
for (const name of Object.keys(methods)) native[name] = (...args) => { calls.push([name, args]); return 1; };
|
||||
const { facade, dispose } = bridge(native);
|
||||
for (const [name, args] of Object.entries(methods)) facade[name](...args);
|
||||
assert.deepEqual(calls, Object.entries(methods));
|
||||
await assert.rejects(facade.start(null, {}, null), /native startup failure/);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test("sync native failures become parent-owned errors and optional channel names stay undefined", () => {
|
||||
const nativeError = new Error("native failure");
|
||||
let channelName = "unobserved";
|
||||
const { facade, dispose } = bridge({
|
||||
stop() {},
|
||||
open() { throw nativeError; },
|
||||
open_channel(name) { channelName = name; return { close() {} }; },
|
||||
});
|
||||
assert.throws(() => facade.open("rrd"), error => error !== nativeError && /native failure/.test(error.message));
|
||||
facade.open_channel();
|
||||
assert.equal(channelName, undefined);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test("dispose closes every auxiliary channel even if one channel fails", () => {
|
||||
const closed = [];
|
||||
let stops = 0;
|
||||
const owner = bridge({
|
||||
stop() { stops++; },
|
||||
open_channel: name => ({ close() {
|
||||
closed.push(name);
|
||||
if (name === "first") throw new Error("already gone");
|
||||
} }),
|
||||
}, {});
|
||||
const first = owner.facade.open_channel("first");
|
||||
const second = owner.facade.open_channel("second");
|
||||
owner.dispose(); owner.dispose(); first.close(); second.close();
|
||||
assert.deepEqual(closed, ["first", "second"]);
|
||||
assert.equal(stops, 1);
|
||||
});
|
||||
|
||||
test("host load timeout removes the frame and clears its timer", async (t) => {
|
||||
const f = fixture(t);
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
const rejected = assert.rejects(scope.ready, /timed out/);
|
||||
[...f.timers.values()][0]();
|
||||
await rejected;
|
||||
assert.equal(f.counts().removals, 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test("blueprint termination cancels renewal and sends one bounded keepalive release", async () => {
|
||||
let tick, canceled = 0;
|
||||
const requests = [];
|
||||
const release = keepRecordedBlueprintSession({
|
||||
endpointUrl: "/api/v1/observation-sessions/source/blueprint.rrd",
|
||||
origin: "http://core.test", applicationId: "nodedc_mission_core_recorded",
|
||||
recordingId: "recording", ownerId: "a".repeat(32),
|
||||
fetcher: async (url, init) => { requests.push({ url, ...init }); return {}; },
|
||||
schedule(fn) { tick = fn; return 1; }, cancel() { canceled++; },
|
||||
});
|
||||
tick();
|
||||
const renewal = requests[0];
|
||||
release(); release(); tick();
|
||||
assert.equal(canceled, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(renewal.signal.aborted, true);
|
||||
assert.equal(requests[1].keepalive, true);
|
||||
assert.equal(requests[1].url, "http://core.test/api/v1/observation-sessions/source/blueprint-lifecycle");
|
||||
assert.deepEqual(JSON.parse(requests[1].body), {
|
||||
action: "release", application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording", blueprint_session_id: "a".repeat(32),
|
||||
});
|
||||
});
|
||||
|
||||
test("blueprint lease refuses a foreign endpoint", () => {
|
||||
assert.throws(() => keepRecordedBlueprintSession({ endpointUrl: "https://other.test/blueprint.rrd",
|
||||
origin: "http://core.test", applicationId: "app", recordingId: "recording", ownerId: "owner",
|
||||
}), /Unsafe/);
|
||||
});
|
||||
@@ -90,8 +90,15 @@ test("one canonical LAB replay generation reaches the same native receiver", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("portable replay uses recorded admission and the shared source blueprint", () => {
|
||||
const source = `/api/v1/observatory/portable-results/m49-tgs-portable-review-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
|
||||
for (const prefix of [
|
||||
"m49-tgs-portable-review",
|
||||
"lab-v1-eomt-ddrnet",
|
||||
"ai-layer-ddrnet",
|
||||
"ai-layer-eomt",
|
||||
"ai-layer-rf-detr",
|
||||
"ai-layer-object-distance",
|
||||
]) test(`${prefix} replay uses recorded admission and the shared source blueprint`, () => {
|
||||
const source = `/api/v1/observatory/portable-results/${prefix}-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
|
||||
const blueprint = "/api/v1/observation-sessions/source/blueprint.rrd";
|
||||
assert.equal(isRecordedRrdSource(source), true);
|
||||
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
|
||||
|
||||
@@ -97,7 +97,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
|
||||
assert.match(source, /aria-label="Слои 3D и плана"/);
|
||||
assert.match(canonical, /data-pane-mode="media"/);
|
||||
assert.match(canonical, /data-pane-mode="spatial"/);
|
||||
assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
|
||||
assert.match(canonical, /modeControlsVisible=\{!splitView && !paneToolbarsAlwaysVisible\}/);
|
||||
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
||||
});
|
||||
|
||||
|
||||
@@ -497,14 +497,17 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
||||
assert.match(rerunSource, /resolveReplay\(resultId, value/);
|
||||
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, /lockPerceptionCameraInteraction: false/);
|
||||
assert.match(rerunSource, /&& !semantics[\s\S]*&& !detections[\s\S]*&& !costmap/);
|
||||
assert.doesNotMatch(rerunSource, /trackNativeSplit|rerunNativeCursor|rerunNativeInput/);
|
||||
assert.match(canonicalSource, /resizable=\{splitView\}/);
|
||||
assert.match(rerunSource, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
|
||||
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, /max=\{500\}/);
|
||||
assert.match(rerunSource, /exactValueBounds=\{\{ min: 0 \}\}/);
|
||||
assert.match(replayStyles, /m4-replay-threat-visual__pane-toolbar \{[\s\S]*flex-wrap: wrap;/);
|
||||
assert.match(replayStyles, /m4-replay-threat-visual__spatial-toolbar-end \{[\s\S]*display: contents;/);
|
||||
assert.match(rerunSource, /<ToastStack items=\{toasts\}/);
|
||||
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
|
||||
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
|
||||
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
|
||||
@@ -517,22 +520,14 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
||||
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: !costmap/);
|
||||
assert.match(rerunSource, /hasTgs \? toggle\("TGS", showTgs/);
|
||||
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(canonicalSource, /resizable=\{splitView\}/);
|
||||
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
|
||||
assert.match(resultSource, /linkedTgsResultId/);
|
||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||
|
||||
@@ -91,6 +91,12 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
|
||||
Reference in New Issue
Block a user