perf(lab): stream sealed spatial playback tracks
This commit is contained in:
@@ -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" | "<f4" | "|u1";
|
||||
shape: readonly number[];
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface M49TgsFullShadowPlaybackFrame {
|
||||
sourceSequence: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sampleAvailable: boolean;
|
||||
metrics: M49TgsFullShadowSpatial["metrics"];
|
||||
}
|
||||
|
||||
export interface M49TgsFullShadowPlaybackPack {
|
||||
resultId: string;
|
||||
frameCount: 4489;
|
||||
cellCount: 2244;
|
||||
totalByteLength: number;
|
||||
centersXyM: readonly (readonly [number, number])[];
|
||||
states: Uint8Array;
|
||||
zBoundsM: Float32Array;
|
||||
frames: readonly M49TgsFullShadowPlaybackFrame[];
|
||||
}
|
||||
|
||||
export class M49TgsFullShadowContractError extends Error {}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
@@ -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 !== "<f4" && parsed !== "|u1") {
|
||||
throw new M49TgsFullShadowContractError(`${label}: неизвестный dtype.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function playbackShape(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
throw new M49TgsFullShadowContractError(`${label}: ожидалась размерность.`);
|
||||
}
|
||||
return value.map((item, index) => integer(item, `${label}.${index}`));
|
||||
}
|
||||
|
||||
async function fetchPlaybackManifest(
|
||||
id: string,
|
||||
fetcher: LaboratoryFetch,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<{
|
||||
frameCount: 4489;
|
||||
cellCount: 2244;
|
||||
totalByteLength: number;
|
||||
tracks: ReadonlyMap<M49TgsFullShadowPlaybackTrackId, M49TgsFullShadowPlaybackTrack>;
|
||||
}> {
|
||||
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<M49TgsFullShadowPlaybackTrackId, M49TgsFullShadowPlaybackTrack>();
|
||||
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<string> {
|
||||
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<ArrayBuffer> {
|
||||
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: "<f4" | "|u1",
|
||||
expectedShape: readonly number[],
|
||||
): Float32Array | Uint8Array {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (bytes.byteLength < 12
|
||||
|| bytes[0] !== 0x93
|
||||
|| String.fromCharCode(...bytes.subarray(1, 6)) !== "NUMPY") {
|
||||
throw new M49TgsFullShadowContractError("M49 playback NPY: неверная сигнатура.");
|
||||
}
|
||||
const major = bytes[6];
|
||||
const headerLength = major === 1
|
||||
? new DataView(buffer).getUint16(8, true)
|
||||
: major === 2 || major === 3
|
||||
? new DataView(buffer).getUint32(8, true)
|
||||
: -1;
|
||||
const headerOffset = major === 1 ? 10 : 12;
|
||||
if (headerLength < 0 || headerOffset + headerLength > 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 === "<f4" ? 4 : 1;
|
||||
if (dataOffset + count * bytesPerValue !== buffer.byteLength) {
|
||||
throw new M49TgsFullShadowContractError("M49 playback NPY: длина payload изменилась.");
|
||||
}
|
||||
return expectedDtype === "<f4"
|
||||
? new Float32Array(buffer, dataOffset, count)
|
||||
: new Uint8Array(buffer, dataOffset, count);
|
||||
}
|
||||
|
||||
function parsePlaybackFrames(buffer: ArrayBuffer): readonly M49TgsFullShadowPlaybackFrame[] {
|
||||
const lines = new TextDecoder().decode(buffer).trim().split("\n");
|
||||
if (lines.length !== 4489) {
|
||||
throw new M49TgsFullShadowContractError("M49 playback frames: неверная размерность.");
|
||||
}
|
||||
return lines.map((line, sourceSequence) => {
|
||||
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<M49TgsFullShadowPlaybackPack> {
|
||||
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<M49TgsFullShadowPlaybackTrackId, ArrayBuffer>();
|
||||
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")!,
|
||||
"<f4",
|
||||
centersTrack.shape,
|
||||
) as Float32Array;
|
||||
const centersXyM = Array.from({ length: manifest.cellCount }, (_, index) => (
|
||||
[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")!,
|
||||
"<f4",
|
||||
zBoundsTrack.shape,
|
||||
) as Float32Array;
|
||||
const frames = parsePlaybackFrames(buffers.get("frames")!);
|
||||
onProgress?.({
|
||||
phase: "ready",
|
||||
trackId: null,
|
||||
loadedBytes: manifest.totalByteLength,
|
||||
totalBytes: manifest.totalByteLength,
|
||||
});
|
||||
return {
|
||||
resultId: id,
|
||||
frameCount: manifest.frameCount,
|
||||
cellCount: manifest.cellCount,
|
||||
totalByteLength: manifest.totalByteLength,
|
||||
centersXyM,
|
||||
states,
|
||||
zBoundsM,
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +226,20 @@ export interface M4ThreatTimelineChunk {
|
||||
frames: readonly M4ThreatTimelineFrame[];
|
||||
}
|
||||
|
||||
export interface M4ThreatPlaybackProgress {
|
||||
phase: "manifest" | "download" | "verify" | "ready";
|
||||
loadedBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface M4ThreatPlaybackPointPack {
|
||||
resultId: string;
|
||||
frameCount: 4489;
|
||||
pointCount: number;
|
||||
pointOffsets: Uint32Array;
|
||||
pointsMapXyzM: Float32Array;
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
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<M4ThreatTimelineChunk> {
|
||||
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<string> {
|
||||
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<M4Point3>(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<M4ThreatPlaybackPointPack> {
|
||||
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, "<f4", "M4.6 playback dtype");
|
||||
const shape = array(track.shape, "M4.6 playback shape").map((value) => 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,
|
||||
|
||||
Reference in New Issue
Block a user