fix(lab): make recorded replay seek-safe

This commit is contained in:
DCCONSTRUCTIONS
2026-08-24 02:15:30 +03:00
parent 7400fe2fd7
commit 992c5a8b74
12 changed files with 1096 additions and 287 deletions
@@ -114,6 +114,9 @@ export interface ObservationRecordedMediaEpoch {
mediaType: string;
byteLength: number;
streamUrl: string;
segmentCount: number | null;
randomAccessSequences: readonly number[];
segmentEndTimesSeconds: readonly number[];
}
export interface ObservationRecordedMediaManifest {
@@ -224,7 +227,7 @@ const RECORDED_MEDIA_MANIFEST_KEYS = new Set([
"synchronization",
"epochs",
]);
const RECORDED_MEDIA_EPOCH_KEYS = new Set([
const RECORDED_MEDIA_EPOCH_V3_KEYS = new Set([
"ordinal",
"timeline_start_seconds",
"timeline_end_seconds",
@@ -232,6 +235,12 @@ const RECORDED_MEDIA_EPOCH_KEYS = new Set([
"byte_length",
"stream_url",
]);
const RECORDED_MEDIA_EPOCH_V4_KEYS = new Set([
...RECORDED_MEDIA_EPOCH_V3_KEYS,
"segment_count",
"random_access_sequences",
"segment_end_times_seconds",
]);
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SAFE_MODALITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const ISO_WITH_TIMEZONE = /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/;
@@ -846,8 +855,12 @@ export function decodeObservationRecordedMediaManifest(
throw new ObservationSessionContractError("Manifest записанного видео должен быть объектом.");
}
assertExactKeys(payload, RECORDED_MEDIA_MANIFEST_KEYS, "Manifest записанного видео");
const cameraFragmentIndex = source.id.startsWith("recorded.camera.");
const expectedSchema = cameraFragmentIndex
? "missioncore.observation-recorded-media/v4"
: "missioncore.observation-recorded-media/v3";
if (
payload.schema_version !== "missioncore.observation-recorded-media/v3" ||
payload.schema_version !== expectedSchema ||
payload.source_id !== source.id ||
typeof payload.generation_sha256 !== "string" ||
!SHA256.test(payload.generation_sha256) ||
@@ -892,7 +905,11 @@ export function decodeObservationRecordedMediaManifest(
if (!isRecord(entry)) {
throw new ObservationSessionContractError(`Codec epoch ${index} должен быть объектом.`);
}
assertExactKeys(entry, RECORDED_MEDIA_EPOCH_KEYS, `Codec epoch ${index}`);
assertExactKeys(
entry,
cameraFragmentIndex ? RECORDED_MEDIA_EPOCH_V4_KEYS : RECORDED_MEDIA_EPOCH_V3_KEYS,
`Codec epoch ${index}`,
);
const ordinal = requireFiniteNumber(entry.ordinal, `epochs[${index}].ordinal`, {
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
@@ -940,6 +957,75 @@ export function decodeObservationRecordedMediaManifest(
) {
throw new ObservationSessionContractError("Codec epoch содержит небезопасный API URL.");
}
const segmentCount = cameraFragmentIndex
? requireFiniteNumber(entry.segment_count, `epochs[${index}].segment_count`, {
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
integer: true,
})
: null;
const randomAccessSequences: number[] = [];
const segmentEndTimesSeconds: number[] = [];
if (cameraFragmentIndex) {
if (!Array.isArray(entry.random_access_sequences)) {
throw new ObservationSessionContractError(
"Codec epoch не содержит индекс random-access фрагментов.",
);
}
let previousRandomAccess = 0;
for (const [sequenceIndex, sequenceValue] of entry.random_access_sequences.entries()) {
const sequence = requireFiniteNumber(
sequenceValue,
`epochs[${index}].random_access_sequences[${sequenceIndex}]`,
{
minimum: 1,
maximum: segmentCount ?? Number.MAX_SAFE_INTEGER,
integer: true,
},
);
if (sequence <= previousRandomAccess) {
throw new ObservationSessionContractError(
"Индекс random-access фрагментов нарушает строгий порядок.",
);
}
previousRandomAccess = sequence;
randomAccessSequences.push(sequence);
}
if (randomAccessSequences[0] !== 1) {
throw new ObservationSessionContractError(
"Codec epoch не начинается с random-access фрагмента.",
);
}
if (
!Array.isArray(entry.segment_end_times_seconds)
|| entry.segment_end_times_seconds.length !== segmentCount
) {
throw new ObservationSessionContractError(
"Codec epoch не содержит полный временной индекс фрагментов.",
);
}
const epochDurationSeconds = timelineEndSeconds - timelineStartSeconds;
let previousSegmentEnd = 0;
for (const [sequenceIndex, endValue] of entry.segment_end_times_seconds.entries()) {
const endSeconds = requireFiniteNumber(
endValue,
`epochs[${index}].segment_end_times_seconds[${sequenceIndex}]`,
{ minimum: 0, maximum: epochDurationSeconds + 0.000001 },
);
if (endSeconds <= previousSegmentEnd) {
throw new ObservationSessionContractError(
"Временной индекс фрагментов нарушает строгий порядок.",
);
}
previousSegmentEnd = endSeconds;
segmentEndTimesSeconds.push(endSeconds);
}
if (Math.abs(previousSegmentEnd - epochDurationSeconds) > 0.000001) {
throw new ObservationSessionContractError(
"Временной индекс фрагментов не покрывает codec epoch целиком.",
);
}
}
declaredBytes += byteLength;
if (!Number.isSafeInteger(declaredBytes)) {
throw new ObservationSessionContractError(
@@ -953,6 +1039,9 @@ export function decodeObservationRecordedMediaManifest(
mediaType,
byteLength,
streamUrl,
segmentCount,
randomAccessSequences,
segmentEndTimesSeconds,
};
});
if (declaredBytes !== manifestByteLength) {