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
@@ -28,7 +28,82 @@ export interface RecordedMediaArchive {
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000;
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
const RECORDED_MEDIA_MAX_RESIDENT_SEGMENTS = 160;
const RECORDED_MEDIA_RETAIN_BEHIND_SEGMENTS = 60;
let recordedMediaWorkerGeneration = 0;
const recordedMediaSourceOwners = new WeakMap<HTMLVideoElement, object>();
function claimRecordedMediaSource(video: HTMLVideoElement): object {
const owner = {};
recordedMediaSourceOwners.set(video, owner);
return owner;
}
function releaseRecordedMediaSource(video: HTMLVideoElement, owner: object): boolean {
if (recordedMediaSourceOwners.get(video) !== owner) return false;
recordedMediaSourceOwners.delete(video);
return true;
}
export function recordedMediaDecodeStartSequence(
randomAccessSequences: readonly number[],
targetSequence: number,
): number | null {
if (!Number.isInteger(targetSequence) || targetSequence < 1) return null;
let left = 0;
let right = randomAccessSequences.length - 1;
let selected: number | null = null;
while (left <= right) {
const middle = Math.floor((left + right) / 2);
const sequence = randomAccessSequences[middle];
if (!Number.isInteger(sequence) || sequence < 1) return null;
if (sequence <= targetSequence) {
selected = sequence;
left = middle + 1;
} else {
right = middle - 1;
}
}
return selected;
}
export function recordedMediaSegmentAppendOrder(
appended: ReadonlySet<number>,
decodeStartSequence: number,
desiredEndSequence: number,
): number[] {
if (
!Number.isInteger(decodeStartSequence)
|| !Number.isInteger(desiredEndSequence)
|| decodeStartSequence < 1
|| desiredEndSequence < decodeStartSequence
) return [];
const missing: number[] = [];
for (let sequence = decodeStartSequence; sequence <= desiredEndSequence; sequence += 1) {
if (!appended.has(sequence)) missing.push(sequence);
}
return missing;
}
function recordedMediaTimeRangesContain(
ranges: TimeRanges,
targetSeconds: number,
toleranceSeconds = 0.15,
): boolean {
if (!Number.isFinite(targetSeconds)) return false;
for (let index = 0; index < ranges.length; index += 1) {
if (
ranges.start(index) - toleranceSeconds <= targetSeconds
&& ranges.end(index) + toleranceSeconds >= targetSeconds
) return true;
}
return false;
}
export function recordedMediaPresentationState(
state: "loading" | "ready" | "error",
@@ -211,13 +286,15 @@ async function mountRecordedEpochStream(
// The generation token binds the native media request to the exact manifest.
// The browser range-streams the virtual init+fragment file from the server;
// no complete camera archive is copied into JavaScript memory.
video.preload = "auto";
const sourceOwner = claimRecordedMediaSource(video);
video.src = descriptor.streamUrl;
const cleanup = () => {
if (!releaseRecordedMediaSource(video, sourceOwner)) return;
video.pause();
video.removeAttribute("src");
video.load();
};
video.preload = "auto";
video.src = descriptor.streamUrl;
video.load();
try {
await waitForSeekableArchive(
@@ -232,22 +309,36 @@ async function mountRecordedEpochStream(
}
}
interface RecordedSegmentTarget {
readonly revision: number;
readonly sequence: number;
readonly decodeStart: number;
readonly readyEnd: number;
readonly desiredEnd: number;
readonly targetSeconds: number;
forceReset: boolean;
resetAttempts: number;
}
interface RecordedSegmentStreamRuntime {
readonly generation: string;
readonly mediaSource: MediaSource;
readonly sourceBuffer: SourceBuffer;
readonly video: HTMLVideoElement;
readonly objectUrl: string;
readonly epoch: ObservationRecordedMediaEpoch;
readonly contractGeneration: string;
readonly segmentCount: number;
readonly randomAccessSequences: readonly number[];
readonly segmentEndTimesSeconds: readonly number[];
readonly appended: Set<number>;
readonly abort: AbortController;
desiredStart: number;
desiredEnd: number;
target: number;
target: RecordedSegmentTarget | null;
fetchAbort: AbortController | null;
notifiedRevision: number;
pumping: boolean;
disposed: boolean;
onTargetReady: ((sequence: number) => void) | null;
onTargetBuffered: ((target: RecordedSegmentTarget) => void) | null;
}
export function recordedMediaFragmentUrl(
@@ -269,7 +360,12 @@ function waitForMediaSourceOpen(mediaSource: MediaSource, signal: AbortSignal):
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (mediaSource.readyState === "open") return Promise.resolve();
return new Promise((resolve, reject) => {
const timeout = globalThis.setTimeout(() => {
cleanup();
reject(new Error("Recorded fragment MediaSource did not open"));
}, RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS);
const cleanup = () => {
globalThis.clearTimeout(timeout);
mediaSource.removeEventListener("sourceopen", onOpen);
mediaSource.removeEventListener("sourceclose", onClose);
signal.removeEventListener("abort", onAbort);
@@ -328,15 +424,170 @@ function appendRecordedFragment(
});
}
async function fetchRecordedFragment(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
const response = await fetch(url, {
method: "GET",
cache: "force-cache",
headers: { Accept: "video/mp4" },
signal,
function removeRecordedMediaRange(
sourceBuffer: SourceBuffer,
startSeconds: number,
endSeconds: number,
signal: AbortSignal,
): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
sourceBuffer.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onUpdateEnd = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Recorded fragment eviction failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
sourceBuffer.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
try {
sourceBuffer.remove(startSeconds, endSeconds);
} catch (error) {
cleanup();
reject(error);
}
});
if (!response.ok) throw new Error(`Recorded fragment returned HTTP ${response.status}`);
return response.arrayBuffer();
}
async function fetchRecordedFragment(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
const controller = new AbortController();
const abort = () => controller.abort();
const timeout = globalThis.setTimeout(
() => controller.abort(),
RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS,
);
signal.addEventListener("abort", abort, { once: true });
try {
const response = await fetch(url, {
method: "GET",
cache: "force-cache",
headers: { Accept: "video/mp4" },
signal: controller.signal,
});
if (!response.ok) throw new Error(`Recorded fragment returned HTTP ${response.status}`);
return await response.arrayBuffer();
} catch (error) {
if (controller.signal.aborted && !signal.aborted) {
throw new Error("Recorded fragment request timed out");
}
throw error;
} finally {
globalThis.clearTimeout(timeout);
signal.removeEventListener("abort", abort);
}
}
function recordedSegmentChainComplete(
appended: ReadonlySet<number>,
target: RecordedSegmentTarget,
): boolean {
for (let sequence = target.decodeStart; sequence <= target.readyEnd; sequence += 1) {
if (!appended.has(sequence)) return false;
}
return true;
}
function recordedSegmentStartSeconds(
segmentEndTimesSeconds: readonly number[],
sequence: number,
): number | null {
if (!Number.isInteger(sequence) || sequence < 1 || sequence > segmentEndTimesSeconds.length) {
return null;
}
return sequence === 1 ? 0 : segmentEndTimesSeconds[sequence - 2] ?? null;
}
function recordedSegmentTargetBuffered(
runtime: RecordedSegmentStreamRuntime,
target: RecordedSegmentTarget,
): boolean {
const readyEndSeconds = runtime.segmentEndTimesSeconds[target.readyEnd - 1];
if (!Number.isFinite(readyEndSeconds)) return false;
const readyProbeSeconds = Math.max(target.targetSeconds, readyEndSeconds - 0.001);
return (
recordedSegmentChainComplete(runtime.appended, target)
&& recordedMediaTimeRangesContain(runtime.sourceBuffer.buffered, target.targetSeconds, 0.02)
&& recordedMediaTimeRangesContain(runtime.sourceBuffer.buffered, readyProbeSeconds, 0.02)
);
}
async function resetRecordedSegmentBuffer(runtime: RecordedSegmentStreamRuntime): Promise<void> {
const duration = runtime.mediaSource.duration;
if (runtime.sourceBuffer.buffered.length && Number.isFinite(duration) && duration > 0) {
await removeRecordedMediaRange(runtime.sourceBuffer, 0, duration, runtime.abort.signal);
}
runtime.appended.clear();
}
async function evictRecordedSegmentHistory(
runtime: RecordedSegmentStreamRuntime,
target: RecordedSegmentTarget,
): Promise<void> {
if (runtime.appended.size <= RECORDED_MEDIA_MAX_RESIDENT_SEGMENTS) return;
const retainedTarget = Math.max(1, target.sequence - RECORDED_MEDIA_RETAIN_BEHIND_SEGMENTS);
const retainedStart = recordedMediaDecodeStartSequence(
runtime.randomAccessSequences,
retainedTarget,
) ?? 1;
const removeEnd = recordedSegmentStartSeconds(
runtime.segmentEndTimesSeconds,
retainedStart,
) ?? 0;
if (removeEnd > 0) {
await removeRecordedMediaRange(runtime.sourceBuffer, 0, removeEnd, runtime.abort.signal);
}
for (const sequence of runtime.appended) {
if (sequence < retainedStart) runtime.appended.delete(sequence);
}
}
async function fetchRecordedRuntimeFragment(
runtime: RecordedSegmentStreamRuntime,
targetRevision: number,
sequence: number,
): Promise<ArrayBuffer | null> {
runtime.fetchAbort?.abort();
const controller = new AbortController();
runtime.fetchAbort = controller;
const abortFetch = () => controller.abort();
runtime.abort.signal.addEventListener("abort", abortFetch, { once: true });
try {
const url = recordedMediaFragmentUrl(runtime.epoch, runtime.contractGeneration, sequence);
const payload = await fetchRecordedFragment(url, controller.signal);
if (
runtime.disposed
|| runtime.abort.signal.aborted
|| runtime.target?.revision !== targetRevision
) return null;
return payload;
} catch (error) {
if (
controller.signal.aborted
&& !runtime.disposed
&& !runtime.abort.signal.aborted
&& runtime.target?.revision !== targetRevision
) return null;
throw error;
} finally {
runtime.abort.signal.removeEventListener("abort", abortFetch);
if (runtime.fetchAbort === controller) runtime.fetchAbort = null;
}
}
async function pumpRecordedSegmentWindow(runtime: RecordedSegmentStreamRuntime): Promise<void> {
@@ -344,29 +595,98 @@ async function pumpRecordedSegmentWindow(runtime: RecordedSegmentStreamRuntime):
runtime.pumping = true;
try {
while (!runtime.disposed && !runtime.abort.signal.aborted) {
const targetMissing = !runtime.appended.has(runtime.target) ? runtime.target : null;
let next = targetMissing;
if (next === null) {
for (let sequence = runtime.desiredStart; sequence <= runtime.desiredEnd; sequence += 1) {
if (!runtime.appended.has(sequence)) {
next = sequence;
break;
}
}
const target = runtime.target;
if (!target) break;
if (target.forceReset) {
await resetRecordedSegmentBuffer(runtime);
const latest = runtime.target;
if (!latest) break;
latest.forceReset = false;
continue;
}
if (next === null) break;
const url = recordedMediaFragmentUrl(runtime.epoch, runtime.contractGeneration, next);
const payload = await fetchRecordedFragment(url, runtime.abort.signal);
if (runtime.disposed || runtime.abort.signal.aborted) return;
await appendRecordedFragment(runtime.sourceBuffer, payload, runtime.abort.signal);
runtime.appended.add(next);
if (runtime.appended.has(runtime.target)) runtime.onTargetReady?.(runtime.target);
const targetDecoderReady = recordedSegmentTargetBuffered(runtime, target);
if (
targetDecoderReady
&& runtime.notifiedRevision !== target.revision
&& runtime.onTargetBuffered !== null
) {
runtime.notifiedRevision = target.revision;
runtime.onTargetBuffered(target);
}
const next = recordedMediaSegmentAppendOrder(
runtime.appended,
target.decodeStart,
target.desiredEnd,
)[0] ?? null;
if (next !== null) {
const payload = await fetchRecordedRuntimeFragment(runtime, target.revision, next);
if (payload === null) continue;
await appendRecordedFragment(runtime.sourceBuffer, payload, runtime.abort.signal);
runtime.appended.add(next);
continue;
}
if (targetDecoderReady) {
await evictRecordedSegmentHistory(runtime, target);
if (runtime.target === target) break;
continue;
}
if (target.resetAttempts < 1) {
target.resetAttempts += 1;
target.forceReset = true;
continue;
}
throw new Error("Recorded fragment target is not decoder-ready");
}
} finally {
runtime.pumping = false;
}
}
function waitForRecordedVideoTarget(
video: HTMLVideoElement,
targetSeconds: number,
signal: AbortSignal,
): Promise<void> {
const targetReady = () => (
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
&& !video.seeking
&& Math.abs(video.currentTime - targetSeconds) <= 0.25
&& recordedMediaTimeRangesContain(video.buffered, targetSeconds)
);
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (targetReady()) return Promise.resolve();
return new Promise((resolve, reject) => {
const events = ["loadeddata", "canplay", "seeked", "timeupdate"] as const;
const timeout = globalThis.setTimeout(() => {
cleanup();
reject(new Error("Recorded fragment target did not become decoder-ready"));
}, RECORDED_MEDIA_TARGET_TIMEOUT_MS);
const cleanup = () => {
globalThis.clearTimeout(timeout);
for (const event of events) video.removeEventListener(event, onProgress);
video.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onProgress = () => {
if (!targetReady()) return;
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Recorded fragment target decode failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
for (const event of events) video.addEventListener(event, onProgress);
video.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
onProgress();
});
}
export function RecordedFmp4Player({
source,
playback,
@@ -378,6 +698,7 @@ export function RecordedFmp4Player({
admissionKey = null,
onAdmissionChange,
onPlaybackChange,
onPlayingRejected,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
@@ -389,12 +710,15 @@ export function RecordedFmp4Player({
admissionKey?: string | null;
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
onPlayingRejected?: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const onAdmissionChangeRef = useRef(onAdmissionChange);
onAdmissionChangeRef.current = onAdmissionChange;
const onPlaybackChangeRef = useRef(onPlaybackChange);
onPlaybackChangeRef.current = onPlaybackChange;
const onPlayingRejectedRef = useRef(onPlayingRejected);
onPlayingRejectedRef.current = onPlayingRejected;
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
recordedMediaWorkerGeneration += 1;
@@ -431,7 +755,12 @@ export function RecordedFmp4Player({
const [bufferRevision, setBufferRevision] = useState(0);
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
const targetRevisionRef = useRef(0);
const targetReadyAbortRef = useRef<AbortController | null>(null);
const playAttemptRevisionRef = useRef(0);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const playbackPlayingRef = useRef(Boolean(playback?.playing));
playbackPlayingRef.current = Boolean(playback?.playing);
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
? Math.min(4, Math.max(0.25, playback.rate))
: 1;
@@ -440,16 +769,17 @@ export function RecordedFmp4Player({
[archive?.manifest.epochs, currentSeconds],
);
const segmented = Boolean(
segmentSequence !== null
&& Number.isInteger(segmentSequence)
&& segmentSequence >= 1
&& segmentCount !== null
segmentCount !== null
&& Number.isInteger(segmentCount)
&& segmentCount >= segmentSequence
&& segmentCount >= 1
&& typeof MediaSource !== "undefined"
&& epoch
&& epoch.segmentCount === segmentCount
&& epoch.randomAccessSequences.length > 0
&& epoch.segmentEndTimesSeconds.length === segmentCount
&& MediaSource.isTypeSupported(epoch.mediaType),
);
const directPlaybackSeconds = segmented ? null : currentSeconds;
const waitingForEpoch = Boolean(archive && !epoch);
const selectedGeneration = contract && epoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
@@ -546,7 +876,15 @@ export function RecordedFmp4Player({
useEffect(() => {
const video = videoRef.current;
if (!video || !archive || !contract || !epoch || !segmented || segmentCount === null) return;
const epochSegmentCount = epoch?.segmentCount ?? null;
if (
!video
|| !archive
|| !contract
|| !epoch
|| !segmented
|| epochSegmentCount === null
) return;
const abort = new AbortController();
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
@@ -557,11 +895,13 @@ export function RecordedFmp4Player({
setSegmentedRuntimeGeneration(null);
setState("loading");
video.pause();
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
const sourceOwner = claimRecordedMediaSource(video);
video.src = objectUrl;
video.load();
void (async () => {
await waitForMediaSourceOpen(mediaSource, abort.signal);
await sourceOpened;
const sourceBuffer = mediaSource.addSourceBuffer(epoch.mediaType);
const initUrl = recordedMediaFragmentUrl(
epoch,
@@ -576,18 +916,21 @@ export function RecordedFmp4Player({
generation,
mediaSource,
sourceBuffer,
video,
objectUrl,
epoch,
contractGeneration: contract.manifestGenerationSha256,
segmentCount,
segmentCount: epochSegmentCount,
randomAccessSequences: epoch.randomAccessSequences,
segmentEndTimesSeconds: epoch.segmentEndTimesSeconds,
appended: new Set(),
abort,
desiredStart: 1,
desiredEnd: 1,
target: 1,
target: null,
fetchAbort: null,
notifiedRevision: 0,
pumping: false,
disposed: false,
onTargetReady: null,
onTargetBuffered: null,
};
segmentedRuntimeRef.current = runtime;
setSegmentedRuntimeGeneration(generation);
@@ -605,15 +948,17 @@ export function RecordedFmp4Player({
});
return () => {
abort.abort();
if (runtime) {
runtime.disposed = true;
runtime.onTargetReady = null;
runtime.fetchAbort?.abort();
runtime.onTargetBuffered = null;
}
abort.abort();
targetReadyAbortRef.current?.abort();
if (segmentedRuntimeRef.current === runtime) segmentedRuntimeRef.current = null;
setSegmentedRuntimeGeneration(null);
video.pause();
if (video.src === objectUrl) {
if (releaseRecordedMediaSource(video, sourceOwner)) {
video.pause();
video.removeAttribute("src");
video.load();
}
@@ -631,45 +976,65 @@ export function RecordedFmp4Player({
|| !segmented
|| segmentedRuntimeGeneration !== runtime.generation
|| segmentSequence === null
|| !Number.isInteger(segmentSequence)
|| segmentSequence < 1
|| segmentSequence > runtime.segmentCount
) return;
const archiveByteLength = archive.byteLength;
runtime.target = segmentSequence;
runtime.desiredStart = Math.max(1, segmentSequence - 2);
runtime.desiredEnd = Math.min(runtime.segmentCount, segmentSequence + 36);
const targetSeconds = recordedMediaLocalTime(
runtime.epoch.timelineStartSeconds,
currentSeconds,
runtime.epoch.timelineEndSeconds - runtime.epoch.timelineStartSeconds,
const decodeStart = recordedMediaDecodeStartSequence(
runtime.randomAccessSequences,
segmentSequence,
);
const markReady = (sequence: number) => {
if (
runtime.disposed
|| segmentedRuntimeRef.current !== runtime
|| runtime.target !== sequence
) return;
try {
if (Math.abs(video.currentTime - targetSeconds) > 0.2) video.currentTime = targetSeconds;
} catch {
setState("error");
return;
}
setBufferRevision((revision) => revision + 1);
setReadyGeneration(runtime.generation);
setState("ready");
reportAdmission({
phase: "ready",
byteLength: archiveByteLength,
message: null,
});
};
runtime.onTargetReady = markReady;
if (runtime.appended.has(segmentSequence)) {
markReady(segmentSequence);
} else {
if (decodeStart === null) {
setReadyGeneration(null);
setState("loading");
setState("error");
reportAdmission({
phase: "error",
byteLength: archiveByteLength,
message: "Для кадра записанной камеры нет random-access фрагмента.",
});
return;
}
void pumpRecordedSegmentWindow(runtime).catch((error: unknown) => {
const targetSeconds = recordedSegmentStartSeconds(
runtime.segmentEndTimesSeconds,
segmentSequence,
);
if (targetSeconds === null) {
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archiveByteLength,
message: "Для кадра записанной камеры нет точной media timestamp.",
});
return;
}
const previousTarget = runtime.target;
const readyEnd = Math.min(
runtime.segmentCount,
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
);
const desiredEnd = Math.min(
runtime.segmentCount,
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
);
const candidateTarget: RecordedSegmentTarget = {
revision: previousTarget?.revision ?? 0,
sequence: segmentSequence,
decodeStart,
readyEnd,
desiredEnd,
targetSeconds,
forceReset: false,
resetAttempts: 0,
};
const rollingTarget = Boolean(
playbackPlayingRef.current
&& previousTarget
&& runtime.notifiedRevision === previousTarget.revision
&& recordedSegmentTargetBuffered(runtime, candidateTarget),
);
const reportPumpError = (error: unknown) => {
if (
runtime.disposed
|| runtime.abort.signal.aborted
@@ -682,11 +1047,95 @@ export function RecordedFmp4Player({
byteLength: archiveByteLength,
message: "Покадровый фрагмент записанной камеры недоступен.",
});
});
return () => {
if (runtime.onTargetReady === markReady) runtime.onTargetReady = null;
};
}, [archive?.byteLength, currentSeconds, segmentSequence, segmented, segmentedRuntimeGeneration]);
if (rollingTarget && previousTarget) {
runtime.target = {
...candidateTarget,
revision: previousTarget.revision,
};
runtime.onTargetBuffered = null;
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
return;
}
targetRevisionRef.current += 1;
const target: RecordedSegmentTarget = {
...candidateTarget,
revision: targetRevisionRef.current,
forceReset: Boolean(
runtime.appended.size
&& !recordedMediaTimeRangesContain(video.buffered, targetSeconds, 0.02)
),
};
const targetReadyAbort = new AbortController();
targetReadyAbortRef.current?.abort();
targetReadyAbortRef.current = targetReadyAbort;
runtime.fetchAbort?.abort();
runtime.target = target;
const alreadyBuffered = recordedSegmentTargetBuffered(runtime, target);
video.pause();
if (!alreadyBuffered) {
setReadyGeneration(null);
setState("loading");
}
const markBuffered = (bufferedTarget: RecordedSegmentTarget) => {
if (
runtime.disposed
|| segmentedRuntimeRef.current !== runtime
|| runtime.target?.revision !== bufferedTarget.revision
|| targetReadyAbort.signal.aborted
) return;
void (async () => {
try {
if (Math.abs(video.currentTime - bufferedTarget.targetSeconds) > 0.05) {
video.currentTime = bufferedTarget.targetSeconds;
}
await waitForRecordedVideoTarget(
video,
bufferedTarget.targetSeconds,
targetReadyAbort.signal,
);
if (
targetReadyAbort.signal.aborted
|| runtime.disposed
|| segmentedRuntimeRef.current !== runtime
|| runtime.target?.revision !== bufferedTarget.revision
) return;
setBufferRevision((revision) => revision + 1);
setReadyGeneration(runtime.generation);
setState("ready");
reportAdmission({
phase: "ready",
byteLength: archiveByteLength,
message: null,
});
} catch (error) {
if (
targetReadyAbort.signal.aborted
|| (error instanceof DOMException && error.name === "AbortError")
) return;
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archiveByteLength,
message: "Кадр записанной камеры не стал decoder-ready.",
});
}
})();
};
runtime.onTargetBuffered = markBuffered;
if (alreadyBuffered) {
runtime.notifiedRevision = target.revision;
markBuffered(target);
}
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
return () => {
targetReadyAbort.abort();
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
};
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]);
useEffect(() => {
const video = videoRef.current;
@@ -739,14 +1188,23 @@ export function RecordedFmp4Player({
}, [archive?.byteLength, contract, epoch, segmented]);
useEffect(() => {
playAttemptRevisionRef.current += 1;
const playAttemptRevision = playAttemptRevisionRef.current;
const video = videoRef.current;
if (!video || !epoch || visualState !== "ready") return;
const target = recordedMediaLocalTime(
epoch.timelineStartSeconds,
currentSeconds,
video.duration,
);
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
const target = segmented
? null
: recordedMediaLocalTime(
epoch.timelineStartSeconds,
directPlaybackSeconds ?? epoch.timelineStartSeconds,
video.duration,
);
if (
!segmented
&& target !== null
&& Number.isFinite(target)
&& Math.abs(video.currentTime - target) > 0.35
) {
try {
video.currentTime = target;
} catch {
@@ -762,17 +1220,28 @@ export function RecordedFmp4Player({
}
video.playbackRate = playbackRate;
if (playback?.playing) {
void video.play().catch(() => undefined);
void video.play().catch(() => {
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
onPlayingRejectedRef.current?.();
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive?.byteLength ?? null,
message: "Запуск записанной камеры отклонён браузером.",
});
});
} else {
video.pause();
}
}, [
archive?.byteLength,
bufferRevision,
currentSeconds,
directPlaybackSeconds,
epoch,
playback?.playing,
playbackRate,
segmented,
visualState,
]);
@@ -781,15 +1250,6 @@ export function RecordedFmp4Player({
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
let videoFrameRequest: number | null = null;
const emitPlayback = () => {
const expectedLocalSeconds = recordedMediaLocalTime(
epoch.timelineStartSeconds,
currentSeconds,
video.duration,
);
// A controlled seek first moves the shared LAB clock and only then fetches
// its exact fMP4 fragment. Do not let the previously displayed native
// frame race that pending seek and rewind the shared clock.
if (Math.abs(video.currentTime - expectedLocalSeconds) > 0.35) return;
onPlaybackChangeRef.current?.({
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
playing: !video.paused && !video.ended,
@@ -816,7 +1276,7 @@ export function RecordedFmp4Player({
video.cancelVideoFrameCallback(videoFrameRequest);
}
};
}, [currentSeconds, epoch, interactive, onPlaybackChange, visualState]);
}, [epoch, interactive, onPlaybackChange, visualState]);
return (
<div
@@ -27,6 +27,7 @@ export function RecordedEvidenceVideoScene({
segmentSequence,
segmentCount,
onPlaybackChange,
onPlayingRejected,
}: {
source: ObservationSourceDescriptor;
playback: RecordedObservationPlayback;
@@ -39,6 +40,7 @@ export function RecordedEvidenceVideoScene({
segmentSequence?: number;
segmentCount?: number;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
onPlayingRejected?: () => void;
}) {
return (
<div className="recorded-evidence-video-scene">
@@ -50,6 +52,7 @@ export function RecordedEvidenceVideoScene({
segmentSequence={segmentSequence}
segmentCount={segmentCount}
onPlaybackChange={onPlaybackChange}
onPlayingRejected={onPlayingRejected}
/>
{semanticOverlay ? (
<RecordedEvidenceSemanticMaskOverlay
@@ -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) {
@@ -37,7 +37,7 @@ import type {
} from "../../core/laboratory/m4ReplayThreat";
import { buildM4LocalSurface } from "../../core/laboratory/m4LocalSurface";
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
import { replayObservationSession } from "../../core/observation/sessionArchive";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
import {
useM4ThreatTimelineFrame,
@@ -148,14 +148,11 @@ export function M4ReplayThreatVisual({
const controller = new AbortController();
setVideoLoading(true);
setVideoError(null);
void replayObservationSession(timeline.recordedSourceSessionId, {
void resolveObservationSessionReplay(timeline.recordedSourceSessionId, {
signal: controller.signal,
})
.then((replay) => {
if (replay.kind !== "ready") {
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
}
const source = recordedObservationSources(replay.launch).find(
.then((launch) => {
const source = recordedObservationSources(launch).find(
(candidate) => candidate.modality === "video"
&& candidate.semanticChannelId === "camera.video.recorded",
);
@@ -532,9 +529,14 @@ export function M4ReplayThreatVisual({
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
interactive={false}
segmentSequence={frame ? frame.sequence + 1 : undefined}
segmentSequence={
timelineFrame.activeSequence === null
? undefined
: timelineFrame.activeSequence + 1
}
segmentCount={timeline.frameCount}
onPlaybackChange={playbackController.synchronize}
onPlayingRejected={() => playbackController.setPlaying(false)}
/>
) : videoError ? (
<SpatialState message={videoError} />
@@ -29,6 +29,18 @@ export function m4ThreatChunkWindowStarts(
).filter((start) => start >= 0 && start < frameCount);
}
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
inFlight: Map<number, T>,
desiredStarts: readonly number[],
): void {
const desired = new Set(desiredStarts);
for (const [start, controller] of inFlight) {
if (desired.has(start)) continue;
controller.abort();
inFlight.delete(start);
}
}
export function useM4ThreatTimelineMetadata(resultId: string) {
const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -105,6 +117,7 @@ export function useM4ThreatTimelineFrame({
chunkSize,
timeline.frameCount,
);
cancelM4ThreatChunkRequestsOutsideWindow(inFlight.current, starts);
for (const start of starts) {
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
const controller = new AbortController();
@@ -14,6 +14,7 @@ let selectM4ThreatTimelineSequence;
let advanceRecordedEvidencePlayback;
let synchronizeRecordedEvidencePlayback;
let m4ThreatChunkWindowStarts;
let cancelM4ThreatChunkRequestsOutsideWindow;
let buildM4LocalSurface;
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
@@ -38,7 +39,10 @@ before(async () => {
} = await server.ssrLoadModule(
"/src/components/laboratory/useRecordedEvidencePlayback.ts",
));
({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule(
({
m4ThreatChunkWindowStarts,
cancelM4ThreatChunkRequestsOutsideWindow,
} = await server.ssrLoadModule(
"/src/workspaces/laboratory/useM4ThreatTimeline.ts",
));
({ buildM4LocalSurface } = await server.ssrLoadModule(
@@ -375,6 +379,23 @@ test("M4.6 spatial buffering keeps previous, active and two future chunks", () =
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24, 48]);
});
test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps", () => {
const aborted = [];
const controller = (start) => ({ abort: () => aborted.push(start) });
const inFlight = new Map(
m4ThreatChunkWindowStarts(0, 24, 4489).map((start) => [start, controller(start)]),
);
for (const activeStart of [1488, 4488]) {
const desired = m4ThreatChunkWindowStarts(activeStart, 24, 4489);
cancelM4ThreatChunkRequestsOutsideWindow(inFlight, desired);
for (const start of desired) {
if (!inFlight.has(start)) inFlight.set(start, controller(start));
}
}
assert.deepEqual([...inFlight.keys()], [4464, 4488]);
assert.deepEqual(aborted, [0, 24, 48, 1464, 1488, 1512, 1536]);
});
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
const range = { startSeconds: 10, endSeconds: 20 };
assert.deepEqual(
@@ -448,6 +469,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
assert.match(visual, /<ObservationTimeline/);
assert.match(visual, /useM4ThreatTimelineFrame/);
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
assert.match(visual, /label: "VIDEO"/);
assert.match(visual, /label: "CAMERA"/);
assert.match(visual, /label: "3D"/);
@@ -463,11 +485,11 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/);
assert.match(visual, /clock: mediaMode === "video" \? "external" : "animation"/);
assert.match(visual, /segmentSequence=\{frame \? frame\.sequence \+ 1 : undefined\}/);
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
assert.match(visual, /onPlayingRejected=\{\(\) => playbackController\.setPlaying\(false\)\}/);
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
assert.doesNotMatch(visual, /setPlaying\(false\)/);
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="spatial"\]/);
@@ -953,7 +953,7 @@ test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest
);
const manifest = decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v3",
schema_version: "missioncore.observation-recorded-media/v4",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_266,
@@ -970,11 +970,17 @@ test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest
"/manifest",
`/epochs/1/recording.mp4?generation=${"c".repeat(64)}`,
),
segment_count: 2,
random_access_sequences: [1],
segment_end_times_seconds: [9.75, 19.75],
}],
}, decoded.mediaSources[0]);
assert.equal(manifest.epochs[0].byteLength, 3_266);
assert.match(manifest.epochs[0].streamUrl, /recording\.mp4\?generation=/);
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
assert.equal(manifest.epochs[0].segmentCount, 2);
assert.deepEqual(manifest.epochs[0].randomAccessSequences, [1]);
assert.deepEqual(manifest.epochs[0].segmentEndTimesSeconds, [9.75, 19.75]);
assert.throws(
() => decodeObservationRecordedMediaManifest({
...{
@@ -992,7 +998,7 @@ test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest
);
assert.throws(
() => decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v3",
schema_version: "missioncore.observation-recorded-media/v4",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_267,
@@ -1106,7 +1112,7 @@ test("recorded camera contracts reject foreign origins, path escapes and unknown
}));
assert.throws(
() => decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v3",
schema_version: "missioncore.observation-recorded-media/v4",
source_id: base.id,
generation_sha256: "c".repeat(64),
byte_length: 384,
@@ -1120,6 +1126,9 @@ test("recorded camera contracts reject foreign origins, path escapes and unknown
media_type: 'video/mp4; codecs="avc1.640028"',
byte_length: 384,
stream_url: "file:///private/recording.mp4",
segment_count: 1,
random_access_sequences: [1],
segment_end_times_seconds: [20],
}],
}, decoded.mediaSources[0]),
/небезопасный API URL/,
@@ -9,6 +9,8 @@ let fetchRecordedMediaArchive;
let recordedMediaPresentationState;
let recordedMediaSeekableCoverage;
let recordedMediaFragmentUrl;
let recordedMediaDecodeStartSequence;
let recordedMediaSegmentAppendOrder;
before(async () => {
server = await createServer({
@@ -21,6 +23,8 @@ before(async () => {
recordedMediaPresentationState,
recordedMediaSeekableCoverage,
recordedMediaFragmentUrl,
recordedMediaDecodeStartSequence,
recordedMediaSegmentAppendOrder,
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
});
@@ -29,6 +33,7 @@ after(async () => {
});
function fixture({ byteLength = 36_000_000_000 } = {}) {
const segmentCount = 1_501;
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
const generation = "a".repeat(64);
const streamUrl = manifestUrl.replace(
@@ -36,7 +41,7 @@ function fixture({ byteLength = 36_000_000_000 } = {}) {
`/epochs/1/recording.mp4?generation=${generation}`,
);
const manifest = {
schema_version: "missioncore.observation-recorded-media/v3",
schema_version: "missioncore.observation-recorded-media/v4",
source_id: "recorded.camera.camera-1",
generation_sha256: generation,
byte_length: byteLength,
@@ -50,6 +55,12 @@ function fixture({ byteLength = 36_000_000_000 } = {}) {
media_type: 'video/mp4; codecs="avc1.640028"',
byte_length: byteLength,
stream_url: streamUrl,
segment_count: segmentCount,
random_access_sequences: [1, 1491, 1501],
segment_end_times_seconds: Array.from(
{ length: segmentCount },
(_value, index) => ((index + 1) * 36_000) / segmentCount,
),
}],
};
const source = {
@@ -92,6 +103,8 @@ test("multi-hour camera admission fetches only its compact generation-bound mani
assert.deepEqual(requested, [source.manifestUrl]);
assert.equal(archive.byteLength, 36_000_000_000);
assert.equal(archive.manifest.epochs[0].streamUrl, streamUrl);
assert.equal(archive.manifest.epochs[0].segmentCount, 1_501);
assert.deepEqual(archive.manifest.epochs[0].randomAccessSequences, [1, 1491, 1501]);
});
test("first camera manifest request rejects a replaced generation", async () => {
@@ -154,8 +167,16 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
assert.match(source, /video\.src\s*=\s*descriptor\.streamUrl/);
assert.doesNotMatch(source, /new Blob\(/);
assert.match(source, /new MediaSource\(\)/);
assert.match(source, /segmentSequence \+ 36/);
assert.match(source, /RECORDED_MEDIA_SEGMENTS_AHEAD = 36/);
assert.match(source, /pumpRecordedSegmentWindow/);
assert.match(source, /waitForRecordedVideoTarget/);
assert.match(source, /removeRecordedMediaRange/);
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
assert.deepEqual(
recordedMediaSegmentAppendOrder(new Set([1491, 1492]), 1491, 1500),
[1493, 1494, 1495, 1496, 1497, 1498, 1499, 1500],
);
const { manifest, generation } = fixture();
const epoch = {
@@ -165,6 +186,9 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
mediaType: manifest.epochs[0].media_type,
byteLength: manifest.epochs[0].byte_length,
streamUrl: manifest.epochs[0].stream_url,
segmentCount: manifest.epochs[0].segment_count,
randomAccessSequences: manifest.epochs[0].random_access_sequences,
segmentEndTimesSeconds: manifest.epochs[0].segment_end_times_seconds,
};
assert.equal(
recordedMediaFragmentUrl(epoch, generation, "init"),