feat(lab): add recorded realtime spatial playback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user