From 757d368c865656eaa0d6b43be7ee7507a7f9611b Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 30 Aug 2026 10:42:41 +0300 Subject: [PATCH] fix(lab): seal RAV004 spatial replay transport --- .../laboratory/RecordedEvidenceVideoScene.tsx | 5 +- .../src/core/laboratory/m4ReplayThreat.ts | 11 +- .../laboratory/M4ReplayThreatVisual.tsx | 4 + .../laboratory/VegetationShadowResult.tsx | 1 + .../laboratory/useM4ThreatTimeline.ts | 16 +- .../test/m4ReplayThreat.test.mjs | 26 +- ...08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md | 56 +++- src/k1link/sessions/canonical_lab_spatial.py | 103 +++++- src/k1link/web/vegetation_shadow_lab_api.py | 292 +++++++++++++++--- tests/test_canonical_lab_spatial.py | 49 ++- tests/test_vegetation_shadow_lab.py | 20 ++ 11 files changed, 492 insertions(+), 91 deletions(-) diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx index aabb3d9..7f228eb 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx @@ -28,6 +28,7 @@ export function RecordedEvidenceVideoScene({ imageWidth, imageHeight, boxes, + overlaySeconds, semanticOverlay, pointCloudOverlay, ariaLabel, @@ -46,6 +47,7 @@ export function RecordedEvidenceVideoScene({ imageWidth: number; imageHeight: number; boxes: readonly RecordedEvidenceBox[]; + overlaySeconds?: number; semanticOverlay?: RecordedEvidenceSemanticOverlay; pointCloudOverlay?: RecordedEvidencePointCloudOverlayData; ariaLabel: string; @@ -77,7 +79,8 @@ export function RecordedEvidenceVideoScene({ const sourceReady = admissionPhase === "ready"; const overlaysPresented = sourceReady && presentedSeconds !== null - && Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25; + && Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25 + && (overlaySeconds === undefined || Math.abs(presentedSeconds - overlaySeconds) <= 0.25); const handlePlaybackChange = (next: RecordedObservationPlayback) => { setPresentedSeconds(next.currentSeconds); // During a paused operator seek the existing media element can emit its old diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index 4537d8b..57e3338 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -238,7 +238,7 @@ export interface M4ThreatPlaybackProgress { export interface M4ThreatPlaybackPointPack { resultId: string; - frameCount: 4489; + frameCount: number; pointCount: number; pointOffsets: Uint32Array; pointsMapXyzM: Float32Array; @@ -261,7 +261,7 @@ export interface M4ThreatPlaybackChunkDescriptor { export interface M4ThreatPlaybackManifest { resultId: string; - frameCount: 4489; + frameCount: number; pointCount: number; pointOffsets: Uint32Array; chunkFrameCount: 24; @@ -341,7 +341,7 @@ const motion = (value: unknown): M4ThreatMotion => { }; const resultId = (value: unknown): string => { const parsed = text(value, "M4.6 result id"); - if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) { + if (!/^[a-z0-9][a-z0-9-]{0,127}-[a-f0-9]{64}$/.test(parsed)) { throw new M4ThreatContractError("M4.6 result id: нарушена идентичность."); } return parsed; @@ -1001,7 +1001,10 @@ export async function fetchM4ThreatPlaybackManifest( exact(manifest.result_id, result, "M4.6 playback result"); exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame"); exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access"); - const frameCount = exact(integer(manifest.frame_count, "M4.6 playback frames"), 4489, "M4.6 playback frames"); + const frameCount = integer(manifest.frame_count, "M4.6 playback frames"); + if (frameCount < 1) { + throw new M4ThreatContractError("M4.6 playback frames: пустой playback недопустим."); + } const pointCount = integer(manifest.point_count, "M4.6 playback points"); const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets"); if (offsetsRaw.length !== frameCount + 1) { diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 950b2c1..d408515 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -180,6 +180,7 @@ export function M4ReplayThreatVisual({ showReferenceMediaLayers = true, showSpatialOverlaySummary = true, playbackTransport = "epoch-stream", + spatialPlaybackTransport = "auto", recoverTimestampStalls = false, onActiveSequenceChange, }: { @@ -198,6 +199,7 @@ export function M4ReplayThreatVisual({ showReferenceMediaLayers?: boolean; showSpatialOverlaySummary?: boolean; playbackTransport?: "segmented" | "epoch-stream"; + spatialPlaybackTransport?: "auto" | "sealed-binary" | "json"; recoverTimestampStalls?: boolean; onActiveSequenceChange?: (sequence: number | null) => void; }) { @@ -293,6 +295,7 @@ export function M4ReplayThreatVisual({ currentSeconds: playbackController.playback.currentSeconds, includeSpatialPoints: evidenceDemand.sourceSpatialPoints, endpointRoot: timelineEndpointRoot, + spatialPlaybackTransport, }); const [videoSource, setVideoSource] = useState(null); const [videoLoading, setVideoLoading] = useState(false); @@ -1143,6 +1146,7 @@ export function M4ReplayThreatVisual({ imageWidth={timeline.imageWidth} imageHeight={timeline.imageHeight} boxes={activeBoxes} + overlaySeconds={frame?.sessionSeconds} semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined} pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined} ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx index 1e058e4..64f6219 100644 --- a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx @@ -80,6 +80,7 @@ function FullRouteReviewEvidence({ classifiedSpatialLayer={sealedSpatialGap} evidenceLabel="RAVNOVES004TREE" playbackTransport="segmented" + spatialPlaybackTransport="sealed-binary" recoverTimestampStalls showReferenceMediaLayers showSpatialOverlaySummary diff --git a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts index 0d6f08b..c60be90 100644 --- a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts @@ -34,14 +34,14 @@ export function m4ThreatChunkWindowStarts( if (chunkSize < 1 || frameCount < 1) return []; return [ activeChunkStart, - ...Array.from( - { length: RETAINED_CHUNKS_BEHIND }, - (_, index) => activeChunkStart - (index + 1) * chunkSize, - ), ...Array.from( { length: PREFETCH_CHUNKS_AHEAD }, (_, index) => activeChunkStart + (index + 1) * chunkSize, ), + ...Array.from( + { length: RETAINED_CHUNKS_BEHIND }, + (_, index) => activeChunkStart - (index + 1) * chunkSize, + ), ].filter((start) => start >= 0 && start < frameCount); } @@ -86,12 +86,14 @@ export function useM4ThreatTimelineFrame({ currentSeconds, includeSpatialPoints = true, endpointRoot, + spatialPlaybackTransport = "auto", }: { resultId: string; timeline: M4ThreatTimeline | null; currentSeconds: number; includeSpatialPoints?: boolean; endpointRoot?: string; + spatialPlaybackTransport?: "auto" | "sealed-binary" | "json"; }) { const [chunks, setChunks] = useState>( () => new Map(), @@ -104,8 +106,10 @@ export function useM4ThreatTimelineFrame({ totalBytes: 0, }); const [playbackError, setPlaybackError] = useState(null); - const binaryPlayback = endpointRoot === undefined - || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT; + const binaryPlayback = spatialPlaybackTransport === "sealed-binary" + || (spatialPlaybackTransport === "auto" && ( + endpointRoot === undefined || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT + )); const inFlight = useRef(new Map()); const chunksRef = useRef(chunks); const activeChunkStartRef = useRef(null); diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index c54aa3b..d31aa8f 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -379,14 +379,16 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point }); test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => { - const pointOffsets = [0, ...Array(4489).fill(2)]; + const playbackResultId = `lab-v1-vegetation-shadow-${"b".repeat(64)}`; + const frameCount = 48; + const pointOffsets = [0, ...Array(frameCount).fill(2)]; const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]); const pointBytes = points.buffer; const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex"); const endpointRoot = "/api/v1/laboratory/m4-threat/results"; - const chunks = Array.from({ length: 188 }, (_, index) => { + const chunks = Array.from({ length: Math.ceil(frameCount / 24) }, (_, index) => { const start = index * 24; - const count = Math.min(24, 4489 - start); + const count = Math.min(24, frameCount - start); const pointStart = pointOffsets[start]; const pointStop = pointOffsets[start + count]; const pointCount = pointStop - pointStart; @@ -396,7 +398,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 count, point_start: pointStart, point_count: pointCount, - url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`, + url: `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/${index}`, media_type: "application/octet-stream", dtype: " { - assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 24, 72]); + assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72, 24]); assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]); }); @@ -787,7 +789,7 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps", } } assert.deepEqual([...inFlight.keys()], [4488, 4464]); - assert.deepEqual(aborted, [0, 24, 1488, 1464, 1512]); + assert.deepEqual(aborted, [0, 24, 1488, 1512, 1464]); }); test("recorded evidence clock advances by selected rate and stops at the sealed end", () => { diff --git a/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md b/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md index 18e17d6..1af5075 100644 --- a/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md +++ b/docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_REPLAY_AUDIT.md @@ -22,6 +22,29 @@ Window structure, switching, seek, buffering and spatial scene code are shared. ## Why the previous LAB failed +### RAV004 did not use the accepted replay data plane + +The working M4/Hologravity LAB uses sealed binary numeric tracks, retained scene +state and bounded JSON metadata. RAV004 reused the visual component but silently +left its custom timeline endpoint on the JSON fallback. Each eight-frame spatial +window therefore transferred approximately 1.2-2.9 MB and took 0.87-1.37 s to +produce while representing only about 0.84 s of playback. The next request +aborted and replaced the previous one before spatial state could catch up. + +The user screenshot captured the failure exactly: camera frame 366 was active +while the last delivered spatial evidence was frame 230, a 136-frame gap. The +backend also reopened and indexed the 6830-entry semantic ZIP for every requested +mask and proposal frame. This explains why the same canonical viewer was smooth +for Hologravity but stalled for RAV004: the window and interaction code were +shared, but the data-plane contract was not. + +RAV004 now publishes the same `missioncore.recorded-spatial-playback/v1` +contract as the accepted LAB: an immutable Float32 map-point track, camera-frame +offsets and 24-frame binary chunks. JSON chunks contain bounded frame metadata +only; retained Local SLAM is reconstructed from exact source increments in the +shared client. The semantic ZIP handle and member index are cached per immutable +artifact instead of being reparsed per frame. + ### Video and spatial state had different clocks The removed RAV004 viewer advanced an animation/host clock even when the browser @@ -77,8 +100,11 @@ dozens of seconds old. The buffer now loads the active chunk first, the precedin chunk second and the next chunk as prefetch. The scene selects the latest proven source increment whose sequence is not later than the active camera frame. -At the final UI check, camera frame 189 causally held spatial frame 184. Before -the fix the same point could hold frame 16. +At the final UI check, playback restarted at frame 1, then ran continuously past +frame 462. At frame 188 the DDRNet mask was frame 188, proposals were frame 187 +and spatial state was delivered without buffering; the one-frame proposal delay +is the recorded causal overlay, not stale UI state. Before the transport fix the +user's run had already fallen 136 frames behind by camera frame 366. ## Capability ledger @@ -104,12 +130,17 @@ full-TGS result is also not reused because it has a different source identity an Measured on the canonical local service and current immutable artifacts: - replay launch POST: 3.55 s on first opening; -- timeline metadata: 0.02 s warm; -- active spatial chunk, eight camera frames: 35.17 s first process-local RRD - index build, 0.67 s warm, approximately 3.81 MB; -- UI replay: passed the previously deterministic 11.422 s decoder stop, then - continued to 59 s with media and timeline advancing together; -- operator reset seek: 16.4 s to 0 s, one mounted media worker, successful; +- first binary playback-manifest build after a service restart: 32.33 s while + the process-local RRD point track is materialized; warm manifest: 0.18-0.20 s; +- full retained point track: 3,893,445 Float32 map points, 46,721,340 bytes, + divided into 285 immutable 24-frame chunks; +- representative active binary chunks: 155-224 KB at 9-10 ms; +- representative 24-frame metadata chunks: 24-32 KB at 0.78-1.0 s, covering + about 2.4 s of playback; +- cached semantic-mask reads: 5.6-7.8 ms instead of approximately 80 ms; +- UI replay: reset to frame 1 and ran continuously beyond frame 462 with camera, + semantic overlay and retained spatial state advancing together; +- operator reset seek: successful, one mounted media worker; - browser console after the acceptance run: no warnings or errors. The first RRD index is still process-local rather than a persistent disk cache. @@ -147,13 +178,14 @@ deploy it. ## Acceptance performed -- 44 focused backend tests passed; -- 37 frontend replay, buffering and LAB contract tests passed; +- 12 focused backend spatial/API tests passed; +- 16 focused frontend replay transport/manifest tests passed; - TypeScript project typecheck passed; - production Vite build passed (only existing large-chunk warnings); - `git diff --check` passed; -- live browser run verified the shared controls, disabled unsealed TGS/3D - semantics, continuous media recovery, causal spatial hold and clean console. +- live browser run verified reset seek, the shared controls, disabled unsealed + TGS/3D semantics, continuous playback through the former failing interval, + causal spatial hold and a clean console. Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`. diff --git a/src/k1link/sessions/canonical_lab_spatial.py b/src/k1link/sessions/canonical_lab_spatial.py index 50a7c12..54ad181 100644 --- a/src/k1link/sessions/canonical_lab_spatial.py +++ b/src/k1link/sessions/canonical_lab_spatial.py @@ -75,7 +75,13 @@ def _session_times(batch: Any) -> Any | None: return batch.column("session_time") -def _point_rows(chunks: list[Any], entity: str, component: str, *, nested: bool = False) -> _TimedPoints: +def _point_rows( + chunks: list[Any], + entity: str, + component: str, + *, + nested: bool = False, +) -> _TimedPoints: rows: list[tuple[int, np.ndarray]] = [] for chunk in chunks: if chunk.entity_path != entity: @@ -436,6 +442,8 @@ def _bounded_local_slam( def _canonical_lab_spatial_frame_from_index( index: _CanonicalSpatialIndex, target_time_ns: int, + *, + include_local_slam: bool = True, ) -> dict[str, object]: point_index = _latest_index(index.points.times_ns, target_time_ns) pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index]) @@ -462,12 +470,17 @@ def _canonical_lab_spatial_frame_from_index( ground_origin, basis_map_from_body, ) - local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam( - index.points, - index.points.times_ns[point_index], - ground_origin, - basis_map_from_body, - ) + if include_local_slam: + local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam( + index.points, + index.points.times_ns[point_index], + ground_origin, + basis_map_from_body, + ) + else: + local_slam = np.empty((0, 3), dtype=np.float32) + local_slam_source_frames = 0 + local_slam_source_points = 0 return { "schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3", "target_time_ns": target_time_ns, @@ -531,6 +544,8 @@ def canonical_lab_spatial_timeline_samples( frame_times_ns: tuple[int, ...], start_sequence: int, frame_count: int, + *, + include_local_slam: bool = True, ) -> tuple[dict[str, object] | None, ...]: """Project only new source increments onto a denser camera timeline. @@ -545,7 +560,10 @@ def canonical_lab_spatial_timeline_samples( start_sequence < 0 or frame_count < 1 or start_sequence >= len(frame_times_ns) - or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:])) + or any( + current <= previous + for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False) + ) ): raise ValueError("Recorded LAB timeline sample request is invalid") stat = recording_path.stat() @@ -566,8 +584,75 @@ def canonical_lab_spatial_timeline_samples( else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1]) ) samples.append( - _canonical_lab_spatial_frame_from_index(index, target_time_ns) + _canonical_lab_spatial_frame_from_index( + index, + target_time_ns, + include_local_slam=include_local_slam, + ) if point_index != previous_point_index else None ) return tuple(samples) + + +@lru_cache(maxsize=2) +def _canonical_lab_spatial_playback_points_cached( + recording_path_text: str, + recording_size: int, + recording_mtime_ns: int, + generation_sha256: str, + frame_times_ns: tuple[int, ...], +) -> tuple[np.ndarray, tuple[int, ...]]: + del recording_size, recording_mtime_ns + recording_path = Path(recording_path_text) + stat = recording_path.stat() + index = _load_index( + str(recording_path), + stat.st_size, + stat.st_mtime_ns, + generation_sha256, + ) + increments: list[np.ndarray] = [] + offsets = [0] + point_count = 0 + previous_point_index = -1 + for target_time_ns in frame_times_ns: + point_index = _latest_index(index.points.times_ns, target_time_ns) + if point_index != previous_point_index: + increment = np.ascontiguousarray(index.points.values[point_index], dtype=" tuple[np.ndarray, tuple[int, ...]]: + """Return one retained map-coordinate point track for a camera timeline.""" + + if ( + not frame_times_ns + or any( + current <= previous + for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False) + ) + ): + raise ValueError("Recorded LAB playback timeline is invalid") + stat = recording_path.stat() + return _canonical_lab_spatial_playback_points_cached( + str(recording_path), + stat.st_size, + stat.st_mtime_ns, + generation_sha256, + frame_times_ns, + ) diff --git a/src/k1link/web/vegetation_shadow_lab_api.py b/src/k1link/web/vegetation_shadow_lab_api.py index 2d8459a..a6a38b3 100644 --- a/src/k1link/web/vegetation_shadow_lab_api.py +++ b/src/k1link/web/vegetation_shadow_lab_api.py @@ -26,13 +26,16 @@ from k1link.laboratory.evidence_report import ( ) from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA from k1link.sessions import RecordedCameraFrame, SessionIntegrityError -from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_timeline_samples +from k1link.sessions.canonical_lab_spatial import ( + canonical_lab_spatial_playback_points, + canonical_lab_spatial_timeline_samples, +) RootProvider = Callable[[], Path | None] CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None] CameraFrameProvider = Callable[[str, int], RecordedCameraFrame] _MAX_DOCUMENT_BYTES: Final = 1024 * 1024 -_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 8 +_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 24 _DEFINITION: Final = LaboratoryEvidenceDefinition( work_id="lab-v1-vegetation-shadow", runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"), @@ -167,19 +170,7 @@ def _build_vegetation_lab_router( archive_path = candidate.joinpath(*relative.parts) member = f"masks/frame-{sequence + 1:06d}.png" try: - before = archive_path.stat() - with zipfile.ZipFile(archive_path) as frozen: - info = frozen.getinfo(member) - if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024: - raise ValueError("Vegetation video mask member is invalid") - payload = frozen.read(info) - after = archive_path.stat() - if ( - before.st_size != after.st_size - or before.st_mtime_ns != after.st_mtime_ns - or len(payload) != info.file_size - ): - raise ValueError("Vegetation video mask archive changed during read") + payload = _read_cached_mask_member(archive_path, member) except (KeyError, OSError, ValueError, zipfile.BadZipFile): raise HTTPException( status_code=503, @@ -286,7 +277,7 @@ def _build_vegetation_lab_router( route, frame_times_ns = _full_route_context(candidate, manifest) intervals = [ (current - previous) / 1_000_000_000 - for previous, current in zip(frame_times_ns, frame_times_ns[1:]) + for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False) ] nominal_interval = statistics.median(intervals) if not math.isfinite(nominal_interval) or nominal_interval <= 0: @@ -350,7 +341,10 @@ def _build_vegetation_lab_router( if start >= len(frame_times_ns): raise HTTPException(status_code=404, detail="Full-route timeline chunk not found") if canonical_recording_provider is None: - raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable") + raise HTTPException( + status_code=503, + detail="Canonical spatial recording is unavailable", + ) recording = canonical_recording_provider(str(route["session_id"])) if recording is None: raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready") @@ -362,6 +356,7 @@ def _build_vegetation_lab_router( frame_times_ns, start, count, + include_local_slam=False, ) except (OSError, ValueError): raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None @@ -391,6 +386,98 @@ def _build_vegetation_lab_router( "access": "read-only-bounded-recorded-replay", } + @router.get("/{result_id}/timeline/playback") + def get_canonical_route_timeline_playback(result_id: str) -> dict[str, object]: + candidate = _resolve_candidate(root_provider, definition, result_id) + manifest = _read_verified(candidate, definition) + route, frame_times_ns = _full_route_context(candidate, manifest) + points, offsets = _canonical_route_playback( + canonical_recording_provider, + route, + frame_times_ns, + ) + points_view = memoryview(points).cast("B") + return { + "schema_version": "missioncore.recorded-spatial-playback/v1", + "result_id": result_id, + "frame_count": len(frame_times_ns), + "point_count": int(points.shape[0]), + "point_offsets": list(offsets), + "chunk_frame_count": _CANONICAL_ROUTE_CHUNK_FRAMES, + "resident_chunk_count_max": 4, + "forward_prefetch_chunk_count": 1, + "chunks": _canonical_route_playback_chunk_catalog( + prefix, + result_id, + points_view, + offsets, + ), + "track": { + "id": "points-map-f32", + "url": f"{prefix}/{result_id}/timeline/playback/tracks/points-map-f32", + "media_type": "application/octet-stream", + "dtype": " Response: + candidate = _resolve_candidate(root_provider, definition, result_id) + manifest = _read_verified(candidate, definition) + route, frame_times_ns = _full_route_context(candidate, manifest) + points, offsets = _canonical_route_playback( + canonical_recording_provider, + route, + frame_times_ns, + ) + points_view = memoryview(points).cast("B") + descriptor = _canonical_route_playback_chunk_descriptor( + prefix, + result_id, + points_view, + offsets, + chunk_index, + ) + if descriptor is None: + raise HTTPException(status_code=404, detail="Full-route playback chunk not found") + point_start = int(descriptor["point_start"]) + byte_length = int(descriptor["bytes"]) + byte_start = point_start * 3 * 4 + payload = bytes(points_view[byte_start : byte_start + byte_length]) + return Response( + content=payload, + media_type="application/octet-stream", + headers=_immutable_binary_headers(byte_length, str(descriptor["sha256"])), + ) + + @router.get("/{result_id}/timeline/playback/tracks/points-map-f32") + def get_canonical_route_timeline_playback_track(result_id: str) -> Response: + candidate = _resolve_candidate(root_provider, definition, result_id) + manifest = _read_verified(candidate, definition) + route, frame_times_ns = _full_route_context(candidate, manifest) + points, _ = _canonical_route_playback( + canonical_recording_provider, + route, + frame_times_ns, + ) + payload = memoryview(points).cast("B") + digest = hashlib.sha256(payload).hexdigest() + return Response( + content=bytes(payload), + media_type="application/octet-stream", + headers=_immutable_binary_headers(payload.nbytes, digest), + ) + @router.get("/{result_id}/timeline/frames/{sequence}/camera") def get_canonical_route_camera(result_id: str, sequence: int) -> Response: if camera_frame_provider is None: @@ -403,7 +490,10 @@ def _build_vegetation_lab_router( try: camera = camera_frame_provider(str(route["session_id"]), sequence) except (OSError, SessionIntegrityError, ValueError): - raise HTTPException(status_code=503, detail="Full-route camera frame unavailable") from None + raise HTTPException( + status_code=503, + detail="Full-route camera frame unavailable", + ) from None if camera.width != route["width"] or camera.height != route["height"]: raise HTTPException(status_code=503, detail="Full-route camera dimensions changed") return Response( @@ -495,15 +585,111 @@ def _full_route_context( values = np.frombuffer(payload, dtype=" tuple[np.ndarray, tuple[int, ...]]: + if provider is None: + raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable") + 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 + try: + return canonical_lab_spatial_playback_points( + recording_path, + generation_sha256, + frame_times_ns, + ) + except (OSError, ValueError): + raise HTTPException(status_code=503, detail="Canonical spatial playback failed") from None + + +def _canonical_route_playback_chunk_descriptor( + endpoint_prefix: str, + result_id: str, + points_view: memoryview, + offsets: tuple[int, ...], + chunk_index: int, +) -> dict[str, object] | None: + frame_count = len(offsets) - 1 + start = chunk_index * _CANONICAL_ROUTE_CHUNK_FRAMES + if chunk_index < 0 or start >= frame_count: + return None + count = min(_CANONICAL_ROUTE_CHUNK_FRAMES, frame_count - start) + point_start = offsets[start] + point_stop = offsets[start + count] + byte_start = point_start * 3 * 4 + byte_stop = point_stop * 3 * 4 + payload = points_view[byte_start:byte_stop] + return { + "index": chunk_index, + "start": start, + "count": count, + "point_start": point_start, + "point_count": point_stop - point_start, + "url": f"{endpoint_prefix}/{result_id}/timeline/playback/chunks/{chunk_index}", + "media_type": "application/octet-stream", + "dtype": " list[dict[str, object]]: + frame_count = len(offsets) - 1 + chunk_count = ( + frame_count + _CANONICAL_ROUTE_CHUNK_FRAMES - 1 + ) // _CANONICAL_ROUTE_CHUNK_FRAMES + return [ + descriptor + for chunk_index in range(chunk_count) + if ( + descriptor := _canonical_route_playback_chunk_descriptor( + endpoint_prefix, + result_id, + points_view, + offsets, + chunk_index, + ) + ) + is not None + ] + + +def _immutable_binary_headers(byte_length: int, sha256: str) -> dict[str, str]: + return { + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Encoding": "identity", + "Content-Length": str(byte_length), + "ETag": f'"{sha256}"', + "X-Content-Type-Options": "nosniff", + "X-Uncompressed-Content-Length": str(byte_length), + } + + def _canonical_timeline_frame( *, result_id: str, @@ -518,7 +704,6 @@ def _canonical_timeline_frame( points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"] point_count = 0 if spatial is None else int(spatial["source_point_count"]) body_frame = None if spatial is None else spatial["body_frame"] - local_slam = [] if spatial is None else spatial["local_slam_body_xyz_m"] return { "schema_version": "missioncore.recorded-spatial-evidence-frame/v1", "sequence": sequence, @@ -534,11 +719,6 @@ def _canonical_timeline_frame( "point_cloud_source_count": point_count, "point_cloud_sample_count": point_count if not include_points else len(points), "point_cloud_layer": "current-increment", - "local_slam_body_xyz_m": local_slam, - "local_slam_source_frame_count": 0 - if spatial is None else spatial["local_slam_source_frame_count"], - "local_slam_source_point_count": 0 - if spatial is None else spatial["local_slam_source_point_count"], "rolling_map_component_count": 0, "metric_obstacles": [], "camera_proposals": _semantic_component_proposals(candidate, route, sequence), @@ -585,12 +765,14 @@ def _semantic_component_proposals_cached( archive_mtime_ns: int, sequence: int, ) -> tuple[dict[str, object], ...]: - del archive_size, archive_mtime_ns - archive_path = Path(archive_path_text) member = f"masks/frame-{sequence + 1:06d}.png" try: - with zipfile.ZipFile(archive_path) as frozen: - payload = frozen.read(member) + frozen = _cached_zip_archive( + archive_path_text, + archive_size, + archive_mtime_ns, + ) + payload = frozen.read(member) with Image.open(io.BytesIO(payload)) as image: mask = np.asarray(image.convert("L"), dtype=np.uint8) except (KeyError, OSError, ValueError, zipfile.BadZipFile): @@ -733,7 +915,10 @@ def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, obj selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32) selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8) selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32) - if not np.isfinite(selected_points).all() or not np.isin(selected_states, [0, 1, 2, 3]).all(): + if ( + not np.isfinite(selected_points).all() + or not np.isin(selected_states, [0, 1, 2, 3]).all() + ): raise ValueError("Route TGS payload changed") result = { "schema_version": "missioncore.lab-v1-route-tgs-anchor/v1", @@ -762,19 +947,7 @@ def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, obj def _zip_mask_response(archive_path: Path, sequence: int) -> Response: member = f"masks/frame-{sequence + 1:06d}.png" try: - before = archive_path.stat() - with zipfile.ZipFile(archive_path) as frozen: - info = frozen.getinfo(member) - if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024: - raise ValueError("Semantic mask member is invalid") - payload = frozen.read(info) - after = archive_path.stat() - if ( - before.st_size != after.st_size - or before.st_mtime_ns != after.st_mtime_ns - or len(payload) != info.file_size - ): - raise ValueError("Semantic mask archive changed during read") + payload = _read_cached_mask_member(archive_path, member) except (KeyError, OSError, ValueError, zipfile.BadZipFile): raise HTTPException( status_code=503, @@ -792,6 +965,37 @@ def _zip_mask_response(archive_path: Path, sequence: int) -> Response: ) +@lru_cache(maxsize=8) +def _cached_zip_archive( + archive_path_text: str, + archive_size: int, + archive_mtime_ns: int, +) -> zipfile.ZipFile: + del archive_size, archive_mtime_ns + return zipfile.ZipFile(archive_path_text) + + +def _read_cached_mask_member(archive_path: Path, member: str) -> bytes: + before = archive_path.stat() + frozen = _cached_zip_archive( + str(archive_path), + before.st_size, + before.st_mtime_ns, + ) + info = frozen.getinfo(member) + if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024: + raise ValueError("Semantic mask member is invalid") + payload = frozen.read(info) + after = archive_path.stat() + if ( + before.st_size != after.st_size + or before.st_mtime_ns != after.st_mtime_ns + or len(payload) != info.file_size + ): + raise ValueError("Semantic mask archive changed during read") + return payload + + def _configured_root(provider: RootProvider) -> Path | None: candidate = provider() if candidate is None: diff --git a/tests/test_canonical_lab_spatial.py b/tests/test_canonical_lab_spatial.py index 73085b0..efddced 100644 --- a/tests/test_canonical_lab_spatial.py +++ b/tests/test_canonical_lab_spatial.py @@ -3,14 +3,17 @@ from __future__ import annotations import numpy as np import pytest +import k1link.sessions.canonical_lab_spatial as spatial_module from k1link.sessions.canonical_lab_spatial import ( - _TimedPoints, - _TimedPoses, _bounded_local_slam, - _estimate_sensor_height, + _CanonicalSpatialIndex, _estimate_local_sensor_height, + _estimate_sensor_height, _gravity_stable_basis_map_from_body, _ground_origin_map, + _TimedPoints, + _TimedPoses, + canonical_lab_spatial_playback_points, ) @@ -127,3 +130,43 @@ def test_sensor_height_tracks_current_source_window_instead_of_fixed_mount() -> assert high == pytest.approx(1.05, abs=0.03) assert low_samples >= 3 and high_samples >= 3 assert low_source == high_source == "local-source-cloud-ground-quantile-median" + + +def test_playback_track_binds_sparse_map_increments_to_dense_camera_timeline( + tmp_path, + monkeypatch, +) -> None: + recording = tmp_path / "recording.rrd" + recording.write_bytes(b"sealed") + points = _TimedPoints( + times_ns=(10, 20), + values=( + np.asarray([[1.0, 2.0, 3.0]], dtype=np.float32), + np.asarray([[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], dtype=np.float32), + ), + ) + empty_poses = _TimedPoses(times_ns=(), translations=(), quaternions_xyzw=()) + index = _CanonicalSpatialIndex( + points=points, + poses=empty_poses, + trajectories=_TimedPoints(times_ns=(), values=()), + sensor_height_m=0.4, + sensor_height_sample_count=0, + sensor_height_mad_m=0.0, + ) + monkeypatch.setattr(spatial_module, "_load_index", lambda *_args: index) + + track, offsets = canonical_lab_spatial_playback_points( + recording, + "a" * 64, + (10, 15, 20, 25), + ) + + assert offsets == (0, 1, 1, 3, 3) + assert track.tolist() == [ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + ] + assert track.dtype == np.dtype(" None: ] +def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None: + points = np.arange(18, dtype=" None: path = tmp_path / "tgs-evidence.npz" point_counts = np.arange(1, 11, dtype=np.int64)