diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index cb22ad3..e533885 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -166,6 +166,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$/; + +export function isRecordedRrdSource(path: string): boolean { + return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path) + || PORTABLE_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$/; const LAB_RECORDED_PERCEPTION_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-overlay\.rrd$/; @@ -230,8 +236,7 @@ export function resolveRecordedViewerSourceUrl( const expectedViewerSourceUrl = `${descriptor.sourceUrl}?generation=${descriptor.sha256}`; const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`); if ( - !(RECORDED_RRD_PATH.test(descriptor.sourceUrl) - || LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) || + !isRecordedRrdSource(descriptor.sourceUrl) || descriptor.viewerSourceUrl !== expectedViewerSourceUrl || endpoint.origin !== base.origin || endpoint.pathname !== descriptor.sourceUrl || @@ -262,7 +267,7 @@ export function resolveRecordedBlueprintUrl( if (explicitSourceUrl !== undefined) { const explicit = explicitSourceUrl.trim(); if ( - !LAB_RECORDED_REPLAY_PATH.test(normalized) + !(LAB_RECORDED_REPLAY_PATH.test(normalized) || PORTABLE_RECORDED_REPLAY_PATH.test(normalized)) || !RECORDED_BLUEPRINT_PATH.test(explicit) ) return null; const endpoint = new URL(explicit, `${base.origin}/`); @@ -438,6 +443,7 @@ export async function fetchRecordedBlueprintRrd( perceptionLayers.detections2d, perceptionLayers.segmentation, perceptionLayers.cuboids3d, + perceptionLayers.costmap ?? false, ].some((value) => typeof value !== "boolean") || identity.applicationId !== "nodedc_mission_core_recorded" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) || @@ -473,6 +479,10 @@ export async function fetchRecordedBlueprintRrd( show_detections_2d: perceptionLayers.detections2d, show_segmentation: perceptionLayers.segmentation, show_cuboids_3d: perceptionLayers.cuboids3d, + ...(perceptionLayers.costmap === undefined ? {} : { + show_costmap: perceptionLayers.costmap, + reactivate_updates: true, + }), }), signal, }); @@ -791,8 +801,7 @@ export function RerunViewport({ return; } - const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource) - || LAB_RECORDED_REPLAY_PATH.test(normalizedSource); + const isRecordedSource = isRecordedRrdSource(normalizedSource); let resolvedSource: string; try { if (isRecordedSource) { @@ -1938,6 +1947,9 @@ export function RerunViewport({ useEffect(() => { if (!recordedBlueprintUrl || !sceneSettings) return; + // Initialize the portable following-eye blueprint after native admission + // and its initial seek, not while the base RRD is still arriving. + if (recordedPerceptionLayers.costmap !== undefined && status !== "ready") return; const active = blueprintChannelRef.current; const identity = recordedIdentityRef.current; if ( @@ -1986,6 +1998,8 @@ export function RerunViewport({ recordedPerceptionLayers.detections2d, recordedPerceptionLayers.segmentation, recordedPerceptionLayers.cuboids3d, + recordedPerceptionLayers.costmap, + status, sceneSettings?.accumulationSeconds, sceneSettings?.customColor, sceneSettings?.colorMode, diff --git a/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx new file mode 100644 index 0000000..cbc36a5 --- /dev/null +++ b/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx @@ -0,0 +1,397 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + ActivityIndicator, + Button, + Icon, + SegmentedControl, +} from "@nodedc/ui-react"; + +import { ObservationTimeline } from "../ObservationTimeline"; +import { + RerunViewport, + isRecordedPlaybackPresentationReady, + type RerunPlaybackController, + type RerunPlaybackState, + type RerunViewportStatus, +} from "../RerunViewport"; +import { + CanonicalRecordedLabReplay, + useCanonicalRecordedLabReplayState, +} from "../laboratory/CanonicalRecordedLabReplay"; +import { + 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"; + +type MediaMode = "video" | "camera"; +type SpatialMode = "3d" | "plan"; +type SpatialLayer = "source" | "local" | "tgs" | "semantic"; +type SemanticLayer = "city" | "vegetation"; + +interface CanonicalReplayLaunch { + base: ObservationSessionReplayLaunch; + replay: CanonicalLabReplayDescriptor; +} + +const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46; +const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10; + +export function CanonicalResultRerunReplay({ + resultId, + sessionId, + initialPlaybackStartSeconds, + resolveReplay, + semantics = false, + costmap = false, + label = "Сохранённый результат · синхронизированный повтор", +}: { + resultId: string; + sessionId: string; + initialPlaybackStartSeconds?: number; + resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise; + semantics?: boolean; + costmap?: boolean; + label?: string; +}) { + const { + mediaMode, + spatialMode, + splitPrimarySize, + splitOrientation, + expanded, + onMediaModeChange, + onSpatialModeChange, + onSplitPrimarySizeChange, + onExpandedChange, + } = useCanonicalRecordedLabReplayState({ + initialMediaMode: "video", + 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 [playback, setPlayback] = 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 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; + } + trackedLaunchSha256Ref.current = launchSha256; + previousSplitViewRef.current = splitView; + const cameraPanePercent = mediaMode === null + ? 0 + : splitView + ? nativeSplitPercentRef.current + : 100; + viewerFrameRef.current?.style.setProperty( + "--canonical-rerun-camera-pane", + `${cameraPanePercent}%`, + ); + }, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]); + + useEffect(() => { + const controller = new AbortController(); + setLaunch(null); + setLaunchError(null); + setPlayback(null); + setPlaybackController(null); + setViewerStatus("idle"); + void resolveObservationSessionReplay(sessionId, { + signal: controller.signal, + maximumWaitMs: 30 * 60 * 1000, + onUpdate: () => undefined, + }).then(async (value) => ({ + base: value, + replay: await resolveReplay(resultId, value, { + signal: controller.signal, + }), + })).then((value) => { + if (!controller.signal.aborted) setLaunch(value); + }).catch((caught: unknown) => { + if (!controller.signal.aborted) { + setLaunchError( + caught instanceof Error ? caught.message : "Сохранённая запись недоступна.", + ); + } + }); + return () => controller.abort(); + }, [resultId, sessionId, resolveReplay]); + + const presentationReady = playbackController !== null + && isRecordedPlaybackPresentationReady(viewerStatus, playback); + const presentationState = launchError || viewerStatus === "error" + ? "error" + : presentationReady + ? "ready" + : "loading"; + const sceneSettings = useMemo(() => ({ + ...defaultSceneSettings, + accumulationSeconds: spatialLayer === "local" ? 5 : 0, + showPoints: spatialMode !== null, + showTrajectory: spatialMode !== null, + showGrid: spatialMode !== null, + pointSize: 3.8, + }), [spatialLayer, spatialMode]); + const profile = launch ? recordedSessionRerunProfile({ + sourceUrl: launch.replay.sourceUrl, + artifact: { + sourceUrl: launch.replay.sourceUrl, + viewerSourceUrl: launch.replay.viewerSourceUrl, + byteLength: launch.replay.byteLength, + sha256: launch.replay.sha256, + }, + blueprintSourceUrl: launch.replay.blueprintSourceUrl, + autoplayWhenReady: false, + presentationGate: "ready", + expectedTimelineStartSeconds: launch.base.timelineStartSeconds, + expectedTimelineEndSeconds: launch.base.timelineEndSeconds, + // 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", + viewResetGeneration, + followTrajectory: true, + semanticLayer, + unifiedPerception: splitView, + planView: spatialMode === "plan", + perceptionLayers: { + enabled: mediaMode !== null, + detections2d: semantics && mediaMode === "video", + segmentation: semantics && mediaMode === "video" && showSemantics, + cuboids3d: false, + ...(costmap ? { costmap: spatialMode !== null && spatialLayer === "tgs" } : {}), + }, + perceptionRetryGeneration: 0, + lockPerceptionCameraInteraction: mediaMode !== null, + }) : null; + + const mediaLayerControls = semantics ? ( +
+ + { + setSemanticLayer(value); + setShowSemantics(true); + }} + /> +
+ ) : undefined; + const spatialLayerControls = ( +
+ +
+ ); + const resetSpatialView = ( + + ); + + const transport = presentationReady && playback && playbackController ? ( + + ) : undefined; + return ( +
+ + +
+ ) : launchError ? ( +
+ {launchError} +
+ ) : ( + + ); +} diff --git a/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx index 7b0cb15..0c5e6c5 100644 --- a/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx +++ b/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx @@ -1,383 +1,12 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type CSSProperties, - type PointerEvent as ReactPointerEvent, -} from "react"; -import { - ActivityIndicator, - Button, - Icon, - SegmentedControl, -} from "@nodedc/ui-react"; - -import { ObservationTimeline } from "../ObservationTimeline"; -import { - RerunViewport, - isRecordedPlaybackPresentationReady, - type RerunPlaybackController, - type RerunPlaybackState, - type RerunViewportStatus, -} from "../RerunViewport"; -import { - CanonicalRecordedLabReplay, - useCanonicalRecordedLabReplayState, -} from "../laboratory/CanonicalRecordedLabReplay"; -import { - resolveCanonicalLabReplay, - type CanonicalLabReplayDescriptor, -} from "../../core/laboratory/canonicalLabReplay"; +import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay"; +import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay"; import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow"; -import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive"; -import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile"; -import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; -import { defaultSceneSettings } from "../../sceneSettings"; -type MediaMode = "video" | "camera"; -type SpatialMode = "3d" | "plan"; -type SpatialLayer = "source" | "local" | "tgs" | "semantic"; -type SemanticLayer = "city" | "vegetation"; - -interface CanonicalReplayLaunch { - base: ObservationSessionReplayLaunch; - replay: CanonicalLabReplayDescriptor; -} - -const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46; -const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10; - -export function CanonicalVegetationRerunReplay({ - resultId, - review, -}: { +export function CanonicalVegetationRerunReplay({ resultId, review }: { resultId: string; review: VegetationFullRouteReview; }) { - const { - mediaMode, - spatialMode, - splitPrimarySize, - splitOrientation, - expanded, - onMediaModeChange, - onSpatialModeChange, - onSplitPrimarySizeChange, - onExpandedChange, - } = useCanonicalRecordedLabReplayState({ - initialMediaMode: "video", - initialSpatialMode: "3d", - }); - const splitView = mediaMode !== null && spatialMode !== null; - const [semanticLayer, setSemanticLayer] = useState("vegetation"); - const [showSemantics, setShowSemantics] = useState(true); - const [spatialLayer, setSpatialLayer] = useState("source"); - const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0); - const [playback, setPlayback] = 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 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; - } - trackedLaunchSha256Ref.current = launchSha256; - previousSplitViewRef.current = splitView; - const cameraPanePercent = mediaMode === null - ? 0 - : splitView - ? nativeSplitPercentRef.current - : 100; - viewerFrameRef.current?.style.setProperty( - "--canonical-rerun-camera-pane", - `${cameraPanePercent}%`, - ); - }, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]); - - useEffect(() => { - const controller = new AbortController(); - setLaunch(null); - setLaunchError(null); - setPlayback(null); - setPlaybackController(null); - setViewerStatus("idle"); - void resolveObservationSessionReplay(review.sessionId, { - signal: controller.signal, - maximumWaitMs: 30 * 60 * 1000, - onUpdate: () => undefined, - }).then(async (value) => ({ - base: value, - replay: await resolveCanonicalLabReplay(resultId, value, { - signal: controller.signal, - }), - })).then((value) => { - if (!controller.signal.aborted) setLaunch(value); - }).catch((caught: unknown) => { - if (!controller.signal.aborted) { - setLaunchError( - caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.", - ); - } - }); - return () => controller.abort(); - }, [resultId, review.sessionId]); - - const presentationReady = playbackController !== null - && isRecordedPlaybackPresentationReady(viewerStatus, playback); - const presentationState = launchError || viewerStatus === "error" - ? "error" - : presentationReady - ? "ready" - : "loading"; - const sceneSettings = useMemo(() => ({ - ...defaultSceneSettings, - accumulationSeconds: spatialLayer === "local" ? 5 : 0, - showPoints: spatialMode !== null, - showTrajectory: spatialMode !== null, - showGrid: spatialMode !== null, - pointSize: 3.8, - }), [spatialLayer, spatialMode]); - const profile = launch ? recordedSessionRerunProfile({ - sourceUrl: launch.replay.sourceUrl, - artifact: { - sourceUrl: launch.replay.sourceUrl, - viewerSourceUrl: launch.replay.viewerSourceUrl, - byteLength: launch.replay.byteLength, - sha256: launch.replay.sha256, - }, - blueprintSourceUrl: launch.replay.blueprintSourceUrl, - autoplayWhenReady: false, - presentationGate: "ready", - expectedTimelineStartSeconds: launch.base.timelineStartSeconds, - expectedTimelineEndSeconds: launch.base.timelineEndSeconds, - initialPlaybackStartSeconds: review.timelineStartSeconds, - view: mediaMode !== null ? "perception" : "spatial", - viewResetGeneration, - followTrajectory: true, - semanticLayer, - unifiedPerception: splitView, - planView: spatialMode === "plan", - perceptionLayers: { - enabled: mediaMode !== null, - detections2d: mediaMode === "video", - segmentation: mediaMode === "video" && showSemantics, - cuboids3d: false, - }, - perceptionRetryGeneration: 0, - lockPerceptionCameraInteraction: mediaMode !== null, - }) : null; - - const mediaLayerControls = ( -
- - { - setSemanticLayer(value); - setShowSemantics(true); - }} - /> -
- ); - const spatialLayerControls = ( -
- -
- ); - const resetSpatialView = ( - - ); - - const transport = presentationReady && playback && playbackController ? ( - - ) : undefined; - return ( -
- - -
- ) : launchError ? ( -
- {launchError} -
- ) : ( - - ); + return ; } diff --git a/apps/control-station/src/components/laboratory/PortableResultReplay.tsx b/apps/control-station/src/components/laboratory/PortableResultReplay.tsx new file mode 100644 index 0000000..e6602c0 --- /dev/null +++ b/apps/control-station/src/components/laboratory/PortableResultReplay.tsx @@ -0,0 +1,30 @@ +import { GlassSurface, StatusBadge } 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, + 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 ( + +
Проверено +

{review.resultKind}

Связанных артефактов: {review.artifacts.length}

+
Документ результата +
{JSON.stringify(review.resultDocument, null, 2)}
+
+
+ ); + return <> +

Камера, исходное облако и TGS · общая шкала времени. Зелёный — опора, + красный — занятое пространство, жёлтый — отклонённые точки. + Отсутствие ячеек не означает свободный путь. Сегментация и детекция в этот профиль не входят.

+ + ; +} diff --git a/apps/control-station/src/core/laboratory/canonicalLabReplay.ts b/apps/control-station/src/core/laboratory/canonicalLabReplay.ts index 6d64d68..ac8257a 100644 --- a/apps/control-station/src/core/laboratory/canonicalLabReplay.ts +++ b/apps/control-station/src/core/laboratory/canonicalLabReplay.ts @@ -23,26 +23,28 @@ export async function resolveCanonicalLabReplay( origin = window.location.origin, signal, fetcher = globalThis.fetch, + sourceKind = "legacy-vegetation", }: { origin?: string; signal?: AbortSignal; fetcher?: typeof globalThis.fetch; + sourceKind?: "legacy-vegetation" | "portable-tgs"; } = {}, ): Promise { const base = new URL(origin); if ( - !SAFE_RESULT_ID.test(resultId) + !(sourceKind === "portable-tgs" ? /^m49-tgs-portable-review-[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 = - `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}` - + "/canonical-replay.rrd"; + const sourceUrl = sourceKind === "portable-tgs" + ? `/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}/`); - descriptorUrl.searchParams.set("base_generation", launch.sha256); + if (sourceKind === "legacy-vegetation") descriptorUrl.searchParams.set("base_generation", launch.sha256); if (descriptorUrl.origin !== base.origin) { throw new Error("Канонический replay LAB должен быть same-origin."); } diff --git a/apps/control-station/src/core/observation/viewerProfile.ts b/apps/control-station/src/core/observation/viewerProfile.ts index 9675da6..e45b53d 100644 --- a/apps/control-station/src/core/observation/viewerProfile.ts +++ b/apps/control-station/src/core/observation/viewerProfile.ts @@ -27,6 +27,7 @@ export interface RecordedPerceptionLayers { detections2d: boolean; segmentation: boolean; cuboids3d: boolean; + costmap?: boolean; } export interface RecordedRrdArtifactDescriptor { diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx index 531b034..8c5a7a3 100644 --- a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -15,6 +15,7 @@ import { } from "@nodedc/ui-react"; import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay"; +import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay"; import type { ObservationSessionStatus } from "../../core/observation/sessionArchive"; import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator"; import { @@ -715,15 +716,13 @@ export function ObservatoryWorkspace({
- {replay.review.kind === "canonical-recorded-rerun" - ? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ" - : "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"} + СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ

{replayEvidence?.label ?? replay.binding.resultId}

{replay.review.kind === "canonical-recorded-rerun" ? "Записанный маршрут синхронизирован по общей временной шкале." - : "Показан проверенный документ результата и связанные с ним артефакты."} + : "Просмотр сохранённых данных результата без повторного расчёта."}