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 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,
|
||||
|
||||
@@ -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 {
|
||||
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<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>
|
||||
);
|
||||
return <CanonicalResultRerunReplay resultId={resultId} sessionId={review.sessionId}
|
||||
initialPlaybackStartSeconds={review.timelineStartSeconds} semantics
|
||||
resolveReplay={resolveCanonicalLabReplay} label="RAVNOVES004TREE · канонический повтор Rerun" />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
sourceKind = "legacy-vegetation",
|
||||
}: {
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs";
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface RecordedPerceptionLayers {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
costmap?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordedRrdArtifactDescriptor {
|
||||
|
||||
@@ -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({
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
|
||||
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
|
||||
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
|
||||
</span>
|
||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||
<p>
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "Записанный маршрут синхронизирован по общей временной шкале."
|
||||
: "Показан проверенный документ результата и связанные с ним артефакты."}
|
||||
: "Просмотр сохранённых данных результата без повторного расчёта."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -741,17 +740,7 @@ export function ObservatoryWorkspace({
|
||||
review={replay.review.review}
|
||||
/>
|
||||
) : (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg">
|
||||
<div>
|
||||
<StatusBadge tone="success">Проверено</StatusBadge>
|
||||
<h3>{replay.review.resultKind}</h3>
|
||||
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
|
||||
<details>
|
||||
<summary>Документ результата</summary>
|
||||
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<PortableResultReplay review={replay.review} />
|
||||
)}
|
||||
</section>
|
||||
) : 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 recordedRun = await read("core/observatory/recordedRun.ts");
|
||||
const sharedReplay = await read(
|
||||
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
|
||||
"components/laboratory/CanonicalResultRerunReplay.tsx",
|
||||
);
|
||||
const workspaceCss = await read("styles/workspaces.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.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.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",
|
||||
});
|
||||
});
|
||||
|
||||
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("core/observatory/useObservatoryCatalog.ts"),
|
||||
read("core/observatory/recordedRun.ts"),
|
||||
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
|
||||
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||
read("core/observation/viewerProfile.ts"),
|
||||
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.match(workspace, /Открыть визуальный разбор/);
|
||||
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
|
||||
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
|
||||
assert.match(workspace, /<summary>Документ результата<\/summary>/);
|
||||
assert.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
|
||||
const portable = await read("components/laboratory/PortableResultReplay.tsx");
|
||||
assert.match(portable, /<summary>Документ результата<\/summary>/);
|
||||
assert.match(portable, /<CanonicalResultRerunReplay/);
|
||||
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
||||
assert.match(workspace, /Проверяем точную связь результата/);
|
||||
assert.match(workspace, /role="alert"/);
|
||||
|
||||
@@ -14,6 +14,8 @@ let liveRerunReceiverBindingIdentity;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
let isRecordedRrdSource;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -31,6 +33,8 @@ before(async () => {
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
isRecordedRrdSource,
|
||||
resolveRecordedBlueprintUrl,
|
||||
} = 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", () => {
|
||||
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 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, /missioncore\/recorded-recording/);
|
||||
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.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||
assert.match(
|
||||
|
||||
@@ -470,7 +470,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
||||
new URL("../src/components/laboratory/CanonicalResultRerunReplay.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
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, /ИСХ\. ТОЧКИ/);
|
||||
assert.match(rerunSource, /ЛОК\. SLAM/);
|
||||
assert.match(rerunSource, /resolveCanonicalLabReplay/);
|
||||
assert.match(rerunSource, /resolveReplay\(resultId, value/);
|
||||
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
||||
assert.match(rerunSource, /unifiedPerception: splitView/);
|
||||
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.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, /secondary=\{spatialPane/);
|
||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||
|
||||
@@ -45,12 +45,11 @@ class RecordedBlueprintError(RuntimeError):
|
||||
|
||||
|
||||
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
|
||||
store. Creating and activating a fresh store for every layer toggle drops
|
||||
that eye and snaps the view back to its fallback camera. This stream keeps
|
||||
the store identity stable until the operator explicitly requests a reset.
|
||||
Upstream 0.36.3 activates a clone, not this source store. Explicit refresh
|
||||
makes layer changes visible but cannot retain the clone's operator eye.
|
||||
It is opt-in for portable replay pending native camera-state support.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -82,18 +81,24 @@ class _RecordedBlueprintStream:
|
||||
|
||||
def render(
|
||||
self,
|
||||
blueprint_factory: Callable[[bool], rrb.Blueprint],
|
||||
blueprint_factory: Callable[[bool, bool], rrb.Blueprint],
|
||||
*,
|
||||
follow_trajectory: bool,
|
||||
plan_view: bool,
|
||||
reactivate_updates: bool = False,
|
||||
) -> bytes:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RecordedBlueprintError("stable blueprint stream is closed")
|
||||
eye_contract = (follow_trajectory, plan_view)
|
||||
update_eye_controls = self._eye_contract != eye_contract
|
||||
blueprint = blueprint_factory(update_eye_controls)
|
||||
make_active = self._sequence == 0
|
||||
# Initial admission/reset uses native framing. Explicit presets
|
||||
# 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(
|
||||
"blueprint",
|
||||
sequence=self._sequence,
|
||||
@@ -124,9 +129,9 @@ class _RecordedBlueprintStream:
|
||||
|
||||
_MAX_RECORDED_BLUEPRINT_STREAMS = 32
|
||||
_recorded_blueprint_streams_lock = Lock()
|
||||
_recorded_blueprint_streams: OrderedDict[
|
||||
tuple[str, str, str], _RecordedBlueprintStream
|
||||
] = OrderedDict()
|
||||
_recorded_blueprint_streams: OrderedDict[tuple[str, str, str], _RecordedBlueprintStream] = (
|
||||
OrderedDict()
|
||||
)
|
||||
|
||||
|
||||
def _stable_recorded_blueprint_stream(
|
||||
@@ -170,8 +175,10 @@ def recorded_blueprint(
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
show_costmap: bool = False,
|
||||
follow_trajectory: bool = False,
|
||||
update_eye_controls: bool = True,
|
||||
explicit_spatial_preset: bool = False,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
@@ -220,6 +227,9 @@ def recorded_blueprint(
|
||||
if plan_view and update_eye_controls
|
||||
else rrb.EyeControls3D.from_fields(
|
||||
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 "",
|
||||
)
|
||||
if update_eye_controls
|
||||
@@ -239,6 +249,7 @@ def recorded_blueprint(
|
||||
# inherits the view's latest-at query and can never turn into an
|
||||
# object-history trail when the operator widens the cloud window.
|
||||
"/world/points": point_overrides,
|
||||
"/world/costmap": rrb.EntityBehavior(visible=show_costmap),
|
||||
"/world/trajectory": trajectory_overrides,
|
||||
"/world/perception": rrb.EntityBehavior(visible=show_cuboids_3d),
|
||||
"/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
|
||||
# for this stable view id is cleared instead of surviving in Rerun.
|
||||
time_ranges=rrb.VisibleTimeRanges([]),
|
||||
# Follow is a property of the same operator eye, not another view.
|
||||
# Keeping the view id stable preserves the current orbit offset when
|
||||
# tracking is toggled. An explicit empty path clears tracking without
|
||||
# overwriting the position/look-target saved by user interaction.
|
||||
# Follow and plan configure the native eye, not the point coordinates.
|
||||
# An empty tracking path clears tracking. Retaining this source view id
|
||||
# alone does not preserve edits in upstream's activated blueprint clone.
|
||||
eye_controls=spatial_eye_controls,
|
||||
)
|
||||
spatial_view.id = (
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID
|
||||
if view_reset_generation
|
||||
else RECORDED_SPATIAL_VIEW_ID
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID if view_reset_generation else RECORDED_SPATIAL_VIEW_ID
|
||||
)
|
||||
camera_view = rrb.Spatial2DView(
|
||||
origin="/perception/camera",
|
||||
@@ -309,6 +317,7 @@ def recorded_blueprint(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
"/world/perception": rrb.EntityBehavior(visible=True),
|
||||
"/world/costmap": rrb.EntityBehavior(visible=show_costmap),
|
||||
# The overlay already carries the selected fusion support points;
|
||||
# its grey diagnostic LiDAR copy would otherwise double-render the
|
||||
# native cloud from /world/points.
|
||||
@@ -420,11 +429,15 @@ def recorded_blueprint_rrd(
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
show_costmap: bool = False,
|
||||
follow_trajectory: bool = False,
|
||||
reactivate_updates: bool = False,
|
||||
) -> bytes:
|
||||
"""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(
|
||||
settings,
|
||||
include_initial_playback_state=False,
|
||||
@@ -436,8 +449,10 @@ def recorded_blueprint_rrd(
|
||||
show_detections_2d=show_detections_2d,
|
||||
show_segmentation=show_segmentation,
|
||||
show_cuboids_3d=show_cuboids_3d,
|
||||
show_costmap=show_costmap,
|
||||
follow_trajectory=follow_trajectory,
|
||||
update_eye_controls=update_eye_controls,
|
||||
explicit_spatial_preset=reactivate_updates and use_spatial_preset,
|
||||
)
|
||||
|
||||
payload: bytes | None
|
||||
@@ -452,6 +467,7 @@ def recorded_blueprint_rrd(
|
||||
build_blueprint,
|
||||
follow_trajectory=follow_trajectory,
|
||||
plan_view=plan_view,
|
||||
reactivate_updates=reactivate_updates,
|
||||
)
|
||||
except RecordedBlueprintError:
|
||||
raise
|
||||
|
||||
@@ -130,6 +130,8 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||
show_detections_2d: StrictBool = False
|
||||
show_segmentation: StrictBool = False
|
||||
show_cuboids_3d: StrictBool = False
|
||||
show_costmap: StrictBool = False
|
||||
reactivate_updates: StrictBool = False
|
||||
follow_trajectory: StrictBool = False
|
||||
|
||||
|
||||
@@ -980,6 +982,8 @@ def build_session_router(
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
show_costmap=request.show_costmap,
|
||||
reactivate_updates=request.reactivate_updates,
|
||||
follow_trajectory=request.follow_trajectory,
|
||||
)
|
||||
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,
|
||||
reactivate_updates: bool,
|
||||
) -> None:
|
||||
activations: list[tuple[object, bool, bool]] = []
|
||||
eye_control_updates: list[bool] = []
|
||||
explicit_presets: list[bool] = []
|
||||
send_blueprint = viewer_recorded_module.bindings.send_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:
|
||||
eye_control_updates.append(bool(kwargs["update_eye_controls"]))
|
||||
explicit_presets.append(bool(kwargs["explicit_spatial_preset"]))
|
||||
return make_blueprint(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -537,6 +541,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
||||
recording_id="stable-camera",
|
||||
blueprint_session_id=session_id,
|
||||
unified_perception=True,
|
||||
reactivate_updates=reactivate_updates,
|
||||
)
|
||||
layers_enabled = viewer_recorded_blueprint_rrd(
|
||||
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_segmentation=True,
|
||||
show_cuboids_3d=True,
|
||||
reactivate_updates=reactivate_updates,
|
||||
)
|
||||
follow_enabled = viewer_recorded_blueprint_rrd(
|
||||
RerunSceneSettings(accumulation_seconds=12.0),
|
||||
@@ -556,6 +562,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
||||
show_segmentation=True,
|
||||
show_cuboids_3d=True,
|
||||
follow_trajectory=True,
|
||||
reactivate_updates=reactivate_updates,
|
||||
)
|
||||
layer_disabled_while_following = viewer_recorded_blueprint_rrd(
|
||||
RerunSceneSettings(accumulation_seconds=12.0),
|
||||
@@ -566,6 +573,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
||||
show_segmentation=True,
|
||||
show_cuboids_3d=True,
|
||||
follow_trajectory=True,
|
||||
reactivate_updates=reactivate_updates,
|
||||
)
|
||||
reset = viewer_recorded_blueprint_rrd(
|
||||
RerunSceneSettings(accumulation_seconds=12.0),
|
||||
@@ -577,6 +585,7 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
||||
show_segmentation=True,
|
||||
show_cuboids_3d=True,
|
||||
follow_trajectory=True,
|
||||
reactivate_updates=reactivate_updates,
|
||||
)
|
||||
|
||||
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 reset_store_ids.isdisjoint(initial_store_ids)
|
||||
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] == [
|
||||
(True, False),
|
||||
(False, False),
|
||||
(False, False),
|
||||
(False, False),
|
||||
(reactivate_updates, False),
|
||||
(reactivate_updates, False),
|
||||
(reactivate_updates, False),
|
||||
(True, False),
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
blueprint = viewer_recorded_blueprint(
|
||||
RerunSceneSettings(accumulation_seconds=12.0),
|
||||
|
||||
Reference in New Issue
Block a user