feat(lab): add recorded realtime spatial playback

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 23:17:48 +03:00
parent aacc6dc43b
commit 9df9f58ab8
23 changed files with 1443 additions and 517 deletions
@@ -125,22 +125,47 @@ export interface M4ThreatVisualIndexItem {
pointCloudSampleCount: number;
}
export interface M4ThreatVideoFrame {
frameIndex: number;
export interface M4ThreatTimelineFrame {
sequence: number;
frameId: string;
sourceTimeNs: number;
sessionSeconds: number;
sourceAvailable: boolean;
spatialAvailable: boolean;
pointCloudBodyXyzM: readonly M4Point3[];
pointCloudSourceCount: number;
pointCloudSampleCount: number;
pointCloudLayer: "current-increment";
rollingMapComponentCount: number;
metricObstacles: readonly M4ThreatMetricVisual[];
cameraProposals: readonly M4ThreatCameraProposal[];
decisionCounts: Record<M4ThreatDecision, number>;
cameraUrl: string;
}
export interface M4ThreatVideoOverlay {
export interface M4ThreatTimeline {
resultId: string;
recordedSourceSessionId: "20260720T065719Z_viewer_live";
imageWidth: 800;
imageHeight: 600;
frameCount: 4489;
frameTimesNs: readonly number[];
timelineStartSeconds: number;
timelineEndSeconds: number;
frames: readonly M4ThreatVideoFrame[];
nominalFrameIntervalSeconds: number;
nominalRateHz: number;
maxChunkFrames: number;
pointSampleLimit: number;
rig: M4ThreatVisualFrame["rig"];
corridor: M4ThreatVisualFrame["corridor"];
}
export interface M4ThreatTimelineChunk {
resultId: string;
startSequence: number;
frameCount: number;
nextSequence: number | null;
frames: readonly M4ThreatTimelineFrame[];
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -245,6 +270,29 @@ function parseCameraProposal(value: unknown): M4ThreatCameraProposal {
};
}
function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
const item = object(value, "M4.6 metric visual");
const state = text(item.state, "M4.6 temporal state");
if (
state !== "current"
&& state !== "retained"
&& state !== "held"
&& state !== "expired"
) {
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
}
return {
componentId: text(item.component_id, "M4.6 visual component"),
state,
motion: motion(item.motion),
centroidBodyXyzM: vector(item.centroid_body_xyz_m, 3, "M4.6 centroid") as [number, number, number],
cellCentersBodyXyzM: array(item.cell_centers_body_xyz_m, "M4.6 cells").map(
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
),
assessment: parseAssessment(item.assessment),
};
}
export async function fetchM4ThreatReplayResult({
fetcher = fetch,
signal,
@@ -425,28 +473,7 @@ export async function fetchM4ThreatVisual(
rollingMapComponentCount: rollingMapV2
? integer(item.rolling_map_component_count, "M4.6 rolling components")
: 0,
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map((raw) => {
const value = object(raw, "M4.6 metric visual");
const state = text(value.state, "M4.6 temporal state");
if (
state !== "current"
&& state !== "retained"
&& state !== "held"
&& state !== "expired"
) {
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
}
return {
componentId: text(value.component_id, "M4.6 visual component"),
state,
motion: motion(value.motion),
centroidBodyXyzM: vector(value.centroid_body_xyz_m, 3, "M4.6 centroid") as [number, number, number],
cellCentersBodyXyzM: array(value.cell_centers_body_xyz_m, "M4.6 cells").map(
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
),
assessment: parseAssessment(value.assessment),
};
}),
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map(parseMetricVisual),
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
rig: {
lengthM: number(rig.length_m, "M4.6 rig length"),
@@ -462,71 +489,211 @@ export async function fetchM4ThreatVisual(
};
}
export async function fetchM4ThreatVideoOverlay(
export async function fetchM4ThreatTimeline(
result: string,
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<M4ThreatVideoOverlay> {
): Promise<M4ThreatTimeline> {
const response = await fetcher(
`/api/v1/laboratory/m4-threat/results/${result}/video-overlay`,
`/api/v1/laboratory/m4-threat/results/${result}/timeline`,
{ headers: { Accept: "application/json" }, signal },
);
if (!response.ok) throw new M4ThreatContractError(`M4.6 video overlay: HTTP ${response.status}.`);
const payload = object(await response.json(), "M4.6 video overlay");
exact(payload.schema_version, "missioncore.m4-threat-video-overlay/v1", "M4.6 video schema");
exact(payload.result_id, result, "M4.6 video result");
exact(payload.authority, "replay-simulated", "M4.6 video authority");
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline: HTTP ${response.status}.`);
const payload = object(await response.json(), "M4.6 timeline");
exact(
payload.schema_version,
"missioncore.recorded-spatial-evidence-timeline/v1",
"M4.6 timeline schema",
);
exact(payload.result_id, result, "M4.6 timeline result");
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
const recorded = object(payload.recorded_source, "M4.6 recorded source");
exact(
recorded.session_id,
"20260720T065719Z_viewer_live",
"M4.6 recorded session",
);
const frames = array(payload.frames, "M4.6 video frames").map((raw, expectedIndex) => {
const item = object(raw, "M4.6 video frame");
const frameIndex = integer(item.frame_index, "M4.6 video index");
if (frameIndex !== expectedIndex) throw new M4ThreatContractError("M4.6 video order.");
const counts = object(item.decision_counts, "M4.6 video decisions");
return {
frameIndex,
sessionSeconds: number(item.session_seconds, "M4.6 video time"),
sourceAvailable: typeof item.source_available === "boolean" ? item.source_available : false,
cameraProposals: array(item.camera_proposals, "M4.6 video proposals").map(parseCameraProposal),
decisionCounts: {
threat: integer(counts.threat, "M4.6 video threat"),
"not-threat": integer(counts["not-threat"], "M4.6 video clear"),
unknown: integer(counts.unknown, "M4.6 video unknown"),
},
};
});
exact(payload.frame_count, 4489, "M4.6 video frame count");
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
exact(
recorded.synchronization,
"host-arrival-best-effort",
"M4.6 recorded synchronization",
);
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
(value) => integer(value, "M4.6 timeline time"),
);
if (
frameTimesNs.length !== frameCount
|| frameTimesNs.some((value, index) => index > 0 && value <= (frameTimesNs[index - 1] ?? value))
) {
throw new M4ThreatContractError("M4.6 timeline index: нарушен порядок.");
}
const rig = object(payload.rig, "M4.6 timeline rig");
const corridor = object(payload.corridor, "M4.6 timeline corridor");
return {
resultId: result,
recordedSourceSessionId: "20260720T065719Z_viewer_live",
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 video start"),
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 video end"),
frameCount,
frameTimesNs,
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 timeline start"),
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 timeline end"),
nominalFrameIntervalSeconds: number(
payload.nominal_frame_interval_seconds,
"M4.6 timeline interval",
),
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"),
rig: {
lengthM: number(rig.length_m, "M4.6 rig length"),
widthM: number(rig.width_m, "M4.6 rig width"),
nominalSensorHeightM: number(rig.nominal_sensor_height_m, "M4.6 sensor height"),
},
corridor: {
forwardLengthM: number(corridor.forward_length_m, "M4.6 forward corridor"),
rearMarginM: number(corridor.rear_margin_m, "M4.6 rear corridor"),
halfWidthM: number(corridor.half_width_m, "M4.6 half width"),
predictionHorizonSeconds: number(
corridor.prediction_horizon_seconds,
"M4.6 prediction horizon",
),
},
};
}
export async function fetchM4ThreatTimelineChunk(
result: string,
startSequence: number,
frameCount: number,
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<M4ThreatTimelineChunk> {
const params = new URLSearchParams({
start: String(startSequence),
count: String(frameCount),
});
const response = await fetcher(
`/api/v1/laboratory/m4-threat/results/${result}/timeline/chunk?${params}`,
{ headers: { Accept: "application/json" }, signal },
);
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline chunk: HTTP ${response.status}.`);
const payload = object(await response.json(), "M4.6 timeline chunk");
exact(
payload.schema_version,
"missioncore.recorded-spatial-evidence-chunk/v1",
"M4.6 timeline chunk schema",
);
exact(payload.result_id, result, "M4.6 timeline chunk result");
exact(payload.authority, "replay-simulated", "M4.6 timeline chunk authority");
const parsedStart = integer(payload.start_sequence, "M4.6 timeline chunk start");
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));
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: нарушен контракт.");
}
return {
resultId: result,
startSequence: parsedStart,
frameCount: parsedCount,
nextSequence: payload.next_sequence === null
? null
: integer(payload.next_sequence, "M4.6 timeline next sequence"),
frames,
};
}
export function selectM4ThreatVideoFrame(
frames: readonly M4ThreatVideoFrame[],
function parseTimelineFrame(
value: unknown,
result: string,
expectedSequence: number,
): M4ThreatTimelineFrame {
const item = object(value, "M4.6 timeline frame");
exact(
item.schema_version,
"missioncore.recorded-spatial-evidence-frame/v1",
"M4.6 timeline frame schema",
);
exact(item.authority, "replay-simulated", "M4.6 timeline frame authority");
const sequence = integer(item.sequence, "M4.6 timeline sequence");
if (sequence !== expectedSequence) {
throw new M4ThreatContractError("M4.6 timeline frame order: нарушен контракт.");
}
const counts = object(item.decision_counts, "M4.6 timeline decisions");
const cameraUrl = text(item.camera_url, "M4.6 timeline camera URL");
if (!cameraUrl.includes(`/results/${result}/timeline/frames/${sequence}/camera`)) {
throw new M4ThreatContractError("M4.6 timeline camera URL: нарушена идентичность.");
}
return {
sequence,
frameId: text(item.frame_id, "M4.6 timeline frame id"),
sourceTimeNs: integer(item.source_time_ns, "M4.6 timeline source time"),
sessionSeconds: number(item.session_seconds, "M4.6 timeline time"),
sourceAvailable: typeof item.source_available === "boolean" && item.source_available,
spatialAvailable: typeof item.spatial_available === "boolean" && item.spatial_available,
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 timeline points").map(
(point) => vector(point, 3, "M4.6 timeline point") as [number, number, number],
),
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sampled points"),
pointCloudLayer: exact(
item.point_cloud_layer,
"current-increment",
"M4.6 timeline point layer",
),
rollingMapComponentCount: integer(
item.rolling_map_component_count,
"M4.6 rolling components",
),
metricObstacles: array(item.metric_obstacles, "M4.6 timeline obstacles").map(
parseMetricVisual,
),
cameraProposals: array(item.camera_proposals, "M4.6 timeline proposals").map(
parseCameraProposal,
),
decisionCounts: {
threat: integer(counts.threat, "M4.6 timeline threat"),
"not-threat": integer(counts["not-threat"], "M4.6 timeline clear"),
unknown: integer(counts.unknown, "M4.6 timeline unknown"),
},
cameraUrl,
};
}
export function selectM4ThreatTimelineSequence(
frameTimesNs: readonly number[],
seconds: number,
): M4ThreatVideoFrame | null {
if (!frames.length) return null;
): number | null {
if (!frameTimesNs.length || !Number.isFinite(seconds)) return null;
const targetNs = seconds * 1_000_000_000;
let low = 0;
let high = frames.length - 1;
let high = frameTimesNs.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
const current = frames[middle];
if (!current || current.sessionSeconds < seconds) low = middle + 1;
const current = frameTimesNs[middle];
if (current === undefined || current < targetNs) low = middle + 1;
else high = middle;
}
const current = frames[low] ?? frames[frames.length - 1] ?? null;
const previous = frames[Math.max(0, low - 1)] ?? null;
if (!current || !previous) return current;
return Math.abs(previous.sessionSeconds - seconds) <= Math.abs(current.sessionSeconds - seconds)
? previous
: current;
const current = frameTimesNs[low];
const previousIndex = Math.max(0, low - 1);
const previous = frameTimesNs[previousIndex];
if (current === undefined) return frameTimesNs.length - 1;
if (previous === undefined) return low;
return Math.abs(previous - targetNs) <= Math.abs(current - targetNs) ? previousIndex : low;
}
export function selectM4ThreatTimelineFrame(
frames: readonly M4ThreatTimelineFrame[],
seconds: number,
): M4ThreatTimelineFrame | null {
if (!frames.length) return null;
const local = selectM4ThreatTimelineSequence(
frames.map((frame) => frame.sourceTimeNs),
seconds,
);
return local === null ? null : frames[local] ?? null;
}