diff --git a/apps/control-station/rerun-runtime.html b/apps/control-station/rerun-runtime.html new file mode 100644 index 0000000..4dad29a --- /dev/null +++ b/apps/control-station/rerun-runtime.html @@ -0,0 +1,16 @@ + + + + + + Синхронизированная запись + + + +
+ + + diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index e533885..22481ae 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -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, diff --git a/apps/control-station/src/components/laboratory/AICompositionReplay.tsx b/apps/control-station/src/components/laboratory/AICompositionReplay.tsx new file mode 100644 index 0000000..ca54d74 --- /dev/null +++ b/apps/control-station/src/components/laboratory/AICompositionReplay.tsx @@ -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 ( + + ); +} diff --git a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx index 814da58..4be7247 100644 --- a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx +++ b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx @@ -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 ? (
{mediaLayerControls} - {mediaModeControls} + {mediaModeControlsVisible ? mediaModeControls : null}
) : null} {mediaContent} @@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay< data-pane="spatial" aria-label={spatialAriaLabel} > - {splitView ? ( + {splitView || paneToolbarsAlwaysVisible ? (
{spatialLayerControls} {spatialModeControls} + {spatialTrailingControl}
) : 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" ? ( diff --git a/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx index cbc36a5..9ee4126 100644 --- a/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx +++ b/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx @@ -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; + 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; 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({ - initialMediaMode: "video", + initialMediaMode: "camera", initialSpatialMode: "3d", }); - const splitView = mediaMode !== null && spatialMode !== null; - const [semanticLayer, setSemanticLayer] = useState("vegetation"); - const [showSemantics, setShowSemantics] = useState(true); - const [spatialLayer, setSpatialLayer] = useState(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(() => loadLabSceneSettings(resultId)); + const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState( + RERUN_UNIFIED_CAMERA_SHARE_PERCENT, + ); + const [toasts, setToasts] = useState([]); + const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0); const [playback, setPlayback] = useState(null); - const [playbackController, setPlaybackController] = - useState(null); + const [playbackController, setPlaybackController] = useState(null); const [viewerStatus, setViewerStatus] = useState("idle"); const [launch, setLaunch] = useState(null); const [launchError, setLaunchError] = useState(null); - const viewerFrameRef = useRef(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(null); - const stopNativeSplitTracking = useCallback(() => { - nativeSplitTrackingCleanupRef.current?.(); - nativeSplitTrackingCleanupRef.current = null; - }, []); - - const trackNativeSplit = useCallback((event: ReactPointerEvent) => { - 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(() => ({ + ...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 ? ( -
- - { - setSemanticLayer(value); - setShowSemantics(true); - }} - /> -
- ) : undefined; - const spatialLayerControls = ( -
- + const toggle = (caption: string, active: boolean, onClick: () => void) => ( + + ); + const openSlamSettings = useCallback(() => { + if (expanded) onExpandedChange(false); + setSettingsOpen(true); + }, [expanded, onExpandedChange]); + const patchSceneDraft = useCallback((patch: Partial) => { + 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 = ( +
+ {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}
); + const spatialLayerControls = ( +
+ {hasSourcePoints + ? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null} + {hasLocalSlam ? ( +
+ {toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))} + + + +
+ ) : null} + {hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null} +
+ ); + const followTargetControl = toggle( + "СЛЕДОВАТЬ", + followTarget, + () => setFollowTarget((value) => !value), + ); const resetSpatialView = ( - + setViewResetGeneration((value) => value === 0 ? 1 : 0)}> + + ); const transport = presentationReady && playback && playbackController ? ( @@ -322,65 +429,39 @@ export function CanonicalResultRerunReplay({ showJumpToEnd={false} /> ) : undefined; + return ( -
+
- +
+
) : launchError ? ( -
- {launchError} -
- ) : ( -