fix(viewer): make recorded layers deterministic

This commit is contained in:
DCCONSTRUCTIONS 2026-07-23 11:42:19 +03:00
parent 3a64f54dea
commit ee15160862
6 changed files with 222 additions and 34 deletions

View File

@ -49,6 +49,7 @@ export interface RerunViewportProps {
presentationGate?: RecordedAdmissionPhase; presentationGate?: RecordedAdmissionPhase;
expectedTimelineStartSeconds?: number; expectedTimelineStartSeconds?: number;
expectedTimelineEndSeconds?: number; expectedTimelineEndSeconds?: number;
initialPlaybackStartSeconds?: number;
onStatusChange?: (status: RerunViewportStatus, message?: string) => void; onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
onSelectionChange?: (selection: RerunSelection | null) => void; onSelectionChange?: (selection: RerunSelection | null) => void;
onPlaybackChange?: (state: RerunPlaybackState | null) => void; onPlaybackChange?: (state: RerunPlaybackState | null) => void;
@ -295,12 +296,21 @@ export function createRecordedAutoplayGate(): {
rangeNs: { min: number; max: number } | null, rangeNs: { min: number; max: number } | null,
seekToStart: (startNs: number) => void, seekToStart: (startNs: number) => void,
startPlaying: () => void, startPlaying: () => void,
preferredStartNs?: number,
) => boolean; ) => boolean;
attempted: () => boolean; attempted: () => boolean;
} { } {
let consumed = false; let consumed = false;
return { return {
attempt(viewerStarted, fullyBuffered, presentationReady, rangeNs, seekToStart, startPlaying) { attempt(
viewerStarted,
fullyBuffered,
presentationReady,
rangeNs,
seekToStart,
startPlaying,
preferredStartNs,
) {
if ( if (
consumed || consumed ||
!viewerStarted || !viewerStarted ||
@ -309,8 +319,11 @@ export function createRecordedAutoplayGate(): {
!isUsableRecordedPlaybackRange(rangeNs) !isUsableRecordedPlaybackRange(rangeNs)
) return false; ) return false;
consumed = true; consumed = true;
const startNs = Number.isFinite(preferredStartNs)
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
: rangeNs.min;
return attemptRecordedAutoplay( return attemptRecordedAutoplay(
() => seekToStart(rangeNs.min), () => seekToStart(startNs),
startPlaying, startPlaying,
); );
}, },
@ -515,7 +528,8 @@ export async function fetchRecordedBlueprintRrd(
custom_color: settings.customColor, custom_color: settings.customColor,
active_view: activeView, active_view: activeView,
view_reset_generation: viewResetGeneration, view_reset_generation: viewResetGeneration,
unified_perception: perceptionLayers.enabled, unified_perception:
perceptionLayers.detections2d || perceptionLayers.segmentation,
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,
@ -616,6 +630,7 @@ export function RerunViewport({
presentationGate = "ready", presentationGate = "ready",
expectedTimelineStartSeconds, expectedTimelineStartSeconds,
expectedTimelineEndSeconds, expectedTimelineEndSeconds,
initialPlaybackStartSeconds,
onStatusChange, onStatusChange,
onSelectionChange, onSelectionChange,
onPlaybackChange, onPlaybackChange,
@ -982,6 +997,9 @@ export function RerunViewport({
() => { () => {
viewer.set_playing(event.recording_id, true); viewer.set_playing(event.recording_id, true);
}, },
initialPlaybackStartSeconds === undefined
? undefined
: initialPlaybackStartSeconds * 1_000_000_000,
); );
if (autoplayStarted) { if (autoplayStarted) {
playing = true; playing = true;
@ -1153,6 +1171,7 @@ export function RerunViewport({
expectedTimelineEndSeconds, expectedTimelineEndSeconds,
expectedTimelineStartSeconds, expectedTimelineStartSeconds,
followLive, followLive,
initialPlaybackStartSeconds,
onPlaybackChange, onPlaybackChange,
onPlaybackControllerChange, onPlaybackControllerChange,
onSelectionChange, onSelectionChange,

View File

@ -336,10 +336,13 @@ function SpatialWorkspace({
(source) => source.capabilities.overlay && source.modality !== "point-cloud", (source) => source.capabilities.overlay && source.modality !== "point-cloud",
); );
const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable"; const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable";
// Keep one stable original-video + world composition for every admitted const recordedPerceptionEnabled =
// perception recording. Layer buttons then only change entity visibility; showDetections2d || showSegmentation || showCuboids3d;
// they never reparent the 3D view or invalidate the operator's camera. // The native recorded camera remains the authoritative original. Only 2D
const unifiedPerception = recordedPerceptionSupported; // image-space overlays need Rerun's paired camera/world composition; 3D
// cuboids are added directly to the stable spatial view.
const unifiedPerception = recordedPerceptionSupported &&
(showDetections2d || showSegmentation);
const livePerceptionAvailable = !recordedSource && streamActive; const livePerceptionAvailable = !recordedSource && streamActive;
const detections2dActive = recordedSource const detections2dActive = recordedSource
? showDetections2d ? showDetections2d
@ -354,6 +357,16 @@ function SpatialWorkspace({
observationLayout.visibleSourceIds.has(source.id) && observationLayout.visibleSourceIds.has(source.id) &&
!source.id.startsWith("recorded.perception."), !source.id.startsWith("recorded.perception."),
); );
const initialRecordedPlaybackStartSeconds = recordedSource
? mediaSources.reduce<number | undefined>((earliest, source) => {
if (
source.id.startsWith("recorded.perception.") ||
source.delivery?.kind !== "recorded-fmp4-manifest"
) return earliest;
const start = source.delivery.timelineStartSeconds;
return earliest === undefined ? start : Math.min(earliest, start);
}, undefined)
: undefined;
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length; const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
const pointCloudFocused = Boolean( const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id, pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
@ -574,10 +587,11 @@ function SpatialWorkspace({
expectedTimelineEndSeconds={recordedSource expectedTimelineEndSeconds={recordedSource
? state?.observationTimeline?.range?.endSeconds ? state?.observationTimeline?.range?.endSeconds
: undefined} : undefined}
initialPlaybackStartSeconds={initialRecordedPlaybackStartSeconds}
sceneSettings={sceneSettings} sceneSettings={sceneSettings}
recordedViewResetGeneration={recordedViewResetGeneration} recordedViewResetGeneration={recordedViewResetGeneration}
recordedPerceptionLayers={{ recordedPerceptionLayers={{
enabled: unifiedPerception, enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
detections2d: showDetections2d, detections2d: showDetections2d,
segmentation: showSegmentation, segmentation: showSegmentation,
cuboids3d: showCuboids3d, cuboids3d: showCuboids3d,

View File

@ -309,6 +309,39 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
show_cuboids_3d: true, show_cuboids_3d: true,
}); });
await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
{
accumulationSeconds: 0,
showGrid: true,
showPoints: true,
showTrajectory: true,
pointSize: 2.5,
palette: "turbo",
customColor: "#ffffff",
},
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
blueprintSessionId: "b".repeat(32),
perceptionLayers: {
enabled: true,
detections2d: false,
segmentation: false,
cuboids3d: true,
},
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: { "Content-Type": "application/vnd.rerun.rrd" },
});
},
},
);
assert.equal(calls[1].body.unified_perception, false);
assert.equal(calls[1].body.show_cuboids_3d, true);
await assert.rejects( await assert.rejects(
fetchRecordedBlueprintRrd( fetchRecordedBlueprintRrd(
"https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd", "https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd",

View File

@ -128,6 +128,51 @@ test("recorded autoplay waits for the full range and then runs exactly once", ()
assert.equal(gate.attempted(), true); assert.equal(gate.attempted(), true);
}); });
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
const gate = createRecordedAutoplayGate();
const seeks = [];
const range = { min: 0, max: 535_717_620_042 };
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
() => {},
35_421_857_292,
), true);
assert.deepEqual(seeks, [35_421_857_292]);
assert.deepEqual(range, { min: 0, max: 535_717_620_042 });
});
test("recorded autoplay clamps an invalid presentation start to the admitted archive", () => {
const before = createRecordedAutoplayGate();
const after = createRecordedAutoplayGate();
const seeks = [];
const range = { min: 5, max: 10 };
assert.equal(before.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
() => {},
-50,
), true);
assert.equal(after.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
() => {},
50,
), true);
assert.deepEqual(seeks, [5, 10]);
});
test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => { test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => {
const gate = createRecordedAutoplayGate(); const gate = createRecordedAutoplayGate();
let seeks = 0; let seeks = 0;

View File

@ -55,48 +55,58 @@ class _RecordedBlueprintStream:
def __init__( def __init__(
self, self,
application_id: str, application_id: str,
recording_id: str,
*, *,
view_reset_generation: Literal[0, 1], view_reset_generation: Literal[0, 1],
) -> None: ) -> None:
self.view_reset_generation = view_reset_generation self.view_reset_generation = view_reset_generation
self._lock = Lock() self._lock = Lock()
self._pending: list[bytes] = [] self._sequence = 0
self._initial_payload: bytes | None = None self._closed = False
self._latest_payload = b""
native = bindings.new_blueprint( native = bindings.new_blueprint(
application_id=application_id, application_id=application_id,
make_default=False, make_default=False,
make_thread_default=False, make_thread_default=False,
default_enabled=True, default_enabled=True,
) )
self._recording = rr.RecordingStream._from_native(native) self._blueprint_recording = rr.RecordingStream._from_native(native)
bindings.set_callback_sink_blueprint( self._blueprint_memory = self._blueprint_recording.memory_recording()
lambda chunk: self._pending.append(bytes(chunk)), self._transport_recording = rr.RecordingStream(
True, application_id,
False, recording_id=recording_id,
native, send_properties=False,
) )
self._recording.set_time("blueprint", sequence=0) self._transport = rr.binary_stream(self._transport_recording)
def render(self, blueprint: rrb.Blueprint) -> bytes: def render(self, blueprint: rrb.Blueprint) -> bytes:
with self._lock: with self._lock:
if self._initial_payload is not None: if self._closed:
self._pending.clear() raise RecordedBlueprintError("stable blueprint stream is closed")
blueprint._log_to_stream(self._recording) self._blueprint_recording.set_time(
self._recording.flush(timeout_sec=5.0) "blueprint",
delta = b"".join(self._pending) sequence=self._sequence,
if not delta: )
self._sequence += 1
blueprint._log_to_stream(self._blueprint_recording)
self._blueprint_recording.flush(timeout_sec=5.0)
bindings.send_blueprint(
self._blueprint_memory.storage,
True,
False,
self._transport_recording.to_native(),
)
payload = self._transport.read(flush=True, flush_timeout_sec=5.0)
if not payload:
raise RecordedBlueprintError("stable blueprint stream produced no data") raise RecordedBlueprintError("stable blueprint stream produced no data")
if self._initial_payload is None: return payload
self._initial_payload = delta
self._latest_payload = b""
else:
self._latest_payload = delta
return self._initial_payload + self._latest_payload
def close(self) -> None: def close(self) -> None:
with suppress(Exception): with self._lock:
self._recording.disconnect() self._closed = True
with suppress(Exception):
self._blueprint_recording.disconnect()
with suppress(Exception):
self._transport_recording.disconnect()
_MAX_RECORDED_BLUEPRINT_STREAMS = 32 _MAX_RECORDED_BLUEPRINT_STREAMS = 32
@ -123,6 +133,7 @@ def _stable_recorded_blueprint_stream(
if stream is None: if stream is None:
stream = _RecordedBlueprintStream( stream = _RecordedBlueprintStream(
application_id, application_id,
recording_id,
view_reset_generation=view_reset_generation, view_reset_generation=view_reset_generation,
) )
_recorded_blueprint_streams[key] = stream _recorded_blueprint_streams[key] = stream
@ -197,7 +208,13 @@ def recorded_blueprint(
# 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/trajectory": trajectory_overrides, "/world/trajectory": trajectory_overrides,
"/world/perception": rrb.EntityBehavior(visible=False), "/world/perception": rrb.EntityBehavior(visible=show_cuboids_3d),
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
"/world/perception/support": rrb.EntityBehavior(visible=False),
"/world/perception/semantic_points": rrb.EntityBehavior(visible=False),
"/world/perception/boxes3d": rrb.EntityBehavior(
visible=show_cuboids_3d,
),
}, },
# 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.

View File

@ -12,6 +12,7 @@ from pathlib import Path
import pytest import pytest
import k1link.device_plugins.xgrids_k1.rrd_export as export_module import k1link.device_plugins.xgrids_k1.rrd_export as export_module
import k1link.viewer.recorded as viewer_recorded_module
from k1link.device_plugins.xgrids_k1.mqtt.capture import ( from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CAPTURE_CLOCK_FILENAME, CAPTURE_CLOCK_FILENAME,
FRAME_HEADER, FRAME_HEADER,
@ -299,6 +300,39 @@ def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> No
assert point_components == {"Points3D:radii"} assert point_components == {"Points3D:radii"}
def test_cuboids_are_overlaid_on_the_stable_spatial_view_without_unifying_video() -> None:
baseline = viewer_recorded_blueprint(
RerunSceneSettings(accumulation_seconds=12.0),
include_initial_playback_state=False,
unified_perception=False,
show_cuboids_3d=False,
)
blueprint = viewer_recorded_blueprint(
RerunSceneSettings(accumulation_seconds=12.0),
include_initial_playback_state=False,
unified_perception=False,
show_cuboids_3d=True,
)
spatial_view = blueprint.root_container.contents[0]
assert spatial_view.id == baseline.root_container.contents[0].id
assert spatial_view.visualizer_overrides[
"/world/perception"
].visible.as_arrow_array().to_pylist() == [True]
assert spatial_view.visualizer_overrides[
"/world/perception/lidar"
].visible.as_arrow_array().to_pylist() == [False]
assert spatial_view.visualizer_overrides[
"/world/perception/support"
].visible.as_arrow_array().to_pylist() == [False]
assert spatial_view.visualizer_overrides[
"/world/perception/semantic_points"
].visible.as_arrow_array().to_pylist() == [False]
assert spatial_view.visualizer_overrides[
"/world/perception/boxes3d"
].visible.as_arrow_array().to_pylist() == [True]
def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None: def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
first = _recorded_blueprint( first = _recorded_blueprint(
RerunSceneSettings(show_points=False, show_trajectory=True), RerunSceneSettings(show_points=False, show_trajectory=True),
@ -416,7 +450,26 @@ def test_viewer_blueprint_reset_is_bounded_and_recreates_render_views() -> None:
assert perception_behavior.visible.as_arrow_array().to_pylist() == [False] assert perception_behavior.visible.as_arrow_array().to_pylist() == [False]
def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset() -> None: def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
activations: list[tuple[object, bool, bool]] = []
send_blueprint = viewer_recorded_module.bindings.send_blueprint
def capture_activation(
storage: object,
make_active: bool,
make_default: bool,
recording: object,
) -> None:
activations.append((storage, make_active, make_default))
send_blueprint(storage, make_active, make_default, recording)
monkeypatch.setattr(
viewer_recorded_module.bindings,
"send_blueprint",
capture_activation,
)
session_id = "b" * 32 session_id = "b" * 32
initial = viewer_recorded_blueprint_rrd( initial = viewer_recorded_blueprint_rrd(
RerunSceneSettings(accumulation_seconds=12.0), RerunSceneSettings(accumulation_seconds=12.0),
@ -452,6 +505,13 @@ def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset()
assert layer_store_ids == initial_store_ids assert layer_store_ids == initial_store_ids
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 [activation[1:] for activation in activations] == [
(True, False),
(True, False),
(True, False),
]
assert activations[0][0] is activations[1][0]
assert activations[2][0] is not activations[0][0]
assert len(initial) < 350_000 assert len(initial) < 350_000
assert len(layers_enabled) < 350_000 assert len(layers_enabled) < 350_000
assert len(reset) < 350_000 assert len(reset) < 350_000