|
|
|
@@ -0,0 +1,460 @@
|
|
|
|
|
export type M4ThreatDecision = "threat" | "not-threat" | "unknown";
|
|
|
|
|
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
|
|
|
|
export type M4Point3 = readonly [number, number, number];
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatReplayResult {
|
|
|
|
|
resultId: string;
|
|
|
|
|
createdAtUtc: string;
|
|
|
|
|
profileId: string;
|
|
|
|
|
rigProfileId: string;
|
|
|
|
|
corridorProfileId: string;
|
|
|
|
|
sourceResultIds: {
|
|
|
|
|
detector: string;
|
|
|
|
|
geometry: string;
|
|
|
|
|
temporal: string;
|
|
|
|
|
};
|
|
|
|
|
metrics: {
|
|
|
|
|
decisions: Record<M4ThreatDecision, number>;
|
|
|
|
|
evidence: {
|
|
|
|
|
cameraOnly: number;
|
|
|
|
|
currentMetric: number;
|
|
|
|
|
staleOrHeld: number;
|
|
|
|
|
};
|
|
|
|
|
fixtures: {
|
|
|
|
|
critical: number;
|
|
|
|
|
criticalFalseNotThreat: number;
|
|
|
|
|
passed: number;
|
|
|
|
|
total: number;
|
|
|
|
|
};
|
|
|
|
|
runtime: {
|
|
|
|
|
framesPerSecond: number;
|
|
|
|
|
providerLatencyP50Ms: number;
|
|
|
|
|
providerLatencyP95Ms: number;
|
|
|
|
|
providerLatencyMaxMs: number;
|
|
|
|
|
};
|
|
|
|
|
reasonCounts: Readonly<Record<string, number>>;
|
|
|
|
|
};
|
|
|
|
|
configuration: {
|
|
|
|
|
virtualBodyM: readonly [number, number];
|
|
|
|
|
nominalSensorHeightM: number;
|
|
|
|
|
forwardCorridorM: number;
|
|
|
|
|
predictionHorizonSeconds: number;
|
|
|
|
|
};
|
|
|
|
|
limitations: readonly string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatAssessment {
|
|
|
|
|
componentId: string;
|
|
|
|
|
decision: M4ThreatDecision;
|
|
|
|
|
corridorIntersection: "intersects" | "clear" | "unknown";
|
|
|
|
|
relativeSpeedMps: number | null;
|
|
|
|
|
closestApproachM: number | null;
|
|
|
|
|
ttcSeconds: number | null;
|
|
|
|
|
reasonCodes: readonly string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatMetricVisual {
|
|
|
|
|
componentId: string;
|
|
|
|
|
state: "current" | "held" | "expired";
|
|
|
|
|
motion: M4ThreatMotion;
|
|
|
|
|
centroidBodyXyzM: M4Point3;
|
|
|
|
|
cellCentersBodyXyzM: readonly M4Point3[];
|
|
|
|
|
assessment: M4ThreatAssessment;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatCameraProposal {
|
|
|
|
|
proposalId: string;
|
|
|
|
|
bboxXyxy: readonly [number, number, number, number];
|
|
|
|
|
objectness: number;
|
|
|
|
|
semanticHint: string | null;
|
|
|
|
|
occupiedSupport: boolean;
|
|
|
|
|
rangeM: number | null;
|
|
|
|
|
threatDecision: M4ThreatDecision | null;
|
|
|
|
|
threatReasonCodes: readonly string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatVisualFrame {
|
|
|
|
|
resultId: string;
|
|
|
|
|
ordinal: number;
|
|
|
|
|
sequence: number;
|
|
|
|
|
frameId: string;
|
|
|
|
|
sourceTimeNs: number;
|
|
|
|
|
pointCloudBodyXyzM: readonly M4Point3[];
|
|
|
|
|
pointCloudSourceCount: number;
|
|
|
|
|
pointCloudSampleCount: number;
|
|
|
|
|
metricObstacles: readonly M4ThreatMetricVisual[];
|
|
|
|
|
cameraProposals: readonly M4ThreatCameraProposal[];
|
|
|
|
|
rig: {
|
|
|
|
|
lengthM: number;
|
|
|
|
|
widthM: number;
|
|
|
|
|
nominalSensorHeightM: number;
|
|
|
|
|
};
|
|
|
|
|
corridor: {
|
|
|
|
|
forwardLengthM: number;
|
|
|
|
|
rearMarginM: number;
|
|
|
|
|
halfWidthM: number;
|
|
|
|
|
predictionHorizonSeconds: number;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatVisualIndexItem {
|
|
|
|
|
ordinal: number;
|
|
|
|
|
sequence: number;
|
|
|
|
|
frameId: string;
|
|
|
|
|
sourceTimeNs: number;
|
|
|
|
|
metricObstacleCount: number;
|
|
|
|
|
cameraProposalCount: number;
|
|
|
|
|
pointCloudSampleCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatVideoFrame {
|
|
|
|
|
frameIndex: number;
|
|
|
|
|
sessionSeconds: number;
|
|
|
|
|
sourceAvailable: boolean;
|
|
|
|
|
cameraProposals: readonly M4ThreatCameraProposal[];
|
|
|
|
|
decisionCounts: Record<M4ThreatDecision, number>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M4ThreatVideoOverlay {
|
|
|
|
|
resultId: string;
|
|
|
|
|
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
|
|
|
|
imageWidth: 800;
|
|
|
|
|
imageHeight: 600;
|
|
|
|
|
timelineStartSeconds: number;
|
|
|
|
|
timelineEndSeconds: number;
|
|
|
|
|
frames: readonly M4ThreatVideoFrame[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
|
|
|
class M4ThreatContractError extends Error {}
|
|
|
|
|
const object = (value: unknown, label: string): Record<string, unknown> => {
|
|
|
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
|
|
|
throw new M4ThreatContractError(`${label}: ожидался объект.`);
|
|
|
|
|
}
|
|
|
|
|
return value as Record<string, unknown>;
|
|
|
|
|
};
|
|
|
|
|
const array = (value: unknown, label: string): readonly unknown[] => {
|
|
|
|
|
if (!Array.isArray(value)) throw new M4ThreatContractError(`${label}: ожидался массив.`);
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
const text = (value: unknown, label: string): string => {
|
|
|
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
|
|
|
throw new M4ThreatContractError(`${label}: ожидалась строка.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
const number = (value: unknown, label: string): number => {
|
|
|
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
|
|
|
throw new M4ThreatContractError(`${label}: ожидалось число.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
const integer = (value: unknown, label: string): number => {
|
|
|
|
|
const parsed = number(value, label);
|
|
|
|
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
|
|
|
throw new M4ThreatContractError(`${label}: ожидалось целое.`);
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
};
|
|
|
|
|
const exact = <T extends string | number | boolean>(
|
|
|
|
|
value: unknown,
|
|
|
|
|
expected: T,
|
|
|
|
|
label: string,
|
|
|
|
|
): T => {
|
|
|
|
|
if (value !== expected) throw new M4ThreatContractError(`${label}: нарушен контракт.`);
|
|
|
|
|
return expected;
|
|
|
|
|
};
|
|
|
|
|
const optionalNumber = (value: unknown, label: string): number | null => (
|
|
|
|
|
value === null ? null : number(value, label)
|
|
|
|
|
);
|
|
|
|
|
const vector = (value: unknown, size: number, label: string): number[] => {
|
|
|
|
|
const parsed = array(value, label).map((item) => number(item, label));
|
|
|
|
|
if (parsed.length !== size) throw new M4ThreatContractError(`${label}: неверная размерность.`);
|
|
|
|
|
return parsed;
|
|
|
|
|
};
|
|
|
|
|
const decision = (value: unknown, label: string): M4ThreatDecision => {
|
|
|
|
|
if (value !== "threat" && value !== "not-threat" && value !== "unknown") {
|
|
|
|
|
throw new M4ThreatContractError(`${label}: неизвестное решение.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
const motion = (value: unknown): M4ThreatMotion => {
|
|
|
|
|
if (value !== "moving" && value !== "stationary" && value !== "unknown") {
|
|
|
|
|
throw new M4ThreatContractError("M4.6 motion: неизвестное состояние.");
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
const resultId = (value: unknown): string => {
|
|
|
|
|
const parsed = text(value, "M4.6 result id");
|
|
|
|
|
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
|
|
|
|
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function parseAssessment(value: unknown): M4ThreatAssessment {
|
|
|
|
|
const item = object(value, "M4.6 assessment");
|
|
|
|
|
const intersection = text(item.corridor_intersection, "M4.6 intersection");
|
|
|
|
|
if (intersection !== "intersects" && intersection !== "clear" && intersection !== "unknown") {
|
|
|
|
|
throw new M4ThreatContractError("M4.6 intersection: неизвестное состояние.");
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
componentId: text(item.component_id, "M4.6 component"),
|
|
|
|
|
decision: decision(item.decision, "M4.6 decision"),
|
|
|
|
|
corridorIntersection: intersection,
|
|
|
|
|
relativeSpeedMps: optionalNumber(item.relative_speed_mps, "M4.6 relative speed"),
|
|
|
|
|
closestApproachM: optionalNumber(item.closest_approach_m, "M4.6 closest approach"),
|
|
|
|
|
ttcSeconds: optionalNumber(item.ttc_seconds, "M4.6 TTC"),
|
|
|
|
|
reasonCodes: array(item.reason_codes, "M4.6 reasons").map((reason) => text(reason, "M4.6 reason")),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseCameraProposal(value: unknown): M4ThreatCameraProposal {
|
|
|
|
|
const item = object(value, "M4.6 camera proposal");
|
|
|
|
|
return {
|
|
|
|
|
proposalId: text(item.proposal_id, "M4.6 proposal id"),
|
|
|
|
|
bboxXyxy: vector(item.bbox_xyxy, 4, "M4.6 bbox") as [number, number, number, number],
|
|
|
|
|
objectness: number(item.objectness, "M4.6 objectness"),
|
|
|
|
|
semanticHint: item.semantic_hint === null ? null : text(item.semantic_hint, "M4.6 hint"),
|
|
|
|
|
occupiedSupport: typeof item.occupied_support === "boolean" ? item.occupied_support : false,
|
|
|
|
|
rangeM: optionalNumber(item.range_m, "M4.6 range"),
|
|
|
|
|
threatDecision: item.threat_decision === null
|
|
|
|
|
? null
|
|
|
|
|
: decision(item.threat_decision, "M4.6 camera threat"),
|
|
|
|
|
threatReasonCodes: array(item.threat_reason_codes, "M4.6 threat reasons").map(
|
|
|
|
|
(reason) => text(reason, "M4.6 threat reason"),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchM4ThreatReplayResult({
|
|
|
|
|
fetcher = fetch,
|
|
|
|
|
signal,
|
|
|
|
|
}: {
|
|
|
|
|
fetcher?: LaboratoryFetch;
|
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
} = {}): Promise<M4ThreatReplayResult | null> {
|
|
|
|
|
const response = await fetcher("/api/v1/laboratory/m4-threat/results?limit=1", {
|
|
|
|
|
headers: { Accept: "application/json" },
|
|
|
|
|
signal,
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new M4ThreatContractError(`M4.6 LAB недоступен: HTTP ${response.status}.`);
|
|
|
|
|
const catalog = object(await response.json(), "M4.6 catalog");
|
|
|
|
|
exact(catalog.schema_version, "missioncore.m4-threat-replay-catalog/v1", "M4.6 catalog schema");
|
|
|
|
|
const items = array(catalog.items, "M4.6 results");
|
|
|
|
|
if (!items.length) return null;
|
|
|
|
|
const item = object(items[0], "M4.6 result");
|
|
|
|
|
exact(item.schema_version, "missioncore.m4-threat-replay-view/v1", "M4.6 view schema");
|
|
|
|
|
exact(item.accepted, true, "M4.6 acceptance");
|
|
|
|
|
exact(item.authority, "replay-simulated", "M4.6 authority");
|
|
|
|
|
exact(item.physical_collision_accepted, false, "M4.6 physical authority");
|
|
|
|
|
exact(item.actuation_allowed, false, "M4.6 actuation");
|
|
|
|
|
const metrics = object(item.metrics, "M4.6 metrics");
|
|
|
|
|
const decisions = object(metrics.decisions, "M4.6 decisions");
|
|
|
|
|
const evidence = object(metrics.evidence, "M4.6 evidence");
|
|
|
|
|
const fixtures = object(metrics.fixtures, "M4.6 fixtures");
|
|
|
|
|
const runtime = object(metrics.runtime, "M4.6 runtime");
|
|
|
|
|
const configuration = object(item.configuration, "M4.6 configuration");
|
|
|
|
|
const sourceResultIds = object(item.source_result_ids, "M4.6 sources");
|
|
|
|
|
return {
|
|
|
|
|
resultId: resultId(item.result_id),
|
|
|
|
|
createdAtUtc: text(item.created_at_utc, "M4.6 created"),
|
|
|
|
|
profileId: text(item.profile_id, "M4.6 profile"),
|
|
|
|
|
rigProfileId: text(item.rig_profile_id, "M4.6 rig"),
|
|
|
|
|
corridorProfileId: text(item.corridor_profile_id, "M4.6 corridor"),
|
|
|
|
|
sourceResultIds: {
|
|
|
|
|
detector: text(sourceResultIds.detector, "M4.6 detector"),
|
|
|
|
|
geometry: text(sourceResultIds.geometry, "M4.6 geometry"),
|
|
|
|
|
temporal: text(sourceResultIds.temporal, "M4.6 temporal"),
|
|
|
|
|
},
|
|
|
|
|
metrics: {
|
|
|
|
|
decisions: {
|
|
|
|
|
threat: integer(decisions.threat, "M4.6 threat count"),
|
|
|
|
|
"not-threat": integer(decisions["not-threat"], "M4.6 clear count"),
|
|
|
|
|
unknown: integer(decisions.unknown, "M4.6 unknown count"),
|
|
|
|
|
},
|
|
|
|
|
evidence: {
|
|
|
|
|
cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"),
|
|
|
|
|
currentMetric: integer(evidence["current-metric"], "M4.6 metric"),
|
|
|
|
|
staleOrHeld: integer(evidence["stale-or-held"], "M4.6 stale"),
|
|
|
|
|
},
|
|
|
|
|
fixtures: {
|
|
|
|
|
critical: integer(fixtures.critical, "M4.6 critical fixtures"),
|
|
|
|
|
criticalFalseNotThreat: integer(fixtures.critical_false_not_threat, "M4.6 false-safe"),
|
|
|
|
|
passed: integer(fixtures.passed, "M4.6 fixtures passed"),
|
|
|
|
|
total: integer(fixtures.total, "M4.6 fixtures total"),
|
|
|
|
|
},
|
|
|
|
|
runtime: {
|
|
|
|
|
framesPerSecond: number(runtime.frames_per_second, "M4.6 FPS"),
|
|
|
|
|
providerLatencyP50Ms: number(runtime.provider_latency_p50_ms, "M4.6 p50"),
|
|
|
|
|
providerLatencyP95Ms: number(runtime.provider_latency_p95_ms, "M4.6 p95"),
|
|
|
|
|
providerLatencyMaxMs: number(runtime.provider_latency_max_ms, "M4.6 max"),
|
|
|
|
|
},
|
|
|
|
|
reasonCounts: Object.fromEntries(
|
|
|
|
|
Object.entries(object(metrics.reason_counts, "M4.6 reasons")).map(
|
|
|
|
|
([key, value]) => [key, integer(value, `M4.6 ${key}`)],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
configuration: {
|
|
|
|
|
virtualBodyM: vector(configuration.virtual_body_m, 2, "M4.6 body") as [number, number],
|
|
|
|
|
nominalSensorHeightM: number(configuration.nominal_sensor_height_m, "M4.6 height"),
|
|
|
|
|
forwardCorridorM: number(configuration.forward_corridor_m, "M4.6 corridor"),
|
|
|
|
|
predictionHorizonSeconds: number(configuration.prediction_horizon_seconds, "M4.6 horizon"),
|
|
|
|
|
},
|
|
|
|
|
limitations: array(item.limitations, "M4.6 limitations").map((value) => text(value, "M4.6 limitation")),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchM4ThreatVisualIndex(
|
|
|
|
|
result: string,
|
|
|
|
|
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
|
|
|
|
): Promise<readonly M4ThreatVisualIndexItem[]> {
|
|
|
|
|
const response = await fetcher(`/api/v1/laboratory/m4-threat/results/${result}/visuals`, {
|
|
|
|
|
headers: { Accept: "application/json" },
|
|
|
|
|
signal,
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual index: HTTP ${response.status}.`);
|
|
|
|
|
const payload = object(await response.json(), "M4.6 visual index");
|
|
|
|
|
exact(payload.schema_version, "missioncore.m4-threat-visual-catalog/v1", "M4.6 visual schema");
|
|
|
|
|
exact(payload.result_id, result, "M4.6 visual result");
|
|
|
|
|
return array(payload.items, "M4.6 visual items").map((raw) => {
|
|
|
|
|
const item = object(raw, "M4.6 visual item");
|
|
|
|
|
return {
|
|
|
|
|
ordinal: integer(item.ordinal, "M4.6 visual ordinal"),
|
|
|
|
|
sequence: integer(item.sequence, "M4.6 visual sequence"),
|
|
|
|
|
frameId: text(item.frame_id, "M4.6 visual frame"),
|
|
|
|
|
sourceTimeNs: integer(item.source_time_ns, "M4.6 visual time"),
|
|
|
|
|
metricObstacleCount: integer(item.metric_obstacle_count, "M4.6 visual metric"),
|
|
|
|
|
cameraProposalCount: integer(item.camera_proposal_count, "M4.6 visual camera"),
|
|
|
|
|
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 visual points"),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchM4ThreatVisual(
|
|
|
|
|
result: string,
|
|
|
|
|
ordinal: number,
|
|
|
|
|
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
|
|
|
|
): Promise<M4ThreatVisualFrame> {
|
|
|
|
|
const response = await fetcher(
|
|
|
|
|
`/api/v1/laboratory/m4-threat/results/${result}/visuals/${ordinal}`,
|
|
|
|
|
{ headers: { Accept: "application/json" }, signal },
|
|
|
|
|
);
|
|
|
|
|
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`);
|
|
|
|
|
const item = object(await response.json(), "M4.6 visual frame");
|
|
|
|
|
exact(item.schema_version, "missioncore.perception-threat-visual-frame/v1", "M4.6 frame schema");
|
|
|
|
|
exact(item.result_id, result, "M4.6 frame result");
|
|
|
|
|
const rig = object(item.rig, "M4.6 visual rig");
|
|
|
|
|
const corridor = object(item.corridor, "M4.6 visual corridor");
|
|
|
|
|
return {
|
|
|
|
|
resultId: result,
|
|
|
|
|
ordinal: integer(item.ordinal, "M4.6 ordinal"),
|
|
|
|
|
sequence: integer(item.sequence, "M4.6 sequence"),
|
|
|
|
|
frameId: text(item.frame_id, "M4.6 frame id"),
|
|
|
|
|
sourceTimeNs: integer(item.source_time_ns, "M4.6 frame time"),
|
|
|
|
|
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 points").map(
|
|
|
|
|
(point) => vector(point, 3, "M4.6 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 sample points"),
|
|
|
|
|
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 !== "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),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
|
|
|
|
|
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 visual horizon"),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchM4ThreatVideoOverlay(
|
|
|
|
|
result: string,
|
|
|
|
|
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
|
|
|
|
): Promise<M4ThreatVideoOverlay> {
|
|
|
|
|
const response = await fetcher(
|
|
|
|
|
`/api/v1/laboratory/m4-threat/results/${result}/video-overlay`,
|
|
|
|
|
{ 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");
|
|
|
|
|
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");
|
|
|
|
|
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"),
|
|
|
|
|
frames,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function selectM4ThreatVideoFrame(
|
|
|
|
|
frames: readonly M4ThreatVideoFrame[],
|
|
|
|
|
seconds: number,
|
|
|
|
|
): M4ThreatVideoFrame | null {
|
|
|
|
|
if (!frames.length) return null;
|
|
|
|
|
let low = 0;
|
|
|
|
|
let high = frames.length - 1;
|
|
|
|
|
while (low < high) {
|
|
|
|
|
const middle = Math.floor((low + high) / 2);
|
|
|
|
|
const current = frames[middle];
|
|
|
|
|
if (!current || current.sessionSeconds < seconds) 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;
|
|
|
|
|
}
|