fix(lab): make recorded replay seek-safe
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user