diff --git a/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts b/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts index be1fd60..82eb56e 100644 --- a/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts +++ b/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts @@ -84,6 +84,48 @@ export interface M49TgsFullShadowSpatialChunk { frames: readonly M49TgsFullShadowSpatial[]; } +export type M49TgsFullShadowPlaybackTrackId = + | "frames" + | "centers" + | "states" + | "z-bounds"; + +export interface M49TgsFullShadowPlaybackProgress { + phase: "manifest" | "download" | "verify" | "ready"; + trackId: M49TgsFullShadowPlaybackTrackId | null; + loadedBytes: number; + totalBytes: number; +} + +interface M49TgsFullShadowPlaybackTrack { + id: M49TgsFullShadowPlaybackTrackId; + url: string; + mediaType: string; + dtype: "ndjson" | " { @@ -335,3 +377,285 @@ export async function fetchM49TgsFullShadowSpatialChunk( )), }; } + +function playbackTrackId(value: unknown, label: string): M49TgsFullShadowPlaybackTrackId { + const parsed = text(value, label); + if (parsed !== "frames" && parsed !== "centers" && parsed !== "states" && parsed !== "z-bounds") { + throw new M49TgsFullShadowContractError(`${label}: неизвестная playback-дорожка.`); + } + return parsed; +} + +function playbackDtype(value: unknown, label: string): M49TgsFullShadowPlaybackTrack["dtype"] { + const parsed = text(value, label); + if (parsed !== "ndjson" && parsed !== " integer(item, `${label}.${index}`)); +} + +async function fetchPlaybackManifest( + id: string, + fetcher: LaboratoryFetch, + signal: AbortSignal | undefined, +): Promise<{ + frameCount: 4489; + cellCount: 2244; + totalByteLength: number; + tracks: ReadonlyMap; +}> { + const response = await fetcher( + `/api/v1/laboratory/m49/tgs-full-shadow/${encodeURIComponent(id)}/playback/manifest`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new M49TgsFullShadowContractError(`M49 playback manifest недоступен: HTTP ${response.status}.`); + } + const payload = objectValue(await response.json(), "M49 playback manifest"); + exact(payload.schema_version, "missioncore.m49-tgs-full-shadow-playback/v1", "M49 playback schema"); + exact(payload.result_id, id, "M49 playback result"); + exact(payload.coordinate_frame, "map-gravity-local", "M49 playback frame"); + exact(payload.access, "read-only", "M49 playback access"); + const frameCount = exact(integer(payload.frame_count, "M49 playback frames"), 4489, "M49 playback frames"); + const cellCount = exact(integer(payload.cell_count, "M49 playback cells"), 2244, "M49 playback cells"); + const totalByteLength = integer(payload.total_byte_length, "M49 playback bytes"); + if (!Array.isArray(payload.tracks) || payload.tracks.length !== 4) { + throw new M49TgsFullShadowContractError("M49 playback tracks: неверная размерность."); + } + const tracks = new Map(); + for (const [index, value] of payload.tracks.entries()) { + const row = objectValue(value, `M49 playback track ${index}`); + const trackId = playbackTrackId(row.id, `M49 playback track ${index}.id`); + const sha256 = text(row.sha256, `M49 playback track ${trackId}.sha256`); + if (!/^[a-f0-9]{64}$/.test(sha256) || tracks.has(trackId)) { + throw new M49TgsFullShadowContractError(`M49 playback track ${trackId}: нарушена идентичность.`); + } + tracks.set(trackId, { + id: trackId, + url: text(row.url, `M49 playback track ${trackId}.url`), + mediaType: text(row.media_type, `M49 playback track ${trackId}.media_type`), + dtype: playbackDtype(row.dtype, `M49 playback track ${trackId}.dtype`), + shape: playbackShape(row.shape, `M49 playback track ${trackId}.shape`), + byteLength: integer(row.byte_length, `M49 playback track ${trackId}.byte_length`), + sha256, + }); + } + if ([...tracks.values()].reduce((sum, track) => sum + track.byteLength, 0) !== totalByteLength) { + throw new M49TgsFullShadowContractError("M49 playback bytes: нарушен accounting."); + } + return { frameCount, cellCount, totalByteLength, tracks }; +} + +function sha256Hex(buffer: ArrayBuffer): Promise { + if (!globalThis.crypto?.subtle) { + throw new M49TgsFullShadowContractError("Браузер не поддерживает проверку playback SHA-256."); + } + return globalThis.crypto.subtle.digest("SHA-256", buffer).then((digest) => ( + [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("") + )); +} + +async function fetchPlaybackTrack( + track: M49TgsFullShadowPlaybackTrack, + fetcher: LaboratoryFetch, + signal: AbortSignal | undefined, + completedBytes: number, + totalBytes: number, + onProgress: ((progress: M49TgsFullShadowPlaybackProgress) => void) | undefined, +): Promise { + const response = await fetcher(track.url, { + method: "GET", + headers: { Accept: track.mediaType }, + signal, + }); + if (!response.ok) { + throw new M49TgsFullShadowContractError(`M49 playback ${track.id} недоступен: HTTP ${response.status}.`); + } + const bytes = new Uint8Array(track.byteLength); + if (response.body) { + const reader = response.body.getReader(); + let offset = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (offset + value.byteLength > bytes.byteLength) { + throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: превышен объявленный размер.`); + } + bytes.set(value, offset); + offset += value.byteLength; + onProgress?.({ + phase: "download", + trackId: track.id, + loadedBytes: completedBytes + offset, + totalBytes, + }); + } + if (offset !== bytes.byteLength) { + throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: получен неполный файл.`); + } + } else { + const fallback = new Uint8Array(await response.arrayBuffer()); + if (fallback.byteLength !== bytes.byteLength) { + throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: получен неверный размер.`); + } + bytes.set(fallback); + } + onProgress?.({ + phase: "verify", + trackId: track.id, + loadedBytes: completedBytes + bytes.byteLength, + totalBytes, + }); + if (await sha256Hex(bytes.buffer) !== track.sha256) { + throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: SHA-256 не совпал.`); + } + return bytes.buffer; +} + +function equalShape(actual: readonly number[], expected: readonly number[]): boolean { + return actual.length === expected.length && actual.every((value, index) => value === expected[index]); +} + +export function parseM49TgsNpyTrack( + buffer: ArrayBuffer, + expectedDtype: " bytes.byteLength) { + throw new M49TgsFullShadowContractError("M49 playback NPY: неверный заголовок."); + } + const header = new TextDecoder("latin1").decode(bytes.subarray(headerOffset, headerOffset + headerLength)); + const dtype = header.match(/["']descr["']\s*:\s*["']([^"']+)["']/)?.[1]; + const fortran = header.match(/["']fortran_order["']\s*:\s*(True|False)/)?.[1]; + const rawShape = header.match(/["']shape["']\s*:\s*\(([^)]*)\)/)?.[1]; + const shape = rawShape?.split(",").map((value) => value.trim()).filter(Boolean).map(Number) ?? []; + if (dtype !== expectedDtype || fortran !== "False" || !equalShape(shape, expectedShape)) { + throw new M49TgsFullShadowContractError("M49 playback NPY: dtype или shape изменились."); + } + const count = expectedShape.reduce((product, value) => product * value, 1); + const dataOffset = headerOffset + headerLength; + const bytesPerValue = expectedDtype === " { + const row = objectValue(JSON.parse(line), `M49 playback frame ${sourceSequence}`); + return { + sourceSequence, + sourceFrameIndex: integer(row.source_frame_index, `M49 playback frame ${sourceSequence}.source`), + sessionSeconds: numberValue(row.session_seconds, `M49 playback frame ${sourceSequence}.time`), + sampleAvailable: booleanValue(row.sample_available, `M49 playback frame ${sourceSequence}.available`), + metrics: { + eligiblePointCount: integer(row.eligible_point_count, `M49 playback frame ${sourceSequence}.eligible`), + groundPointCount: integer(row.ground_point_count, `M49 playback frame ${sourceSequence}.ground`), + nongroundPointCount: integer(row.nonground_point_count, `M49 playback frame ${sourceSequence}.nonground`), + rejectedPointCount: integer(row.rejected_point_count, `M49 playback frame ${sourceSequence}.rejected`), + occupiedCellCount: integer(row.occupied_cell_count, `M49 playback frame ${sourceSequence}.occupied`), + }, + }; + }); +} + +export async function fetchM49TgsFullShadowPlaybackPack( + id: string, + { + fetcher = fetch, + signal, + onProgress, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + onProgress?: (progress: M49TgsFullShadowPlaybackProgress) => void; + } = {}, +): Promise { + if (!RESULT_ID.test(id)) { + throw new M49TgsFullShadowContractError("M49 playback identity недопустима."); + } + onProgress?.({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 }); + const manifest = await fetchPlaybackManifest(id, fetcher, signal); + const buffers = new Map(); + let completedBytes = 0; + const order: readonly M49TgsFullShadowPlaybackTrackId[] = ["frames", "centers", "states", "z-bounds"]; + for (const trackId of order) { + const track = manifest.tracks.get(trackId); + if (!track) throw new M49TgsFullShadowContractError(`M49 playback ${trackId}: дорожка отсутствует.`); + const buffer = await fetchPlaybackTrack( + track, + fetcher, + signal, + completedBytes, + manifest.totalByteLength, + onProgress, + ); + buffers.set(trackId, buffer); + completedBytes += track.byteLength; + } + const centersTrack = manifest.tracks.get("centers")!; + const statesTrack = manifest.tracks.get("states")!; + const zBoundsTrack = manifest.tracks.get("z-bounds")!; + const centersRaw = parseM49TgsNpyTrack( + buffers.get("centers")!, + " ( + [centersRaw[index * 2]!, centersRaw[index * 2 + 1]!] as const + )); + const states = parseM49TgsNpyTrack( + buffers.get("states")!, + "|u1", + statesTrack.shape, + ) as Uint8Array; + const zBoundsM = parseM49TgsNpyTrack( + buffers.get("z-bounds")!, + " Promise; export const M4_THREAT_TIMELINE_ENDPOINT_ROOT = "/api/v1/laboratory/m4-threat/results"; class M4ThreatContractError extends Error {} @@ -803,11 +817,13 @@ export async function fetchM4ThreatTimelineChunk( signal, endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, cameraObstacleProjectionDelivery = null, + playbackPointPack, }: { fetcher?: LaboratoryFetch; signal?: AbortSignal; endpointRoot?: string; cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"]; + playbackPointPack?: M4ThreatPlaybackPointPack; } = {}, ): Promise { const params = new URLSearchParams({ @@ -817,6 +833,7 @@ export async function fetchM4ThreatTimelineChunk( if (cameraObstacleProjectionDelivery !== null) { params.set("obstacle_projection", cameraObstacleProjectionDelivery); } + if (playbackPointPack) params.set("include_points", "false"); const response = await fetcher( `${endpointRoot}/${result}/timeline/chunk?${params}`, { headers: { Accept: "application/json" }, signal }, @@ -834,8 +851,12 @@ export async function fetchM4ThreatTimelineChunk( if (parsedStart !== startSequence) { throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт."); } - const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) => - parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot)); + const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) => { + const parsed = parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot); + return playbackPointPack + ? hydrateM4ThreatTimelineFrame(parsed, playbackPointPack) + : parsed; + }); const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count"); if (parsedCount !== frames.length || parsedCount > frameCount) { throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт."); @@ -851,6 +872,159 @@ export async function fetchM4ThreatTimelineChunk( }; } +function playbackSha256Hex(buffer: ArrayBuffer): Promise { + if (!globalThis.crypto?.subtle) { + throw new M4ThreatContractError("Браузер не поддерживает проверку playback SHA-256."); + } + return globalThis.crypto.subtle.digest("SHA-256", buffer).then((digest) => ( + [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("") + )); +} + +function roundedBodyCoordinate(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} + +export function hydrateM4ThreatTimelineFrame( + frame: M4ThreatTimelineFrame, + pack: M4ThreatPlaybackPointPack, +): M4ThreatTimelineFrame { + if (frame.sequence >= pack.frameCount || pack.resultId === "") { + throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность."); + } + if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) { + if (frame.pointCloudSourceCount !== 0) { + throw new M4ThreatContractError("M4.6 binary playback unavailable frame содержит точки."); + } + return { ...frame, pointCloudBodyXyzM: [], pointCloudSampleCount: 0 }; + } + const start = pack.pointOffsets[frame.sequence]; + const stop = pack.pointOffsets[frame.sequence + 1]; + if (start === undefined || stop === undefined || stop < start || stop > pack.pointCount) { + throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность."); + } + const count = stop - start; + if (frame.pointCloudSourceCount !== count || frame.pointCloudSampleCount !== count) { + throw new M4ThreatContractError("M4.6 binary playback point accounting: нарушен контракт."); + } + const origin = frame.bodyFrame.originMapXyzM; + const basis = frame.bodyFrame.basisMapFromBody; + const points = new Array(count); + for (let index = 0; index < count; index += 1) { + const sourceOffset = (start + index) * 3; + const dx = pack.pointsMapXyzM[sourceOffset]! - origin[0]; + const dy = pack.pointsMapXyzM[sourceOffset + 1]! - origin[1]; + const dz = pack.pointsMapXyzM[sourceOffset + 2]! - origin[2]; + points[index] = [ + roundedBodyCoordinate(dx * basis[0][0] + dy * basis[1][0] + dz * basis[2][0]), + roundedBodyCoordinate(dx * basis[0][1] + dy * basis[1][1] + dz * basis[2][1]), + roundedBodyCoordinate(dx * basis[0][2] + dy * basis[1][2] + dz * basis[2][2]), + ]; + } + return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: count }; +} + +export async function fetchM4ThreatPlaybackPointPack( + result: string, + { + fetcher = fetch, + signal, + endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, + onProgress, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + endpointRoot?: string; + onProgress?: (progress: M4ThreatPlaybackProgress) => void; + } = {}, +): Promise { + resultId(result); + onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 }); + const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, { + headers: { Accept: "application/json" }, + signal, + }); + if (!manifestResponse.ok) { + throw new M4ThreatContractError(`M4.6 playback manifest: HTTP ${manifestResponse.status}.`); + } + const manifest = object(await manifestResponse.json(), "M4.6 playback manifest"); + exact(manifest.schema_version, "missioncore.recorded-spatial-playback/v1", "M4.6 playback schema"); + 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 pointCount = integer(manifest.point_count, "M4.6 playback points"); + const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets"); + if (offsetsRaw.length !== frameCount + 1) { + throw new M4ThreatContractError("M4.6 playback offsets: неверная размерность."); + } + const pointOffsets = Uint32Array.from(offsetsRaw, (value) => integer(value, "M4.6 playback offset")); + if (pointOffsets[0] !== 0 || pointOffsets[pointOffsets.length - 1] !== pointCount) { + throw new M4ThreatContractError("M4.6 playback offsets: нарушено замыкание."); + } + for (let index = 1; index < pointOffsets.length; index += 1) { + if (pointOffsets[index]! < pointOffsets[index - 1]!) { + throw new M4ThreatContractError("M4.6 playback offsets: нарушена монотонность."); + } + } + const track = object(manifest.track, "M4.6 playback track"); + exact(track.id, "points-map-f32", "M4.6 playback track id"); + exact(track.dtype, " integer(value, "M4.6 playback shape")); + if (shape.length !== 2 || shape[0] !== pointCount || shape[1] !== 3) { + throw new M4ThreatContractError("M4.6 playback shape: нарушена размерность."); + } + const byteLength = integer(track.bytes, "M4.6 playback bytes"); + if (byteLength !== pointCount * 3 * Float32Array.BYTES_PER_ELEMENT) { + throw new M4ThreatContractError("M4.6 playback bytes: нарушен accounting."); + } + const sha256 = text(track.sha256, "M4.6 playback SHA-256"); + if (!/^[a-f0-9]{64}$/.test(sha256)) { + throw new M4ThreatContractError("M4.6 playback SHA-256: нарушен контракт."); + } + const response = await fetcher(text(track.url, "M4.6 playback URL"), { + headers: { Accept: "application/octet-stream" }, + signal, + }); + if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`); + const bytes = new Uint8Array(byteLength); + if (response.body) { + const reader = response.body.getReader(); + let offset = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (offset + value.byteLength > bytes.byteLength) { + throw new M4ThreatContractError("M4.6 playback points превысил объявленный размер."); + } + bytes.set(value, offset); + offset += value.byteLength; + onProgress?.({ phase: "download", loadedBytes: offset, totalBytes: byteLength }); + } + if (offset !== byteLength) { + throw new M4ThreatContractError("M4.6 playback points получен не полностью."); + } + } else { + const fallback = new Uint8Array(await response.arrayBuffer()); + if (fallback.byteLength !== byteLength) { + throw new M4ThreatContractError("M4.6 playback points: неверный размер."); + } + bytes.set(fallback); + } + onProgress?.({ phase: "verify", loadedBytes: byteLength, totalBytes: byteLength }); + if (await playbackSha256Hex(bytes.buffer) !== sha256) { + throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал."); + } + onProgress?.({ phase: "ready", loadedBytes: byteLength, totalBytes: byteLength }); + return { + resultId: result, + frameCount, + pointCount, + pointOffsets, + pointsMapXyzM: new Float32Array(bytes.buffer), + }; +} + export async function fetchM4ThreatCameraPointOverlay( result: string, sequence: number, diff --git a/apps/control-station/test/m49TgsFullShadow.test.mjs b/apps/control-station/test/m49TgsFullShadow.test.mjs index 498e4a5..47d8a09 100644 --- a/apps/control-station/test/m49TgsFullShadow.test.mjs +++ b/apps/control-station/test/m49TgsFullShadow.test.mjs @@ -6,6 +6,7 @@ import { createServer } from "vite"; let server; let fetchM49TgsFullShadowSpatialChunk; +let parseM49TgsNpyTrack; const resultId = `m49-tgs-full-shadow-${"a".repeat(64)}`; @@ -15,7 +16,7 @@ before(async () => { logLevel: "silent", server: { middlewareMode: true }, }); - ({ fetchM49TgsFullShadowSpatialChunk } = await server.ssrLoadModule( + ({ fetchM49TgsFullShadowSpatialChunk, parseM49TgsNpyTrack } = await server.ssrLoadModule( "/src/core/laboratory/m49TgsFullShadow.ts", )); }); @@ -73,7 +74,7 @@ test("M4.9T5 chunk keeps every missing-LiDAR cell explicitly UNOBSERVED", async assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0])); }); -test("M4.9T5 viewer prefetches 24-frame immutable chunks", async () => { +test("M4.9T5 viewer preloads one immutable binary playback pack", async () => { const [source, contract] = await Promise.all([ readFile( new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url), @@ -84,12 +85,37 @@ test("M4.9T5 viewer prefetches 24-frame immutable chunks", async () => { "utf8", ), ]); - assert.match(source, /const CHUNK_FRAMES = 24/); - assert.match(source, /activeChunkStart \+ CHUNK_FRAMES/); - assert.match(source, /fetchM49TgsFullShadowSpatialChunk/); + assert.match(source, /fetchM49TgsFullShadowPlaybackPack/); + assert.match(source, /playbackProgress/); + assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/); assert.match(source, /sampleAvailable: spatial\.sampleAvailable/); assert.match(source, /fetchE47SemanticSlamResult/); assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/); assert.match(source, /semantic=\{semantic \? \{/); assert.match(contract, /linked_semantic_result_id/); + assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/); +}); + +function npyFloat32(values, shape) { + const shapeText = shape.length === 1 ? `${shape[0]},` : shape.join(", "); + const prefixLength = 10; + let header = `{'descr': ' { + const parsed = parseM49TgsNpyTrack( + npyFloat32([1.25, -2.5, 3.75, 4.5], [2, 2]), + " { fetchM4ThreatTimeline, fetchM4ThreatTimelineChunk, fetchM4ThreatCameraPointOverlay, + hydrateM4ThreatTimelineFrame, selectM4ThreatTimelineFrame, selectM4ThreatTimelineSequence, } = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts")); @@ -332,6 +334,45 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1); }); +test("M4.6 hydrates a lightweight timeline frame from one retained binary point track", async () => { + const raw = timelineFrame(0, 35.421857292, { + body_frame: { + origin_map_xyz_m: [10, 20, 30], + basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + }, + point_cloud_body_xyz_m: [], + point_cloud_source_count: 2, + point_cloud_sample_count: 2, + }); + const parsed = await fetchM4ThreatTimelineChunk(resultId, 0, 1, { + playbackPointPack: { + resultId, + frameCount: 4489, + pointCount: 2, + pointOffsets: new Uint32Array([0, 2, ...Array(4488).fill(2)]), + pointsMapXyzM: new Float32Array([11, 20, 30.25, 12, 19.5, 30]), + }, + fetcher: async (input) => { + assert.match(String(input), /include_points=false/); + return new Response(JSON.stringify({ + schema_version: "missioncore.recorded-spatial-evidence-chunk/v1", + result_id: resultId, + start_sequence: 0, + frame_count: 1, + next_sequence: 1, + frames: [raw], + authority: "replay-simulated", + }), { status: 200 }); + }, + }); + assert.deepEqual(parsed.frames[0].pointCloudBodyXyzM, [ + [1, 0, 0.25], + [2, -0.5, 0], + ]); + assert.equal(parsed.frames[0].pointCloudSampleCount, 2); + assert.equal(typeof hydrateM4ThreatTimelineFrame, "function"); +}); + test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => { const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`; const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector"; diff --git a/src/k1link/perception/geometry.py b/src/k1link/perception/geometry.py index 690bdf2..ed74e6f 100644 --- a/src/k1link/perception/geometry.py +++ b/src/k1link/perception/geometry.py @@ -344,6 +344,27 @@ class RecordedGeometryStore: points.setflags(write=False) return points + def playback_points_map(self) -> FloatArray: + """Expose the sealed contiguous map-point track for binary LAB playback. + + The returned array is the exact source-pack point index space. It is + read-only and deliberately excludes any UI projection or resampling so + the browser can retain it once and derive the current increment by the + verified offsets below. + """ + + points = np.asarray(self._source["cloud_points_map"], dtype=np.dtype(" tuple[int, ...]: + """Return immutable offsets into :meth:`playback_points_map`.""" + + offsets = np.asarray(self._source["cloud_offsets"], dtype=np.int64) + return tuple(int(value) for value in offsets) + def point_step_candidates_for_frame(self, frame_index: int) -> UInt8Array | None: """Expose the sealed low-step diagnostic in the source point index space. diff --git a/src/k1link/perception/threat_timeline.py b/src/k1link/perception/threat_timeline.py index bd1e60d..4cae20d 100644 --- a/src/k1link/perception/threat_timeline.py +++ b/src/k1link/perception/threat_timeline.py @@ -150,14 +150,23 @@ class RecordedThreatTimeline: "access": "read-only-bounded-recorded-replay", } - def chunk(self, *, start_sequence: int, frame_count: int) -> dict[str, object]: + def chunk( + self, + *, + start_sequence: int, + frame_count: int, + include_points: bool = True, + ) -> dict[str, object]: if not 0 <= start_sequence < len(self.index.offsets): raise RecordedThreatTimelineError("recorded timeline chunk start is invalid") if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES: raise RecordedThreatTimelineError("recorded timeline chunk size is invalid") stop = min(len(self.index.offsets), start_sequence + frame_count) with self._lock: - frames = [self._project_frame(sequence) for sequence in range(start_sequence, stop)] + frames = [ + self._project_frame(sequence, include_points=include_points) + for sequence in range(start_sequence, stop) + ] return { "schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA, "result_id": self.result.result_id, @@ -170,7 +179,12 @@ class RecordedThreatTimeline: "access": "read-only-bounded-recorded-replay", } - def _project_frame(self, sequence: int) -> dict[str, object]: + def _project_frame( + self, + sequence: int, + *, + include_points: bool, + ) -> dict[str, object]: row = _read_frame_at(self.frames_path, self.index, sequence) frame_id = row.get("frame_id") if not isinstance(frame_id, str) or not frame_id: @@ -193,11 +207,13 @@ class RecordedThreatTimeline: raise RecordedThreatTimelineError( "recorded timeline current increment binding changed" ) - point_cloud, point_source_count = sample_points_in_body_frame( - points, - body_frame, - point_limit=RECORDED_SPATIAL_POINT_LIMIT, - ) + point_source_count = int(points.shape[0]) + if include_points: + point_cloud, point_source_count = sample_points_in_body_frame( + points, + body_frame, + point_limit=RECORDED_SPATIAL_POINT_LIMIT, + ) metric_visuals = project_metric_obstacles_to_body( _mapping_array(row.get("metric_obstacles"), "metric obstacles"), body_frame, @@ -219,13 +235,13 @@ class RecordedThreatTimeline: if body_frame is None else { "origin_map_xyz_m": list(body_frame.origin_map_xyz_m), - "basis_map_from_body": [ - list(row) for row in body_frame.basis_map_from_body - ], + "basis_map_from_body": [list(row) for row in body_frame.basis_map_from_body], }, "point_cloud_body_xyz_m": point_cloud, "point_cloud_source_count": point_source_count, - "point_cloud_sample_count": len(point_cloud), + "point_cloud_sample_count": point_source_count + if not include_points + else len(point_cloud), "point_cloud_layer": "current-increment", "rolling_map_component_count": sum( item.get("state") == "retained" for item in metric_visuals diff --git a/src/k1link/web/m49_tgs_full_shadow_api.py b/src/k1link/web/m49_tgs_full_shadow_api.py index 73a3424..01bc421 100644 --- a/src/k1link/web/m49_tgs_full_shadow_api.py +++ b/src/k1link/web/m49_tgs_full_shadow_api.py @@ -13,17 +13,44 @@ from typing import Final import numpy as np from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import FileResponse from k1link.laboratory.m49_tgs_full_shadow import ( + PREFIX, M49TgsFullShadowError, M49TgsFullShadowResult, - PREFIX, read_m49_tgs_full_shadow, ) RootProvider = Callable[[], Path | None] RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$") ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/tgs-full-shadow" +PLAYBACK_TRACKS: Final = { + "centers": ( + "costmap-cell-centers-xy-m.npy", + "application/x-npy", + " APIRouter: @@ -55,16 +82,46 @@ def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: No if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None: continue try: - results.append(_project(_read_cached(str(candidate.resolve()), _signature(candidate)))) + results.append( + _project(_read_cached(str(candidate.resolve()), _signature(candidate))) + ) except (M49TgsFullShadowError, OSError, ValueError): invalid += 1 - results.sort(key=lambda row: (str(row["created_at_utc"]), str(row["result_id"])), reverse=True) + results.sort( + key=lambda row: (str(row["created_at_utc"]), str(row["result_id"])), reverse=True + ) return _catalog(results[:limit], configured=True, invalid_total=invalid) @router.get("/{result_id}") def get_result(result_id: str) -> dict[str, object]: return _project(sealed(result_id)) + @router.get("/{result_id}/playback/manifest") + def get_playback_manifest(result_id: str) -> dict[str, object]: + return _playback_manifest(sealed(result_id)) + + @router.get("/{result_id}/playback/tracks/{track_id}") + def get_playback_track(result_id: str, track_id: str) -> FileResponse: + result = sealed(result_id) + descriptor = PLAYBACK_TRACKS.get(track_id) + if descriptor is None: + raise HTTPException(status_code=404, detail="M49 playback track not found") + name, media_type, _dtype, _shape = descriptor + artifact = _artifact_descriptor(result, name) + return FileResponse( + result.root / name, + media_type=media_type, + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + # These playback artifacts are consumed on the local control + # station. Avoid spending multiple seconds recompressing + # already compact numeric tracks on every cold open. + "Content-Encoding": "identity", + "ETag": f'"{artifact["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + @router.get("/{result_id}/frames/{source_sequence}/spatial") def get_spatial(result_id: str, source_sequence: int) -> Response: if source_sequence < 0 or source_sequence >= 4489: @@ -75,11 +132,16 @@ def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: No str(result.root), result_id, source_sequence, _evidence_signature(result.root) ) except (OSError, ValueError, KeyError, json.JSONDecodeError): - raise HTTPException(status_code=503, detail="M49 TGS full-shadow spatial evidence failed verification") from None + raise HTTPException( + status_code=503, detail="M49 TGS full-shadow spatial evidence failed verification" + ) from None return Response( content=content, media_type="application/json", - headers={"Cache-Control": "private, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff"}, + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }, ) @router.get("/{result_id}/spatial/chunk") @@ -134,7 +196,9 @@ def _frame_json_cached( root_path = Path(root) frame_signature = (signature[-2], signature[-1]) frame = _frames(root, frame_signature)[source_sequence] - centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False) + centers = np.load( + root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False + ) states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False) z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False) states = states_all[source_sequence] @@ -169,13 +233,20 @@ def _frame_json_cached( ], }, "metrics": copy.deepcopy(frame), - "state_codes": {"UNOBSERVED": 0, "GROUND_SUPPORT": 1, "NONGROUND_OCCUPIED": 2, "UNKNOWN_REJECTED": 3}, + "state_codes": { + "UNOBSERVED": 0, + "GROUND_SUPPORT": 1, + "NONGROUND_OCCUPIED": 2, + "UNKNOWN_REJECTED": 3, + }, "aos_used": False, "gpu_used": False, "authority": {"navigation_or_safety_accepted": False, "visual_quality_accepted": False}, "access": "read-only", } - return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + return json.dumps( + payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False + ).encode("utf-8") @lru_cache(maxsize=16) @@ -189,7 +260,9 @@ def _chunk_json_cached( root_path = Path(root) frame_signature = (signature[-2], signature[-1]) frames = _frames(root, frame_signature) - centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False) + centers = np.load( + root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False + ) states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False) z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False) if ( @@ -258,6 +331,46 @@ def _chunk_json_cached( ).encode("utf-8") +def _artifact_descriptor( + result: M49TgsFullShadowResult, + name: str, +) -> dict[str, object]: + for artifact in result.manifest["artifacts"]: + if artifact.get("path") == name: + return artifact + raise ValueError(f"full-shadow artifact is not declared: {name}") + + +def _playback_manifest(result: M49TgsFullShadowResult) -> dict[str, object]: + tracks: list[dict[str, object]] = [] + total_bytes = 0 + for track_id, (name, media_type, dtype, shape) in PLAYBACK_TRACKS.items(): + artifact = _artifact_descriptor(result, name) + byte_length = int(artifact["byte_length"]) + total_bytes += byte_length + tracks.append( + { + "id": track_id, + "url": (f"{ENDPOINT_ROOT}/{result.result_id}/playback/tracks/{track_id}"), + "media_type": media_type, + "dtype": dtype, + "shape": shape, + "byte_length": byte_length, + "sha256": artifact["sha256"], + } + ) + return { + "schema_version": "missioncore.m49-tgs-full-shadow-playback/v1", + "result_id": result.result_id, + "coordinate_frame": "map-gravity-local", + "frame_count": 4489, + "cell_count": 2244, + "total_byte_length": total_bytes, + "tracks": tracks, + "access": "read-only", + } + + def _project(result: M49TgsFullShadowResult) -> dict[str, object]: return { **copy.deepcopy(result.report), @@ -269,7 +382,9 @@ def _project(result: M49TgsFullShadowResult) -> dict[str, object]: } -def _catalog(items: list[dict[str, object]], *, configured: bool, invalid_total: int) -> dict[str, object]: +def _catalog( + items: list[dict[str, object]], *, configured: bool, invalid_total: int +) -> dict[str, object]: return { "schema_version": "missioncore.m49-tgs-full-shadow-catalog/v1", "configured": configured, diff --git a/src/k1link/web/m4_threat_replay_api.py b/src/k1link/web/m4_threat_replay_api.py index 92dcaf8..b6fa8e5 100644 --- a/src/k1link/web/m4_threat_replay_api.py +++ b/src/k1link/web/m4_threat_replay_api.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import hashlib import json import re from collections.abc import Callable, Iterator @@ -11,6 +12,7 @@ from pathlib import Path from typing import Final from fastapi import APIRouter, HTTPException, Query, Response +from fastapi.responses import StreamingResponse from k1link.perception.threat_replay import ( THREAT_REPLAY_FRAME_SCHEMA, @@ -166,7 +168,7 @@ def build_m4_threat_replay_router( def get_timeline(result_id: str) -> dict[str, object]: return copy.deepcopy(timeline(result_id).metadata()) - @router.get("/results/{result_id}/timeline/chunk") + @router.get("/results/{result_id}/timeline/chunk", response_model=None) def get_timeline_chunk( result_id: str, start: int = Query(default=0, ge=0), @@ -175,14 +177,87 @@ def build_m4_threat_replay_router( ge=1, le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES, ), - ) -> dict[str, object]: + include_points: bool = Query(default=True), + ) -> dict[str, object] | Response: try: - return timeline(result_id).chunk(start_sequence=start, frame_count=count) + payload = timeline(result_id).chunk( + start_sequence=start, + frame_count=count, + include_points=include_points, + ) except RecordedThreatTimelineError: raise HTTPException( status_code=404, detail="M4.6 timeline chunk не найден", ) from None + if include_points: + return payload + return Response( + content=json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ), + media_type="application/json", + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Encoding": "identity", + "X-Content-Type-Options": "nosniff", + }, + ) + + @router.get("/results/{result_id}/timeline/playback") + def get_timeline_playback(result_id: str) -> dict[str, object]: + projected = timeline(result_id) + points = projected.store.playback_points_map() + offsets = projected.store.playback_point_offsets() + content_sha256 = hashlib.sha256(memoryview(points).cast("B")).hexdigest() + return { + "schema_version": "missioncore.recorded-spatial-playback/v1", + "result_id": result_id, + "frame_count": len(offsets) - 1, + "point_count": int(points.shape[0]), + "point_offsets": list(offsets), + "track": { + "id": "points-map-f32", + "url": ( + f"/api/v1/laboratory/m4-threat/results/{result_id}" + "/timeline/playback/tracks/points-map-f32" + ), + "media_type": "application/octet-stream", + "dtype": " StreamingResponse: + projected = timeline(result_id) + points = projected.store.playback_points_map() + source_digest = projected.profile.source_pack_sha256 + return StreamingResponse( + _binary_chunks(memoryview(points).cast("B")), + media_type="application/octet-stream", + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Encoding": "identity", + "Content-Length": str(points.nbytes), + "ETag": f'"{source_digest}-points-map-f32"', + "X-Content-Type-Options": "nosniff", + "X-Uncompressed-Content-Length": str(points.nbytes), + }, + ) @router.get("/results/{result_id}/timeline/frames/{sequence}/camera") def get_timeline_camera(result_id: str, sequence: int) -> Response: @@ -196,6 +271,11 @@ def build_m4_threat_replay_router( return router +def _binary_chunks(view: memoryview, chunk_size: int = 1024 * 1024) -> Iterator[bytes]: + for start in range(0, view.nbytes, chunk_size): + yield bytes(view[start : start + chunk_size]) + + @lru_cache(maxsize=4) def _read_threat_result_cached( root_value: str, diff --git a/tests/test_m49_tgs_full_shadow.py b/tests/test_m49_tgs_full_shadow.py index e0d3e2b..6a52303 100644 --- a/tests/test_m49_tgs_full_shadow.py +++ b/tests/test_m49_tgs_full_shadow.py @@ -8,7 +8,10 @@ from pathlib import Path import numpy as np -from k1link.laboratory.m49_tgs_full_shadow import seal_m49_tgs_full_shadow +from k1link.laboratory.m49_tgs_full_shadow import ( + M49TgsFullShadowResult, + seal_m49_tgs_full_shadow, +) from k1link.web import m49_tgs_full_shadow_api as full_shadow_api REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -96,24 +99,34 @@ def test_full_shadow_seal_binds_visual_and_semantic_timelines(tmp_path: Path) -> "files": files, } (source / "result.json").write_text(json.dumps(worker), encoding="utf-8") - (source / "worker-summary.json").write_text(json.dumps({ - "schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1", - "gpu_requested": False, - "aos_used": False, - "all_timeline_frames_accounted": True, - "all_eligible_points_accounted": True, - "canonical_triton_health": "healthy", - "canonical_triton_id": "triton", - "wall_seconds": 1.0, - }), encoding="utf-8") + (source / "worker-summary.json").write_text( + json.dumps( + { + "schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1", + "gpu_requested": False, + "aos_used": False, + "all_timeline_frames_accounted": True, + "all_eligible_points_accounted": True, + "canonical_triton_health": "healthy", + "canonical_triton_id": "triton", + "wall_seconds": 1.0, + } + ), + encoding="utf-8", + ) profile = tmp_path / "profile.json" - profile.write_text(json.dumps({ - "schema_version": "missioncore.m49-tgs-full-shadow-profile/v1", - "profile_id": "test", - "profile": {"history_seconds": 1.0}, - "costmap": {"state_priority": ["NONGROUND_OCCUPIED"]}, - "state_codes": {"UNOBSERVED": 0}, - }), encoding="utf-8") + profile.write_text( + json.dumps( + { + "schema_version": "missioncore.m49-tgs-full-shadow-profile/v1", + "profile_id": "test", + "profile": {"history_seconds": 1.0}, + "costmap": {"state_priority": ["NONGROUND_OCCUPIED"]}, + "state_codes": {"UNOBSERVED": 0}, + } + ), + encoding="utf-8", + ) visual = "m4-threat-replay-" + "d" * 64 semantic = "e47-semantic-slam-" + "e" * 64 @@ -176,3 +189,35 @@ def test_full_shadow_chunk_contract_keeps_missing_lidar_unobserved( assert len(payload["costmap"]["centers_xy_m"]) == 2244 assert payload["frames"][1]["sample_available"] is False assert set(payload["frames"][1]["states"]) == {0} + + +def test_full_shadow_playback_manifest_exposes_sealed_binary_tracks(tmp_path: Path) -> None: + names = {descriptor[0] for descriptor in full_shadow_api.PLAYBACK_TRACKS.values()} + artifacts = [ + { + "path": name, + "byte_length": index + 10, + "sha256": f"{index + 1:064x}", + } + for index, name in enumerate(sorted(names)) + ] + result = M49TgsFullShadowResult( + "m49-tgs-full-shadow-" + "a" * 64, + tmp_path, + {"artifacts": artifacts}, + {}, + ) + + payload = full_shadow_api._playback_manifest(result) + + assert payload["schema_version"] == "missioncore.m49-tgs-full-shadow-playback/v1" + assert payload["frame_count"] == 4489 + assert payload["cell_count"] == 2244 + assert payload["total_byte_length"] == sum(item["byte_length"] for item in artifacts) + assert {item["id"] for item in payload["tracks"]} == { + "frames", + "centers", + "states", + "z-bounds", + } + assert all("/playback/tracks/" in item["url"] for item in payload["tracks"]) diff --git a/tests/test_m4_threat_replay_result.py b/tests/test_m4_threat_replay_result.py index 8689fb5..28c45b7 100644 --- a/tests/test_m4_threat_replay_result.py +++ b/tests/test_m4_threat_replay_result.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path from fastapi.routing import APIRoute @@ -126,6 +127,7 @@ def test_m4_6_lab_api_projects_report_and_exact_visual_frame() -> None: def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None: get_timeline = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline") get_chunk = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/chunk") + get_playback = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback") timeline = get_timeline(RESULT_ID) assert timeline["schema_version"] == "missioncore.recorded-spatial-evidence-timeline/v1" @@ -160,6 +162,23 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None: assert 0 < first["point_cloud_sample_count"] <= 4096 assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera") + lightweight_response = get_chunk(RESULT_ID, start=1880, count=1, include_points=False) + lightweight = json.loads(lightweight_response.body) + assert lightweight["frames"][0]["point_cloud_body_xyz_m"] == [] + assert lightweight["frames"][0]["point_cloud_source_count"] == first["point_cloud_source_count"] + assert lightweight["frames"][0]["point_cloud_sample_count"] == first["point_cloud_sample_count"] + + playback = get_playback(RESULT_ID) + assert playback["schema_version"] == "missioncore.recorded-spatial-playback/v1" + assert playback["frame_count"] == 4489 + assert playback["point_count"] == 9_207_270 + assert len(playback["point_offsets"]) == 4490 + assert playback["point_offsets"][-1] == playback["point_count"] + assert playback["track"]["dtype"] == " None: calls: list[tuple[str, int]] = []