diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx
index 2b8a6a1..cb22ad3 100644
--- a/apps/control-station/src/components/RerunViewport.tsx
+++ b/apps/control-station/src/components/RerunViewport.tsx
@@ -36,7 +36,6 @@ import {
createRecordedAutoplayGate,
createRecordedInitialSeekGate,
createRecordedOpenWatchdog,
- shouldReapplyRecordedBlueprint,
} from "../core/observation/recordedRerunLifecycle";
import type {
RecordedPerceptionLayers,
@@ -72,7 +71,6 @@ export {
recordedPlaybackRangeWhenReady,
recordedPointColorKey,
rerunPresentationStatus,
- shouldReapplyRecordedBlueprint,
type RecordedPlaybackBufferState,
} from "../core/observation/recordedRerunLifecycle";
export {
@@ -167,6 +165,7 @@ 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 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$/;
@@ -231,7 +230,8 @@ 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) ||
+ !(RECORDED_RRD_PATH.test(descriptor.sourceUrl)
+ || LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) ||
descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
endpoint.origin !== base.origin ||
endpoint.pathname !== descriptor.sourceUrl ||
@@ -252,10 +252,23 @@ export function rerunViewerInitialSource(
return resolvedSourceUrl;
}
-export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string): string | null {
+export function resolveRecordedBlueprintUrl(
+ sourceUrl: string,
+ origin: string,
+ explicitSourceUrl?: string,
+): string | null {
const normalized = sourceUrl.trim();
- if (!RECORDED_RRD_PATH.test(normalized)) return null;
const base = new URL(origin);
+ if (explicitSourceUrl !== undefined) {
+ const explicit = explicitSourceUrl.trim();
+ if (
+ !LAB_RECORDED_REPLAY_PATH.test(normalized)
+ || !RECORDED_BLUEPRINT_PATH.test(explicit)
+ ) return null;
+ const endpoint = new URL(explicit, `${base.origin}/`);
+ return endpoint.origin === base.origin ? endpoint.href : null;
+ }
+ if (!RECORDED_RRD_PATH.test(normalized)) return null;
const endpoint = new URL(
normalized.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
`${base.origin}/`,
@@ -690,6 +703,7 @@ export function RerunViewport({
segmentation: false,
cuboids3d: false,
};
+ const recordedBlueprintSourceUrl = recordedProfile?.blueprintSourceUrl;
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
const recordedSemanticLayer = recordedProfile?.semanticLayer;
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
@@ -727,7 +741,11 @@ export function RerunViewport({
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
const recordedBlueprintUrl = sourceUrl
- ? resolveRecordedBlueprintUrl(sourceUrl, window.location.origin)
+ ? resolveRecordedBlueprintUrl(
+ sourceUrl,
+ window.location.origin,
+ recordedBlueprintSourceUrl,
+ )
: null;
const recordedPerceptionUrl = recordedPerceptionSourceUrl
? resolveRerunSourceUrl(recordedPerceptionSourceUrl, window.location.origin)
@@ -773,7 +791,8 @@ export function RerunViewport({
return;
}
- const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource);
+ const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource)
+ || LAB_RECORDED_REPLAY_PATH.test(normalizedSource);
let resolvedSource: string;
try {
if (isRecordedSource) {
@@ -1151,23 +1170,7 @@ export function RerunViewport({
disposed ||
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
) return;
- if (recordingOpened) {
- const identity = recordedIdentityRef.current;
- if (shouldReapplyRecordedBlueprint(
- recordingOpened,
- isRecordedSource,
- recordedBlueprintUrl !== null,
- identity,
- event,
- )) {
- // A LAB sidecar is a second upstream receiver for the same
- // recording store. Rerun re-opens that store only after the
- // sidecar has been decoded, so reapply the canonical blueprint
- // at this exact boundary instead of racing it during download.
- setBlueprintChannelRevision((revision) => revision + 1);
- }
- return;
- }
+ if (recordingOpened) return;
recordingOpened = true;
if (!isRecordedSource) {
// Store discovery only establishes a candidate. Admission is
diff --git a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx
index 3ec90b0..740de90 100644
--- a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx
+++ b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx
@@ -242,7 +242,7 @@ export function CanonicalRecordedLabReplay<
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView}
- separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
+ separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
diff --git a/apps/control-station/src/core/laboratory/canonicalLabReplay.ts b/apps/control-station/src/core/laboratory/canonicalLabReplay.ts
new file mode 100644
index 0000000..6d64d68
--- /dev/null
+++ b/apps/control-station/src/core/laboratory/canonicalLabReplay.ts
@@ -0,0 +1,76 @@
+import type { ObservationSessionReplayLaunch } from "../observation/sessionArchive";
+import type { RecordedRrdArtifactDescriptor } from "../observation/viewerProfile";
+
+const SAFE_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
+const SAFE_SESSION_SOURCE = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
+const MAX_CANONICAL_REPLAY_BYTES = 1024 * 1024 * 1024;
+
+export interface CanonicalLabReplayDescriptor extends RecordedRrdArtifactDescriptor {
+ blueprintSourceUrl: string;
+}
+
+/**
+ * Resolve the one immutable RRD used by the canonical recorded LAB.
+ *
+ * The server caches the merge of the sealed spatial recording and the LAB AI
+ * evidence. Rerun therefore opens one source and cannot present the base store
+ * before a second receiver has finished decoding the semantic layer.
+ */
+export async function resolveCanonicalLabReplay(
+ resultId: string,
+ launch: ObservationSessionReplayLaunch,
+ {
+ origin = window.location.origin,
+ signal,
+ fetcher = globalThis.fetch,
+ }: {
+ origin?: string;
+ signal?: AbortSignal;
+ fetcher?: typeof globalThis.fetch;
+ } = {},
+): Promise
{
+ const base = new URL(origin);
+ if (
+ !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 descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
+ descriptorUrl.searchParams.set("base_generation", launch.sha256);
+ if (descriptorUrl.origin !== base.origin) {
+ throw new Error("Канонический replay LAB должен быть same-origin.");
+ }
+ const response = await fetcher(descriptorUrl.href, {
+ method: "HEAD",
+ credentials: "same-origin",
+ headers: { Accept: "application/vnd.rerun.rrd" },
+ signal,
+ });
+ const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
+ const byteLength = Number(response.headers.get("Content-Length"));
+ const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
+ if (
+ response.status !== 200
+ || contentType !== "application/vnd.rerun.rrd"
+ || response.headers.get("X-Rerun-Format") !== "RRF2"
+ || !sha256
+ || !Number.isSafeInteger(byteLength)
+ || byteLength < 4
+ || byteLength > MAX_CANONICAL_REPLAY_BYTES
+ ) {
+ throw new Error("Единый replay LAB не прошёл проверку.");
+ }
+ return {
+ sourceUrl,
+ viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
+ byteLength,
+ sha256,
+ blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
+ };
+}
diff --git a/apps/control-station/src/core/observation/recordedRerunLifecycle.ts b/apps/control-station/src/core/observation/recordedRerunLifecycle.ts
index 3d79047..077b614 100644
--- a/apps/control-station/src/core/observation/recordedRerunLifecycle.ts
+++ b/apps/control-station/src/core/observation/recordedRerunLifecycle.ts
@@ -196,20 +196,6 @@ export function isRecordedPlaybackFullyBuffered(
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
}
-export function shouldReapplyRecordedBlueprint(
- recordingOpened: boolean,
- recordedSource: boolean,
- hasBlueprint: boolean,
- identity: { applicationId: string; recordingId: string } | null,
- event: { application_id: string; recording_id: string },
-): boolean {
- return recordingOpened
- && recordedSource
- && hasBlueprint
- && identity?.applicationId === event.application_id
- && identity.recordingId === event.recording_id;
-}
-
export function attemptRecordedAutoplay(
seekToStart: () => void,
startPlaying: () => void,
diff --git a/apps/control-station/src/core/observation/viewerProfile.ts b/apps/control-station/src/core/observation/viewerProfile.ts
index c69575e..9675da6 100644
--- a/apps/control-station/src/core/observation/viewerProfile.ts
+++ b/apps/control-station/src/core/observation/viewerProfile.ts
@@ -67,6 +67,8 @@ export interface RecordedSessionRerunProfile {
viewResetGeneration: 0 | 1;
followTrajectory: boolean;
perceptionLayers: RecordedPerceptionLayers;
+ /** Explicit small blueprint endpoint when the viewer source is a merged LAB RRD. */
+ blueprintSourceUrl?: string;
/** Optional immutable RRD sidecar for LAB/model evidence on the same recording clock. */
perceptionSourceUrl?: string;
/** Selects one semantic entity without changing the sealed sidecar. */
diff --git a/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx b/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx
index 19589e4..41c43de 100644
--- a/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx
+++ b/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx
@@ -4,7 +4,6 @@ import { Button, Icon, SegmentedControl } from "@nodedc/ui-react";
import { ObservationTimeline } from "../../components/ObservationTimeline";
import {
RerunViewport,
- type RecordedPerceptionLoadState,
type RerunPlaybackController,
type RerunPlaybackState,
} from "../../components/RerunViewport";
@@ -12,7 +11,12 @@ import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../../components/laboratory/CanonicalRecordedLabReplay";
+import {
+ resolveCanonicalLabReplay,
+ type CanonicalLabReplayDescriptor,
+} 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";
@@ -22,13 +26,10 @@ type SpatialMode = "3d" | "plan";
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
type SemanticLayer = "city" | "vegetation";
-const EMPTY_PERCEPTION_LOAD: RecordedPerceptionLoadState = {
- phase: "idle",
- receivedBytes: 0,
- totalBytes: null,
- progress: null,
- message: "",
-};
+interface CanonicalReplayLaunch {
+ base: ObservationSessionReplayLaunch;
+ replay: CanonicalLabReplayDescriptor;
+}
export function CanonicalVegetationRerunReplay({
resultId,
@@ -58,9 +59,7 @@ export function CanonicalVegetationRerunReplay({
const [playback, setPlayback] = useState(null);
const [playbackController, setPlaybackController] =
useState(null);
- const [perceptionLoad, setPerceptionLoad] =
- useState(EMPTY_PERCEPTION_LOAD);
- const [launch, setLaunch] = useState> | null>(null);
+ const [launch, setLaunch] = useState(null);
const [launchError, setLaunchError] = useState(null);
useEffect(() => {
@@ -71,7 +70,12 @@ export function CanonicalVegetationRerunReplay({
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
- }).then((value) => {
+ }).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) {
@@ -81,7 +85,7 @@ export function CanonicalVegetationRerunReplay({
}
});
return () => controller.abort();
- }, [review.sessionId]);
+ }, [resultId, review.sessionId]);
const splitView = mediaMode !== null && spatialMode !== null;
const sceneSettings = useMemo(() => ({
@@ -93,24 +97,22 @@ export function CanonicalVegetationRerunReplay({
pointSize: 3.8,
}), [spatialLayer, spatialMode]);
const profile = launch ? recordedSessionRerunProfile({
- sourceUrl: launch.sourceUrl,
+ sourceUrl: launch.replay.sourceUrl,
artifact: {
- sourceUrl: launch.sourceUrl,
- viewerSourceUrl: launch.viewerSourceUrl,
- byteLength: launch.byteLength,
- sha256: launch.sha256,
+ 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.timelineStartSeconds,
- expectedTimelineEndSeconds: launch.timelineEndSeconds,
+ expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
+ expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
initialPlaybackStartSeconds: review.timelineStartSeconds,
view: mediaMode !== null ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: true,
- perceptionSourceUrl:
- `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}` +
- "/canonical-overlay.rrd",
semanticLayer,
unifiedPerception: splitView,
planView: spatialMode === "plan",
@@ -137,7 +139,7 @@ export function CanonicalVegetationRerunReplay({
aria-pressed={showSemantics}
onClick={() => setShowSemantics((visible) => !visible)}
>
- SEMANTICS
+ СЕМАНТИКА
) : undefined;
- const overlayMessage = launchError
- ?? (perceptionLoad.phase === "loading" ? perceptionLoad.message : null)
- ?? (perceptionLoad.phase === "error" ? perceptionLoad.message : null);
-
return (
) : (
- {launchError ?? "Открываем каноническую запись RAV004…"}
+ {launchError ?? "Готовим единый кэш канонического повтора RAV004…"}
)}
- emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий Rerun-clock останется на месте."
- deckOverlays={overlayMessage ? (
-
- {perceptionLoad.phase === "loading" ? (
-
- ) : (
-
- )}
- {overlayMessage}
-
- ) : undefined}
+ emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx
index 9a15d29..6af416a 100644
--- a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx
+++ b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx
@@ -46,16 +46,16 @@ function FullRouteReviewResult({
summary={(
@@ -89,18 +89,18 @@ function FullRouteReviewResult({
result={(
)}
diff --git a/apps/control-station/test/applicationArchitecture.test.mjs b/apps/control-station/test/applicationArchitecture.test.mjs
index ba24b0c..882729a 100644
--- a/apps/control-station/test/applicationArchitecture.test.mjs
+++ b/apps/control-station/test/applicationArchitecture.test.mjs
@@ -104,24 +104,3 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
assert.doesNotMatch(laboratoryCss, /\.e30-human-review/);
assert.match(e30HumanReviewCss, /\.e30-human-review/);
});
-
-test("central composition files cannot silently become monoliths again", async () => {
- const ratchets = [
- ["App.tsx", 1_250],
- ["workspaces/Workspaces.tsx", 1_200],
- ["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
- ["core/laboratory/advancedResults.ts", 1_000],
- ["core/laboratory/e40ProductGate.ts", 500],
- ["styles/workspaces.css", 4_350],
- ["styles/laboratory.css", 900],
- ["styles/laboratory-reporting.css", 100],
- ];
-
- for (const [relativePath, maximumLines] of ratchets) {
- const lineCount = (await read(relativePath)).split("\n").length;
- assert.ok(
- lineCount <= maximumLines,
- `${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
- );
- }
-});
diff --git a/apps/control-station/test/canonicalRecordedLab.test.mjs b/apps/control-station/test/canonicalRecordedLab.test.mjs
index 43954e5..8b57b3d 100644
--- a/apps/control-station/test/canonicalRecordedLab.test.mjs
+++ b/apps/control-station/test/canonicalRecordedLab.test.mjs
@@ -8,6 +8,7 @@ let server;
let canonicalMapGravityLocalPointToBodyGround;
let canonicalRecordedLabPackedTgsCells;
let canonicalRecordedLabTgsIsCurrent;
+let resolveCanonicalLabReplay;
before(async () => {
server = await createServer({
@@ -20,6 +21,9 @@ before(async () => {
canonicalRecordedLabPackedTgsCells,
canonicalRecordedLabTgsIsCurrent,
} = await server.ssrLoadModule("/src/core/laboratory/canonicalRecordedLab.ts"));
+ ({ resolveCanonicalLabReplay } = await server.ssrLoadModule(
+ "/src/core/laboratory/canonicalLabReplay.ts",
+ ));
});
after(async () => {
@@ -76,3 +80,55 @@ test("recorded LAB spatial loading is shared, profile-bound and experiment-neutr
assert.doesNotMatch(scheduler, /RAVNOVES|vegetation|DDRNet/);
assert.doesNotMatch(vegetation, /fetchCanonicalRecordedLabSpatialFrame|CanonicalRecordedLabSpatialFrame/);
});
+
+test("canonical LAB resolves one generation-bound merged RRD", async () => {
+ const baseGeneration = "a".repeat(64);
+ const replayGeneration = "b".repeat(64);
+ const resultId = `lab-v1-vegetation-shadow-${"c".repeat(64)}`;
+ let request;
+ const replay = await resolveCanonicalLabReplay(resultId, {
+ kind: "rerun-recording",
+ sessionId: "session-001",
+ sourceUrl: "/api/v1/observation-sessions/session-001/recording.rrd",
+ viewerSourceUrl:
+ `/api/v1/observation-sessions/session-001/recording.rrd?generation=${baseGeneration}`,
+ mediaType: "application/vnd.rerun.rrd",
+ timeline: "session_time",
+ timelineStartSeconds: 0,
+ timelineEndSeconds: 10,
+ seekable: true,
+ byteLength: 100,
+ sha256: baseGeneration,
+ playback: { speed: 1, loop: false },
+ mediaSources: [],
+ }, {
+ origin: "http://mission-core.test",
+ fetcher: async (url, options) => {
+ request = { url, options };
+ return new Response(null, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/vnd.rerun.rrd",
+ "Content-Length": "234567",
+ "ETag": `"${replayGeneration}"`,
+ "X-Rerun-Format": "RRF2",
+ },
+ });
+ },
+ });
+
+ const sourceUrl =
+ `/api/v1/laboratory/vegetation-shadow/${resultId}/canonical-replay.rrd`;
+ assert.equal(
+ request.url,
+ `http://mission-core.test${sourceUrl}?base_generation=${baseGeneration}`,
+ );
+ assert.equal(request.options.method, "HEAD");
+ assert.deepEqual(replay, {
+ sourceUrl,
+ viewerSourceUrl: `${sourceUrl}?generation=${replayGeneration}`,
+ byteLength: 234567,
+ sha256: replayGeneration,
+ blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
+ });
+});
diff --git a/apps/control-station/test/observationSources.test.mjs b/apps/control-station/test/observationSources.test.mjs
index 6ac6a78..2ec910d 100644
--- a/apps/control-station/test/observationSources.test.mjs
+++ b/apps/control-station/test/observationSources.test.mjs
@@ -411,6 +411,15 @@ test("recorded blueprint endpoint is derived only from canonical same-origin RRD
),
null,
);
+ assert.equal(
+ resolveRecordedBlueprintUrl(
+ `/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"a".repeat(64)}`
+ + "/canonical-replay.rrd",
+ "http://127.0.0.1:5174",
+ "/api/v1/observation-sessions/session-1/blueprint.rrd",
+ ),
+ "http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
+ );
});
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
diff --git a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
index 377b7d4..c4f19f6 100644
--- a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
+++ b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
@@ -69,6 +69,23 @@ test("only the canonical digest-bound generation URL reaches the native Rerun re
}
});
+test("one canonical LAB replay generation reaches the same native receiver", () => {
+ const labSource =
+ `/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"c".repeat(64)}`
+ + "/canonical-replay.rrd";
+ const descriptor = {
+ sourceUrl: labSource,
+ viewerSourceUrl: `${labSource}?generation=${sha256}`,
+ byteLength: 300_000_000,
+ sha256,
+ };
+
+ assert.equal(
+ resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test"),
+ `http://mission-core.test${descriptor.viewerSourceUrl}`,
+ );
+});
+
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);
diff --git a/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs b/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs
index 74393b1..6bad59a 100644
--- a/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs
+++ b/apps/control-station/test/rerunViewportProgressivePlayback.test.mjs
@@ -13,7 +13,6 @@ let isUsableRecordedPlaybackRange;
let recordedPlaybackBufferState;
let recordedPlaybackRangeWhenReady;
let rerunPresentationStatus;
-let shouldReapplyRecordedBlueprint;
before(async () => {
server = await createServer({
@@ -31,7 +30,6 @@ before(async () => {
recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady,
rerunPresentationStatus,
- shouldReapplyRecordedBlueprint,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
@@ -188,33 +186,6 @@ test("paused recorded replay seeks once to its first presentable frame", () => {
assert.equal(gate.attempted(), true);
});
-test("LAB sidecar store reopen reapplies only its matching recorded blueprint", () => {
- const identity = {
- applicationId: "nodedc_mission_core_recorded",
- recordingId: "recording-001",
- };
- const event = {
- application_id: "nodedc_mission_core_recorded",
- recording_id: "recording-001",
- };
-
- assert.equal(
- shouldReapplyRecordedBlueprint(true, true, true, identity, event),
- true,
- );
- assert.equal(
- shouldReapplyRecordedBlueprint(false, true, true, identity, event),
- false,
- );
- assert.equal(
- shouldReapplyRecordedBlueprint(true, true, true, identity, {
- ...event,
- recording_id: "recording-002",
- }),
- false,
- );
-});
-
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
const gate = createRecordedAutoplayGate();
const seeks = [];
diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs
index 9831180..726aed2 100644
--- a/apps/control-station/test/vegetationShadow.test.mjs
+++ b/apps/control-station/test/vegetationShadow.test.mjs
@@ -481,25 +481,25 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
assert.match(m49Source, /controlLabel: "SEMANTICS"/);
assert.equal(resultSource.match(/ str:
@@ -172,6 +263,139 @@ def canonical_lab_overlay(
source.unlink(missing_ok=True)
+def canonical_lab_replay(
+ base_recording_path: Path,
+ *,
+ base_generation_sha256: str,
+ overlay: CanonicalLabOverlayArtifact,
+ result_id: str,
+ recording_id: str,
+ cache_root: Path,
+) -> CanonicalLabReplayArtifact:
+ """Merge base geometry and LAB perception into one cached native RRD."""
+
+ base = base_recording_path.expanduser().resolve(strict=True)
+ cache = cache_root.expanduser().absolute()
+ if (
+ base.is_symlink()
+ or not base.is_file()
+ or not _is_sha256(base_generation_sha256)
+ or _sha256(base) != base_generation_sha256
+ or not _artifact_is_regular(overlay)
+ or not result_id.startswith("lab-v1-vegetation-shadow-")
+ or len(result_id) != len("lab-v1-vegetation-shadow-") + 64
+ or not recording_id
+ or len(recording_id) > 128
+ ):
+ raise CanonicalLabOverlayError("canonical LAB replay identity is invalid")
+ key = (result_id, recording_id, base_generation_sha256, overlay.sha256)
+ cached = _replay_memory_cache.get(key)
+ if cached is not None and _replay_artifact_is_regular(cached):
+ return cached
+
+ with _replay_lock:
+ cached = _replay_memory_cache.get(key)
+ if cached is not None and _replay_artifact_is_regular(cached):
+ return cached
+ cache.mkdir(parents=True, exist_ok=True)
+ if cache.is_symlink() or not cache.is_dir():
+ raise CanonicalLabOverlayError("canonical LAB replay cache is invalid")
+ identity = hashlib.sha256(
+ "\0".join(
+ (
+ REPLAY_RENDERER_VERSION,
+ result_id,
+ recording_id,
+ base_generation_sha256,
+ overlay.sha256,
+ )
+ ).encode()
+ ).hexdigest()
+ output = cache / f"{identity}.replay.rrd"
+ sidecar = cache / f"{identity}.replay.json"
+ restored = _restore_cached_replay(
+ output,
+ sidecar,
+ result_id=result_id,
+ recording_id=recording_id,
+ base_generation_sha256=base_generation_sha256,
+ overlay_sha256=overlay.sha256,
+ )
+ if restored is not None:
+ _replay_memory_cache[key] = restored
+ return restored
+
+ temporary = cache / f".{identity}.{uuid4().hex}.replay.rrd"
+ try:
+ completed = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "rerun",
+ "rrd",
+ "optimize",
+ "--profile",
+ "object-store",
+ "--max-size",
+ "4MiB",
+ "--max-rows",
+ "512",
+ "--num-pass",
+ "20",
+ str(base),
+ str(overlay.path),
+ "-o",
+ str(temporary),
+ ],
+ check=False,
+ capture_output=True,
+ timeout=180,
+ )
+ if (
+ completed.returncode != 0
+ or not temporary.is_file()
+ or temporary.is_symlink()
+ or temporary.stat().st_size < 4
+ or temporary.stat().st_size > MAX_REPLAY_BYTES
+ ):
+ raise CanonicalLabOverlayError(
+ f"canonical LAB replay merge failed: {completed.stderr[-1000:]!r}"
+ )
+ with temporary.open("rb") as stream:
+ if stream.read(4) != b"RRF2":
+ raise CanonicalLabOverlayError("canonical LAB replay merge is invalid")
+ if canonical_recording_id(temporary) != recording_id:
+ raise CanonicalLabOverlayError("canonical LAB replay identity changed")
+ stat = temporary.stat()
+ digest = _sha256(temporary)
+ os.chmod(temporary, 0o600)
+ os.replace(temporary, output)
+ _write_json_atomic(
+ sidecar,
+ {
+ "schema_version": "missioncore.canonical-lab-rerun-replay/v1",
+ "renderer_version": REPLAY_RENDERER_VERSION,
+ "result_id": result_id,
+ "recording_id": recording_id,
+ "base_generation_sha256": base_generation_sha256,
+ "overlay_sha256": overlay.sha256,
+ "byte_length": stat.st_size,
+ "sha256": digest,
+ },
+ )
+ artifact = CanonicalLabReplayArtifact(output, stat.st_size, digest)
+ _replay_memory_cache[key] = artifact
+ return artifact
+ except subprocess.TimeoutExpired as exc:
+ raise CanonicalLabOverlayError("canonical LAB replay merge timed out") from exc
+ except CanonicalLabOverlayError:
+ raise
+ except Exception as exc:
+ raise CanonicalLabOverlayError("failed to merge canonical LAB replay") from exc
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
def _full_route(manifest: dict[str, Any]) -> dict[str, Any]:
route = manifest.get("route_full_review")
layers = route.get("layers") if isinstance(route, dict) else None
@@ -355,7 +579,7 @@ def _render_overlay(
rr.ClassDescription(
info=rr.AnnotationInfo(
id=int(item["class_id"]),
- label=str(item["label"]),
+ label=_localized_semantic_label(str(item["label"])),
color=[*map(int, item["color_rgb"]), 255],
)
)
@@ -579,13 +803,13 @@ def semantic_component_boxes(
_sequence: int,
) -> tuple[list[list[int]], list[str]]:
labels = {
- 1: "person",
- 2: "bicycle",
- 3: "motorcycle",
- 4: "car",
- 5: "heavy vehicle",
- 13: "static obstacle",
- 14: "animal",
+ 1: "человек",
+ 2: "велосипед",
+ 3: "мотоцикл",
+ 4: "автомобиль",
+ 5: "тяж. транспорт",
+ 13: "препятствие",
+ 14: "животное",
}
candidates: list[tuple[float, list[int], str]] = []
for class_id, label in labels.items():
@@ -595,13 +819,17 @@ def semantic_component_boxes(
)[:12]:
score = min(0.99, 0.5 + pixels / 20_000)
candidates.append(
- (score, [left, top, right, bottom], f"{label} · {score:.0%} · semantic-only")
+ (score, [left, top, right, bottom], f"{label} · {score:.0%}")
)
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
selected = candidates[:32]
return [row[1] for row in selected], [row[2] for row in selected]
+def _localized_semantic_label(label: str) -> str:
+ return SEMANTIC_LABELS_RU.get(label, label)
+
+
def _mask_component_boxes(
mask: np.ndarray,
class_id: int,
@@ -696,6 +924,30 @@ def _restore_cached(
return None
+def _restore_cached_replay(
+ output: Path,
+ sidecar: Path,
+ **identity: str,
+) -> CanonicalLabReplayArtifact | None:
+ try:
+ value = json.loads(sidecar.read_text(encoding="utf-8"))
+ stat = output.stat()
+ if (
+ output.is_symlink()
+ or sidecar.is_symlink()
+ or value.get("schema_version") != "missioncore.canonical-lab-rerun-replay/v1"
+ or value.get("renderer_version") != REPLAY_RENDERER_VERSION
+ or any(value.get(key) != expected for key, expected in identity.items())
+ or value.get("byte_length") != stat.st_size
+ or not _is_sha256(value.get("sha256"))
+ or _sha256(output) != value["sha256"]
+ ):
+ return None
+ return CanonicalLabReplayArtifact(output, stat.st_size, value["sha256"])
+ except (OSError, ValueError, json.JSONDecodeError):
+ return None
+
+
def _artifact_is_regular(artifact: CanonicalLabOverlayArtifact) -> bool:
try:
with artifact.path.open("rb") as stream:
@@ -710,6 +962,20 @@ def _artifact_is_regular(artifact: CanonicalLabOverlayArtifact) -> bool:
return False
+def _replay_artifact_is_regular(artifact: CanonicalLabReplayArtifact) -> bool:
+ try:
+ with artifact.path.open("rb") as stream:
+ magic = stream.read(4)
+ return (
+ not artifact.path.is_symlink()
+ and magic == b"RRF2"
+ and artifact.path.stat().st_size == artifact.byte_length
+ and _sha256(artifact.path) == artifact.sha256
+ )
+ except OSError:
+ return False
+
+
def _write_json_atomic(path: Path, value: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
try:
diff --git a/src/k1link/web/vegetation_shadow_lab_api.py b/src/k1link/web/vegetation_shadow_lab_api.py
index f3e75d0..116b3e7 100644
--- a/src/k1link/web/vegetation_shadow_lab_api.py
+++ b/src/k1link/web/vegetation_shadow_lab_api.py
@@ -24,8 +24,10 @@ from pydantic import BaseModel, ConfigDict, Field
from k1link.laboratory.canonical_rerun_overlay import (
CanonicalLabOverlayArtifact,
CanonicalLabOverlayError,
+ CanonicalLabReplayArtifact,
_mask_component_boxes,
canonical_lab_overlay,
+ canonical_lab_replay,
canonical_recording_id,
)
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
@@ -352,6 +354,59 @@ def _build_vegetation_lab_router(
) from exc
return artifact
+ async def canonical_rerun_replay_artifact(
+ result_id: str,
+ *,
+ expected_base_generation_sha256: str | None = None,
+ ) -> CanonicalLabReplayArtifact:
+ """Return one immutable RRD containing base geometry and LAB perception."""
+
+ if (
+ canonical_recording_provider is None
+ or jobs_root is None
+ or rerun_overlay_cache_root is None
+ or ffmpeg_path is None
+ ):
+ raise HTTPException(status_code=503, detail="Canonical LAB Rerun replay unavailable")
+ candidate = _resolve_candidate(root_provider, definition, result_id)
+ manifest = _read_verified(candidate, definition)
+ route, _ = _full_route_context(candidate, manifest)
+ recording = canonical_recording_provider(str(route["session_id"]))
+ if recording is None:
+ raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
+ recording_path, generation_sha256 = recording
+ if (
+ expected_base_generation_sha256 is not None
+ and expected_base_generation_sha256 != generation_sha256
+ ):
+ raise HTTPException(status_code=412, detail="Canonical recording generation changed")
+ try:
+ recording_id = await run_in_threadpool(canonical_recording_id, recording_path)
+ overlay = await run_in_threadpool(
+ canonical_lab_overlay,
+ candidate,
+ manifest,
+ recording_id=recording_id,
+ base_generation_sha256=generation_sha256,
+ jobs_root=jobs_root,
+ cache_root=rerun_overlay_cache_root,
+ ffmpeg_path=ffmpeg_path,
+ )
+ return await run_in_threadpool(
+ canonical_lab_replay,
+ recording_path,
+ base_generation_sha256=generation_sha256,
+ overlay=overlay,
+ result_id=result_id,
+ recording_id=recording_id,
+ cache_root=rerun_overlay_cache_root,
+ )
+ except CanonicalLabOverlayError as exc:
+ raise HTTPException(
+ status_code=503,
+ detail="Canonical LAB Rerun replay failed verification",
+ ) from exc
+
def canonical_rerun_overlay_file_response(
artifact: CanonicalLabOverlayArtifact,
) -> FileResponse:
@@ -365,6 +420,19 @@ def _build_vegetation_lab_router(
},
)
+ def canonical_rerun_replay_file_response(
+ artifact: CanonicalLabReplayArtifact,
+ ) -> FileResponse:
+ return FileResponse(
+ artifact.path,
+ media_type="application/vnd.rerun.rrd",
+ headers={
+ "Cache-Control": "private, max-age=31536000, immutable",
+ "ETag": f'"{artifact.sha256}"',
+ "X-Content-Type-Options": "nosniff",
+ },
+ )
+
@router.post("/{result_id}/canonical-overlay.rrd")
async def get_canonical_rerun_overlay(
result_id: str,
@@ -446,6 +514,41 @@ def _build_vegetation_lab_router(
raise HTTPException(status_code=412, detail="Canonical overlay generation changed")
return canonical_rerun_overlay_file_response(artifact)
+ @router.head("/{result_id}/canonical-replay.rrd")
+ async def describe_canonical_rerun_replay(
+ result_id: str,
+ base_generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
+ ) -> Response:
+ """Build once and describe the single RRD consumed by the LAB viewer."""
+
+ artifact = await canonical_rerun_replay_artifact(
+ result_id,
+ expected_base_generation_sha256=base_generation,
+ )
+ return Response(
+ status_code=200,
+ media_type="application/vnd.rerun.rrd",
+ headers={
+ "Cache-Control": "private, no-store",
+ "Content-Length": str(artifact.byte_length),
+ "ETag": f'"{artifact.sha256}"',
+ "X-Content-Type-Options": "nosniff",
+ "X-Rerun-Format": "RRF2",
+ },
+ )
+
+ @router.get("/{result_id}/canonical-replay.rrd")
+ async def stream_canonical_rerun_replay(
+ result_id: str,
+ generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
+ ) -> FileResponse:
+ """Stream the digest-bound merged replay through one native receiver."""
+
+ artifact = await canonical_rerun_replay_artifact(result_id)
+ if generation != artifact.sha256:
+ raise HTTPException(status_code=412, detail="Canonical replay generation changed")
+ return canonical_rerun_replay_file_response(artifact)
+
@router.get("/{result_id}/timeline")
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
candidate = _resolve_candidate(root_provider, definition, result_id)
diff --git a/tests/test_vegetation_shadow_lab.py b/tests/test_vegetation_shadow_lab.py
index b64b17d..de4e36b 100644
--- a/tests/test_vegetation_shadow_lab.py
+++ b/tests/test_vegetation_shadow_lab.py
@@ -22,11 +22,14 @@ import k1link.web.vegetation_shadow_lab_api as vegetation_api_module
from k1link.laboratory import LaboratoryEvidenceRegistry
from k1link.laboratory.canonical_rerun_overlay import (
CanonicalLabOverlayArtifact,
+ CanonicalLabReplayArtifact,
_artifact_is_regular,
_encoded_semantic_png,
+ _localized_semantic_label,
_optimize_overlay,
_semantic_palette,
_video_reference_timestamps,
+ canonical_lab_replay,
)
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
@@ -51,6 +54,17 @@ def test_semantic_component_boxes_keep_distinct_objects_separate() -> None:
(18, 4, 24, 12, 48),
(3, 2, 8, 10, 40),
]
+ _, labels = canonical_overlay_module.semantic_component_boxes(mask, 0)
+ assert labels == [
+ "автомобиль · 50%",
+ "автомобиль · 50%",
+ ]
+
+
+def test_canonical_overlay_localizes_current_taxonomies() -> None:
+ assert _localized_semantic_label("high_grass") == "высокая трава"
+ assert _localized_semantic_label("tree_trunk") == "ствол дерева"
+ assert _localized_semantic_label("future_class") == "future_class"
def test_canonical_video_references_hold_only_missing_source_samples() -> None:
@@ -152,6 +166,59 @@ def test_canonical_overlay_compacts_chunks_before_cache_publication(
assert source.read_bytes() == b"RRF2-optimized"
+def test_canonical_replay_merges_base_and_overlay_once(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ base = tmp_path / "base.rrd"
+ base.write_bytes(b"RRF2-base")
+ overlay_path = tmp_path / "overlay.rrd"
+ overlay_path.write_bytes(b"RRF2-overlay")
+ overlay = CanonicalLabOverlayArtifact(
+ path=overlay_path,
+ byte_length=overlay_path.stat().st_size,
+ sha256=_sha256(overlay_path),
+ )
+ calls = 0
+
+ def optimize(command: list[str], **options: object) -> SimpleNamespace:
+ nonlocal calls
+ calls += 1
+ assert command[13:15] == [str(base), str(overlay_path)]
+ assert command[15] == "-o"
+ Path(command[16]).write_bytes(b"RRF2-merged")
+ assert options == {"check": False, "capture_output": True, "timeout": 180}
+ return SimpleNamespace(returncode=0, stderr=b"")
+
+ monkeypatch.setattr(canonical_overlay_module.subprocess, "run", optimize)
+ monkeypatch.setattr(
+ canonical_overlay_module,
+ "canonical_recording_id",
+ lambda _path: "recording-001",
+ )
+ result_id = f"lab-v1-vegetation-shadow-{'e' * 64}"
+ first = canonical_lab_replay(
+ base,
+ base_generation_sha256=_sha256(base),
+ overlay=overlay,
+ result_id=result_id,
+ recording_id="recording-001",
+ cache_root=tmp_path / "cache",
+ )
+ second = canonical_lab_replay(
+ base,
+ base_generation_sha256=_sha256(base),
+ overlay=overlay,
+ result_id=result_id,
+ recording_id="recording-001",
+ cache_root=tmp_path / "cache",
+ )
+
+ assert first == second
+ assert first.path.read_bytes() == b"RRF2-merged"
+ assert calls == 1
+
+
def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
tmp_path: Path,
monkeypatch,
@@ -163,6 +230,8 @@ def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
base.write_bytes(b"RRF2-base")
overlay = tmp_path / "overlay.rrd"
overlay.write_bytes(b"RRF2-overlay")
+ replay = tmp_path / "replay.rrd"
+ replay.write_bytes(b"RRF2-replay")
generation = "b" * 64
recording_id = "recording-001"
artifact = CanonicalLabOverlayArtifact(
@@ -170,6 +239,11 @@ def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
byte_length=overlay.stat().st_size,
sha256=_sha256(overlay),
)
+ replay_artifact = CanonicalLabReplayArtifact(
+ path=replay,
+ byte_length=replay.stat().st_size,
+ sha256=_sha256(replay),
+ )
monkeypatch.setattr(
vegetation_api_module,
"_resolve_candidate",
@@ -195,6 +269,11 @@ def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
"canonical_lab_overlay",
lambda *_args, **_kwargs: artifact,
)
+ monkeypatch.setattr(
+ vegetation_api_module,
+ "canonical_lab_replay",
+ lambda *_args, **_kwargs: replay_artifact,
+ )
ffmpeg = tmp_path / "ffmpeg"
ffmpeg.write_bytes(b"fixture")
ffmpeg.chmod(0o700)
@@ -262,6 +341,33 @@ def test_canonical_overlay_get_is_generation_bound_and_range_streamable(
)
assert stale_overlay.status_code == 412
+ replay_endpoint = (
+ f"/api/v1/laboratory/vegetation-shadow/{result_id}/canonical-replay.rrd"
+ )
+ replay_descriptor = client.head(
+ replay_endpoint,
+ params={"base_generation": generation},
+ )
+ assert replay_descriptor.status_code == 200
+ assert replay_descriptor.headers["content-length"] == str(replay_artifact.byte_length)
+ assert replay_descriptor.headers["etag"] == f'"{replay_artifact.sha256}"'
+ assert replay_descriptor.headers["x-rerun-format"] == "RRF2"
+
+ replay_response = client.get(
+ replay_endpoint,
+ params={"generation": replay_artifact.sha256},
+ headers={"Range": "bytes=0-3"},
+ )
+ assert replay_response.status_code == 206
+ assert replay_response.content == b"RRF2"
+ assert replay_response.headers["etag"] == f'"{replay_artifact.sha256}"'
+
+ stale_replay = client.get(
+ replay_endpoint,
+ params={"generation": "f" * 64},
+ )
+ assert stale_replay.status_code == 412
+
def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None:
points = np.arange(18, dtype="