From e38f2fb1da0e226ba266731271f2a5bde35061c9 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 6 Aug 2026 08:51:10 +0300 Subject: [PATCH] fix(lab): stabilize recorded spatial playback --- .../LaboratoryMetricEvidenceScene.tsx | 78 ++++++--- .../src/core/laboratory/m4ReplayThreat.ts | 23 +++ .../src/styles/m4-replay-threat.css | 42 +++++ .../laboratory/M4ReplayThreatVisual.tsx | 161 ++++++++++++------ .../laboratory/useM4ThreatTimeline.ts | 58 +++++-- .../test/m4ReplayThreat.test.mjs | 20 ++- scripts/build_m4_worker_shadow_artifact.py | 2 +- src/k1link/perception/geometry.py | 12 +- src/k1link/perception/threat_timeline.py | 11 +- src/k1link/web/app.py | 2 + tests/test_m4_threat_replay_result.py | 6 +- 11 files changed, 316 insertions(+), 99 deletions(-) diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index 518349f..8da93f7 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -96,6 +96,7 @@ export function LaboratoryMetricEvidenceScene({ obstacles, rig, corridor, + occupiedVoxelSizeM, mode, label, }: { @@ -103,6 +104,7 @@ export function LaboratoryMetricEvidenceScene({ obstacles: readonly LaboratoryMetricObstacleVisual[]; rig: LaboratoryMetricRigVisual; corridor: LaboratoryMetricCorridorVisual; + occupiedVoxelSizeM: number; mode: LaboratoryMetricSceneMode; label: string; }) { @@ -205,10 +207,10 @@ export function LaboratoryMetricEvidenceScene({ contextGeometry, new THREE.PointsMaterial({ color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]), - size: 1.7, + size: 1.55, sizeAttenuation: false, transparent: true, - opacity: 0.34, + opacity: 0.58, depthWrite: false, }), )); @@ -224,36 +226,62 @@ export function LaboratoryMetricEvidenceScene({ continue; } const color = decisionColor(host, obstacle.decision); - const cellsGeometry = new THREE.BufferGeometry(); - cellsGeometry.setAttribute( - "position", - new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3), - ); - content.add(new THREE.Points( - cellsGeometry, - new THREE.PointsMaterial({ + if (obstacle.state === "retained") { + const geometry = new THREE.BoxGeometry( + occupiedVoxelSizeM * 0.82, + occupiedVoxelSizeM * 0.82, + occupiedVoxelSizeM * 0.82, + ); + const material = new THREE.MeshBasicMaterial({ color, - size: obstacle.state === "current" ? 4.8 : 5.2, - sizeAttenuation: false, + wireframe: true, transparent: true, - opacity: obstacle.state === "current" ? 0.94 : 0.78, + opacity: 0.34, depthWrite: false, - }), - )); - const centroid = new THREE.Mesh( - new THREE.SphereGeometry(0.1, 16, 12), - new THREE.MeshBasicMaterial({ - color, - wireframe: obstacle.state === "retained", - }), - ); - centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM)); - centroid.userData.evidenceId = obstacle.id; - content.add(centroid); + }); + const voxels = new THREE.InstancedMesh( + geometry, + material, + obstacle.cellCentersBodyXyzM.length, + ); + const matrix = new THREE.Matrix4(); + obstacle.cellCentersBodyXyzM.forEach((point, index) => { + matrix.makeTranslation(...scenePoint(point)); + voxels.setMatrixAt(index, matrix); + }); + voxels.instanceMatrix.needsUpdate = true; + voxels.userData.evidenceId = obstacle.id; + content.add(voxels); + } else { + const cellsGeometry = new THREE.BufferGeometry(); + cellsGeometry.setAttribute( + "position", + new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3), + ); + content.add(new THREE.Points( + cellsGeometry, + new THREE.PointsMaterial({ + color, + size: 4.4, + sizeAttenuation: false, + transparent: true, + opacity: 0.96, + depthWrite: false, + }), + )); + const centroid = new THREE.Mesh( + new THREE.SphereGeometry(0.065, 12, 8), + new THREE.MeshBasicMaterial({ color }), + ); + centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM)); + centroid.userData.evidenceId = obstacle.id; + content.add(centroid); + } } }, [ obstacles, + occupiedVoxelSizeM, pointCloudBodyXyzM, showCurrentIncrement, showRollingMap, diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index dd2d764..ce3a4e4 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -156,6 +156,10 @@ export interface M4ThreatTimeline { nominalRateHz: number; maxChunkFrames: number; pointSampleLimit: number; + maximumSourcePointsPerFrame: number; + pointDelivery: "exact-current-increment"; + sourceRepresentationId: "registered-map-increment-v1"; + occupiedVoxelSizeM: number; rig: M4ThreatVisualFrame["rig"]; corridor: M4ThreatVisualFrame["corridor"]; } @@ -513,6 +517,11 @@ export async function fetchM4ThreatTimeline( "M4.6 recorded session", ); exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id"); + exact( + recorded.representation_id, + "registered-map-increment-v1", + "M4.6 recorded representation", + ); exact( recorded.synchronization, "host-arrival-best-effort", @@ -546,6 +555,20 @@ export async function fetchM4ThreatTimeline( nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"), maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"), pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"), + maximumSourcePointsPerFrame: integer( + payload.maximum_source_points_per_frame, + "M4.6 maximum source points", + ), + pointDelivery: exact( + payload.point_delivery, + "exact-current-increment", + "M4.6 point delivery", + ), + sourceRepresentationId: "registered-map-increment-v1", + occupiedVoxelSizeM: number( + corridor.occupied_voxel_size_m, + "M4.6 occupied voxel size", + ), rig: { lengthM: number(rig.length_m, "M4.6 rig length"), widthM: number(rig.width_m, "M4.6 rig width"), diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index f4a7360..c881dbf 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -12,6 +12,48 @@ background: var(--nodedc-canvas); } +.m4-replay-threat-visual__deck, +.m4-replay-threat-visual__layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.m4-replay-threat-visual__layer { + visibility: hidden; + opacity: 0; + pointer-events: none; +} + +.m4-replay-threat-visual__layer[data-active="true"] { + z-index: 1; + visibility: visible; + opacity: 1; + pointer-events: auto; +} + +.m4-replay-threat-visual__buffering { + position: absolute; + z-index: 5; + top: 4.9rem; + left: 50%; + display: flex; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--nodedc-glass-outline); + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-floating-surface); + padding: 0.42rem 0.58rem; + color: var(--nodedc-text-secondary); + font-size: 0.54rem; + backdrop-filter: blur(var(--nodedc-blur-control)); + pointer-events: none; + transform: translateX(-50%); +} + .laboratory-metric-evidence-scene__viewport { position: absolute; inset: 0; diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index bc14f2f..8bc415c 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Icon, IconButton } from "@nodedc/ui-react"; import { ObservationTimeline } from "../../components/ObservationTimeline"; @@ -13,7 +13,10 @@ import { type RecordedEvidenceBox, } from "../../components/laboratory/RecordedEvidenceVideoScene"; import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback"; -import type { M4ThreatCameraProposal } from "../../core/laboratory/m4ReplayThreat"; +import type { + M4ThreatCameraProposal, + M4ThreatTimelineFrame, +} from "../../core/laboratory/m4ReplayThreat"; import { recordedObservationSources } from "../../core/observation/recordedObservationSources"; import { replayObservationSession } from "../../core/observation/sessionArchive"; import type { ObservationSourceDescriptor } from "../../core/runtime/contracts"; @@ -62,6 +65,7 @@ function SpatialState({ message: text }: { message: string }) { export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { const [mode, setMode] = useState("video"); + const [spatialMode, setSpatialMode] = useState("3d"); const [expanded, setExpanded] = useState(false); const metadata = useM4ThreatTimelineMetadata(resultId); const playbackRange = useMemo(() => metadata.timeline ? ({ @@ -85,7 +89,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { useEffect(() => { const timeline = metadata.timeline; - if (mode !== "video" || !timeline || videoSource) return; + if (!timeline || videoSource) return; const controller = new AbortController(); setVideoLoading(true); setVideoError(null); @@ -122,9 +126,19 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { if (!controller.signal.aborted) setVideoLoading(false); }); return () => controller.abort(); - }, [metadata.timeline, mode, videoSource]); + }, [metadata.timeline, videoSource]); - const frame = timelineFrame.activeFrame; + const lastFrameRef = useRef(null); + useEffect(() => { + lastFrameRef.current = null; + }, [resultId]); + if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame; + const frame = timelineFrame.activeFrame ?? lastFrameRef.current; + const displayingBufferedFrame = Boolean( + frame + && timelineFrame.activeSequence !== null + && frame.sequence !== timelineFrame.activeSequence, + ); const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]); const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({ id: obstacle.componentId, @@ -147,9 +161,16 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { const seek = (seconds: number) => playbackController.seek(seconds); const handleModeChange = (next: M4ThreatViewMode) => { if (next === "camera") playbackController.setPlaying(false); + if (next === "3d" || next === "plan") setSpatialMode(next); setMode(next); }; + useEffect(() => { + if (playbackController.playback.playing || !frame) return; + const image = new Image(); + image.src = frame.cameraUrl; + }, [frame?.cameraUrl, playbackController.playback.playing]); + const actions = (
@@ -178,7 +199,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { frame {frame.sequence + 1}/{metadata.timeline.frameCount} +{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с - · {playbackController.playback.playing ? "воспроизведение" : "пауза / seek"} + · {displayingBufferedFrame + ? "держим последний кадр, следующий в буфере" + : playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
@@ -188,7 +211,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { {frame.spatialAvailable - ? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} LiDAR points` + ? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact lio_pcl increment` : "body frame / current increment unavailable"}
@@ -204,65 +227,97 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
) : undefined; + const timeline = metadata.timeline; let content; if (metadata.error) { content = ; - } else if (mode === "video") { - content = videoLoading - || metadata.loading - || Boolean(metadata.timeline && !videoSource && !videoError) ? ( -
-
- ) : videoError || !metadata.timeline || !videoSource ? ( - - ) : ( - - ); - } else if (timelineFrame.error) { - content = ; - } else if (timelineFrame.loading || !metadata.timeline || !frame) { + } else if (!timeline) { content = (
); - } else if (mode === "camera") { - content = ( - - ); - } else if (!frame.spatialAvailable) { - content = ; } else { content = ( - +
+
+ {videoSource ? ( + + ) : videoError ? ( + + ) : ( +
+
+ )} +
+
+ {mode === "camera" && frame ? ( + + ) : null} +
+
+ {frame ? ( + + ) : null} +
+ {timelineFrame.loading || displayingBufferedFrame ? ( +
+
+ ) : null} + {timelineFrame.error ? ( +
+ + {timelineFrame.error} +
+ ) : null} + {frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? ( +
+ На этом кадре нет квалифицированного body frame; сцена сохранена. +
+ ) : null} +
); } - const timeline = metadata.timeline; const transport = timeline ? ( activeChunkStart + (index - 1) * chunkSize, + ).filter((start) => start >= 0 && start < frameCount); +} + export function useM4ThreatTimelineMetadata(resultId: string) { const [timeline, setTimeline] = useState(null); const [error, setError] = useState(null); @@ -52,14 +65,22 @@ export function useM4ThreatTimelineFrame({ () => new Map(), ); const [error, setError] = useState(null); - const inFlight = useRef(new Set()); + const inFlight = useRef(new Map()); const chunksRef = useRef(chunks); + const activeChunkStartRef = useRef(null); chunksRef.current = chunks; useEffect(() => { - setChunks(new Map()); - setError(null); + for (const controller of inFlight.current.values()) controller.abort(); inFlight.current.clear(); + const empty = new Map(); + chunksRef.current = empty; + setChunks(empty); + setError(null); + return () => { + for (const controller of inFlight.current.values()) controller.abort(); + inFlight.current.clear(); + }; }, [resultId, timeline]); const activeSequence = useMemo( @@ -75,18 +96,19 @@ export function useM4ThreatTimelineFrame({ const activeChunkStart = activeSequence === null ? null : Math.floor(activeSequence / chunkSize) * chunkSize; + activeChunkStartRef.current = activeChunkStart; useEffect(() => { if (!timeline || activeChunkStart === null) return; - const starts = [activeChunkStart, activeChunkStart + chunkSize].filter( - (start) => start < timeline.frameCount, + const starts = m4ThreatChunkWindowStarts( + activeChunkStart, + chunkSize, + timeline.frameCount, ); - const controllers: AbortController[] = []; for (const start of starts) { if (chunksRef.current.has(start) || inFlight.current.has(start)) continue; const controller = new AbortController(); - controllers.push(controller); - inFlight.current.add(start); + inFlight.current.set(start, controller); void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, { signal: controller.signal, }) @@ -95,23 +117,27 @@ export function useM4ThreatTimelineFrame({ setChunks((current) => { const next = new Map(current); next.set(start, chunk); + const center = activeChunkStartRef.current ?? start; const retained = [...next.keys()] .sort((left, right) => ( - Math.abs(left - activeChunkStart) - Math.abs(right - activeChunkStart) + Math.abs(left - center) - Math.abs(right - center) )) .slice(0, RETAINED_CHUNK_COUNT); - return new Map(retained.map((key) => [key, next.get(key)!])); + const bounded = new Map(retained.map((key) => [key, next.get(key)!])); + chunksRef.current = bounded; + return bounded; }); - if (start === activeChunkStart) setError(null); + if (start === activeChunkStartRef.current) setError(null); }) .catch((caught: unknown) => { - if (!controller.signal.aborted && start === activeChunkStart) { + if (!controller.signal.aborted && start === activeChunkStartRef.current) { setError(errorMessage(caught, "3D chunk M4.6 недоступен.")); } }) - .finally(() => inFlight.current.delete(start)); + .finally(() => { + if (inFlight.current.get(start) === controller) inFlight.current.delete(start); + }); } - return () => controllers.forEach((controller) => controller.abort()); }, [activeChunkStart, chunkSize, resultId, timeline]); const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index a1f4a40..c034722 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -12,6 +12,7 @@ let fetchM4ThreatTimelineChunk; let selectM4ThreatTimelineFrame; let selectM4ThreatTimelineSequence; let advanceRecordedEvidencePlayback; +let m4ThreatChunkWindowStarts; const resultId = `m4-threat-replay-${"a".repeat(64)}`; @@ -32,6 +33,9 @@ before(async () => { ({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule( "/src/components/laboratory/useRecordedEvidencePlayback.ts", )); + ({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule( + "/src/workspaces/laboratory/useM4ThreatTimeline.ts", + )); }); after(async () => { @@ -243,6 +247,7 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk recorded_source: { session_id: "20260720T065719Z_viewer_live", source_id: "RAVNOVES00", + representation_id: "registered-map-increment-v1", synchronization: "host-arrival-best-effort", }, image_width: 800, @@ -254,18 +259,24 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk nominal_frame_interval_seconds: 0.1, nominal_rate_hz: 10, max_chunk_frames: 24, - point_sample_limit: 2000, + point_sample_limit: 4096, + maximum_source_points_per_frame: 3092, + point_delivery: "exact-current-increment", rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 }, corridor: { forward_length_m: 8, rear_margin_m: 0.5, half_width_m: 0.5, + occupied_voxel_size_m: 0.45, prediction_horizon_seconds: 5, }, authority: "replay-simulated", }), { status: 200 }), }); assert.equal(timeline.frameTimesNs.length, 4489); + assert.equal(timeline.pointDelivery, "exact-current-increment"); + assert.equal(timeline.maximumSourcePointsPerFrame, 3092); + assert.equal(timeline.occupiedVoxelSizeM, 0.45); assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1); const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, { @@ -289,6 +300,11 @@ 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 spatial buffering keeps previous, active and two future chunks", () => { + assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [24, 48, 72, 96]); + assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24, 48]); +}); + test("recorded evidence clock advances by selected rate and stops at the sealed end", () => { const range = { startSeconds: 10, endSeconds: 20 }; assert.deepEqual( @@ -319,6 +335,8 @@ test("M4.6 viewer reuses shared camera, video and metric evidence renderers", as assert.match(visual, / int: + """Return the immutable source-pack upper bound for one recorded increment.""" + + counts = np.diff(np.asarray(self._source["cloud_offsets"], dtype=np.int64)) + return int(counts.max(initial=0)) + def pose_values_for_frame( self, frame_id: str, @@ -946,7 +953,10 @@ def _camera_unavailable_observation( ) -def _load_npz(path: Path, label: str) -> dict[str, npt.NDArray[np.generic]]: +def _load_npz( + path: Path, + label: str, +) -> dict[str, npt.NDArray[np.generic]]: try: with np.load(path, allow_pickle=False) as archive: return {name: np.asarray(archive[name]) for name in archive.files} diff --git a/src/k1link/perception/threat_timeline.py b/src/k1link/perception/threat_timeline.py index 89a88fc..1e475f8 100644 --- a/src/k1link/perception/threat_timeline.py +++ b/src/k1link/perception/threat_timeline.py @@ -14,6 +14,7 @@ from threading import RLock from typing import Final from .geometry import RecordedGeometryStore +from .recorded_source import RECORDED_REPRESENTATION_ID from .spatial_evidence import ( project_metric_obstacles_to_body, sample_points_in_body_frame, @@ -32,7 +33,7 @@ from .threat_replay import ( RECORDED_SPATIAL_TIMELINE_SCHEMA: Final = "missioncore.recorded-spatial-evidence-timeline/v1" RECORDED_SPATIAL_CHUNK_SCHEMA: Final = "missioncore.recorded-spatial-evidence-chunk/v1" RECORDED_SPATIAL_FRAME_SCHEMA: Final = "missioncore.recorded-spatial-evidence-frame/v1" -RECORDED_SPATIAL_POINT_LIMIT: Final = 2_000 +RECORDED_SPATIAL_POINT_LIMIT: Final = 4_096 RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24 _EXPECTED_FRAME_COUNT: Final = 4_489 _SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)') @@ -80,6 +81,10 @@ class RecordedThreatTimeline: or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT ): raise RecordedThreatTimelineError("recorded timeline geometry identity changed") + if self.store.maximum_current_point_count > RECORDED_SPATIAL_POINT_LIMIT: + raise RecordedThreatTimelineError( + "recorded timeline exact point delivery exceeds its declared bound" + ) self.body_frames = RecordedReplayBodyFrameResolver( self.store, profile=self.profile.body_frame, @@ -99,6 +104,7 @@ class RecordedThreatTimeline: "recorded_source": { "session_id": self.profile.session_id, "source_id": self.profile.source_id, + "representation_id": RECORDED_REPRESENTATION_ID, "synchronization": "host-arrival-best-effort", }, "frame_count": len(times), @@ -109,6 +115,8 @@ class RecordedThreatTimeline: "nominal_rate_hz": 1 / nominal_interval, "max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES, "point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT, + "point_delivery": "exact-current-increment", + "maximum_source_points_per_frame": self.store.maximum_current_point_count, "image_width": 800, "image_height": 600, "rig": { @@ -119,6 +127,7 @@ class RecordedThreatTimeline: "corridor": { "forward_length_m": self.profile.corridor.forward_length_m, "rear_margin_m": self.profile.corridor.rear_margin_m, + "occupied_voxel_size_m": self.profile.corridor.occupied_voxel_size_m, "half_width_m": ( self.profile.rig.body_width_m / 2 + self.profile.corridor.lateral_clearance_m ), diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 7c85c12..3812397 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -13,6 +13,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import ValidationError +from starlette.middleware.gzip import GZipMiddleware from k1link import __version__ from k1link.artifact_gateway import configured_artifact_gateway @@ -414,6 +415,7 @@ app = FastAPI( openapi_url="/api/openapi.json", lifespan=app_lifespan, ) +app.add_middleware(GZipMiddleware, minimum_size=1_024, compresslevel=5) @app.exception_handler(RequestValidationError) diff --git a/tests/test_m4_threat_replay_result.py b/tests/test_m4_threat_replay_result.py index 034fa00..a8f81b8 100644 --- a/tests/test_m4_threat_replay_result.py +++ b/tests/test_m4_threat_replay_result.py @@ -130,6 +130,9 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None: assert timeline["frame_count"] == 4489 assert len(timeline["frame_times_ns"]) == 4489 assert timeline["recorded_source"]["session_id"] == "20260720T065719Z_viewer_live" + assert timeline["recorded_source"]["representation_id"] == "registered-map-increment-v1" + assert timeline["point_delivery"] == "exact-current-increment" + assert timeline["maximum_source_points_per_frame"] == 3092 assert "frames" not in timeline chunk = get_chunk(RESULT_ID, start=1880, count=12) @@ -141,7 +144,8 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None: assert first["schema_version"] == "missioncore.recorded-spatial-evidence-frame/v1" assert first["spatial_available"] is True assert first["point_cloud_layer"] == "current-increment" - assert 0 < first["point_cloud_sample_count"] <= 2000 + assert first["point_cloud_sample_count"] == first["point_cloud_source_count"] + assert 0 < first["point_cloud_sample_count"] <= 4096 assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera")