From 81fdf6904a9be469ec733b28d470bc8b74133008 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 30 Aug 2026 16:40:12 +0300 Subject: [PATCH] =?UTF-8?q?refactor(lab):=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20RAV004=20=D0=B2=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B=D0=B9=20Rerun=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/RerunViewport.tsx | 51 +-- .../laboratory/CanonicalRecordedLabReplay.tsx | 2 +- .../src/core/laboratory/canonicalLabReplay.ts | 76 +++++ .../observation/recordedRerunLifecycle.ts | 14 - .../src/core/observation/viewerProfile.ts | 2 + .../CanonicalVegetationRerunReplay.tsx | 85 +++-- .../laboratory/VegetationShadowResult.tsx | 36 +-- .../test/applicationArchitecture.test.mjs | 21 -- .../test/canonicalRecordedLab.test.mjs | 56 ++++ .../test/observationSources.test.mjs | 9 + .../rerunViewportAtomicAdmission.test.mjs | 17 + .../rerunViewportProgressivePlayback.test.mjs | 29 -- .../test/vegetationShadow.test.mjs | 14 +- ...16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md | 3 +- docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md | 22 +- .../laboratory/canonical_rerun_overlay.py | 290 +++++++++++++++++- src/k1link/web/vegetation_shadow_lab_api.py | 103 +++++++ tests/test_vegetation_shadow_lab.py | 106 +++++++ 18 files changed, 750 insertions(+), 186 deletions(-) create mode 100644 apps/control-station/src/core/laboratory/canonicalLabReplay.ts 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" ? ( -
- ) : 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="