feat(observatory): share native result replay and expose saved TGS layers
This commit is contained in:
@@ -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 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 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_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 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$/;
|
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 expectedViewerSourceUrl = `${descriptor.sourceUrl}?generation=${descriptor.sha256}`;
|
||||||
const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`);
|
const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`);
|
||||||
if (
|
if (
|
||||||
!(RECORDED_RRD_PATH.test(descriptor.sourceUrl)
|
!isRecordedRrdSource(descriptor.sourceUrl) ||
|
||||||
|| LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) ||
|
|
||||||
descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
|
descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
|
||||||
endpoint.origin !== base.origin ||
|
endpoint.origin !== base.origin ||
|
||||||
endpoint.pathname !== descriptor.sourceUrl ||
|
endpoint.pathname !== descriptor.sourceUrl ||
|
||||||
@@ -262,7 +267,7 @@ export function resolveRecordedBlueprintUrl(
|
|||||||
if (explicitSourceUrl !== undefined) {
|
if (explicitSourceUrl !== undefined) {
|
||||||
const explicit = explicitSourceUrl.trim();
|
const explicit = explicitSourceUrl.trim();
|
||||||
if (
|
if (
|
||||||
!LAB_RECORDED_REPLAY_PATH.test(normalized)
|
!(LAB_RECORDED_REPLAY_PATH.test(normalized) || PORTABLE_RECORDED_REPLAY_PATH.test(normalized))
|
||||||
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
|
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
|
||||||
) return null;
|
) return null;
|
||||||
const endpoint = new URL(explicit, `${base.origin}/`);
|
const endpoint = new URL(explicit, `${base.origin}/`);
|
||||||
@@ -438,6 +443,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
perceptionLayers.detections2d,
|
perceptionLayers.detections2d,
|
||||||
perceptionLayers.segmentation,
|
perceptionLayers.segmentation,
|
||||||
perceptionLayers.cuboids3d,
|
perceptionLayers.cuboids3d,
|
||||||
|
perceptionLayers.costmap ?? false,
|
||||||
].some((value) => typeof value !== "boolean") ||
|
].some((value) => typeof value !== "boolean") ||
|
||||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) ||
|
!/^[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_detections_2d: perceptionLayers.detections2d,
|
||||||
show_segmentation: perceptionLayers.segmentation,
|
show_segmentation: perceptionLayers.segmentation,
|
||||||
show_cuboids_3d: perceptionLayers.cuboids3d,
|
show_cuboids_3d: perceptionLayers.cuboids3d,
|
||||||
|
...(perceptionLayers.costmap === undefined ? {} : {
|
||||||
|
show_costmap: perceptionLayers.costmap,
|
||||||
|
reactivate_updates: true,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -791,8 +801,7 @@ export function RerunViewport({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource)
|
const isRecordedSource = isRecordedRrdSource(normalizedSource);
|
||||||
|| LAB_RECORDED_REPLAY_PATH.test(normalizedSource);
|
|
||||||
let resolvedSource: string;
|
let resolvedSource: string;
|
||||||
try {
|
try {
|
||||||
if (isRecordedSource) {
|
if (isRecordedSource) {
|
||||||
@@ -1938,6 +1947,9 @@ export function RerunViewport({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recordedBlueprintUrl || !sceneSettings) return;
|
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 active = blueprintChannelRef.current;
|
||||||
const identity = recordedIdentityRef.current;
|
const identity = recordedIdentityRef.current;
|
||||||
if (
|
if (
|
||||||
@@ -1986,6 +1998,8 @@ export function RerunViewport({
|
|||||||
recordedPerceptionLayers.detections2d,
|
recordedPerceptionLayers.detections2d,
|
||||||
recordedPerceptionLayers.segmentation,
|
recordedPerceptionLayers.segmentation,
|
||||||
recordedPerceptionLayers.cuboids3d,
|
recordedPerceptionLayers.cuboids3d,
|
||||||
|
recordedPerceptionLayers.costmap,
|
||||||
|
status,
|
||||||
sceneSettings?.accumulationSeconds,
|
sceneSettings?.accumulationSeconds,
|
||||||
sceneSettings?.customColor,
|
sceneSettings?.customColor,
|
||||||
sceneSettings?.colorMode,
|
sceneSettings?.colorMode,
|
||||||
|
|||||||
@@ -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<CanonicalLabReplayDescriptor>;
|
||||||
|
semantics?: boolean;
|
||||||
|
costmap?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
expanded,
|
||||||
|
onMediaModeChange,
|
||||||
|
onSpatialModeChange,
|
||||||
|
onSplitPrimarySizeChange,
|
||||||
|
onExpandedChange,
|
||||||
|
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||||
|
initialMediaMode: "video",
|
||||||
|
initialSpatialMode: "3d",
|
||||||
|
});
|
||||||
|
const splitView = mediaMode !== null && spatialMode !== null;
|
||||||
|
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||||
|
const [showSemantics, setShowSemantics] = useState(true);
|
||||||
|
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>(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<RerunPlaybackState | null>(null);
|
||||||
|
const [playbackController, setPlaybackController] =
|
||||||
|
useState<RerunPlaybackController | null>(null);
|
||||||
|
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||||
|
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||||
|
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||||
|
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||||
|
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const previousSplitViewRef = useRef(splitView);
|
||||||
|
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const stopNativeSplitTracking = useCallback(() => {
|
||||||
|
nativeSplitTrackingCleanupRef.current?.();
|
||||||
|
nativeSplitTrackingCleanupRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (
|
||||||
|
!splitView
|
||||||
|
|| event.button !== 0
|
||||||
|
|| !(event.target instanceof HTMLCanvasElement)
|
||||||
|
) return;
|
||||||
|
const frame = viewerFrameRef.current;
|
||||||
|
if (!frame) return;
|
||||||
|
const bounds = frame.getBoundingClientRect();
|
||||||
|
if (bounds.width <= 0) return;
|
||||||
|
const dividerX = bounds.left
|
||||||
|
+ bounds.width * nativeSplitPercentRef.current / 100;
|
||||||
|
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
|
||||||
|
|
||||||
|
stopNativeSplitTracking();
|
||||||
|
const pointerId = event.pointerId;
|
||||||
|
const update = (pointerEvent: PointerEvent) => {
|
||||||
|
if (pointerEvent.pointerId !== pointerId) return;
|
||||||
|
const currentBounds = frame.getBoundingClientRect();
|
||||||
|
if (currentBounds.width <= 0) return;
|
||||||
|
const next = Math.min(90, Math.max(
|
||||||
|
10,
|
||||||
|
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
|
||||||
|
));
|
||||||
|
nativeSplitPercentRef.current = next;
|
||||||
|
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
|
||||||
|
};
|
||||||
|
const stop = (pointerEvent: PointerEvent) => {
|
||||||
|
if (pointerEvent.pointerId !== pointerId) return;
|
||||||
|
stopNativeSplitTracking();
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", update, true);
|
||||||
|
window.addEventListener("pointerup", stop, true);
|
||||||
|
window.addEventListener("pointercancel", stop, true);
|
||||||
|
nativeSplitTrackingCleanupRef.current = () => {
|
||||||
|
window.removeEventListener("pointermove", update, true);
|
||||||
|
window.removeEventListener("pointerup", stop, true);
|
||||||
|
window.removeEventListener("pointercancel", stop, true);
|
||||||
|
};
|
||||||
|
}, [splitView, stopNativeSplitTracking]);
|
||||||
|
|
||||||
|
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!splitView) stopNativeSplitTracking();
|
||||||
|
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||||
|
if (
|
||||||
|
trackedLaunchSha256Ref.current !== launchSha256
|
||||||
|
|| (splitView && !previousSplitViewRef.current)
|
||||||
|
) {
|
||||||
|
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
||||||
|
}
|
||||||
|
trackedLaunchSha256Ref.current = launchSha256;
|
||||||
|
previousSplitViewRef.current = splitView;
|
||||||
|
const cameraPanePercent = mediaMode === null
|
||||||
|
? 0
|
||||||
|
: splitView
|
||||||
|
? nativeSplitPercentRef.current
|
||||||
|
: 100;
|
||||||
|
viewerFrameRef.current?.style.setProperty(
|
||||||
|
"--canonical-rerun-camera-pane",
|
||||||
|
`${cameraPanePercent}%`,
|
||||||
|
);
|
||||||
|
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLaunch(null);
|
||||||
|
setLaunchError(null);
|
||||||
|
setPlayback(null);
|
||||||
|
setPlaybackController(null);
|
||||||
|
setViewerStatus("idle");
|
||||||
|
void resolveObservationSessionReplay(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 ? (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const resetSpatialView = (
|
||||||
|
<Button
|
||||||
|
size="dense"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<Icon name="refresh" size={14} />}
|
||||||
|
aria-label="Сбросить положение 3D камеры"
|
||||||
|
title="Сбросить положение 3D камеры"
|
||||||
|
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = presentationReady && playback && playbackController ? (
|
||||||
|
<ObservationTimeline
|
||||||
|
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
||||||
|
active
|
||||||
|
sourceCount={3}
|
||||||
|
mode="recorded"
|
||||||
|
seekable
|
||||||
|
synchronization="shared-clock"
|
||||||
|
rangeNs={playback.rangeNs}
|
||||||
|
currentNs={playback.currentNs}
|
||||||
|
playing={playback.playing}
|
||||||
|
onSeek={playbackController.seek}
|
||||||
|
onPlayingChange={playbackController.setPlaying}
|
||||||
|
showJumpToEnd={false}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="canonical-vegetation-rerun-replay"
|
||||||
|
data-presentation-state={presentationState}
|
||||||
|
aria-busy={presentationState === "loading"}
|
||||||
|
>
|
||||||
|
<CanonicalRecordedLabReplay
|
||||||
|
label={label}
|
||||||
|
mediaMode={mediaMode ?? "none"}
|
||||||
|
mediaModes={[
|
||||||
|
{ value: "video", label: "ВИДЕО" },
|
||||||
|
{ value: "camera", label: "КАМЕРА" },
|
||||||
|
]}
|
||||||
|
spatialMode={spatialMode ?? "none"}
|
||||||
|
spatialModes={[
|
||||||
|
{ value: "3d", label: "3D" },
|
||||||
|
{ value: "plan", label: "ПЛАН" },
|
||||||
|
]}
|
||||||
|
expanded={expanded}
|
||||||
|
splitPrimarySize={splitPrimarySize}
|
||||||
|
splitOrientation={splitOrientation}
|
||||||
|
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||||
|
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||||
|
mediaLayerControls={mediaLayerControls}
|
||||||
|
spatialLayerControls={spatialLayerControls}
|
||||||
|
spatialLeadingControl={resetSpatialView}
|
||||||
|
mediaMultiLayer
|
||||||
|
unifiedContent={profile ? (
|
||||||
|
<div
|
||||||
|
ref={viewerFrameRef}
|
||||||
|
className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||||
|
data-split-view={splitView ? "true" : undefined}
|
||||||
|
style={{
|
||||||
|
"--canonical-rerun-camera-pane": `${
|
||||||
|
mediaMode === null
|
||||||
|
? 0
|
||||||
|
: splitView
|
||||||
|
? nativeSplitPercentRef.current
|
||||||
|
: 100
|
||||||
|
}%`,
|
||||||
|
} as CSSProperties}
|
||||||
|
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
|
||||||
|
>
|
||||||
|
<RerunViewport
|
||||||
|
profile={profile}
|
||||||
|
sceneSettings={sceneSettings}
|
||||||
|
onStatusChange={setViewerStatus}
|
||||||
|
onPlaybackChange={setPlayback}
|
||||||
|
onPlaybackControllerChange={setPlaybackController}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : launchError ? (
|
||||||
|
<div className="l3-visual-audit__state" role="alert">
|
||||||
|
{launchError}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||||
|
deckOverlays={presentationState === "loading" ? (
|
||||||
|
<div className="canonical-vegetation-rerun-replay__loading">
|
||||||
|
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
transport={transport}
|
||||||
|
onMediaModeChange={onMediaModeChange}
|
||||||
|
onSpatialModeChange={onSpatialModeChange}
|
||||||
|
onExpandedChange={onExpandedChange}
|
||||||
|
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,383 +1,12 @@
|
|||||||
import {
|
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||||
useCallback,
|
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type CSSProperties,
|
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Button,
|
|
||||||
Icon,
|
|
||||||
SegmentedControl,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import { ObservationTimeline } from "../ObservationTimeline";
|
|
||||||
import {
|
|
||||||
RerunViewport,
|
|
||||||
isRecordedPlaybackPresentationReady,
|
|
||||||
type RerunPlaybackController,
|
|
||||||
type RerunPlaybackState,
|
|
||||||
type RerunViewportStatus,
|
|
||||||
} from "../RerunViewport";
|
|
||||||
import {
|
|
||||||
CanonicalRecordedLabReplay,
|
|
||||||
useCanonicalRecordedLabReplayState,
|
|
||||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
|
||||||
import {
|
|
||||||
resolveCanonicalLabReplay,
|
|
||||||
type CanonicalLabReplayDescriptor,
|
|
||||||
} from "../../core/laboratory/canonicalLabReplay";
|
|
||||||
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
|
import type { 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";
|
export function CanonicalVegetationRerunReplay({ resultId, review }: {
|
||||||
type SpatialMode = "3d" | "plan";
|
|
||||||
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
|
||||||
type SemanticLayer = "city" | "vegetation";
|
|
||||||
|
|
||||||
interface CanonicalReplayLaunch {
|
|
||||||
base: ObservationSessionReplayLaunch;
|
|
||||||
replay: CanonicalLabReplayDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
|
||||||
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
|
||||||
|
|
||||||
export function CanonicalVegetationRerunReplay({
|
|
||||||
resultId,
|
|
||||||
review,
|
|
||||||
}: {
|
|
||||||
resultId: string;
|
resultId: string;
|
||||||
review: VegetationFullRouteReview;
|
review: VegetationFullRouteReview;
|
||||||
}) {
|
}) {
|
||||||
const {
|
return <CanonicalResultRerunReplay resultId={resultId} sessionId={review.sessionId}
|
||||||
mediaMode,
|
initialPlaybackStartSeconds={review.timelineStartSeconds} semantics
|
||||||
spatialMode,
|
resolveReplay={resolveCanonicalLabReplay} label="RAVNOVES004TREE · канонический повтор Rerun" />;
|
||||||
splitPrimarySize,
|
|
||||||
splitOrientation,
|
|
||||||
expanded,
|
|
||||||
onMediaModeChange,
|
|
||||||
onSpatialModeChange,
|
|
||||||
onSplitPrimarySizeChange,
|
|
||||||
onExpandedChange,
|
|
||||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
|
||||||
initialMediaMode: "video",
|
|
||||||
initialSpatialMode: "3d",
|
|
||||||
});
|
|
||||||
const splitView = mediaMode !== null && spatialMode !== null;
|
|
||||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
|
||||||
const [showSemantics, setShowSemantics] = useState(true);
|
|
||||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
|
||||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
|
|
||||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
|
||||||
const [playbackController, setPlaybackController] =
|
|
||||||
useState<RerunPlaybackController | null>(null);
|
|
||||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
|
||||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
|
||||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
|
||||||
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
|
||||||
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
|
||||||
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
|
||||||
const previousSplitViewRef = useRef(splitView);
|
|
||||||
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const stopNativeSplitTracking = useCallback(() => {
|
|
||||||
nativeSplitTrackingCleanupRef.current?.();
|
|
||||||
nativeSplitTrackingCleanupRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
if (
|
|
||||||
!splitView
|
|
||||||
|| event.button !== 0
|
|
||||||
|| !(event.target instanceof HTMLCanvasElement)
|
|
||||||
) return;
|
|
||||||
const frame = viewerFrameRef.current;
|
|
||||||
if (!frame) return;
|
|
||||||
const bounds = frame.getBoundingClientRect();
|
|
||||||
if (bounds.width <= 0) return;
|
|
||||||
const dividerX = bounds.left
|
|
||||||
+ bounds.width * nativeSplitPercentRef.current / 100;
|
|
||||||
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
|
|
||||||
|
|
||||||
stopNativeSplitTracking();
|
|
||||||
const pointerId = event.pointerId;
|
|
||||||
const update = (pointerEvent: PointerEvent) => {
|
|
||||||
if (pointerEvent.pointerId !== pointerId) return;
|
|
||||||
const currentBounds = frame.getBoundingClientRect();
|
|
||||||
if (currentBounds.width <= 0) return;
|
|
||||||
const next = Math.min(90, Math.max(
|
|
||||||
10,
|
|
||||||
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
|
|
||||||
));
|
|
||||||
nativeSplitPercentRef.current = next;
|
|
||||||
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
|
|
||||||
};
|
|
||||||
const stop = (pointerEvent: PointerEvent) => {
|
|
||||||
if (pointerEvent.pointerId !== pointerId) return;
|
|
||||||
stopNativeSplitTracking();
|
|
||||||
};
|
|
||||||
window.addEventListener("pointermove", update, true);
|
|
||||||
window.addEventListener("pointerup", stop, true);
|
|
||||||
window.addEventListener("pointercancel", stop, true);
|
|
||||||
nativeSplitTrackingCleanupRef.current = () => {
|
|
||||||
window.removeEventListener("pointermove", update, true);
|
|
||||||
window.removeEventListener("pointerup", stop, true);
|
|
||||||
window.removeEventListener("pointercancel", stop, true);
|
|
||||||
};
|
|
||||||
}, [splitView, stopNativeSplitTracking]);
|
|
||||||
|
|
||||||
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!splitView) stopNativeSplitTracking();
|
|
||||||
const launchSha256 = launch?.replay.sha256 ?? null;
|
|
||||||
if (
|
|
||||||
trackedLaunchSha256Ref.current !== launchSha256
|
|
||||||
|| (splitView && !previousSplitViewRef.current)
|
|
||||||
) {
|
|
||||||
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
|
||||||
}
|
|
||||||
trackedLaunchSha256Ref.current = launchSha256;
|
|
||||||
previousSplitViewRef.current = splitView;
|
|
||||||
const cameraPanePercent = mediaMode === null
|
|
||||||
? 0
|
|
||||||
: splitView
|
|
||||||
? nativeSplitPercentRef.current
|
|
||||||
: 100;
|
|
||||||
viewerFrameRef.current?.style.setProperty(
|
|
||||||
"--canonical-rerun-camera-pane",
|
|
||||||
`${cameraPanePercent}%`,
|
|
||||||
);
|
|
||||||
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
setLaunch(null);
|
|
||||||
setLaunchError(null);
|
|
||||||
setPlayback(null);
|
|
||||||
setPlaybackController(null);
|
|
||||||
setViewerStatus("idle");
|
|
||||||
void resolveObservationSessionReplay(review.sessionId, {
|
|
||||||
signal: controller.signal,
|
|
||||||
maximumWaitMs: 30 * 60 * 1000,
|
|
||||||
onUpdate: () => undefined,
|
|
||||||
}).then(async (value) => ({
|
|
||||||
base: value,
|
|
||||||
replay: await resolveCanonicalLabReplay(resultId, value, {
|
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
})).then((value) => {
|
|
||||||
if (!controller.signal.aborted) setLaunch(value);
|
|
||||||
}).catch((caught: unknown) => {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setLaunchError(
|
|
||||||
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [resultId, review.sessionId]);
|
|
||||||
|
|
||||||
const presentationReady = playbackController !== null
|
|
||||||
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
|
||||||
const presentationState = launchError || viewerStatus === "error"
|
|
||||||
? "error"
|
|
||||||
: presentationReady
|
|
||||||
? "ready"
|
|
||||||
: "loading";
|
|
||||||
const sceneSettings = useMemo(() => ({
|
|
||||||
...defaultSceneSettings,
|
|
||||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
|
||||||
showPoints: spatialMode !== null,
|
|
||||||
showTrajectory: spatialMode !== null,
|
|
||||||
showGrid: spatialMode !== null,
|
|
||||||
pointSize: 3.8,
|
|
||||||
}), [spatialLayer, spatialMode]);
|
|
||||||
const profile = launch ? recordedSessionRerunProfile({
|
|
||||||
sourceUrl: launch.replay.sourceUrl,
|
|
||||||
artifact: {
|
|
||||||
sourceUrl: launch.replay.sourceUrl,
|
|
||||||
viewerSourceUrl: launch.replay.viewerSourceUrl,
|
|
||||||
byteLength: launch.replay.byteLength,
|
|
||||||
sha256: launch.replay.sha256,
|
|
||||||
},
|
|
||||||
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
|
|
||||||
autoplayWhenReady: false,
|
|
||||||
presentationGate: "ready",
|
|
||||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
|
||||||
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
|
||||||
initialPlaybackStartSeconds: review.timelineStartSeconds,
|
|
||||||
view: mediaMode !== null ? "perception" : "spatial",
|
|
||||||
viewResetGeneration,
|
|
||||||
followTrajectory: true,
|
|
||||||
semanticLayer,
|
|
||||||
unifiedPerception: splitView,
|
|
||||||
planView: spatialMode === "plan",
|
|
||||||
perceptionLayers: {
|
|
||||||
enabled: mediaMode !== null,
|
|
||||||
detections2d: mediaMode === "video",
|
|
||||||
segmentation: mediaMode === "video" && showSemantics,
|
|
||||||
cuboids3d: false,
|
|
||||||
},
|
|
||||||
perceptionRetryGeneration: 0,
|
|
||||||
lockPerceptionCameraInteraction: mediaMode !== null,
|
|
||||||
}) : null;
|
|
||||||
|
|
||||||
const mediaLayerControls = (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-layer-controls"
|
|
||||||
role="group"
|
|
||||||
aria-label="Слои камеры и видео"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="dense"
|
|
||||||
shape="pill"
|
|
||||||
variant={showSemantics ? "primary" : "secondary"}
|
|
||||||
aria-pressed={showSemantics}
|
|
||||||
onClick={() => setShowSemantics((visible) => !visible)}
|
|
||||||
>
|
|
||||||
СЕМАНТИКА
|
|
||||||
</Button>
|
|
||||||
<SegmentedControl
|
|
||||||
value={semanticLayer}
|
|
||||||
items={[
|
|
||||||
{ value: "city", label: "ГОРОД · EoMT" },
|
|
||||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
|
||||||
]}
|
|
||||||
label="Источник семантики"
|
|
||||||
size="dense"
|
|
||||||
onChange={(value) => {
|
|
||||||
setSemanticLayer(value);
|
|
||||||
setShowSemantics(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const spatialLayerControls = (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-layer-controls"
|
|
||||||
role="group"
|
|
||||||
aria-label="Пространственные слои RAV004"
|
|
||||||
>
|
|
||||||
<SegmentedControl
|
|
||||||
value={spatialLayer}
|
|
||||||
items={[
|
|
||||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
|
||||||
{ value: "local", label: "ЛОК. SLAM" },
|
|
||||||
{ value: "tgs", label: "TGS", disabled: true },
|
|
||||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
|
||||||
]}
|
|
||||||
label="Пространственные слои"
|
|
||||||
size="dense"
|
|
||||||
onChange={setSpatialLayer}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const resetSpatialView = (
|
|
||||||
<Button
|
|
||||||
size="dense"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<Icon name="refresh" size={14} />}
|
|
||||||
aria-label="Сбросить положение 3D камеры"
|
|
||||||
title="Сбросить положение 3D камеры"
|
|
||||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const transport = presentationReady && playback && playbackController ? (
|
|
||||||
<ObservationTimeline
|
|
||||||
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
|
||||||
active
|
|
||||||
sourceCount={3}
|
|
||||||
mode="recorded"
|
|
||||||
seekable
|
|
||||||
synchronization="shared-clock"
|
|
||||||
rangeNs={playback.rangeNs}
|
|
||||||
currentNs={playback.currentNs}
|
|
||||||
playing={playback.playing}
|
|
||||||
onSeek={playbackController.seek}
|
|
||||||
onPlayingChange={playbackController.setPlaying}
|
|
||||||
showJumpToEnd={false}
|
|
||||||
/>
|
|
||||||
) : undefined;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="canonical-vegetation-rerun-replay"
|
|
||||||
data-presentation-state={presentationState}
|
|
||||||
aria-busy={presentationState === "loading"}
|
|
||||||
>
|
|
||||||
<CanonicalRecordedLabReplay
|
|
||||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
|
||||||
mediaMode={mediaMode ?? "none"}
|
|
||||||
mediaModes={[
|
|
||||||
{ value: "video", label: "ВИДЕО" },
|
|
||||||
{ value: "camera", label: "КАМЕРА" },
|
|
||||||
]}
|
|
||||||
spatialMode={spatialMode ?? "none"}
|
|
||||||
spatialModes={[
|
|
||||||
{ value: "3d", label: "3D" },
|
|
||||||
{ value: "plan", label: "ПЛАН" },
|
|
||||||
]}
|
|
||||||
expanded={expanded}
|
|
||||||
splitPrimarySize={splitPrimarySize}
|
|
||||||
splitOrientation={splitOrientation}
|
|
||||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
|
||||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
|
||||||
mediaLayerControls={mediaLayerControls}
|
|
||||||
spatialLayerControls={spatialLayerControls}
|
|
||||||
spatialLeadingControl={resetSpatialView}
|
|
||||||
mediaMultiLayer
|
|
||||||
unifiedContent={profile ? (
|
|
||||||
<div
|
|
||||||
ref={viewerFrameRef}
|
|
||||||
className="canonical-vegetation-rerun-replay__viewport-lock"
|
|
||||||
data-split-view={splitView ? "true" : undefined}
|
|
||||||
style={{
|
|
||||||
"--canonical-rerun-camera-pane": `${
|
|
||||||
mediaMode === null
|
|
||||||
? 0
|
|
||||||
: splitView
|
|
||||||
? nativeSplitPercentRef.current
|
|
||||||
: 100
|
|
||||||
}%`,
|
|
||||||
} as CSSProperties}
|
|
||||||
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
|
|
||||||
>
|
|
||||||
<RerunViewport
|
|
||||||
profile={profile}
|
|
||||||
sceneSettings={sceneSettings}
|
|
||||||
onStatusChange={setViewerStatus}
|
|
||||||
onPlaybackChange={setPlayback}
|
|
||||||
onPlaybackControllerChange={setPlaybackController}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : launchError ? (
|
|
||||||
<div className="l3-visual-audit__state" role="alert">
|
|
||||||
{launchError}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
|
||||||
deckOverlays={presentationState === "loading" ? (
|
|
||||||
<div className="canonical-vegetation-rerun-replay__loading">
|
|
||||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
transport={transport}
|
|
||||||
onMediaModeChange={onMediaModeChange}
|
|
||||||
onSpatialModeChange={onSpatialModeChange}
|
|
||||||
onExpandedChange={onExpandedChange}
|
|
||||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
return <>
|
||||||
|
<p>Камера, исходное облако и TGS · общая шкала времени. Зелёный — опора,
|
||||||
|
красный — занятое пространство, жёлтый — отклонённые точки.
|
||||||
|
Отсутствие ячеек не означает свободный путь. Сегментация и детекция в этот профиль не входят.</p>
|
||||||
|
<CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
||||||
|
sessionId={review.sourceSessionId} costmap resolveReplay={resolvePortableReplay} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -23,26 +23,28 @@ export async function resolveCanonicalLabReplay(
|
|||||||
origin = window.location.origin,
|
origin = window.location.origin,
|
||||||
signal,
|
signal,
|
||||||
fetcher = globalThis.fetch,
|
fetcher = globalThis.fetch,
|
||||||
|
sourceKind = "legacy-vegetation",
|
||||||
}: {
|
}: {
|
||||||
origin?: string;
|
origin?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
fetcher?: typeof globalThis.fetch;
|
fetcher?: typeof globalThis.fetch;
|
||||||
|
sourceKind?: "legacy-vegetation" | "portable-tgs";
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<CanonicalLabReplayDescriptor> {
|
): Promise<CanonicalLabReplayDescriptor> {
|
||||||
const base = new URL(origin);
|
const base = new URL(origin);
|
||||||
if (
|
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)
|
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||||
) {
|
) {
|
||||||
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
||||||
}
|
}
|
||||||
const sourceUrl =
|
const sourceUrl = sourceKind === "portable-tgs"
|
||||||
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
|
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
|
||||||
+ "/canonical-replay.rrd";
|
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
|
||||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
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) {
|
if (descriptorUrl.origin !== base.origin) {
|
||||||
throw new Error("Канонический replay LAB должен быть same-origin.");
|
throw new Error("Канонический replay LAB должен быть same-origin.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface RecordedPerceptionLayers {
|
|||||||
detections2d: boolean;
|
detections2d: boolean;
|
||||||
segmentation: boolean;
|
segmentation: boolean;
|
||||||
cuboids3d: boolean;
|
cuboids3d: boolean;
|
||||||
|
costmap?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordedRrdArtifactDescriptor {
|
export interface RecordedRrdArtifactDescriptor {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||||
|
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
|
||||||
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
||||||
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
||||||
import {
|
import {
|
||||||
@@ -715,15 +716,13 @@ export function ObservatoryWorkspace({
|
|||||||
<header className="observatory-replay__header">
|
<header className="observatory-replay__header">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">
|
<span className="section-eyebrow">
|
||||||
{replay.review.kind === "canonical-recorded-rerun"
|
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
|
||||||
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
|
|
||||||
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
|
|
||||||
</span>
|
</span>
|
||||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||||
<p>
|
<p>
|
||||||
{replay.review.kind === "canonical-recorded-rerun"
|
{replay.review.kind === "canonical-recorded-rerun"
|
||||||
? "Записанный маршрут синхронизирован по общей временной шкале."
|
? "Записанный маршрут синхронизирован по общей временной шкале."
|
||||||
: "Показан проверенный документ результата и связанные с ним артефакты."}
|
: "Просмотр сохранённых данных результата без повторного расчёта."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -741,17 +740,7 @@ export function ObservatoryWorkspace({
|
|||||||
review={replay.review.review}
|
review={replay.review.review}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<GlassSurface className="observatory-replay-state" padding="lg">
|
<PortableResultReplay review={replay.review} />
|
||||||
<div>
|
|
||||||
<StatusBadge tone="success">Проверено</StatusBadge>
|
|
||||||
<h3>{replay.review.resultKind}</h3>
|
|
||||||
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
|
|
||||||
<details>
|
|
||||||
<summary>Документ результата</summary>
|
|
||||||
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
|||||||
const observatoryCore = await read("core/observatory/catalog.ts");
|
const observatoryCore = await read("core/observatory/catalog.ts");
|
||||||
const recordedRun = await read("core/observatory/recordedRun.ts");
|
const recordedRun = await read("core/observatory/recordedRun.ts");
|
||||||
const sharedReplay = await read(
|
const sharedReplay = await read(
|
||||||
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
|
"components/laboratory/CanonicalResultRerunReplay.tsx",
|
||||||
);
|
);
|
||||||
const workspaceCss = await read("styles/workspaces.css");
|
const workspaceCss = await read("styles/workspaces.css");
|
||||||
const observatoryCss = await read("styles/observatory.css");
|
const observatoryCss = await read("styles/observatory.css");
|
||||||
@@ -144,6 +144,13 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
|||||||
);
|
);
|
||||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||||
|
assert.match(sharedReplay, /useState<0 \| 1>\(costmap \? 1 : 0\)/);
|
||||||
|
assert.match(sharedReplay, /costmap \? 0\.000001 : 0/);
|
||||||
|
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||||
|
const source = await read(`components/laboratory/${adapter}.tsx`);
|
||||||
|
assert.match(source, /<CanonicalResultRerunReplay/);
|
||||||
|
assert.doesNotMatch(source, /<RerunViewport|<video|setInterval|Canvas|THREE\./);
|
||||||
|
}
|
||||||
assert.doesNotMatch(workspaceCss, /\.observatory-/);
|
assert.doesNotMatch(workspaceCss, /\.observatory-/);
|
||||||
assert.match(observatoryCss, /\.observatory-workspace/);
|
assert.match(observatoryCss, /\.observatory-workspace/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,3 +132,26 @@ test("canonical LAB resolves one generation-bound merged RRD", async () => {
|
|||||||
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
|
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("portable TGS 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 launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
|
||||||
|
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
|
||||||
|
sha256: base };
|
||||||
|
let calls = 0;
|
||||||
|
const fetcher = async (url, options) => {
|
||||||
|
calls++;
|
||||||
|
assert.equal(url, `http://mission-core.test/api/v1/observatory/portable-results/${resultId}/replays/${base}/recording.rrd`);
|
||||||
|
assert.equal(options.method, "HEAD");
|
||||||
|
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
|
||||||
|
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
|
||||||
|
};
|
||||||
|
const options = { sourceKind: "portable-tgs", 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, sha256: "d".repeat(64) }, options));
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||||
read("core/observatory/useObservatoryCatalog.ts"),
|
read("core/observatory/useObservatoryCatalog.ts"),
|
||||||
read("core/observatory/recordedRun.ts"),
|
read("core/observatory/recordedRun.ts"),
|
||||||
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
|
||||||
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||||
read("core/observation/viewerProfile.ts"),
|
read("core/observation/viewerProfile.ts"),
|
||||||
read("styles/observatory.css"),
|
read("styles/observatory.css"),
|
||||||
@@ -99,9 +99,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
|
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
|
||||||
assert.match(workspace, /Открыть визуальный разбор/);
|
assert.match(workspace, /Открыть визуальный разбор/);
|
||||||
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||||
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
|
assert.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
|
||||||
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
|
const portable = await read("components/laboratory/PortableResultReplay.tsx");
|
||||||
assert.match(workspace, /<summary>Документ результата<\/summary>/);
|
assert.match(portable, /<summary>Документ результата<\/summary>/);
|
||||||
|
assert.match(portable, /<CanonicalResultRerunReplay/);
|
||||||
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
||||||
assert.match(workspace, /Проверяем точную связь результата/);
|
assert.match(workspace, /Проверяем точную связь результата/);
|
||||||
assert.match(workspace, /role="alert"/);
|
assert.match(workspace, /role="alert"/);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ let liveRerunReceiverBindingIdentity;
|
|||||||
let recordedOpenWatchdogTimeoutMs;
|
let recordedOpenWatchdogTimeoutMs;
|
||||||
let rerunViewerInitialSource;
|
let rerunViewerInitialSource;
|
||||||
let resolveRecordedViewerSourceUrl;
|
let resolveRecordedViewerSourceUrl;
|
||||||
|
let isRecordedRrdSource;
|
||||||
|
let resolveRecordedBlueprintUrl;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -31,6 +33,8 @@ before(async () => {
|
|||||||
recordedOpenWatchdogTimeoutMs,
|
recordedOpenWatchdogTimeoutMs,
|
||||||
rerunViewerInitialSource,
|
rerunViewerInitialSource,
|
||||||
resolveRecordedViewerSourceUrl,
|
resolveRecordedViewerSourceUrl,
|
||||||
|
isRecordedRrdSource,
|
||||||
|
resolveRecordedBlueprintUrl,
|
||||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,6 +90,18 @@ 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`;
|
||||||
|
const blueprint = "/api/v1/observation-sessions/source/blueprint.rrd";
|
||||||
|
assert.equal(isRecordedRrdSource(source), true);
|
||||||
|
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
|
||||||
|
assert.equal(resolveRecordedViewerSourceUrl({ sourceUrl: source,
|
||||||
|
viewerSourceUrl: `${source}?generation=${sha256}`, sha256, byteLength: 100 },
|
||||||
|
"http://mission-core.test"), `http://mission-core.test${source}?generation=${sha256}`);
|
||||||
|
assert.equal(resolveRecordedBlueprintUrl(source, "http://mission-core.test", blueprint),
|
||||||
|
`http://mission-core.test${blueprint}`);
|
||||||
|
});
|
||||||
|
|
||||||
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
||||||
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false);
|
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false);
|
||||||
assert.equal(isLiveRerunPresentationReady(true, null, 1), false);
|
assert.equal(isLiveRerunPresentationReady(true, null, 1), false);
|
||||||
@@ -225,6 +241,9 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
|||||||
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
|
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
|
||||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||||
assert.doesNotMatch(source, /recordedChannel/);
|
assert.doesNotMatch(source, /recordedChannel/);
|
||||||
|
assert.match(source, /recordedPerceptionLayers\.costmap,/);
|
||||||
|
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
|
||||||
|
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
|
||||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||||
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
new URL("../src/components/laboratory/CanonicalResultRerunReplay.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
readFile(
|
readFile(
|
||||||
@@ -494,7 +494,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
assert.match(rerunSource, /<RerunViewport/);
|
assert.match(rerunSource, /<RerunViewport/);
|
||||||
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
|
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
|
||||||
assert.match(rerunSource, /ЛОК\. SLAM/);
|
assert.match(rerunSource, /ЛОК\. SLAM/);
|
||||||
assert.match(rerunSource, /resolveCanonicalLabReplay/);
|
assert.match(rerunSource, /resolveReplay\(resultId, value/);
|
||||||
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
||||||
assert.match(rerunSource, /unifiedPerception: splitView/);
|
assert.match(rerunSource, /unifiedPerception: splitView/);
|
||||||
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
|
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
|
||||||
@@ -527,7 +527,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
);
|
);
|
||||||
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
||||||
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||||
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
|
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: !costmap/);
|
||||||
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||||
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||||
|
|||||||
@@ -45,12 +45,11 @@ class RecordedBlueprintError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class _RecordedBlueprintStream:
|
class _RecordedBlueprintStream:
|
||||||
"""Keep one browser viewport on one mutable Rerun blueprint store.
|
"""Keep one bounded SDK blueprint source for each browser viewport.
|
||||||
|
|
||||||
Rerun persists the operator-controlled eye inside the active blueprint
|
Upstream 0.36.3 activates a clone, not this source store. Explicit refresh
|
||||||
store. Creating and activating a fresh store for every layer toggle drops
|
makes layer changes visible but cannot retain the clone's operator eye.
|
||||||
that eye and snaps the view back to its fallback camera. This stream keeps
|
It is opt-in for portable replay pending native camera-state support.
|
||||||
the store identity stable until the operator explicitly requests a reset.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -82,18 +81,24 @@ class _RecordedBlueprintStream:
|
|||||||
|
|
||||||
def render(
|
def render(
|
||||||
self,
|
self,
|
||||||
blueprint_factory: Callable[[bool], rrb.Blueprint],
|
blueprint_factory: Callable[[bool, bool], rrb.Blueprint],
|
||||||
*,
|
*,
|
||||||
follow_trajectory: bool,
|
follow_trajectory: bool,
|
||||||
plan_view: bool,
|
plan_view: bool,
|
||||||
|
reactivate_updates: bool = False,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise RecordedBlueprintError("stable blueprint stream is closed")
|
raise RecordedBlueprintError("stable blueprint stream is closed")
|
||||||
eye_contract = (follow_trajectory, plan_view)
|
eye_contract = (follow_trajectory, plan_view)
|
||||||
update_eye_controls = self._eye_contract != eye_contract
|
update_eye_controls = self._eye_contract != eye_contract
|
||||||
blueprint = blueprint_factory(update_eye_controls)
|
# Initial admission/reset uses native framing. Explicit presets
|
||||||
make_active = self._sequence == 0
|
# apply to mode transitions, after the viewer has a source cursor.
|
||||||
|
blueprint = blueprint_factory(update_eye_controls, self._eye_contract is not None)
|
||||||
|
# Appending rows alone does not refresh upstream's active clone.
|
||||||
|
# Keep legacy admission unchanged; portable replay opts into
|
||||||
|
# working layer updates with an explicitly documented eye reset.
|
||||||
|
make_active = self._sequence == 0 or reactivate_updates
|
||||||
self._blueprint_recording.set_time(
|
self._blueprint_recording.set_time(
|
||||||
"blueprint",
|
"blueprint",
|
||||||
sequence=self._sequence,
|
sequence=self._sequence,
|
||||||
@@ -124,9 +129,9 @@ class _RecordedBlueprintStream:
|
|||||||
|
|
||||||
_MAX_RECORDED_BLUEPRINT_STREAMS = 32
|
_MAX_RECORDED_BLUEPRINT_STREAMS = 32
|
||||||
_recorded_blueprint_streams_lock = Lock()
|
_recorded_blueprint_streams_lock = Lock()
|
||||||
_recorded_blueprint_streams: OrderedDict[
|
_recorded_blueprint_streams: OrderedDict[tuple[str, str, str], _RecordedBlueprintStream] = (
|
||||||
tuple[str, str, str], _RecordedBlueprintStream
|
OrderedDict()
|
||||||
] = OrderedDict()
|
)
|
||||||
|
|
||||||
|
|
||||||
def _stable_recorded_blueprint_stream(
|
def _stable_recorded_blueprint_stream(
|
||||||
@@ -170,8 +175,10 @@ def recorded_blueprint(
|
|||||||
show_detections_2d: bool = False,
|
show_detections_2d: bool = False,
|
||||||
show_segmentation: bool = False,
|
show_segmentation: bool = False,
|
||||||
show_cuboids_3d: bool = False,
|
show_cuboids_3d: bool = False,
|
||||||
|
show_costmap: bool = False,
|
||||||
follow_trajectory: bool = False,
|
follow_trajectory: bool = False,
|
||||||
update_eye_controls: bool = True,
|
update_eye_controls: bool = True,
|
||||||
|
explicit_spatial_preset: bool = False,
|
||||||
) -> rrb.Blueprint:
|
) -> rrb.Blueprint:
|
||||||
accumulation = max(0.0, settings.accumulation_seconds)
|
accumulation = max(0.0, settings.accumulation_seconds)
|
||||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||||
@@ -220,6 +227,9 @@ def recorded_blueprint(
|
|||||||
if plan_view and update_eye_controls
|
if plan_view and update_eye_controls
|
||||||
else rrb.EyeControls3D.from_fields(
|
else rrb.EyeControls3D.from_fields(
|
||||||
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
|
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
|
||||||
|
position=[16.0, -16.0, 18.0] if explicit_spatial_preset else None,
|
||||||
|
look_target=[0.0, 0.0, 0.0] if explicit_spatial_preset else None,
|
||||||
|
eye_up=[0.0, 0.0, 1.0] if explicit_spatial_preset else None,
|
||||||
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
|
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
|
||||||
)
|
)
|
||||||
if update_eye_controls
|
if update_eye_controls
|
||||||
@@ -239,6 +249,7 @@ def recorded_blueprint(
|
|||||||
# inherits the view's latest-at query and can never turn into an
|
# inherits the view's latest-at query and can never turn into an
|
||||||
# object-history trail when the operator widens the cloud window.
|
# object-history trail when the operator widens the cloud window.
|
||||||
"/world/points": point_overrides,
|
"/world/points": point_overrides,
|
||||||
|
"/world/costmap": rrb.EntityBehavior(visible=show_costmap),
|
||||||
"/world/trajectory": trajectory_overrides,
|
"/world/trajectory": trajectory_overrides,
|
||||||
"/world/perception": rrb.EntityBehavior(visible=show_cuboids_3d),
|
"/world/perception": rrb.EntityBehavior(visible=show_cuboids_3d),
|
||||||
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
||||||
@@ -251,16 +262,13 @@ def recorded_blueprint(
|
|||||||
# Log an explicit empty range set so a previous accumulated blueprint
|
# Log an explicit empty range set so a previous accumulated blueprint
|
||||||
# for this stable view id is cleared instead of surviving in Rerun.
|
# for this stable view id is cleared instead of surviving in Rerun.
|
||||||
time_ranges=rrb.VisibleTimeRanges([]),
|
time_ranges=rrb.VisibleTimeRanges([]),
|
||||||
# Follow is a property of the same operator eye, not another view.
|
# Follow and plan configure the native eye, not the point coordinates.
|
||||||
# Keeping the view id stable preserves the current orbit offset when
|
# An empty tracking path clears tracking. Retaining this source view id
|
||||||
# tracking is toggled. An explicit empty path clears tracking without
|
# alone does not preserve edits in upstream's activated blueprint clone.
|
||||||
# overwriting the position/look-target saved by user interaction.
|
|
||||||
eye_controls=spatial_eye_controls,
|
eye_controls=spatial_eye_controls,
|
||||||
)
|
)
|
||||||
spatial_view.id = (
|
spatial_view.id = (
|
||||||
RECORDED_SPATIAL_RESET_VIEW_ID
|
RECORDED_SPATIAL_RESET_VIEW_ID if view_reset_generation else RECORDED_SPATIAL_VIEW_ID
|
||||||
if view_reset_generation
|
|
||||||
else RECORDED_SPATIAL_VIEW_ID
|
|
||||||
)
|
)
|
||||||
camera_view = rrb.Spatial2DView(
|
camera_view = rrb.Spatial2DView(
|
||||||
origin="/perception/camera",
|
origin="/perception/camera",
|
||||||
@@ -309,6 +317,7 @@ def recorded_blueprint(
|
|||||||
visible=settings.show_trajectory,
|
visible=settings.show_trajectory,
|
||||||
),
|
),
|
||||||
"/world/perception": rrb.EntityBehavior(visible=True),
|
"/world/perception": rrb.EntityBehavior(visible=True),
|
||||||
|
"/world/costmap": rrb.EntityBehavior(visible=show_costmap),
|
||||||
# The overlay already carries the selected fusion support points;
|
# The overlay already carries the selected fusion support points;
|
||||||
# its grey diagnostic LiDAR copy would otherwise double-render the
|
# its grey diagnostic LiDAR copy would otherwise double-render the
|
||||||
# native cloud from /world/points.
|
# native cloud from /world/points.
|
||||||
@@ -420,11 +429,15 @@ def recorded_blueprint_rrd(
|
|||||||
show_detections_2d: bool = False,
|
show_detections_2d: bool = False,
|
||||||
show_segmentation: bool = False,
|
show_segmentation: bool = False,
|
||||||
show_cuboids_3d: bool = False,
|
show_cuboids_3d: bool = False,
|
||||||
|
show_costmap: bool = False,
|
||||||
follow_trajectory: bool = False,
|
follow_trajectory: bool = False,
|
||||||
|
reactivate_updates: bool = False,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Serialize a bounded active blueprint update without recorded data."""
|
"""Serialize a bounded active blueprint update without recorded data."""
|
||||||
|
|
||||||
def build_blueprint(update_eye_controls: bool) -> rrb.Blueprint:
|
def build_blueprint(
|
||||||
|
update_eye_controls: bool, use_spatial_preset: bool = False
|
||||||
|
) -> rrb.Blueprint:
|
||||||
return recorded_blueprint(
|
return recorded_blueprint(
|
||||||
settings,
|
settings,
|
||||||
include_initial_playback_state=False,
|
include_initial_playback_state=False,
|
||||||
@@ -436,8 +449,10 @@ def recorded_blueprint_rrd(
|
|||||||
show_detections_2d=show_detections_2d,
|
show_detections_2d=show_detections_2d,
|
||||||
show_segmentation=show_segmentation,
|
show_segmentation=show_segmentation,
|
||||||
show_cuboids_3d=show_cuboids_3d,
|
show_cuboids_3d=show_cuboids_3d,
|
||||||
|
show_costmap=show_costmap,
|
||||||
follow_trajectory=follow_trajectory,
|
follow_trajectory=follow_trajectory,
|
||||||
update_eye_controls=update_eye_controls,
|
update_eye_controls=update_eye_controls,
|
||||||
|
explicit_spatial_preset=reactivate_updates and use_spatial_preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload: bytes | None
|
payload: bytes | None
|
||||||
@@ -452,6 +467,7 @@ def recorded_blueprint_rrd(
|
|||||||
build_blueprint,
|
build_blueprint,
|
||||||
follow_trajectory=follow_trajectory,
|
follow_trajectory=follow_trajectory,
|
||||||
plan_view=plan_view,
|
plan_view=plan_view,
|
||||||
|
reactivate_updates=reactivate_updates,
|
||||||
)
|
)
|
||||||
except RecordedBlueprintError:
|
except RecordedBlueprintError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ class RecordedBlueprintRequest(StrictApiModel):
|
|||||||
show_detections_2d: StrictBool = False
|
show_detections_2d: StrictBool = False
|
||||||
show_segmentation: StrictBool = False
|
show_segmentation: StrictBool = False
|
||||||
show_cuboids_3d: StrictBool = False
|
show_cuboids_3d: StrictBool = False
|
||||||
|
show_costmap: StrictBool = False
|
||||||
|
reactivate_updates: StrictBool = False
|
||||||
follow_trajectory: StrictBool = False
|
follow_trajectory: StrictBool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -980,6 +982,8 @@ def build_session_router(
|
|||||||
show_detections_2d=request.show_detections_2d,
|
show_detections_2d=request.show_detections_2d,
|
||||||
show_segmentation=request.show_segmentation,
|
show_segmentation=request.show_segmentation,
|
||||||
show_cuboids_3d=request.show_cuboids_3d,
|
show_cuboids_3d=request.show_cuboids_3d,
|
||||||
|
show_costmap=request.show_costmap,
|
||||||
|
reactivate_updates=request.reactivate_updates,
|
||||||
follow_trajectory=request.follow_trajectory,
|
follow_trajectory=request.follow_trajectory,
|
||||||
)
|
)
|
||||||
except SessionNotFoundError as exc:
|
except SessionNotFoundError as exc:
|
||||||
|
|||||||
@@ -499,11 +499,14 @@ def test_recorded_follow_mode_tracks_sensor_pose_with_the_same_orbital_eye() ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
@pytest.mark.parametrize("reactivate_updates", [False, True])
|
||||||
|
def test_recorded_blueprint_updates_reuse_source_store_and_opt_in_to_activation(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
reactivate_updates: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
activations: list[tuple[object, bool, bool]] = []
|
activations: list[tuple[object, bool, bool]] = []
|
||||||
eye_control_updates: list[bool] = []
|
eye_control_updates: list[bool] = []
|
||||||
|
explicit_presets: list[bool] = []
|
||||||
send_blueprint = viewer_recorded_module.bindings.send_blueprint
|
send_blueprint = viewer_recorded_module.bindings.send_blueprint
|
||||||
make_blueprint = viewer_recorded_module.recorded_blueprint
|
make_blueprint = viewer_recorded_module.recorded_blueprint
|
||||||
|
|
||||||
@@ -524,6 +527,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
|
|
||||||
def capture_eye_control_update(*args: object, **kwargs: object) -> object:
|
def capture_eye_control_update(*args: object, **kwargs: object) -> object:
|
||||||
eye_control_updates.append(bool(kwargs["update_eye_controls"]))
|
eye_control_updates.append(bool(kwargs["update_eye_controls"]))
|
||||||
|
explicit_presets.append(bool(kwargs["explicit_spatial_preset"]))
|
||||||
return make_blueprint(*args, **kwargs)
|
return make_blueprint(*args, **kwargs)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -537,6 +541,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
recording_id="stable-camera",
|
recording_id="stable-camera",
|
||||||
blueprint_session_id=session_id,
|
blueprint_session_id=session_id,
|
||||||
unified_perception=True,
|
unified_perception=True,
|
||||||
|
reactivate_updates=reactivate_updates,
|
||||||
)
|
)
|
||||||
layers_enabled = viewer_recorded_blueprint_rrd(
|
layers_enabled = viewer_recorded_blueprint_rrd(
|
||||||
RerunSceneSettings(accumulation_seconds=12.0),
|
RerunSceneSettings(accumulation_seconds=12.0),
|
||||||
@@ -546,6 +551,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
show_detections_2d=True,
|
show_detections_2d=True,
|
||||||
show_segmentation=True,
|
show_segmentation=True,
|
||||||
show_cuboids_3d=True,
|
show_cuboids_3d=True,
|
||||||
|
reactivate_updates=reactivate_updates,
|
||||||
)
|
)
|
||||||
follow_enabled = viewer_recorded_blueprint_rrd(
|
follow_enabled = viewer_recorded_blueprint_rrd(
|
||||||
RerunSceneSettings(accumulation_seconds=12.0),
|
RerunSceneSettings(accumulation_seconds=12.0),
|
||||||
@@ -556,6 +562,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
show_segmentation=True,
|
show_segmentation=True,
|
||||||
show_cuboids_3d=True,
|
show_cuboids_3d=True,
|
||||||
follow_trajectory=True,
|
follow_trajectory=True,
|
||||||
|
reactivate_updates=reactivate_updates,
|
||||||
)
|
)
|
||||||
layer_disabled_while_following = viewer_recorded_blueprint_rrd(
|
layer_disabled_while_following = viewer_recorded_blueprint_rrd(
|
||||||
RerunSceneSettings(accumulation_seconds=12.0),
|
RerunSceneSettings(accumulation_seconds=12.0),
|
||||||
@@ -566,6 +573,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
show_segmentation=True,
|
show_segmentation=True,
|
||||||
show_cuboids_3d=True,
|
show_cuboids_3d=True,
|
||||||
follow_trajectory=True,
|
follow_trajectory=True,
|
||||||
|
reactivate_updates=reactivate_updates,
|
||||||
)
|
)
|
||||||
reset = viewer_recorded_blueprint_rrd(
|
reset = viewer_recorded_blueprint_rrd(
|
||||||
RerunSceneSettings(accumulation_seconds=12.0),
|
RerunSceneSettings(accumulation_seconds=12.0),
|
||||||
@@ -577,6 +585,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
show_segmentation=True,
|
show_segmentation=True,
|
||||||
show_cuboids_3d=True,
|
show_cuboids_3d=True,
|
||||||
follow_trajectory=True,
|
follow_trajectory=True,
|
||||||
|
reactivate_updates=reactivate_updates,
|
||||||
)
|
)
|
||||||
|
|
||||||
store_pattern = rb"rec_[0-9a-f]{32}"
|
store_pattern = rb"rec_[0-9a-f]{32}"
|
||||||
@@ -592,11 +601,14 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
assert len(reset_store_ids) == 1
|
assert len(reset_store_ids) == 1
|
||||||
assert reset_store_ids.isdisjoint(initial_store_ids)
|
assert reset_store_ids.isdisjoint(initial_store_ids)
|
||||||
assert eye_control_updates == [True, False, True, False, True]
|
assert eye_control_updates == [True, False, True, False, True]
|
||||||
|
assert explicit_presets == [
|
||||||
|
False, reactivate_updates, reactivate_updates, reactivate_updates, False
|
||||||
|
]
|
||||||
assert [activation[1:] for activation in activations] == [
|
assert [activation[1:] for activation in activations] == [
|
||||||
(True, False),
|
(True, False),
|
||||||
(False, False),
|
(reactivate_updates, False),
|
||||||
(False, False),
|
(reactivate_updates, False),
|
||||||
(False, False),
|
(reactivate_updates, False),
|
||||||
(True, False),
|
(True, False),
|
||||||
]
|
]
|
||||||
assert activations[0][0] is activations[1][0]
|
assert activations[0][0] is activations[1][0]
|
||||||
@@ -610,6 +622,23 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
|||||||
assert len(reset) < 350_000
|
assert len(reset) < 350_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_portable_replay_has_distinct_explicit_3d_and_plan_presets() -> None:
|
||||||
|
eyes = []
|
||||||
|
for plan in [False, True]:
|
||||||
|
blueprint = viewer_recorded_blueprint(
|
||||||
|
RerunSceneSettings(),
|
||||||
|
include_initial_playback_state=False,
|
||||||
|
explicit_spatial_preset=True,
|
||||||
|
follow_trajectory=True,
|
||||||
|
plan_view=plan,
|
||||||
|
)
|
||||||
|
eyes.append(blueprint.root_container.contents[0].properties["EyeControls3D"])
|
||||||
|
assert eyes[0].position.as_arrow_array().to_pylist() == [[16.0, -16.0, 18.0]]
|
||||||
|
assert eyes[1].position.as_arrow_array().to_pylist() == [[0.0, 0.0, 30.0]]
|
||||||
|
assert eyes[0].eye_up.as_arrow_array().to_pylist() == [[0.0, 0.0, 1.0]]
|
||||||
|
assert eyes[1].eye_up.as_arrow_array().to_pylist() == [[0.0, 1.0, 0.0]]
|
||||||
|
|
||||||
|
|
||||||
def test_viewer_blueprint_unifies_original_video_and_independent_ai_layers() -> None:
|
def test_viewer_blueprint_unifies_original_video_and_independent_ai_layers() -> None:
|
||||||
blueprint = viewer_recorded_blueprint(
|
blueprint = viewer_recorded_blueprint(
|
||||||
RerunSceneSettings(accumulation_seconds=12.0),
|
RerunSceneSettings(accumulation_seconds=12.0),
|
||||||
|
|||||||
Reference in New Issue
Block a user