feat(lab): stabilize autonomous TGS playback

This commit is contained in:
DCCONSTRUCTIONS
2026-08-27 20:34:20 +03:00
parent c8593207d3
commit 95a1ef5057
26 changed files with 3326 additions and 253 deletions
@@ -0,0 +1,671 @@
import type { LaboratoryFetch } from "./advancedResults";
const RESULT_ID = /^m49-physical-safety-playback-[a-f0-9]{64}$/;
const SOURCE_RESULT_ID = /^m49-tgs-full-shadow-[a-f0-9]{64}$/;
const SCHEMA = "missioncore.m49-physical-safety-playback/v1";
const CHUNK_MAGIC = "MCPSCH01";
const CHUNK_HEADER_BYTES = 28;
const ENDPOINT_ROOT = "/api/v1/laboratory/m49/physical-safety-playback";
export interface M49PhysicalSafetyPlaybackFrameMetadata {
sourceSequence: number;
sourceFrameIndex: number;
sessionSeconds: number;
sampleAvailable: boolean;
metrics: Readonly<Record<string, number>>;
}
interface ArtifactDescriptor {
url: string;
mediaType: string;
byteLength: number;
sha256: string;
}
interface ChunkDescriptor extends ArtifactDescriptor {
index: number;
start: number;
count: number;
headerBytes: 28;
format: "mcpsch01-states-u8-aligned-z-bounds-f32le";
}
export interface M49PhysicalSafetyPlaybackManifest {
resultId: string;
createdAtUtc: string;
sourceResultId: string;
frameCount: number;
cellCount: number;
cellSizeM: number;
radiusM: number;
sourcePaceHz: number;
stateCodes: Readonly<Record<string, number>>;
chunkFrameCount: 32;
startupPrebufferChunkCount: 2;
residentChunkCountMax: 3;
forwardPrefetchChunkCount: 1;
centers: ArtifactDescriptor;
frames: ArtifactDescriptor;
chunks: readonly ChunkDescriptor[];
}
interface ResidentChunk {
descriptor: ChunkDescriptor;
states: Uint8Array;
zBoundsM: Float32Array;
byteLength: number;
}
export interface M49PhysicalSafetyPlaybackFrame {
metadata: M49PhysicalSafetyPlaybackFrameMetadata;
centersXyM: Float32Array;
states: Uint8Array;
zBoundsM: Float32Array;
cellSizeM: number;
radiusM: number;
}
export interface M49PhysicalSafetyPlaybackProgress {
phase: "manifest" | "static" | "prebuffer" | "ready" | "seek";
residentChunkIndexes: readonly number[];
loadedBytes: number;
}
export class M49PhysicalSafetyPlaybackContractError extends Error {}
function objectValue(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалась строка.`);
}
return value;
}
function finiteNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = finiteNumber(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось целое число.`);
}
return parsed;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался boolean.`);
}
return value;
}
function exact<T extends string | number | boolean>(value: unknown, expected: T, label: string): T {
if (value !== expected) {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: нарушен контракт.`);
}
return expected;
}
function sha256(value: unknown, label: string): string {
const parsed = text(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: SHA-256 недопустим.`);
}
return parsed;
}
function localUrl(value: unknown, expected: string, label: string): string {
const parsed = text(value, label);
if (parsed !== expected || parsed.includes("worker")) {
throw new M49PhysicalSafetyPlaybackContractError(`${label}: разрешён только sealed local API.`);
}
return parsed;
}
function descriptor(
value: unknown,
expectedUrl: string,
label: string,
): ArtifactDescriptor {
const row = objectValue(value, label);
return {
url: localUrl(row.url, expectedUrl, `${label}.url`),
mediaType: text(row.media_type, `${label}.media_type`),
byteLength: integer(row.byte_length, `${label}.byte_length`),
sha256: sha256(row.sha256, `${label}.sha256`),
};
}
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
if (!globalThis.crypto?.subtle) {
throw new M49PhysicalSafetyPlaybackContractError(
"Браузер не поддерживает проверку playback SHA-256.",
);
}
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
return [...new Uint8Array(digest)]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
async function fetchVerified(
artifact: ArtifactDescriptor,
fetcher: LaboratoryFetch,
signal?: AbortSignal,
): Promise<ArrayBuffer> {
const response = await fetcher(artifact.url, {
method: "GET",
headers: { Accept: artifact.mediaType },
signal,
});
if (!response.ok) {
throw new M49PhysicalSafetyPlaybackContractError(
`Sealed local playback недоступен: HTTP ${response.status}.`,
);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength !== artifact.byteLength) {
throw new M49PhysicalSafetyPlaybackContractError(
"Sealed local playback: получен неверный размер.",
);
}
if (await sha256Hex(buffer) !== artifact.sha256) {
throw new M49PhysicalSafetyPlaybackContractError(
"Sealed local playback: SHA-256 не совпал.",
);
}
return buffer;
}
function parseStateCodes(value: unknown): Readonly<Record<string, number>> {
const row = objectValue(value, "M49 physical-safety state codes");
const parsed: Record<string, number> = {};
for (const [name, code] of Object.entries(row)) {
const stateCode = integer(code, `M49 physical-safety state code ${name}`);
if (stateCode > 255) {
throw new M49PhysicalSafetyPlaybackContractError("M49 state code превышает uint8.");
}
parsed[name] = stateCode;
}
if (parsed.UNOBSERVED !== 0 || !Object.keys(parsed).length) {
throw new M49PhysicalSafetyPlaybackContractError("M49 UNOBSERVED state изменился.");
}
return parsed;
}
async function fetchManifest(
resultId: string,
fetcher: LaboratoryFetch,
signal?: AbortSignal,
): Promise<M49PhysicalSafetyPlaybackManifest> {
if (!RESULT_ID.test(resultId)) {
throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety identity недопустима.");
}
const base = `${ENDPOINT_ROOT}/${encodeURIComponent(resultId)}`;
const response = await fetcher(`${base}/manifest`, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
throw new M49PhysicalSafetyPlaybackContractError(
`M49 physical-safety manifest недоступен: HTTP ${response.status}.`,
);
}
const payload = objectValue(await response.json(), "M49 physical-safety manifest");
exact(payload.schema_version, SCHEMA, "M49 physical-safety schema");
exact(payload.result_id, resultId, "M49 physical-safety result");
exact(payload.access, "read-only-sealed-local", "M49 physical-safety access");
const identity = objectValue(payload.identity, "M49 physical-safety identity");
const execution = objectValue(payload.execution, "M49 physical-safety execution");
exact(execution.execution_class, "local-sequential-offline", "M49 execution class");
exact(execution.worker_role, "realtime-only", "M49 worker role");
exact(execution.worker_runtime_dependency, false, "M49 worker dependency");
exact(execution.worker_requests_required, 0, "M49 worker requests");
const authority = objectValue(payload.authority, "M49 physical-safety authority");
exact(authority.commands_enabled, false, "M49 commands");
exact(authority.navigation_or_safety_accepted, false, "M49 safety authority");
exact(authority.actuation_accepted, false, "M49 actuation authority");
const playback = objectValue(payload.playback, "M49 physical-safety playback");
exact(playback.coordinate_frame, "map-gravity-local", "M49 coordinate frame");
const frameCount = integer(playback.frame_count, "M49 frame count");
const cellCount = integer(playback.cell_count, "M49 cell count");
if (!frameCount || !cellCount) {
throw new M49PhysicalSafetyPlaybackContractError("M49 playback не содержит evidence.");
}
const chunkFrameCount = exact(
integer(playback.chunk_frame_count, "M49 chunk frame count"),
32,
"M49 chunk frame count",
);
const startupPrebufferChunkCount = exact(
integer(playback.startup_prebuffer_chunk_count, "M49 startup prebuffer"),
2,
"M49 startup prebuffer",
);
const residentChunkCountMax = exact(
integer(playback.resident_chunk_count_max, "M49 resident chunks"),
3,
"M49 resident chunks",
);
const forwardPrefetchChunkCount = exact(
integer(playback.forward_prefetch_chunk_count, "M49 forward prefetch"),
1,
"M49 forward prefetch",
);
const centers = descriptor(playback.centers, `${base}/tracks/centers`, "M49 centers");
const centerRow = objectValue(playback.centers, "M49 centers");
exact(centerRow.dtype, "<f4", "M49 centers dtype");
if (!Array.isArray(centerRow.shape)
|| centerRow.shape.length !== 2
|| centerRow.shape[0] !== cellCount
|| centerRow.shape[1] !== 2
|| centers.byteLength !== cellCount * 2 * 4) {
throw new M49PhysicalSafetyPlaybackContractError("M49 centers shape изменилась.");
}
const frames = descriptor(playback.frames, `${base}/tracks/frames`, "M49 frames");
const frameRow = objectValue(playback.frames, "M49 frames");
exact(frameRow.dtype, "ndjson", "M49 frames dtype");
if (!Array.isArray(frameRow.shape)
|| frameRow.shape.length !== 1
|| frameRow.shape[0] !== frameCount) {
throw new M49PhysicalSafetyPlaybackContractError("M49 frames shape изменилась.");
}
if (!Array.isArray(playback.chunks)) {
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk catalog отсутствует.");
}
const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount);
if (playback.chunks.length !== expectedChunkCount) {
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk accounting нарушен.");
}
const chunks = playback.chunks.map((value, index): ChunkDescriptor => {
const row = objectValue(value, `M49 chunk ${index}`);
const common = descriptor(value, `${base}/chunks/${index}`, `M49 chunk ${index}`);
const start = index * chunkFrameCount;
const count = Math.min(chunkFrameCount, frameCount - start);
exact(integer(row.index, `M49 chunk ${index}.index`), index, `M49 chunk ${index}.index`);
exact(integer(row.start, `M49 chunk ${index}.start`), start, `M49 chunk ${index}.start`);
exact(integer(row.count, `M49 chunk ${index}.count`), count, `M49 chunk ${index}.count`);
return {
...common,
index,
start,
count,
headerBytes: exact(
integer(row.header_bytes, `M49 chunk ${index}.header`),
CHUNK_HEADER_BYTES,
`M49 chunk ${index}.header`,
),
format: exact(
row.format,
"mcpsch01-states-u8-aligned-z-bounds-f32le",
`M49 chunk ${index}.format`,
),
};
});
return {
resultId,
createdAtUtc: text(payload.created_at_utc, "M49 physical-safety created"),
sourceResultId: text(identity.source_result_id, "M49 physical-safety source"),
frameCount,
cellCount,
cellSizeM: finiteNumber(playback.cell_size_m, "M49 cell size"),
radiusM: finiteNumber(playback.radius_m, "M49 radius"),
sourcePaceHz: finiteNumber(playback.source_pace_hz, "M49 source pace"),
stateCodes: parseStateCodes(playback.state_codes),
chunkFrameCount,
startupPrebufferChunkCount,
residentChunkCountMax,
forwardPrefetchChunkCount,
centers,
frames,
chunks,
};
}
export async function fetchM49PhysicalSafetyPlaybackIdForSource(
sourceResultId: string,
{
fetcher = fetch,
signal,
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<string | null> {
if (!SOURCE_RESULT_ID.test(sourceResultId)) {
throw new M49PhysicalSafetyPlaybackContractError("M49 source identity недопустима.");
}
const response = await fetcher(
`${ENDPOINT_ROOT}/results?limit=2&source_result_id=${encodeURIComponent(sourceResultId)}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new M49PhysicalSafetyPlaybackContractError(
`M49 physical-safety catalog недоступен: HTTP ${response.status}.`,
);
}
const payload = objectValue(await response.json(), "M49 physical-safety catalog");
exact(
payload.schema_version,
"missioncore.m49-physical-safety-playback-catalog/v1",
"M49 physical-safety catalog schema",
);
exact(payload.worker_runtime_dependency, false, "M49 physical-safety catalog worker");
exact(payload.access, "read-only-sealed-local", "M49 physical-safety catalog access");
if (!Array.isArray(payload.items)) {
throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety catalog изменился.");
}
if (!payload.items.length) return null;
if (payload.items.length !== 1) {
throw new M49PhysicalSafetyPlaybackContractError(
"M49 physical-safety source имеет несколько активных playback artifacts.",
);
}
const item = objectValue(payload.items[0], "M49 physical-safety catalog item");
exact(item.source_result_id, sourceResultId, "M49 physical-safety catalog source");
exact(item.worker_runtime_dependency, false, "M49 physical-safety item worker");
exact(item.navigation_or_safety_accepted, false, "M49 physical-safety item authority");
const resultId = text(item.result_id, "M49 physical-safety catalog result");
if (!RESULT_ID.test(resultId)) {
throw new M49PhysicalSafetyPlaybackContractError(
"M49 physical-safety catalog result недопустим.",
);
}
return resultId;
}
function parseFrames(
buffer: ArrayBuffer,
frameCount: number,
): readonly M49PhysicalSafetyPlaybackFrameMetadata[] {
const lines = new TextDecoder().decode(buffer).trim().split("\n");
if (lines.length !== frameCount) {
throw new M49PhysicalSafetyPlaybackContractError("M49 frame catalog изменил размер.");
}
return lines.map((line, sourceSequence) => {
const row = objectValue(JSON.parse(line), `M49 frame ${sourceSequence}`);
const metrics: Record<string, number> = {};
for (const [name, value] of Object.entries(row)) {
if (name.endsWith("_count") && typeof value === "number" && Number.isFinite(value)) {
metrics[name] = value;
}
}
return {
sourceSequence,
sourceFrameIndex: integer(row.source_frame_index, `M49 frame ${sourceSequence}.source`),
sessionSeconds: finiteNumber(row.session_seconds, `M49 frame ${sourceSequence}.time`),
sampleAvailable: booleanValue(
row.sample_available,
`M49 frame ${sourceSequence}.available`,
),
metrics,
};
});
}
function parseChunk(
buffer: ArrayBuffer,
descriptorValue: ChunkDescriptor,
cellCount: number,
frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[],
admittedStateCodes: ReadonlySet<number>,
): ResidentChunk {
const bytes = new Uint8Array(buffer);
if (bytes.byteLength < CHUNK_HEADER_BYTES
|| new TextDecoder("latin1").decode(bytes.subarray(0, 8)) !== CHUNK_MAGIC) {
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk signature изменилась.");
}
const view = new DataView(buffer);
const start = view.getUint32(8, true);
const count = view.getUint32(12, true);
const cells = view.getUint32(16, true);
const stateBytes = view.getUint32(20, true);
const zBytes = view.getUint32(24, true);
const expectedStateBytes = count * cellCount;
const expectedZBytes = count * cellCount * 2 * 4;
const zOffset = CHUNK_HEADER_BYTES + stateBytes
+ ((4 - ((CHUNK_HEADER_BYTES + stateBytes) % 4)) % 4);
if (start !== descriptorValue.start
|| count !== descriptorValue.count
|| cells !== cellCount
|| stateBytes !== expectedStateBytes
|| zBytes !== expectedZBytes
|| zOffset + zBytes !== bytes.byteLength) {
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk layout изменился.");
}
const states = new Uint8Array(buffer, CHUNK_HEADER_BYTES, stateBytes);
const zBoundsM = new Float32Array(buffer, zOffset, count * cellCount * 2);
for (let localFrame = 0; localFrame < count; localFrame += 1) {
const stateStart = localFrame * cellCount;
const zStart = localFrame * cellCount * 2;
for (let cell = 0; cell < cellCount; cell += 1) {
const state = states[stateStart + cell];
if (!admittedStateCodes.has(state)) {
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk содержит неизвестный state.");
}
if (!frames[start + localFrame].sampleAvailable
&& (state !== 0
|| Number.isFinite(zBoundsM[zStart + cell * 2])
|| Number.isFinite(zBoundsM[zStart + cell * 2 + 1]))) {
throw new M49PhysicalSafetyPlaybackContractError(
"M49 missing sample перестал быть fail-closed.",
);
}
}
}
return { descriptor: descriptorValue, states, zBoundsM, byteLength: buffer.byteLength };
}
export class M49PhysicalSafetyPlaybackBuffer {
readonly manifest: M49PhysicalSafetyPlaybackManifest;
readonly centersXyM: Float32Array;
readonly frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[];
private readonly fetcher: LaboratoryFetch;
private readonly signal: AbortSignal | undefined;
private readonly onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined;
private readonly chunks = new Map<number, ResidentChunk>();
private readonly pending = new Map<number, Promise<ResidentChunk>>();
private readonly admittedStateCodes: ReadonlySet<number>;
private staticBytes: number;
private preparedWindowKey: string | null = null;
private pendingPrepare: { key: string; promise: Promise<void> } | null = null;
private constructor(
manifest: M49PhysicalSafetyPlaybackManifest,
centersXyM: Float32Array,
frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[],
fetcher: LaboratoryFetch,
signal: AbortSignal | undefined,
onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined,
) {
this.manifest = manifest;
this.centersXyM = centersXyM;
this.frames = frames;
this.fetcher = fetcher;
this.signal = signal;
this.onProgress = onProgress;
this.admittedStateCodes = new Set(Object.values(manifest.stateCodes));
this.staticBytes = centersXyM.byteLength + manifest.frames.byteLength;
}
static async open(
resultId: string,
{
fetcher = fetch,
signal,
onProgress,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
onProgress?: (value: M49PhysicalSafetyPlaybackProgress) => void;
} = {},
): Promise<M49PhysicalSafetyPlaybackBuffer> {
onProgress?.({ phase: "manifest", residentChunkIndexes: [], loadedBytes: 0 });
const manifest = await fetchManifest(resultId, fetcher, signal);
const [centersBuffer, framesBuffer] = await Promise.all([
fetchVerified(manifest.centers, fetcher, signal),
fetchVerified(manifest.frames, fetcher, signal),
]);
onProgress?.({
phase: "static",
residentChunkIndexes: [],
loadedBytes: centersBuffer.byteLength + framesBuffer.byteLength,
});
const centersXyM = new Float32Array(centersBuffer);
const frames = parseFrames(framesBuffer, manifest.frameCount);
const playback = new M49PhysicalSafetyPlaybackBuffer(
manifest,
centersXyM,
frames,
fetcher,
signal,
onProgress,
);
const startupCount = Math.min(
manifest.startupPrebufferChunkCount,
manifest.chunks.length,
);
await Promise.all(Array.from(
{ length: startupCount },
(_, index) => playback.loadChunk(index, "prebuffer"),
));
playback.emit("ready");
return playback;
}
get residentChunkIndexes(): readonly number[] {
return [...this.chunks.keys()].sort((left, right) => left - right);
}
get residentByteLength(): number {
return this.staticBytes
+ [...this.chunks.values()].reduce((sum, chunk) => sum + chunk.byteLength, 0);
}
async prepare(sourceSequence: number): Promise<void> {
const chunkIndex = this.chunkIndex(sourceSequence);
const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount;
const indexes = [chunkIndex, nextIndex].filter(
(index) => index < this.manifest.chunks.length,
);
const key = indexes.join(":");
if (this.preparedWindowKey === key && indexes.every((index) => this.chunks.has(index))) {
return;
}
if (this.pendingPrepare?.key === key) return this.pendingPrepare.promise;
const promise = Promise.all(indexes.map((index) => this.loadChunk(index, "seek")))
.then(() => {
this.trim(new Set(indexes));
this.preparedWindowKey = key;
this.emit("seek");
})
.finally(() => {
if (this.pendingPrepare?.key === key) this.pendingPrepare = null;
});
this.pendingPrepare = { key, promise };
return promise;
}
async frame(sourceSequence: number): Promise<M49PhysicalSafetyPlaybackFrame> {
const chunkIndex = this.chunkIndex(sourceSequence);
const chunk = await this.loadChunk(chunkIndex, "seek");
const frame = this.frameFromChunk(sourceSequence, chunk);
const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount;
if (nextIndex < this.manifest.chunks.length && !this.chunks.has(nextIndex)) {
void this.loadChunk(nextIndex, "seek").catch(() => undefined);
}
return frame;
}
frameIfResident(sourceSequence: number): M49PhysicalSafetyPlaybackFrame | null {
const chunkIndex = this.chunkIndex(sourceSequence);
const chunk = this.chunks.get(chunkIndex);
return chunk ? this.frameFromChunk(sourceSequence, chunk) : null;
}
private frameFromChunk(
sourceSequence: number,
chunk: ResidentChunk,
): M49PhysicalSafetyPlaybackFrame {
const localFrame = sourceSequence - chunk.descriptor.start;
const stateOffset = localFrame * this.manifest.cellCount;
const zOffset = stateOffset * 2;
return {
metadata: this.frames[sourceSequence],
centersXyM: this.centersXyM,
states: chunk.states.subarray(stateOffset, stateOffset + this.manifest.cellCount),
zBoundsM: chunk.zBoundsM.subarray(zOffset, zOffset + this.manifest.cellCount * 2),
cellSizeM: this.manifest.cellSizeM,
radiusM: this.manifest.radiusM,
};
}
private chunkIndex(sourceSequence: number): number {
if (!Number.isSafeInteger(sourceSequence)
|| sourceSequence < 0
|| sourceSequence >= this.manifest.frameCount) {
throw new M49PhysicalSafetyPlaybackContractError("M49 source sequence недопустима.");
}
return Math.floor(sourceSequence / this.manifest.chunkFrameCount);
}
private async loadChunk(
index: number,
phase: "prebuffer" | "seek",
): Promise<ResidentChunk> {
const existing = this.chunks.get(index);
if (existing) {
this.chunks.delete(index);
this.chunks.set(index, existing);
return existing;
}
const inflight = this.pending.get(index);
if (inflight) return inflight;
const descriptorValue = this.manifest.chunks[index];
if (!descriptorValue) {
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk отсутствует.");
}
const promise = fetchVerified(descriptorValue, this.fetcher, this.signal)
.then((buffer) => parseChunk(
buffer,
descriptorValue,
this.manifest.cellCount,
this.frames,
this.admittedStateCodes,
))
.then((chunk) => {
this.chunks.set(index, chunk);
this.trim(new Set([index]));
this.emit(phase);
return chunk;
})
.finally(() => this.pending.delete(index));
this.pending.set(index, promise);
return promise;
}
private trim(protectedIndexes: ReadonlySet<number>): void {
while (this.chunks.size > this.manifest.residentChunkCountMax) {
const removable = [...this.chunks.keys()].find((index) => !protectedIndexes.has(index));
if (removable === undefined) break;
this.chunks.delete(removable);
}
}
private emit(phase: M49PhysicalSafetyPlaybackProgress["phase"]): void {
this.onProgress?.({
phase,
residentChunkIndexes: this.residentChunkIndexes,
loadedBytes: this.residentByteLength,
});
}
}
@@ -120,7 +120,7 @@ export interface M49TgsFullShadowPlaybackPack {
frameCount: 4489;
cellCount: 2244;
totalByteLength: number;
centersXyM: readonly (readonly [number, number])[];
centersXyM: Float32Array;
states: Uint8Array;
zBoundsM: Float32Array;
frames: readonly M49TgsFullShadowPlaybackFrame[];
@@ -628,9 +628,6 @@ export async function fetchM49TgsFullShadowPlaybackPack(
"<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",
@@ -653,7 +650,7 @@ export async function fetchM49TgsFullShadowPlaybackPack(
frameCount: manifest.frameCount,
cellCount: manifest.cellCount,
totalByteLength: manifest.totalByteLength,
centersXyM,
centersXyM: centersRaw,
states,
zBoundsM,
frames,
@@ -238,6 +238,37 @@ export interface M4ThreatPlaybackPointPack {
pointCount: number;
pointOffsets: Uint32Array;
pointsMapXyzM: Float32Array;
pointStart?: number;
startSequence?: number;
sequenceCount?: number;
}
export interface M4ThreatPlaybackChunkDescriptor {
index: number;
start: number;
count: number;
pointStart: number;
pointCount: number;
url: string;
mediaType: "application/octet-stream";
byteLength: number;
sha256: string;
}
export interface M4ThreatPlaybackManifest {
resultId: string;
frameCount: 4489;
pointCount: number;
pointOffsets: Uint32Array;
chunkFrameCount: 24;
residentChunkCountMax: 4;
forwardPrefetchChunkCount: 1;
chunks: readonly M4ThreatPlaybackChunkDescriptor[];
track: {
url: string;
byteLength: number;
sha256: string;
};
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -889,7 +920,14 @@ export function hydrateM4ThreatTimelineFrame(
frame: M4ThreatTimelineFrame,
pack: M4ThreatPlaybackPointPack,
): M4ThreatTimelineFrame {
if (frame.sequence >= pack.frameCount || pack.resultId === "") {
if (
frame.sequence >= pack.frameCount
|| pack.resultId === ""
|| (pack.startSequence !== undefined && (
frame.sequence < pack.startSequence
|| frame.sequence >= pack.startSequence + (pack.sequenceCount ?? 0)
))
) {
throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность.");
}
if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) {
@@ -900,7 +938,15 @@ export function hydrateM4ThreatTimelineFrame(
}
const start = pack.pointOffsets[frame.sequence];
const stop = pack.pointOffsets[frame.sequence + 1];
if (start === undefined || stop === undefined || stop < start || stop > pack.pointCount) {
const pointStart = pack.pointStart ?? 0;
const pointStop = pointStart + pack.pointCount;
if (
start === undefined
|| stop === undefined
|| stop < start
|| start < pointStart
|| stop > pointStop
) {
throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность.");
}
const count = stop - start;
@@ -911,7 +957,7 @@ export function hydrateM4ThreatTimelineFrame(
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 sourceOffset = (start - pointStart + 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];
@@ -924,22 +970,19 @@ export function hydrateM4ThreatTimelineFrame(
return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: count };
}
export async function fetchM4ThreatPlaybackPointPack(
export async function fetchM4ThreatPlaybackManifest(
result: string,
{
fetcher = fetch,
signal,
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
onProgress,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
endpointRoot?: string;
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
} = {},
): Promise<M4ThreatPlaybackPointPack> {
): Promise<M4ThreatPlaybackManifest> {
resultId(result);
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, {
headers: { Accept: "application/json" },
signal,
@@ -982,46 +1025,180 @@ export async function fetchM4ThreatPlaybackPointPack(
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,
const trackUrl = text(track.url, "M4.6 playback URL");
const chunkFrameCount = exact(
integer(manifest.chunk_frame_count, "M4.6 playback chunk frames"),
24,
"M4.6 playback chunk frames",
);
const residentChunkCountMax = exact(
integer(manifest.resident_chunk_count_max, "M4.6 playback resident chunks"),
4,
"M4.6 playback resident chunks",
);
const forwardPrefetchChunkCount = exact(
integer(manifest.forward_prefetch_chunk_count, "M4.6 playback prefetch chunks"),
1,
"M4.6 playback prefetch chunks",
);
const chunkRows = array(manifest.chunks, "M4.6 playback chunks");
const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount);
if (chunkRows.length !== expectedChunkCount) {
throw new M4ThreatContractError("M4.6 playback chunk catalog: неверный размер.");
}
const chunks = chunkRows.map((value, index): M4ThreatPlaybackChunkDescriptor => {
const row = object(value, `M4.6 playback chunk ${index}`);
const start = index * chunkFrameCount;
const count = Math.min(chunkFrameCount, frameCount - start);
const pointStart = pointOffsets[start]!;
const pointStop = pointOffsets[start + count]!;
const pointChunkCount = pointStop - pointStart;
exact(integer(row.index, `M4.6 playback chunk ${index}.index`), index, `M4.6 playback chunk ${index}.index`);
exact(integer(row.start, `M4.6 playback chunk ${index}.start`), start, `M4.6 playback chunk ${index}.start`);
exact(integer(row.count, `M4.6 playback chunk ${index}.count`), count, `M4.6 playback chunk ${index}.count`);
exact(integer(row.point_start, `M4.6 playback chunk ${index}.point start`), pointStart, `M4.6 playback chunk ${index}.point start`);
exact(integer(row.point_count, `M4.6 playback chunk ${index}.point count`), pointChunkCount, `M4.6 playback chunk ${index}.point count`);
exact(row.media_type, "application/octet-stream", `M4.6 playback chunk ${index}.media type`);
exact(row.dtype, "<f4", `M4.6 playback chunk ${index}.dtype`);
const shapeValue = array(row.shape, `M4.6 playback chunk ${index}.shape`)
.map((item) => integer(item, `M4.6 playback chunk ${index}.shape`));
const chunkBytes = integer(row.bytes, `M4.6 playback chunk ${index}.bytes`);
if (
shapeValue.length !== 2
|| shapeValue[0] !== pointChunkCount
|| shapeValue[1] !== 3
|| chunkBytes !== pointChunkCount * 3 * Float32Array.BYTES_PER_ELEMENT
) {
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: нарушена размерность.`);
}
const chunkSha256 = text(row.sha256, `M4.6 playback chunk ${index}.SHA-256`);
if (!/^[a-f0-9]{64}$/.test(chunkSha256)) {
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: SHA-256 недопустим.`);
}
const expectedUrl = `${endpointRoot}/${result}/timeline/playback/chunks/${index}`;
const url = text(row.url, `M4.6 playback chunk ${index}.URL`);
if (url !== expectedUrl || url.includes("worker")) {
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: разрешён только sealed local API.`);
}
return {
index,
start,
count,
pointStart,
pointCount: pointChunkCount,
url,
mediaType: "application/octet-stream",
byteLength: chunkBytes,
sha256: chunkSha256,
};
});
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),
chunkFrameCount,
residentChunkCountMax,
forwardPrefetchChunkCount,
chunks,
track: { url: trackUrl, byteLength, sha256 },
};
}
export async function fetchM4ThreatPlaybackPointChunk(
manifest: M4ThreatPlaybackManifest,
chunkIndex: number,
{
fetcher = fetch,
signal,
onProgress,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
} = {},
): Promise<M4ThreatPlaybackPointPack> {
const descriptor = manifest.chunks[chunkIndex];
if (!descriptor || descriptor.index !== chunkIndex) {
throw new M4ThreatContractError("M4.6 playback chunk отсутствует.");
}
onProgress?.({ phase: "download", loadedBytes: 0, totalBytes: descriptor.byteLength });
const response = await fetcher(descriptor.url, {
headers: { Accept: "application/octet-stream" },
signal,
});
if (!response.ok) {
throw new M4ThreatContractError(`M4.6 playback chunk: HTTP ${response.status}.`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength !== descriptor.byteLength) {
throw new M4ThreatContractError("M4.6 playback chunk: неверный размер.");
}
onProgress?.({
phase: "verify",
loadedBytes: descriptor.byteLength,
totalBytes: descriptor.byteLength,
});
if (await playbackSha256Hex(buffer) !== descriptor.sha256) {
throw new M4ThreatContractError("M4.6 playback chunk: SHA-256 не совпал.");
}
onProgress?.({
phase: "ready",
loadedBytes: descriptor.byteLength,
totalBytes: descriptor.byteLength,
});
return {
resultId: manifest.resultId,
frameCount: manifest.frameCount,
pointCount: descriptor.pointCount,
pointOffsets: manifest.pointOffsets,
pointsMapXyzM: new Float32Array(buffer),
pointStart: descriptor.pointStart,
startSequence: descriptor.start,
sequenceCount: descriptor.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> {
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
const manifest = await fetchM4ThreatPlaybackManifest(result, {
fetcher,
signal,
endpointRoot,
});
const response = await fetcher(manifest.track.url, {
headers: { Accept: "application/octet-stream" },
signal,
});
if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`);
const buffer = await response.arrayBuffer();
if (buffer.byteLength !== manifest.track.byteLength) {
throw new M4ThreatContractError("M4.6 playback points: неверный размер.");
}
onProgress?.({ phase: "verify", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength });
if (await playbackSha256Hex(buffer) !== manifest.track.sha256) {
throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал.");
}
onProgress?.({ phase: "ready", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength });
return {
resultId: manifest.resultId,
frameCount: manifest.frameCount,
pointCount: manifest.pointCount,
pointOffsets: manifest.pointOffsets,
pointsMapXyzM: new Float32Array(buffer),
};
}