refactor(viewer): profile rerun loading by playback stage

This commit is contained in:
DCCONSTRUCTIONS
2026-08-29 17:46:07 +03:00
parent 856b61be99
commit c06b709fd7
21 changed files with 1313 additions and 609 deletions
@@ -29,7 +29,9 @@ export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1; export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000; const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000; // A valid local fragment becomes decoder-ready well below one second. Keeping
// a damaged GOP on screen for ten seconds only delays the keyframe recovery.
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 2_500;
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000; const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12; const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36; const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
@@ -72,6 +74,39 @@ export function recordedMediaDecodeStartSequence(
return selected; return selected;
} }
export function nextRecordedMediaRandomAccessSequence(
randomAccessSequences: readonly number[],
failedSequence: number,
): number | null {
if (!Number.isInteger(failedSequence) || failedSequence < 1) return null;
for (const sequence of randomAccessSequences) {
if (!Number.isInteger(sequence) || sequence < 1) return null;
if (sequence > failedSequence) return sequence;
}
return null;
}
export function recordedMediaRecoveryTargetSequence(
requestedSequence: number | null,
failedSequence: number | null,
recoverySequence: number | null,
): number | null {
if (requestedSequence === null) return null;
if (
!Number.isInteger(requestedSequence)
|| requestedSequence < 1
|| failedSequence === null
|| recoverySequence === null
|| !Number.isInteger(failedSequence)
|| !Number.isInteger(recoverySequence)
|| failedSequence < 1
|| recoverySequence <= failedSequence
) return requestedSequence;
return requestedSequence >= failedSequence && requestedSequence < recoverySequence
? recoverySequence
: requestedSequence;
}
export function recordedMediaSegmentAppendOrder( export function recordedMediaSegmentAppendOrder(
appended: ReadonlySet<number>, appended: ReadonlySet<number>,
decodeStartSequence: number, decodeStartSequence: number,
@@ -90,6 +125,32 @@ export function recordedMediaSegmentAppendOrder(
return missing; return missing;
} }
/** Resolve a source-clock timestamp to the first fMP4 fragment covering it. */
export function recordedMediaSegmentSequenceAtTime(
segmentEndTimesSeconds: readonly number[],
epochStartSeconds: number,
currentSeconds: number,
): number | null {
if (
!segmentEndTimesSeconds.length ||
!Number.isFinite(epochStartSeconds) ||
!Number.isFinite(currentSeconds)
) return null;
const localSeconds = Math.max(0, currentSeconds - epochStartSeconds);
let left = 0;
let right = segmentEndTimesSeconds.length - 1;
while (left < right) {
const middle = Math.floor((left + right) / 2);
const endSeconds = segmentEndTimesSeconds[middle];
if (!Number.isFinite(endSeconds) || endSeconds <= 0) return null;
if (endSeconds + 0.001 >= localSeconds) right = middle;
else left = middle + 1;
}
const finalEndSeconds = segmentEndTimesSeconds[left];
if (!Number.isFinite(finalEndSeconds) || finalEndSeconds + 0.001 < localSeconds) return null;
return left + 1;
}
export function recordedMediaCanRollTarget( export function recordedMediaCanRollTarget(
previousSequence: number, previousSequence: number,
nextSequence: number, nextSequence: number,
@@ -151,6 +212,24 @@ export function selectRecordedMediaEpoch(
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null; return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
} }
/**
* Keep admission bounded even when the shared Rerun clock is currently before,
* between, or after camera epochs. Presentation still reports `waiting`; this
* selector only chooses the nearest epoch whose first/last fragment can prove
* that the camera transport is usable without downloading the whole MP4.
*/
export function selectRecordedMediaPreparationEpoch(
epochs: readonly ObservationRecordedMediaEpoch[],
currentSeconds: number,
): ObservationRecordedMediaEpoch | null {
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
const active = selectRecordedMediaEpoch(epochs, currentSeconds);
if (active) return active;
return epochs.find((epoch) => epoch.timelineStartSeconds > currentSeconds)
?? epochs.at(-1)
?? null;
}
export function recordedMediaSeekableCoverage( export function recordedMediaSeekableCoverage(
durationSeconds: number, durationSeconds: number,
seekableEndSeconds: number, seekableEndSeconds: number,
@@ -231,29 +310,14 @@ export async function fetchRecordedMediaArchive(
return { manifest, byteLength: totalBytes }; return { manifest, byteLength: totalBytes };
} }
function videoHasSeekableArchive( function waitForRecordedVideoInitialFrame(
video: HTMLVideoElement, video: HTMLVideoElement,
declaredDurationSeconds: number,
): boolean {
if (video.readyState < 1 || video.seekable.length < 1) return false;
return recordedMediaSeekableCoverage(
video.duration,
video.seekable.end(video.seekable.length - 1),
declaredDurationSeconds,
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
video.seekable.start(0),
);
}
function waitForSeekableArchive(
video: HTMLVideoElement,
declaredDurationSeconds: number,
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve(); if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const; const events = ["loadeddata", "canplay", "progress"] as const;
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined; let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
const armStallTimer = () => { const armStallTimer = () => {
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer); if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
@@ -270,7 +334,7 @@ function waitForSeekableArchive(
}; };
const onProgress = () => { const onProgress = () => {
armStallTimer(); armStallTimer();
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return; if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
cleanup(); cleanup();
resolve(); resolve();
}; };
@@ -313,11 +377,7 @@ async function mountRecordedEpochStream(
}; };
video.load(); video.load();
try { try {
await waitForSeekableArchive( await waitForRecordedVideoInitialFrame(video, signal);
video,
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
signal,
);
return cleanup; return cleanup;
} catch (error) { } catch (error) {
cleanup(); cleanup();
@@ -336,6 +396,11 @@ interface RecordedSegmentTarget {
resetAttempts: number; resetAttempts: number;
} }
interface RecordedSegmentRecovery {
readonly failedSequence: number;
readonly recoverySequence: number;
}
interface RecordedSegmentStreamRuntime { interface RecordedSegmentStreamRuntime {
readonly generation: string; readonly generation: string;
readonly mediaSource: MediaSource; readonly mediaSource: MediaSource;
@@ -768,12 +833,16 @@ export function RecordedFmp4Player({
); );
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null); const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [readyGeneration, setReadyGeneration] = useState<string | null>(null); const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [bufferRevision, setBufferRevision] = useState(0); const [bufferRevision, setBufferRevision] = useState(0);
const [segmentRecoveryGeneration, setSegmentRecoveryGeneration] = useState(0);
const [segmentRecovery, setSegmentRecovery] = useState<RecordedSegmentRecovery | null>(null);
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null); const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null); const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
const targetRevisionRef = useRef(0); const targetRevisionRef = useRef(0);
const targetReadyAbortRef = useRef<AbortController | null>(null); const targetReadyAbortRef = useRef<AbortController | null>(null);
const lastSegmentRecoveryRef = useRef<string | null>(null);
const playAttemptRevisionRef = useRef(0); const playAttemptRevisionRef = useRef(0);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0; const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const playbackPlayingRef = useRef(Boolean(playback?.playing)); const playbackPlayingRef = useRef(Boolean(playback?.playing));
@@ -781,25 +850,60 @@ export function RecordedFmp4Player({
const playbackRate = playback?.rate && Number.isFinite(playback.rate) const playbackRate = playback?.rate && Number.isFinite(playback.rate)
? Math.min(4, Math.max(0.25, playback.rate)) ? Math.min(4, Math.max(0.25, playback.rate))
: 1; : 1;
const epoch = useMemo( const presentationEpoch = useMemo(
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds), () => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds], [archive?.manifest.epochs, currentSeconds],
); );
const epoch = useMemo(
() => selectRecordedMediaPreparationEpoch(
archive?.manifest.epochs ?? [],
currentSeconds,
),
[archive?.manifest.epochs, currentSeconds],
);
const segmentClockSeconds = epoch
? Math.min(
Math.max(currentSeconds, epoch.timelineStartSeconds),
epoch.timelineEndSeconds,
)
: currentSeconds;
const effectiveSegmentCount = segmentCount ?? epoch?.segmentCount ?? null;
const requestedSegmentSequence = segmentSequence ?? (epoch
? recordedMediaSegmentSequenceAtTime(
epoch.segmentEndTimesSeconds,
epoch.timelineStartSeconds,
segmentClockSeconds,
)
: null);
const effectiveSegmentSequence = recordedMediaRecoveryTargetSequence(
requestedSegmentSequence,
segmentRecovery?.failedSequence ?? null,
segmentRecovery?.recoverySequence ?? null,
);
const holdingForSegmentRecovery = Boolean(
segmentRecovery
&& requestedSegmentSequence !== null
&& effectiveSegmentSequence !== requestedSegmentSequence,
);
const segmented = Boolean( const segmented = Boolean(
segmentCount !== null requestedSegmentSequence !== null
&& Number.isInteger(segmentCount) && Number.isInteger(requestedSegmentSequence)
&& segmentCount >= 1 && requestedSegmentSequence >= 1
&&
effectiveSegmentCount !== null
&& Number.isInteger(effectiveSegmentCount)
&& effectiveSegmentCount >= 1
&& typeof MediaSource !== "undefined" && typeof MediaSource !== "undefined"
&& epoch && epoch
&& epoch.segmentCount === segmentCount && epoch.segmentCount === effectiveSegmentCount
&& epoch.randomAccessSequences.length > 0 && epoch.randomAccessSequences.length > 0
&& epoch.segmentEndTimesSeconds.length === segmentCount && epoch.segmentEndTimesSeconds.length === effectiveSegmentCount
&& MediaSource.isTypeSupported(epoch.mediaType), && MediaSource.isTypeSupported(epoch.mediaType),
); );
const directPlaybackSeconds = segmented ? null : currentSeconds; const directPlaybackSeconds = segmented ? null : currentSeconds;
const waitingForEpoch = Boolean(archive && !epoch); const waitingForEpoch = Boolean(archive && !presentationEpoch);
const selectedGeneration = contract && epoch const selectedGeneration = contract && presentationEpoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}` ? `${contract.manifestGenerationSha256}:${presentationEpoch.ordinal}:${presentationEpoch.timelineStartSeconds}:${presentationEpoch.timelineEndSeconds}`
: null; : null;
const visualState = recordedMediaPresentationState( const visualState = recordedMediaPresentationState(
state, state,
@@ -813,6 +917,7 @@ export function RecordedFmp4Player({
if (!contract) { if (!contract) {
setArchive(null); setArchive(null);
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Некорректный descriptor записанной камеры.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -825,6 +930,9 @@ export function RecordedFmp4Player({
const abort = new AbortController(); const abort = new AbortController();
setArchive(null); setArchive(null);
setReadyGeneration(null); setReadyGeneration(null);
setSegmentRecovery(null);
lastSegmentRecoveryRef.current = null;
setErrorMessage(null);
setState("loading"); setState("loading");
reportAdmission({ reportAdmission({
phase: "loading", phase: "loading",
@@ -842,6 +950,7 @@ export function RecordedFmp4Player({
} }
setArchive(null); setArchive(null);
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Архив записанной камеры не прошёл проверку.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -853,43 +962,14 @@ export function RecordedFmp4Player({
}, [admissionKey, contract, prepare]); }, [admissionKey, contract, prepare]);
useEffect(() => { useEffect(() => {
if (!archive || !contract || !prepare || segmented) return; if (!segmentRecovery || requestedSegmentSequence === null) return;
const abort = new AbortController(); if (
let disposed = false; requestedSegmentSequence >= segmentRecovery.failedSequence
void (async () => { && requestedSegmentSequence < segmentRecovery.recoverySequence
for (const candidate of archive.manifest.epochs) { ) return;
const probe = document.createElement("video"); lastSegmentRecoveryRef.current = null;
probe.muted = true; setSegmentRecovery(null);
probe.playsInline = true; }, [requestedSegmentSequence, segmentRecovery]);
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
cleanup();
if (disposed || abort.signal.aborted) return;
}
if (disposed || abort.signal.aborted) return;
reportAdmission({
phase: "ready",
byteLength: archive.byteLength,
message: null,
});
})().catch((error: unknown) => {
if (
disposed ||
abort.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) return;
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive.byteLength,
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
});
});
return () => {
disposed = true;
abort.abort();
};
}, [admissionKey, archive, contract, prepare, segmented]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -910,6 +990,7 @@ export function RecordedFmp4Player({
setReadyGeneration(null); setReadyGeneration(null);
setSegmentedRuntimeGeneration(null); setSegmentedRuntimeGeneration(null);
setErrorMessage(null);
setState("loading"); setState("loading");
video.pause(); video.pause();
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal); const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
@@ -957,6 +1038,7 @@ export function RecordedFmp4Player({
return; return;
} }
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Покадровый буфер записанной камеры не открылся.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -982,7 +1064,14 @@ export function RecordedFmp4Player({
} }
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
}; };
}, [archive, contract, epoch, segmentCount, segmented]); }, [
archive,
contract,
effectiveSegmentCount,
epoch,
segmented,
segmentRecoveryGeneration,
]);
useEffect(() => { useEffect(() => {
const runtime = segmentedRuntimeRef.current; const runtime = segmentedRuntimeRef.current;
@@ -993,18 +1082,19 @@ export function RecordedFmp4Player({
|| !archive || !archive
|| !segmented || !segmented
|| segmentedRuntimeGeneration !== runtime.generation || segmentedRuntimeGeneration !== runtime.generation
|| segmentSequence === null || effectiveSegmentSequence === null
|| !Number.isInteger(segmentSequence) || !Number.isInteger(effectiveSegmentSequence)
|| segmentSequence < 1 || effectiveSegmentSequence < 1
|| segmentSequence > runtime.segmentCount || effectiveSegmentSequence > runtime.segmentCount
) return; ) return;
const archiveByteLength = archive.byteLength; const archiveByteLength = archive.byteLength;
const decodeStart = recordedMediaDecodeStartSequence( const decodeStart = recordedMediaDecodeStartSequence(
runtime.randomAccessSequences, runtime.randomAccessSequences,
segmentSequence, effectiveSegmentSequence,
); );
if (decodeStart === null) { if (decodeStart === null) {
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Для кадра записанной камеры нет random-access фрагмента.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1015,10 +1105,11 @@ export function RecordedFmp4Player({
} }
const targetSeconds = recordedSegmentStartSeconds( const targetSeconds = recordedSegmentStartSeconds(
runtime.segmentEndTimesSeconds, runtime.segmentEndTimesSeconds,
segmentSequence, effectiveSegmentSequence,
); );
if (targetSeconds === null) { if (targetSeconds === null) {
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Для кадра записанной камеры нет точной media timestamp.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1030,15 +1121,15 @@ export function RecordedFmp4Player({
const previousTarget = runtime.target; const previousTarget = runtime.target;
const readyEnd = Math.min( const readyEnd = Math.min(
runtime.segmentCount, runtime.segmentCount,
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS, effectiveSegmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
); );
const desiredEnd = Math.min( const desiredEnd = Math.min(
runtime.segmentCount, runtime.segmentCount,
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD, effectiveSegmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
); );
const candidateTarget: RecordedSegmentTarget = { const candidateTarget: RecordedSegmentTarget = {
revision: previousTarget?.revision ?? 0, revision: previousTarget?.revision ?? 0,
sequence: segmentSequence, sequence: effectiveSegmentSequence,
decodeStart, decodeStart,
readyEnd, readyEnd,
desiredEnd, desiredEnd,
@@ -1046,6 +1137,24 @@ export function RecordedFmp4Player({
forceReset: false, forceReset: false,
resetAttempts: 0, resetAttempts: 0,
}; };
const recoverFromSegmentFailure = (failedSequence: number): boolean => {
const recoverySequence = nextRecordedMediaRandomAccessSequence(
runtime.randomAccessSequences,
failedSequence,
);
if (recoverySequence === null) return false;
const recoveryToken = `${runtime.generation}:${failedSequence}:${recoverySequence}`;
if (lastSegmentRecoveryRef.current === recoveryToken) return true;
lastSegmentRecoveryRef.current = recoveryToken;
setReadyGeneration(null);
setSegmentRecovery({ failedSequence, recoverySequence });
setErrorMessage(
`Восстанавливаем камеру с ключевого кадра ${recoverySequence}.`,
);
setState("loading");
setSegmentRecoveryGeneration((generation) => generation + 1);
return true;
};
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget( const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
previousTarget.sequence, previousTarget.sequence,
candidateTarget.sequence, candidateTarget.sequence,
@@ -1059,12 +1168,19 @@ export function RecordedFmp4Player({
|| runtime.abort.signal.aborted || runtime.abort.signal.aborted
|| (error instanceof DOMException && error.name === "AbortError") || (error instanceof DOMException && error.name === "AbortError")
) return; ) return;
const failedSequence = runtime.target?.sequence ?? effectiveSegmentSequence;
if (failedSequence !== null && recoverFromSegmentFailure(failedSequence)) return;
const detail = error instanceof Error && error.message
? `: ${error.message}`
: ".";
const message = `Покадровый фрагмент записанной камеры недоступен${detail}`;
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage(message);
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
byteLength: archiveByteLength, byteLength: archiveByteLength,
message: "Покадровый фрагмент записанной камеры недоступен.", message,
}); });
}; };
if (rollingTarget && previousTarget) { if (rollingTarget && previousTarget) {
@@ -1128,6 +1244,7 @@ export function RecordedFmp4Player({
setBufferRevision((revision) => revision + 1); setBufferRevision((revision) => revision + 1);
runtime.hasPresentedFrame = true; runtime.hasPresentedFrame = true;
setReadyGeneration(runtime.generation); setReadyGeneration(runtime.generation);
setErrorMessage(null);
setState("ready"); setState("ready");
reportAdmission({ reportAdmission({
phase: "ready", phase: "ready",
@@ -1139,7 +1256,9 @@ export function RecordedFmp4Player({
targetReadyAbort.signal.aborted targetReadyAbort.signal.aborted
|| (error instanceof DOMException && error.name === "AbortError") || (error instanceof DOMException && error.name === "AbortError")
) return; ) return;
if (recoverFromSegmentFailure(bufferedTarget.sequence)) return;
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Кадр записанной камеры не стал decoder-ready.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1160,7 +1279,7 @@ export function RecordedFmp4Player({
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null; if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null; if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
}; };
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]); }, [archive?.byteLength, effectiveSegmentSequence, segmented, segmentedRuntimeGeneration]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -1170,6 +1289,7 @@ export function RecordedFmp4Player({
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}` ? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
: null; : null;
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage(null);
setState("loading"); setState("loading");
const abort = new AbortController(); const abort = new AbortController();
let disposed = false; let disposed = false;
@@ -1185,7 +1305,13 @@ export function RecordedFmp4Player({
} }
setBufferRevision((revision) => revision + 1); setBufferRevision((revision) => revision + 1);
setReadyGeneration(generation); setReadyGeneration(generation);
setErrorMessage(null);
setState("ready"); setState("ready");
reportAdmission({
phase: "ready",
byteLength: archive?.byteLength ?? null,
message: null,
});
} catch (error) { } catch (error) {
if ( if (
disposed || disposed ||
@@ -1195,11 +1321,12 @@ export function RecordedFmp4Player({
return; return;
} }
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Записанная камера не открыла первый декодируемый кадр.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
byteLength: archive?.byteLength ?? null, byteLength: archive?.byteLength ?? null,
message: "Записанная камера не стала seekable.", message: "Записанная камера не открыла первый декодируемый кадр.",
}); });
} }
}; };
@@ -1234,6 +1361,7 @@ export function RecordedFmp4Player({
video.currentTime = target; video.currentTime = target;
} catch { } catch {
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Seek записанной камеры завершился ошибкой.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1244,11 +1372,12 @@ export function RecordedFmp4Player({
} }
} }
video.playbackRate = playbackRate; video.playbackRate = playbackRate;
if (playback?.playing) { if (playback?.playing && !holdingForSegmentRecovery) {
void video.play().catch(() => { void video.play().catch(() => {
if (playAttemptRevisionRef.current !== playAttemptRevision) return; if (playAttemptRevisionRef.current !== playAttemptRevision) return;
onPlayingRejectedRef.current?.(); onPlayingRejectedRef.current?.();
setReadyGeneration(null); setReadyGeneration(null);
setErrorMessage("Запуск записанной камеры отклонён браузером.");
setState("error"); setState("error");
reportAdmission({ reportAdmission({
phase: "error", phase: "error",
@@ -1264,6 +1393,7 @@ export function RecordedFmp4Player({
bufferRevision, bufferRevision,
directPlaybackSeconds, directPlaybackSeconds,
epoch, epoch,
holdingForSegmentRecovery,
playback?.playing, playback?.playing,
playbackRate, playbackRate,
segmented, segmented,
@@ -1327,7 +1457,9 @@ export function RecordedFmp4Player({
{visualState === "waiting" {visualState === "waiting"
? "Камера на этой позиции ещё не записывалась" ? "Камера на этой позиции ещё не записывалась"
: visualState === "error" : visualState === "error"
? "Записанное видео недоступно" ? errorMessage ?? "Записанное видео недоступно"
: errorMessage
? errorMessage
: archive : archive
? "Проверяем seek и codec записанного видео…" ? "Проверяем seek и codec записанного видео…"
: "Читаем manifest записанного видео…"} : "Читаем manifest записанного видео…"}
@@ -11,10 +11,18 @@ import {
LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS, LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS,
requestLiveReceiverRecovery, requestLiveReceiverRecovery,
} from "../core/observation/liveReceiverWatchdog"; } from "../core/observation/liveReceiverWatchdog";
import {
claimExclusiveLiveViewer,
createReentrantViewerDisposer,
isLiveRerunPresentationReady,
liveRerunReceiverBindingIdentity,
liveTimelineNeedsSynchronization,
} from "../core/observation/liveRerunLifecycle";
import { import {
createLiveViewerDiagnosticLifecycle, createLiveViewerDiagnosticLifecycle,
createLiveViewerInstanceId, createLiveViewerInstanceId,
createLiveViewerLineage, createLiveViewerLineage,
reloadRecordedViewerAfterStaleModuleFailure,
subscribeToLiveViewerBuildFence, subscribeToLiveViewerBuildFence,
type LiveViewerFailureStage, type LiveViewerFailureStage,
} from "../core/observation/liveViewerDiagnostics"; } from "../core/observation/liveViewerDiagnostics";
@@ -22,10 +30,63 @@ import {
fetchPerceptionPreparationStatus, fetchPerceptionPreparationStatus,
perceptionPreparationMessage, perceptionPreparationMessage,
} from "../core/observation/perceptionPreparation"; } from "../core/observation/perceptionPreparation";
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission"; import {
RECORDED_BASE_POINT_COLOR_KEY,
canPublishRecordedPlaybackController,
createRecordedAutoplayGate,
createRecordedOpenWatchdog,
} from "../core/observation/recordedRerunLifecycle";
import type {
RecordedPerceptionLayers,
RecordedRerunView,
RecordedRrdArtifactDescriptor,
RerunPlaybackController,
RerunPlaybackState,
RerunViewerProfile,
RerunViewportStatus,
} from "../core/observation/viewerProfile";
export type {
RecordedPerceptionLayers,
RecordedRerunView,
RecordedRrdArtifactDescriptor,
RerunPlaybackController,
RerunPlaybackState,
RerunViewerProfile,
RerunViewportStatus,
} from "../core/observation/viewerProfile";
export {
attemptRecordedAutoplay,
canPublishRecordedPlaybackController,
createRecordedAutoplayGate,
createRecordedOpenWatchdog,
isRecordedPlaybackFullyBuffered,
isRecordedPlaybackPresentationReady,
isRecordedPlaybackReady,
isUsableRecordedPlaybackRange,
recordedOpenWatchdogTimeoutMs,
recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady,
recordedPointColorKey,
rerunPresentationStatus,
type RecordedPlaybackBufferState,
} from "../core/observation/recordedRerunLifecycle";
export {
claimExclusiveLiveViewer,
createReentrantViewerDisposer,
isLiveRerunPresentationReady,
liveRerunReceiverBindingIdentity,
liveTimelineNeedsSynchronization,
} from "../core/observation/liveRerunLifecycle";
import {
isRecordedPlaybackReady,
recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady,
recordedPointColorKey,
rerunPresentationStatus,
} from "../core/observation/recordedRerunLifecycle";
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
export type RecordedPerceptionLoadPhase = export type RecordedPerceptionLoadPhase =
| "idle" | "idle"
| "loading" | "loading"
@@ -46,55 +107,14 @@ export type RecordedPointColorLoadState = Pick<
"phase" | "receivedBytes" | "totalBytes" | "progress" | "message" "phase" | "receivedBytes" | "totalBytes" | "progress" | "message"
>; >;
export interface RecordedPerceptionLayers {
enabled: boolean;
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
}
export interface RerunSelection { export interface RerunSelection {
entityPath: string; entityPath: string;
viewName?: string; viewName?: string;
position?: [number, number, number]; position?: [number, number, number];
} }
export interface RerunPlaybackState {
recordingId: string;
timeline: string;
rangeNs: { min: number; max: number } | null;
currentNs: number;
playing: boolean;
/** Latest session-time value currently available to the browser receiver. */
bufferedEndNs: number | null;
/** Declared first session-time value, when the archive descriptor provides it. */
expectedStartNs: number | null;
/** Declared final session-time value, when the archive descriptor provides it. */
expectedEndNs: number | null;
/** Download progress in the closed interval 0..1, or null without a valid expectation. */
bufferProgress: number | null;
/** True only when the buffered range has reached the declared archive end. */
fullyBuffered: boolean;
}
export interface RerunPlaybackController {
seek: (timeNs: number) => void;
setPlaying: (playing: boolean) => void;
jumpToEnd: () => void;
}
export interface RerunViewportProps { export interface RerunViewportProps {
sourceUrl: string; profile: RerunViewerProfile;
recordedArtifact?: RecordedRrdArtifactDescriptor | null;
followLive?: boolean;
liveActivitySequence?: number | null;
liveStreamId?: string | null;
liveRecoveryAuthorityIdentity?: string | null;
autoplayWhenReady?: boolean;
presentationGate?: RecordedAdmissionPhase;
expectedTimelineStartSeconds?: number;
expectedTimelineEndSeconds?: number;
initialPlaybackStartSeconds?: number;
onStatusChange?: (status: RerunViewportStatus, message?: string) => void; onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
onSelectionChange?: (selection: RerunSelection | null) => void; onSelectionChange?: (selection: RerunSelection | null) => void;
onPlaybackChange?: (state: RerunPlaybackState | null) => void; onPlaybackChange?: (state: RerunPlaybackState | null) => void;
@@ -110,23 +130,10 @@ export interface RerunViewportProps {
| "palette" | "palette"
| "customColor" | "customColor"
>; >;
recordedView?: RecordedRerunView;
recordedViewResetGeneration?: 0 | 1;
recordedFollowTrajectory?: boolean;
recordedPerceptionLayers?: RecordedPerceptionLayers;
recordedPerceptionRetryGeneration?: number;
lockPerceptionCameraInteraction?: boolean;
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void; onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void; onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void;
} }
export interface RecordedRrdArtifactDescriptor {
sourceUrl: string;
viewerSourceUrl: string;
byteLength: number;
sha256: string;
}
interface RerunBlueprintChannel { interface RerunBlueprintChannel {
endpointUrl: string; endpointUrl: string;
channel: { channel: {
@@ -147,375 +154,6 @@ const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][
const RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.rrd$/; const RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.rrd$/;
const MAX_BLUEPRINT_BYTES = 1_048_576; const MAX_BLUEPRINT_BYTES = 1_048_576;
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024; const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
const BUFFER_END_TOLERANCE_NS = 1_000_000;
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
const RECORDED_OPEN_GRACE_MS = 30_000;
const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024;
const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
export function recordedPointColorKey(
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
): string {
const custom = settings.palette === "custom" || settings.colorMode === "class"
? settings.customColor.toLowerCase()
: "-";
return `${settings.colorMode}|${settings.palette}|${custom}`;
}
export function recordedOpenWatchdogTimeoutMs(byteLength: number): number {
if (!Number.isSafeInteger(byteLength) || byteLength < 4) {
throw new Error("Unsafe recorded RRD byte length");
}
const transferBudgetMs = Math.ceil(
(byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000,
);
return Math.min(
RECORDED_OPEN_MAX_TIMEOUT_MS,
Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS),
);
}
export function createRecordedOpenWatchdog<T>({
byteLength,
schedule,
cancel,
onTimeout,
}: {
byteLength: number;
schedule: (callback: () => void, timeoutMs: number) => T;
cancel: (handle: T) => void;
onTimeout: () => void;
}): { arm: () => void; clear: () => void; pending: () => boolean } {
const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength);
let handle: T | null = null;
return {
arm() {
if (handle !== null) return;
handle = schedule(() => {
handle = null;
onTimeout();
}, timeoutMs);
},
clear() {
if (handle === null) return;
cancel(handle);
handle = null;
},
pending: () => handle !== null,
};
}
export function createReentrantViewerDisposer(
cleanupOnce: () => void,
releaseNativeViewer: () => void,
): () => void {
let cleanupComplete = false;
return () => {
try {
if (!cleanupComplete) {
cleanupComplete = true;
cleanupOnce();
}
} finally {
// `viewer.start()` can resolve after an earlier pre-ready stop. Reapply
// native release on every disposal boundary so that a stale viewer can
// never reopen after React and diagnostics have already unmounted it.
releaseNativeViewer();
}
};
}
interface ActiveLiveViewerOwner {
release: () => void;
}
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
/**
* Own exactly one native live receiver per application document.
*
* React route/StrictMode transitions can overlap two mounted workspaces for a
* render turn. Rerun keeps each native gRPC receiver alive independently, so
* the overlap used to consume the bounded live replay slots and leave the
* operator's visible canvas black. Claiming the next owner synchronously
* retires the previous native receiver before the next one starts.
*/
export function claimExclusiveLiveViewer(release: () => void): () => void {
const owner = { release };
const previous = activeLiveViewerOwner;
activeLiveViewerOwner = owner;
previous?.release();
return () => {
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
};
}
export interface RecordedPlaybackBufferState {
bufferedEndNs: number | null;
expectedStartNs: number | null;
expectedEndNs: number | null;
bufferProgress: number | null;
fullyBuffered: boolean;
}
/**
* A Rerun time range is usable as soon as it contains one finite timestamp.
* A first frame commonly has min === max; waiting for positive duration would
* needlessly keep that visible frame behind the loading screen.
*/
export function isUsableRecordedPlaybackRange(
rangeNs: { min: number; max: number } | null,
): rangeNs is { min: number; max: number } {
return Boolean(
rangeNs &&
Number.isFinite(rangeNs.min) &&
Number.isFinite(rangeNs.max) &&
rangeNs.max >= rangeNs.min,
);
}
/**
* A live receiver is presentable only after the exact browser store exposes
* real timeline data that the backend has also confirmed publishing.
* `WebViewer.start()` and a non-null active recording id are transport setup,
* not evidence that the spatial scene can render.
*/
export function isLiveRerunPresentationReady(
viewerStarted: boolean,
rangeNs: { min: number; max: number } | null,
backendActivitySequence: number | null,
): boolean {
return viewerStarted &&
Number.isSafeInteger(backendActivitySequence) &&
(backendActivitySequence ?? 0) > 0 &&
isUsableRecordedPlaybackRange(rangeNs);
}
/**
* `recording_open` can arrive before Rerun has registered the live timeline.
* Selecting it at that point is a silent no-op, so keep retrying only until
* the exact live timeline is both available and active.
*/
export function liveTimelineNeedsSynchronization(
followLive: boolean,
activeTimeline: string | null | undefined,
rangeNs: { min: number; max: number } | null,
): boolean {
return followLive &&
isUsableRecordedPlaybackRange(rangeNs) &&
activeTimeline !== "stream_time";
}
/**
* Key the native receiver to its data-plane binding. Recovery authority is a
* retry fence projected from changing supervisor snapshots; it must not tear
* down a healthy WebViewer while this acquisition and URL remain unchanged.
*/
export function liveRerunReceiverBindingIdentity(
sourceUrl: string,
liveStreamId: string | null,
followLive: boolean,
): string {
return JSON.stringify([
followLive ? "live" : "recorded",
sourceUrl.trim(),
followLive ? liveStreamId?.trim() ?? "" : "",
]);
}
/** Describe progressive archive availability without gating first rendering. */
export function recordedPlaybackBufferState(
rangeNs: { min: number; max: number } | null,
expectedTimelineEndSeconds?: number,
expectedTimelineStartSeconds?: number,
): RecordedPlaybackBufferState {
const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null;
const bufferedEndNs = usableRange?.max ?? null;
if (expectedTimelineEndSeconds === undefined) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: usableRange !== null,
};
}
if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: false,
};
}
const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000;
const expectedStartNs = expectedTimelineStartSeconds === undefined
? 0
: expectedTimelineStartSeconds * 1_000_000_000;
if (
!Number.isFinite(expectedEndNs) ||
!Number.isFinite(expectedStartNs) ||
expectedStartNs < 0 ||
expectedEndNs < expectedStartNs
) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: false,
};
}
// Rerun split preserves whole boundary chunks so that all generated splits
// still sum exactly to the source recording. A verified bounded artifact can
// therefore begin slightly before its declared operator window. Admission
// requires coverage of the declared window, not byte-chunk boundary equality.
const fullyBuffered = usableRange !== null &&
usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS &&
usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS;
const expectedDurationNs = expectedEndNs - expectedStartNs;
const bufferProgress = bufferedEndNs === null
? 0
: expectedDurationNs <= 0
? (fullyBuffered ? 1 : 0)
: Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs));
return {
bufferedEndNs,
expectedStartNs,
expectedEndNs,
bufferProgress,
fullyBuffered,
};
}
/** A recorded viewport is publishable only after its declared range is complete. */
export function isRecordedPlaybackReady(
viewerStarted: boolean,
artifactVerified: boolean,
buffer: RecordedPlaybackBufferState,
): boolean {
return viewerStarted && artifactVerified && buffer.fullyBuffered;
}
/** Keep the host scrubber and its controls disconnected from a partial archive. */
export function recordedPlaybackRangeWhenReady(
rangeNs: { min: number; max: number } | null,
buffer: RecordedPlaybackBufferState,
artifactVerified: boolean,
): { min: number; max: number } | null {
if (
!artifactVerified ||
!buffer.fullyBuffered ||
!isUsableRecordedPlaybackRange(rangeNs)
) return null;
return {
min: buffer.expectedStartNs === null
? rangeNs.min
: Math.max(rangeNs.min, buffer.expectedStartNs),
max: buffer.expectedEndNs === null
? rangeNs.max
: Math.min(rangeNs.max, buffer.expectedEndNs),
};
}
/** Do not mount host timeline controls while a recorded generation is partial. */
export function isRecordedPlaybackPresentationReady(
status: RerunViewportStatus,
playback: RerunPlaybackState | null,
): boolean {
return status === "ready" &&
playback?.fullyBuffered === true &&
isUsableRecordedPlaybackRange(playback.rangeNs);
}
export function rerunPresentationStatus(
status: RerunViewportStatus,
gate: RecordedAdmissionPhase,
recorded: boolean,
): RerunViewportStatus {
if (!recorded) return status;
if (status === "error" || gate === "error") return "error";
if (gate !== "ready") return "loading";
return status;
}
export function isRecordedPlaybackFullyBuffered(
rangeNs: { min: number; max: number } | null,
expectedTimelineEndSeconds?: number,
): boolean {
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
}
export function attemptRecordedAutoplay(
seekToStart: () => void,
startPlaying: () => void,
): boolean {
try {
seekToStart();
startPlaying();
return true;
} catch {
return false;
}
}
/**
* Admit autoplay once, after the viewer and the complete recorded range are
* ready. The attempt is consumed even if a vendor call throws: subsequent
* polling must never seek the operator back to the beginning a second time.
*/
export function createRecordedAutoplayGate(): {
attempt: (
viewerStarted: boolean,
fullyBuffered: boolean,
presentationReady: boolean,
rangeNs: { min: number; max: number } | null,
seekToStart: (startNs: number) => void,
startPlaying: () => void,
preferredStartNs?: number,
) => boolean;
attempted: () => boolean;
} {
let consumed = false;
return {
attempt(
viewerStarted,
fullyBuffered,
presentationReady,
rangeNs,
seekToStart,
startPlaying,
preferredStartNs,
) {
if (
consumed ||
!viewerStarted ||
!fullyBuffered ||
!presentationReady ||
!isUsableRecordedPlaybackRange(rangeNs)
) return false;
consumed = true;
const startNs = Number.isFinite(preferredStartNs)
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
: rangeNs.min;
return attemptRecordedAutoplay(
() => seekToStart(startNs),
startPlaying,
);
},
attempted: () => consumed,
};
}
export function canPublishRecordedPlaybackController(
readyToRender: boolean,
presentationGate: RecordedAdmissionPhase,
): boolean {
return readyToRender && presentationGate === "ready";
}
export function createLatestAnimationFrameEmitter<T>({ export function createLatestAnimationFrameEmitter<T>({
emit, emit,
@@ -917,36 +555,43 @@ export async function fetchRecordedPerceptionRrd(
} }
export function RerunViewport({ export function RerunViewport({
sourceUrl, profile,
recordedArtifact = null,
followLive = false,
liveActivitySequence = null,
liveStreamId = null,
liveRecoveryAuthorityIdentity = null,
autoplayWhenReady = false,
presentationGate = "ready",
expectedTimelineStartSeconds,
expectedTimelineEndSeconds,
initialPlaybackStartSeconds,
onStatusChange, onStatusChange,
onSelectionChange, onSelectionChange,
onPlaybackChange, onPlaybackChange,
onPlaybackControllerChange, onPlaybackControllerChange,
sceneSettings, sceneSettings,
recordedView = "spatial", onPerceptionLoadChange,
recordedViewResetGeneration = 0, onPointColorLoadChange,
recordedFollowTrajectory = false, }: RerunViewportProps) {
recordedPerceptionLayers = { const recordedProfile = profile.kind === "recorded-session" ? profile : null;
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
const sourceUrl = profile.sourceUrl;
const recordedArtifact = recordedProfile?.artifact ?? null;
const followLive = liveProfile !== null;
const liveActivitySequence = liveProfile?.liveActivitySequence ?? null;
const liveStreamId = liveProfile?.liveStreamId ?? null;
const liveRecoveryAuthorityIdentity =
liveProfile?.liveRecoveryAuthorityIdentity ?? null;
const autoplayWhenReady = recordedProfile?.autoplayWhenReady ?? false;
const presentationGate = recordedProfile?.presentationGate ?? "ready";
const expectedTimelineStartSeconds =
recordedProfile?.expectedTimelineStartSeconds;
const expectedTimelineEndSeconds = recordedProfile?.expectedTimelineEndSeconds;
const initialPlaybackStartSeconds = recordedProfile?.initialPlaybackStartSeconds;
const recordedView = recordedProfile?.view ?? "spatial";
const recordedViewResetGeneration = recordedProfile?.viewResetGeneration ?? 0;
const recordedFollowTrajectory = recordedProfile?.followTrajectory ?? false;
const recordedPerceptionLayers = recordedProfile?.perceptionLayers ?? {
enabled: false, enabled: false,
detections2d: false, detections2d: false,
segmentation: false, segmentation: false,
cuboids3d: false, cuboids3d: false,
}, };
recordedPerceptionRetryGeneration = 0, const recordedPerceptionRetryGeneration =
lockPerceptionCameraInteraction = false, recordedProfile?.perceptionRetryGeneration ?? 0;
onPerceptionLoadChange, const lockPerceptionCameraInteraction =
onPointColorLoadChange, recordedProfile?.lockPerceptionCameraInteraction ?? false;
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle"); const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null); const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null);
@@ -1588,7 +1233,7 @@ export function RerunViewport({
liveRecoveryRef.current = initialLiveReceiverRecoveryState(); liveRecoveryRef.current = initialLiveReceiverRecoveryState();
} }
if (!followLive) clearRecordedAdmissionWatchdog(); if (!followLive) clearRecordedAdmissionWatchdog();
if (!followLive) setRecordingBufferProgress(1); if (!followLive) setRecordingBufferProgress(recordedBuffer.bufferProgress);
recordedSceneAdmitted = true; recordedSceneAdmitted = true;
if (!followLive) onPlaybackChange?.(playbackState); if (!followLive) onPlaybackChange?.(playbackState);
setStatus("ready"); setStatus("ready");
@@ -1597,7 +1242,10 @@ export function RerunViewport({
if ( if (
!followLive && !followLive &&
!playbackControllerPublished && !playbackControllerPublished &&
canPublishRecordedPlaybackController(readyToRender, presentationGateRef.current) canPublishRecordedPlaybackController(
recordedBuffer.fullyBuffered,
presentationGateRef.current,
)
) { ) {
playbackControllerPublished = true; playbackControllerPublished = true;
onPlaybackControllerChange?.(playbackController); onPlaybackControllerChange?.(playbackController);
@@ -1740,9 +1388,16 @@ export function RerunViewport({
} }
} }
}) })
.catch(() => { .catch(async () => {
if (disposed) return; if (disposed) return;
disposeViewer?.(); disposeViewer?.();
if (
isRecordedSource &&
await reloadRecordedViewerAfterStaleModuleFailure({
loadedUiBuildId: diagnosticLifecycle.lineage.uiBuildId,
signal: diagnosticLifecycle.signal,
})
) return;
if (requestLiveRecovery("module-load")) return; if (requestLiveRecovery("module-load")) return;
reportError("Не удалось загрузить модуль визуализатора."); reportError("Не удалось загрузить модуль визуализатора.");
}); });
@@ -1786,7 +1441,18 @@ export function RerunViewport({
}, [recordedPointColorsUrl]); }, [recordedPointColorsUrl]);
useEffect(() => { useEffect(() => {
if (!recordedPerceptionUrl) return; if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) {
if (!recordedPerceptionLayers.enabled) {
onPerceptionLoadChange?.({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}
return;
}
const active = perceptionChannelRef.current; const active = perceptionChannelRef.current;
const identity = recordedIdentityRef.current; const identity = recordedIdentityRef.current;
if ( if (
@@ -1931,6 +1597,7 @@ export function RerunViewport({
}, [ }, [
onPerceptionLoadChange, onPerceptionLoadChange,
perceptionChannelRevision, perceptionChannelRevision,
recordedPerceptionLayers.enabled,
recordedPerceptionRetryGeneration, recordedPerceptionRetryGeneration,
recordedPerceptionUrl, recordedPerceptionUrl,
]); ]);
@@ -849,12 +849,14 @@ export async function fetchM4ThreatTimelineChunk(
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
cameraObstacleProjectionDelivery = null, cameraObstacleProjectionDelivery = null,
playbackPointPack, playbackPointPack,
includePoints = true,
}: { }: {
fetcher?: LaboratoryFetch; fetcher?: LaboratoryFetch;
signal?: AbortSignal; signal?: AbortSignal;
endpointRoot?: string; endpointRoot?: string;
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"]; cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
playbackPointPack?: M4ThreatPlaybackPointPack; playbackPointPack?: M4ThreatPlaybackPointPack;
includePoints?: boolean;
} = {}, } = {},
): Promise<M4ThreatTimelineChunk> { ): Promise<M4ThreatTimelineChunk> {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -864,7 +866,7 @@ export async function fetchM4ThreatTimelineChunk(
if (cameraObstacleProjectionDelivery !== null) { if (cameraObstacleProjectionDelivery !== null) {
params.set("obstacle_projection", cameraObstacleProjectionDelivery); params.set("obstacle_projection", cameraObstacleProjectionDelivery);
} }
if (playbackPointPack) params.set("include_points", "false"); if (playbackPointPack || !includePoints) params.set("include_points", "false");
const response = await fetcher( const response = await fetcher(
`${endpointRoot}/${result}/timeline/chunk?${params}`, `${endpointRoot}/${result}/timeline/chunk?${params}`,
{ headers: { Accept: "application/json" }, signal }, { headers: { Accept: "application/json" }, signal },
@@ -0,0 +1,57 @@
import {
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
type LaboratoryRecordedEvidenceViewerProfile,
} from "../observation/viewerProfile";
export type LaboratoryRecordedMediaMode = "video" | "camera" | null;
export type LaboratoryRecordedSpatialMode = "3d" | "plan" | null;
export type LaboratoryClassifiedSpatialMode = "none" | "overlay" | "replace-source";
export interface LaboratoryRecordedEvidenceVisibility {
mediaMode: LaboratoryRecordedMediaMode;
spatialMode: LaboratoryRecordedSpatialMode;
showMediaSemantic: boolean;
showSpatialSemantic: boolean;
showMediaPoints: boolean;
classifiedSpatialMode: LaboratoryClassifiedSpatialMode;
}
export interface LaboratoryRecordedEvidenceDemand {
sourceTimelineMetadata: true;
recordedVideo: boolean;
exactCameraFrame: boolean;
sourceSpatialPoints: boolean;
cameraPointOverlay: boolean;
selectedSemanticMask: boolean;
selectedSemanticPoints: boolean;
classifiedSpatial: boolean;
}
/**
* Translate the already-visible M4 evidence composition into explicit data
* demand. The profile never starts a hidden point, semantic or camera channel
* merely because the selected LAB happens to publish that artifact.
*/
export function laboratoryRecordedEvidenceDemand(
visibility: LaboratoryRecordedEvidenceVisibility,
profile: LaboratoryRecordedEvidenceViewerProfile =
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
): LaboratoryRecordedEvidenceDemand {
if (profile.loadPolicy !== "visible-evidence-only") {
throw new Error("Unsupported LAB recorded evidence load policy");
}
const mediaVisible = visibility.mediaMode !== null;
const spatialVisible = visibility.spatialMode !== null;
return {
sourceTimelineMetadata: true,
recordedVideo: visibility.mediaMode === "video",
exactCameraFrame: visibility.mediaMode === "camera",
sourceSpatialPoints:
spatialVisible && visibility.classifiedSpatialMode !== "replace-source",
cameraPointOverlay: mediaVisible && visibility.showMediaPoints,
selectedSemanticMask: mediaVisible && visibility.showMediaSemantic,
selectedSemanticPoints: spatialVisible && visibility.showSpatialSemantic,
classifiedSpatial:
spatialVisible && visibility.classifiedSpatialMode !== "none",
};
}
@@ -0,0 +1,70 @@
import { isUsableRecordedPlaybackRange } from "./recordedRerunLifecycle";
export function createReentrantViewerDisposer(
cleanupOnce: () => void,
releaseNativeViewer: () => void,
): () => void {
let cleanupComplete = false;
return () => {
try {
if (!cleanupComplete) {
cleanupComplete = true;
cleanupOnce();
}
} finally {
// A deferred native start can resolve after a pre-ready stop. Reapply
// release at every boundary so it cannot reopen after React unmount.
releaseNativeViewer();
}
};
}
interface ActiveLiveViewerOwner {
release: () => void;
}
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
/** Own exactly one native live receiver per application document. */
export function claimExclusiveLiveViewer(release: () => void): () => void {
const owner = { release };
const previous = activeLiveViewerOwner;
activeLiveViewerOwner = owner;
previous?.release();
return () => {
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
};
}
export function isLiveRerunPresentationReady(
viewerStarted: boolean,
rangeNs: { min: number; max: number } | null,
backendActivitySequence: number | null,
): boolean {
return viewerStarted
&& Number.isSafeInteger(backendActivitySequence)
&& (backendActivitySequence ?? 0) > 0
&& isUsableRecordedPlaybackRange(rangeNs);
}
export function liveTimelineNeedsSynchronization(
followLive: boolean,
activeTimeline: string | null | undefined,
rangeNs: { min: number; max: number } | null,
): boolean {
return followLive
&& isUsableRecordedPlaybackRange(rangeNs)
&& activeTimeline !== "stream_time";
}
export function liveRerunReceiverBindingIdentity(
sourceUrl: string,
liveStreamId: string | null,
followLive: boolean,
): string {
return JSON.stringify([
followLive ? "live" : "recorded",
sourceUrl.trim(),
followLive ? liveStreamId?.trim() ?? "" : "",
]);
}
@@ -327,6 +327,53 @@ export function verifyLiveViewerClientBuild(
}); });
} }
/**
* Recover a recorded viewer whose lazy module disappeared during a frontend
* deployment. Live acquisition deliberately never reloads automatically, but
* a saved recording has no device-side authority to preserve and can safely
* move to the current immutable application build.
*/
export async function reloadRecordedViewerAfterStaleModuleFailure({
loadedUiBuildId,
signal,
fetcher = globalThis.fetch,
reload = () => window.location.reload(),
}: {
loadedUiBuildId: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
reload?: () => void;
}): Promise<boolean> {
if (
signal?.aborted ||
loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID ||
!HASHED_UI_BUILD_ID.test(loadedUiBuildId)
) return false;
try {
const response = await fetcher("/api/v1/viewer/client-contract", {
method: "GET",
headers: {
Accept: "application/json",
"X-MissionCore-UI-Build": loadedUiBuildId,
},
cache: "no-store",
signal,
});
if (signal?.aborted || !response.ok) return false;
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
if (
!expectedUiBuildId ||
expectedUiBuildId === loadedUiBuildId ||
!HASHED_UI_BUILD_ID.test(expectedUiBuildId)
) return false;
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
reload();
return true;
} catch {
return false;
}
}
function startBuildMonitor(): void { function startBuildMonitor(): void {
if (buildMonitorAbort || typeof window === "undefined") return; if (buildMonitorAbort || typeof window === "undefined") return;
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1); const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
@@ -0,0 +1,260 @@
import type { SceneSettings } from "../../sceneSettings";
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
import type { RerunPlaybackState, RerunViewportStatus } from "./viewerProfile";
const BUFFER_END_TOLERANCE_NS = 1_000_000;
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
const RECORDED_OPEN_GRACE_MS = 30_000;
const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024;
export const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
export interface RecordedPlaybackBufferState {
bufferedEndNs: number | null;
expectedStartNs: number | null;
expectedEndNs: number | null;
bufferProgress: number | null;
fullyBuffered: boolean;
}
export function recordedPointColorKey(
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
): string {
const custom = settings.palette === "custom" || settings.colorMode === "class"
? settings.customColor.toLowerCase()
: "-";
return `${settings.colorMode}|${settings.palette}|${custom}`;
}
export function recordedOpenWatchdogTimeoutMs(byteLength: number): number {
if (!Number.isSafeInteger(byteLength) || byteLength < 4) {
throw new Error("Unsafe recorded RRD byte length");
}
const transferBudgetMs = Math.ceil(
(byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000,
);
return Math.min(
RECORDED_OPEN_MAX_TIMEOUT_MS,
Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS),
);
}
export function createRecordedOpenWatchdog<T>({
byteLength,
schedule,
cancel,
onTimeout,
}: {
byteLength: number;
schedule: (callback: () => void, timeoutMs: number) => T;
cancel: (handle: T) => void;
onTimeout: () => void;
}): { arm: () => void; clear: () => void; pending: () => boolean } {
const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength);
let handle: T | null = null;
return {
arm() {
if (handle !== null) return;
handle = schedule(() => {
handle = null;
onTimeout();
}, timeoutMs);
},
clear() {
if (handle === null) return;
cancel(handle);
handle = null;
},
pending: () => handle !== null,
};
}
export function isUsableRecordedPlaybackRange(
rangeNs: { min: number; max: number } | null,
): rangeNs is { min: number; max: number } {
return Boolean(
rangeNs
&& Number.isFinite(rangeNs.min)
&& Number.isFinite(rangeNs.max)
&& rangeNs.max >= rangeNs.min,
);
}
export function recordedPlaybackBufferState(
rangeNs: { min: number; max: number } | null,
expectedTimelineEndSeconds?: number,
expectedTimelineStartSeconds?: number,
): RecordedPlaybackBufferState {
const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null;
const bufferedEndNs = usableRange?.max ?? null;
if (expectedTimelineEndSeconds === undefined) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: usableRange !== null,
};
}
if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: false,
};
}
const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000;
const expectedStartNs = expectedTimelineStartSeconds === undefined
? 0
: expectedTimelineStartSeconds * 1_000_000_000;
if (
!Number.isFinite(expectedEndNs)
|| !Number.isFinite(expectedStartNs)
|| expectedStartNs < 0
|| expectedEndNs < expectedStartNs
) {
return {
bufferedEndNs,
expectedStartNs: null,
expectedEndNs: null,
bufferProgress: null,
fullyBuffered: false,
};
}
const fullyBuffered = usableRange !== null
&& usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS
&& usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS;
const expectedDurationNs = expectedEndNs - expectedStartNs;
const bufferProgress = bufferedEndNs === null
? 0
: expectedDurationNs <= 0
? (fullyBuffered ? 1 : 0)
: Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs));
return {
bufferedEndNs,
expectedStartNs,
expectedEndNs,
bufferProgress,
fullyBuffered,
};
}
export function isRecordedPlaybackReady(
viewerStarted: boolean,
artifactVerified: boolean,
buffer: RecordedPlaybackBufferState,
): boolean {
return viewerStarted && artifactVerified && buffer.bufferedEndNs !== null;
}
export function recordedPlaybackRangeWhenReady(
rangeNs: { min: number; max: number } | null,
buffer: RecordedPlaybackBufferState,
artifactVerified: boolean,
): { min: number; max: number } | null {
if (
!artifactVerified
|| !buffer.fullyBuffered
|| !isUsableRecordedPlaybackRange(rangeNs)
) return null;
return {
min: buffer.expectedStartNs === null
? rangeNs.min
: Math.max(rangeNs.min, buffer.expectedStartNs),
max: buffer.expectedEndNs === null
? rangeNs.max
: Math.min(rangeNs.max, buffer.expectedEndNs),
};
}
export function isRecordedPlaybackPresentationReady(
status: RerunViewportStatus,
playback: RerunPlaybackState | null,
): boolean {
return status === "ready"
&& playback?.fullyBuffered === true
&& isUsableRecordedPlaybackRange(playback.rangeNs);
}
export function rerunPresentationStatus(
status: RerunViewportStatus,
_gate: RecordedAdmissionPhase,
recorded: boolean,
): RerunViewportStatus {
if (!recorded) return status;
return status === "error" ? "error" : status;
}
export function isRecordedPlaybackFullyBuffered(
rangeNs: { min: number; max: number } | null,
expectedTimelineEndSeconds?: number,
): boolean {
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
}
export function attemptRecordedAutoplay(
seekToStart: () => void,
startPlaying: () => void,
): boolean {
try {
seekToStart();
startPlaying();
return true;
} catch {
return false;
}
}
export function createRecordedAutoplayGate(): {
attempt: (
viewerStarted: boolean,
fullyBuffered: boolean,
presentationReady: boolean,
rangeNs: { min: number; max: number } | null,
seekToStart: (startNs: number) => void,
startPlaying: () => void,
preferredStartNs?: number,
) => boolean;
attempted: () => boolean;
} {
let consumed = false;
return {
attempt(
viewerStarted,
fullyBuffered,
presentationReady,
rangeNs,
seekToStart,
startPlaying,
preferredStartNs,
) {
if (
consumed
|| !viewerStarted
|| !fullyBuffered
|| !presentationReady
|| !isUsableRecordedPlaybackRange(rangeNs)
) return false;
consumed = true;
const startNs = Number.isFinite(preferredStartNs)
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
: rangeNs.min;
return attemptRecordedAutoplay(
() => seekToStart(startNs),
startPlaying,
);
},
attempted: () => consumed,
};
}
export function canPublishRecordedPlaybackController(
readyToRender: boolean,
presentationGate: RecordedAdmissionPhase,
): boolean {
return readyToRender && presentationGate === "ready";
}
@@ -0,0 +1,114 @@
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
export interface RerunPlaybackState {
recordingId: string;
timeline: string;
rangeNs: { min: number; max: number } | null;
currentNs: number;
playing: boolean;
bufferedEndNs: number | null;
expectedStartNs: number | null;
expectedEndNs: number | null;
bufferProgress: number | null;
fullyBuffered: boolean;
}
export interface RerunPlaybackController {
seek: (timeNs: number) => void;
setPlaying: (playing: boolean) => void;
jumpToEnd: () => void;
}
export interface RecordedPerceptionLayers {
enabled: boolean;
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
}
export interface RecordedRrdArtifactDescriptor {
sourceUrl: string;
viewerSourceUrl: string;
byteLength: number;
sha256: string;
}
/**
* The native live receiver owns one acquisition lineage and never exposes
* recorded transport or autoplay policy.
*/
export interface LiveAcquisitionRerunProfile {
kind: "live-acquisition";
clock: "stream_time";
sourceUrl: string;
liveActivitySequence: number | null;
liveStreamId: string | null;
liveRecoveryAuthorityIdentity: string | null;
}
/**
* A sealed session is progressively presentable, while its shared playback
* controls remain fenced by the aggregate session admission gate.
*/
export interface RecordedSessionRerunProfile {
kind: "recorded-session";
clock: "session_time";
sourceUrl: string;
artifact: RecordedRrdArtifactDescriptor | null;
autoplayWhenReady: boolean;
presentationGate: RecordedAdmissionPhase;
expectedTimelineStartSeconds?: number;
expectedTimelineEndSeconds?: number;
initialPlaybackStartSeconds?: number;
view: RecordedRerunView;
viewResetGeneration: 0 | 1;
followTrajectory: boolean;
perceptionLayers: RecordedPerceptionLayers;
perceptionRetryGeneration: number;
lockPerceptionCameraInteraction: boolean;
}
/**
* LAB recorded evidence is not a native Rerun mode. It owns a source-sequence
* clock and composes the existing sealed fMP4 and retained spatial primitives.
*/
export interface LaboratoryRecordedEvidenceViewerProfile {
kind: "lab-recorded-evidence";
clock: "source-sequence";
cameraTransport: "generation-bound-fmp4";
spatialTransport: "bounded-sealed-artifacts";
loadPolicy: "visible-evidence-only";
workerRequired: false;
}
export type RerunViewerProfile =
| LiveAcquisitionRerunProfile
| RecordedSessionRerunProfile;
export type ObservationViewerProfile =
| RerunViewerProfile
| LaboratoryRecordedEvidenceViewerProfile;
export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
kind: "lab-recorded-evidence",
clock: "source-sequence",
cameraTransport: "generation-bound-fmp4",
spatialTransport: "bounded-sealed-artifacts",
loadPolicy: "visible-evidence-only",
workerRequired: false,
} satisfies LaboratoryRecordedEvidenceViewerProfile);
export function liveAcquisitionRerunProfile(
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
): LiveAcquisitionRerunProfile {
return { kind: "live-acquisition", clock: "stream_time", ...input };
}
export function recordedSessionRerunProfile(
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
): RecordedSessionRerunProfile {
return { kind: "recorded-session", clock: "session_time", ...input };
}
@@ -12,6 +12,7 @@ import type {
RecordedCameraAdmissionState, RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission"; } from "../core/observation/recordedSessionAdmission";
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog"; import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts"; import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { import {
RerunViewport, RerunViewport,
@@ -46,7 +47,6 @@ function statusTone(status: CapabilityStatus): "success" | "accent" | "warning"
if (status === "contract") return "warning"; if (status === "contract") return "warning";
return "neutral"; return "neutral";
} }
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) { function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
return ( return (
<div className="feature-inventory"> <div className="feature-inventory">
@@ -76,7 +76,6 @@ function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
</div> </div>
); );
} }
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) { function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
return ( return (
<section className="workspace-lead workspace-lead--compact"> <section className="workspace-lead workspace-lead--compact">
@@ -89,7 +88,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
</section> </section>
); );
} }
function EmptySpatialStage({ settings }: { settings: SceneSettings }) { function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
return ( return (
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}> <div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
@@ -181,7 +179,7 @@ function SpatialWorkspace({
); );
const recordedPerceptionSupported = const recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable"; recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready"; const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
const recordedPerceptionEnabled = const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d; showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D // The native recorded camera remains the authoritative original. Only 2D
@@ -261,7 +259,7 @@ function SpatialWorkspace({
const shouldPrepareRecordedSource = useCallback((sourceId: string) => { const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedSessionAdmission) return false; if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) || return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready"; ["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedSessionAdmission]); }, [recordedSessionAdmission]);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []); const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback( const onPlaybackChange = useCallback(
@@ -381,6 +379,35 @@ function SpatialWorkspace({
: presentedViewerStatus === "error" : presentedViewerStatus === "error"
? "danger" ? "danger"
: "neutral"; : "neutral";
const rerunViewerProfile = recordedSource
? recordedSessionRerunProfile({
sourceUrl,
artifact: recordedReplay,
autoplayWhenReady: true,
presentationGate: recordedSessionGate,
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
view: "spatial",
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: followRecordedTrajectory,
perceptionLayers: {
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
},
perceptionRetryGeneration,
lockPerceptionCameraInteraction: unifiedPerception,
})
: liveAcquisitionRerunProfile({
sourceUrl,
liveActivitySequence: livePresentationActivitySequence,
liveStreamId: state?.spatialSource?.id ?? null,
liveRecoveryAuthorityIdentity: streamActive
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
: null,
});
return ( return (
<div <div
@@ -405,7 +432,7 @@ function SpatialWorkspace({
variant={detections2dActive ? "primary" : "secondary"} variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />} icon={<Icon name="target" />}
aria-pressed={detections2dActive} aria-pressed={detections2dActive}
disabled={recordedSource && !recordedPerceptionReady} disabled={recordedPerceptionLoading}
onClick={() => recordedSource onClick={() => recordedSource
? setShowDetections2d((current) => !current) ? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({ : onLivePerceptionLayersChange({
@@ -420,7 +447,7 @@ function SpatialWorkspace({
variant={segmentationActive ? "primary" : "secondary"} variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />} icon={<Icon name="image" />}
aria-pressed={segmentationActive} aria-pressed={segmentationActive}
disabled={recordedSource && !recordedPerceptionReady} disabled={recordedPerceptionLoading}
onClick={() => recordedSource onClick={() => recordedSource
? setShowSegmentation((current) => !current) ? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({ : onLivePerceptionLayersChange({
@@ -435,7 +462,7 @@ function SpatialWorkspace({
variant={cuboids3dActive ? "primary" : "secondary"} variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />} icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive} aria-pressed={cuboids3dActive}
disabled={recordedSource && !recordedPerceptionReady} disabled={recordedPerceptionLoading}
onClick={() => recordedSource onClick={() => recordedSource
? setShowCuboids3d((current) => !current) ? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({ : onLivePerceptionLayersChange({
@@ -489,35 +516,8 @@ function SpatialWorkspace({
> >
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? ( {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport <RerunViewport
sourceUrl={sourceUrl} profile={rerunViewerProfile}
recordedArtifact={recordedSource ? recordedReplay : null}
followLive={liveRerunSource}
liveActivitySequence={livePresentationActivitySequence}
liveStreamId={state?.spatialSource?.id}
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
autoplayWhenReady={recordedSource}
presentationGate={recordedSessionGate}
expectedTimelineStartSeconds={recordedSource
? state?.observationTimeline?.range?.startSeconds
: undefined}
expectedTimelineEndSeconds={recordedSource
? state?.observationTimeline?.range?.endSeconds
: undefined}
initialPlaybackStartSeconds={initialRecordedPlaybackStartSeconds}
sceneSettings={sceneSettings} sceneSettings={sceneSettings}
recordedViewResetGeneration={recordedViewResetGeneration}
recordedFollowTrajectory={followRecordedTrajectory}
recordedPerceptionLayers={{
enabled:
recordedPerceptionSupported &&
recordedPerceptionReady &&
recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
}}
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
lockPerceptionCameraInteraction={unifiedPerception}
onPerceptionLoadChange={onPerceptionLoadChange} onPerceptionLoadChange={onPerceptionLoadChange}
onPointColorLoadChange={onPointColorLoadChange} onPointColorLoadChange={onPointColorLoadChange}
onStatusChange={onStatusChange} onStatusChange={onStatusChange}
@@ -905,7 +905,7 @@ function CamerasWorkspace({
if (!recordedReplay) return true; if (!recordedReplay) return true;
if (!recordedSessionAdmission) return false; if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) || return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready"; ["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedReplay, recordedSessionAdmission]); }, [recordedReplay, recordedSessionAdmission]);
return ( return (
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}> <div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
@@ -35,6 +35,7 @@ import {
type E47SemanticClass, type E47SemanticClass,
type E47SemanticTimelineFrame, type E47SemanticTimelineFrame,
} from "../../core/laboratory/e47SemanticSlam"; } from "../../core/laboratory/e47SemanticSlam";
import { laboratoryRecordedEvidenceDemand } from "../../core/laboratory/recordedEvidenceProfile";
import type { import type {
M4ThreatCameraProposal, M4ThreatCameraProposal,
M4ThreatTimelineFrame, M4ThreatTimelineFrame,
@@ -234,6 +235,26 @@ export function M4ReplayThreatVisual({
const activeSemantic = availableSemanticLayers.find( const activeSemantic = availableSemanticLayers.find(
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId, (layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
) ?? availableSemanticLayers[0]; ) ?? availableSemanticLayers[0];
const evidenceDemand = useMemo(() => laboratoryRecordedEvidenceDemand({
mediaMode,
spatialMode,
showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic,
showSpatialSemantic: Boolean(activeSemantic) && showSpatialSemantic,
showMediaPoints,
classifiedSpatialMode: !classifiedSpatialLayer
? "none"
: classifiedSpatialLayer.replacePointCloud
? "replace-source"
: "overlay",
}), [
activeSemantic,
classifiedSpatialLayer,
mediaMode,
showMediaPoints,
showMediaSemantic,
showSpatialSemantic,
spatialMode,
]);
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null); const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot); const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
const playbackRange = useMemo(() => metadata.timeline ? ({ const playbackRange = useMemo(() => metadata.timeline ? ({
@@ -249,6 +270,7 @@ export function M4ReplayThreatVisual({
resultId, resultId,
timeline: metadata.timeline, timeline: metadata.timeline,
currentSeconds: playbackController.playback.currentSeconds, currentSeconds: playbackController.playback.currentSeconds,
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
endpointRoot: timelineEndpointRoot, endpointRoot: timelineEndpointRoot,
}); });
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null); const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
@@ -270,6 +292,10 @@ export function M4ReplayThreatVisual({
useEffect(() => { useEffect(() => {
const timeline = metadata.timeline; const timeline = metadata.timeline;
if (!evidenceDemand.recordedVideo) {
setVideoLoading(false);
return;
}
if (!timeline || videoSource) return; if (!timeline || videoSource) return;
const controller = new AbortController(); const controller = new AbortController();
setVideoLoading(true); setVideoLoading(true);
@@ -304,7 +330,7 @@ export function M4ReplayThreatVisual({
if (!controller.signal.aborted) setVideoLoading(false); if (!controller.signal.aborted) setVideoLoading(false);
}); });
return () => controller.abort(); return () => controller.abort();
}, [metadata.timeline, videoSource]); }, [evidenceDemand.recordedVideo, metadata.timeline, videoSource]);
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null); const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
useEffect(() => { useEffect(() => {
@@ -319,6 +345,9 @@ export function M4ReplayThreatVisual({
resultId: string; resultId: string;
frame: M4ThreatTimelineFrame; frame: M4ThreatTimelineFrame;
} | null>(null); } | null>(null);
useEffect(() => {
lastSpatialFrameRef.current = null;
}, [evidenceDemand.sourceSpatialPoints, resultId]);
if (frame?.spatialAvailable) { if (frame?.spatialAvailable) {
lastSpatialFrameRef.current = { resultId, frame }; lastSpatialFrameRef.current = { resultId, frame };
} }
@@ -328,7 +357,7 @@ export function M4ReplayThreatVisual({
? lastSpatialFrameRef.current.frame ? lastSpatialFrameRef.current.frame
: null; : null;
const cameraPointOverlay = useM4ThreatCameraPointOverlay({ const cameraPointOverlay = useM4ThreatCameraPointOverlay({
enabled: showReferenceMediaLayers && showMediaPoints, enabled: showReferenceMediaLayers && evidenceDemand.cameraPointOverlay,
resultId, resultId,
sequence: frame?.sequence ?? null, sequence: frame?.sequence ?? null,
endpointRoot: timelineEndpointRoot, endpointRoot: timelineEndpointRoot,
@@ -354,6 +383,7 @@ export function M4ReplayThreatVisual({
activeSequence: frame?.sequence ?? timelineFrame.activeSequence, activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
frameCount: metadata.timeline?.frameCount ?? 0, frameCount: metadata.timeline?.frameCount ?? 0,
taxonomy: spatialSemanticTaxonomy, taxonomy: spatialSemanticTaxonomy,
enabled: evidenceDemand.selectedSemanticPoints,
}); });
const displayingBufferedFrame = Boolean( const displayingBufferedFrame = Boolean(
frame frame
@@ -647,7 +677,7 @@ export function M4ReplayThreatVisual({
}, },
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]); ), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined = const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
activeSemantic && showMediaSemantic && frame activeSemantic && evidenceDemand.selectedSemanticMask && frame
? { ? {
src: activeSemantic.maskUrl?.(frame.sequence) src: activeSemantic.maskUrl?.(frame.sequence)
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence), ?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
@@ -694,10 +724,14 @@ export function M4ReplayThreatVisual({
}; };
useEffect(() => { useEffect(() => {
if (playbackController.playback.playing || !frame) return; if (
playbackController.playback.playing
|| !evidenceDemand.exactCameraFrame
|| !frame
) return;
const image = new Image(); const image = new Image();
image.src = frame.cameraUrl; image.src = frame.cameraUrl;
}, [frame?.cameraUrl, playbackController.playback.playing]); }, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]);
const splitView = mediaMode !== null && spatialMode !== null; const splitView = mediaMode !== null && spatialMode !== null;
@@ -11,11 +11,30 @@ const CHUNK_SIZE = 24;
const RETAINED_CHUNK_COUNT = 8; const RETAINED_CHUNK_COUNT = 8;
const PREFETCH_CHUNKS_AHEAD = 2; const PREFETCH_CHUNKS_AHEAD = 2;
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] { export function e47SemanticChunkWindowStarts(
return Array.from( activeStart: number,
{ length: PREFETCH_CHUNKS_AHEAD + 2 }, frameCount: number,
(_, index) => activeStart + (index - 1) * CHUNK_SIZE, ): readonly number[] {
).filter((start) => start >= 0 && start < frameCount); return [
activeStart,
...Array.from(
{ length: PREFETCH_CHUNKS_AHEAD },
(_, index) => activeStart + (index + 1) * CHUNK_SIZE,
),
activeStart - CHUNK_SIZE,
].filter((start) => start >= 0 && start < frameCount);
}
export function cancelE47SemanticRequestsOutsideWindow<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);
}
} }
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
@@ -29,11 +48,13 @@ export function useE47SemanticTimelineFrame({
activeSequence, activeSequence,
frameCount, frameCount,
taxonomy, taxonomy,
enabled = true,
}: { }: {
resultId: string | null; resultId: string | null;
activeSequence: number | null; activeSequence: number | null;
frameCount: number; frameCount: number;
taxonomy: readonly E47SemanticClass[]; taxonomy: readonly E47SemanticClass[];
enabled?: boolean;
}) { }) {
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>( const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
() => new Map(), () => new Map(),
@@ -55,16 +76,21 @@ export function useE47SemanticTimelineFrame({
for (const controller of inFlight.current.values()) controller.abort(); for (const controller of inFlight.current.values()) controller.abort();
inFlight.current.clear(); inFlight.current.clear();
}; };
}, [resultId]); }, [enabled, resultId]);
const activeStart = activeSequence === null const activeStart = !enabled || activeSequence === null
? null ? null
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE; : Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
activeStartRef.current = activeStart; activeStartRef.current = activeStart;
useEffect(() => { useEffect(() => {
if (!resultId || activeStart === null || frameCount < 1) return; if (!enabled || !resultId || activeStart === null || frameCount < 1) {
for (const start of chunkWindowStarts(activeStart, frameCount)) { cancelE47SemanticRequestsOutsideWindow(inFlight.current, []);
return;
}
const starts = e47SemanticChunkWindowStarts(activeStart, frameCount);
cancelE47SemanticRequestsOutsideWindow(inFlight.current, starts);
for (const start of starts) {
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue; if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
const controller = new AbortController(); const controller = new AbortController();
inFlight.current.set(start, controller); inFlight.current.set(start, controller);
@@ -95,8 +121,11 @@ export function useE47SemanticTimelineFrame({
.finally(() => { .finally(() => {
if (inFlight.current.get(start) === controller) inFlight.current.delete(start); if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
}); });
// Semantic point arrays are large JSON payloads. Admit the active chunk
// first, then advance the bounded prefetch window one request per render.
break;
} }
}, [activeStart, frameCount, resultId, taxonomy]); }, [activeStart, chunks, enabled, frameCount, resultId, taxonomy]);
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => { const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
if (activeSequence === null || activeStart === null) return null; if (activeSequence === null || activeStart === null) return null;
@@ -107,7 +136,7 @@ export function useE47SemanticTimelineFrame({
return { return {
activeFrame, activeFrame,
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error, loading: enabled && Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
error, error,
}; };
} }
@@ -76,11 +76,13 @@ export function useM4ThreatTimelineFrame({
resultId, resultId,
timeline, timeline,
currentSeconds, currentSeconds,
includeSpatialPoints = true,
endpointRoot, endpointRoot,
}: { }: {
resultId: string; resultId: string;
timeline: M4ThreatTimeline | null; timeline: M4ThreatTimeline | null;
currentSeconds: number; currentSeconds: number;
includeSpatialPoints?: boolean;
endpointRoot?: string; endpointRoot?: string;
}) { }) {
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>( const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
@@ -107,7 +109,7 @@ export function useM4ThreatTimelineFrame({
setPlaybackError(null); setPlaybackError(null);
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 }); setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
if (!timeline) return () => controller.abort(); if (!timeline) return () => controller.abort();
if (!binaryPlayback) { if (!binaryPlayback || !includeSpatialPoints) {
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 }); setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
return () => controller.abort(); return () => controller.abort();
} }
@@ -127,7 +129,7 @@ export function useM4ThreatTimelineFrame({
} }
}); });
return () => controller.abort(); return () => controller.abort();
}, [binaryPlayback, endpointRoot, resultId, timeline]); }, [binaryPlayback, endpointRoot, includeSpatialPoints, resultId, timeline]);
useEffect(() => { useEffect(() => {
for (const controller of inFlight.current.values()) controller.abort(); for (const controller of inFlight.current.values()) controller.abort();
@@ -140,7 +142,7 @@ export function useM4ThreatTimelineFrame({
for (const controller of inFlight.current.values()) controller.abort(); for (const controller of inFlight.current.values()) controller.abort();
inFlight.current.clear(); inFlight.current.clear();
}; };
}, [resultId, timeline]); }, [includeSpatialPoints, resultId, timeline]);
const activeSequence = useMemo( const activeSequence = useMemo(
() => timeline () => timeline
@@ -158,7 +160,11 @@ export function useM4ThreatTimelineFrame({
activeChunkStartRef.current = activeChunkStart; activeChunkStartRef.current = activeChunkStart;
useEffect(() => { useEffect(() => {
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return; if (
!timeline
|| activeChunkStart === null
|| (binaryPlayback && includeSpatialPoints && !playbackManifest)
) return;
const starts = m4ThreatChunkWindowStarts( const starts = m4ThreatChunkWindowStarts(
activeChunkStart, activeChunkStart,
chunkSize, chunkSize,
@@ -170,7 +176,7 @@ export function useM4ThreatTimelineFrame({
const controller = new AbortController(); const controller = new AbortController();
inFlight.current.set(start, controller); inFlight.current.set(start, controller);
void (async () => { void (async () => {
const playbackPointPack = binaryPlayback && playbackManifest const playbackPointPack = binaryPlayback && includeSpatialPoints && playbackManifest
? await fetchM4ThreatPlaybackPointChunk( ? await fetchM4ThreatPlaybackPointChunk(
playbackManifest, playbackManifest,
Math.floor(start / playbackManifest.chunkFrameCount), Math.floor(start / playbackManifest.chunkFrameCount),
@@ -189,6 +195,7 @@ export function useM4ThreatTimelineFrame({
endpointRoot, endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery, cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
playbackPointPack, playbackPointPack,
includePoints: includeSpatialPoints,
}); });
})() })()
.then((chunk) => { .then((chunk) => {
@@ -220,7 +227,7 @@ export function useM4ThreatTimelineFrame({
// loaded first, then the next chunk is prefetched on the following render. // loaded first, then the next chunk is prefetched on the following render.
break; break;
} }
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]); }, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, includeSpatialPoints, playbackManifest, resultId, timeline]);
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
if (activeSequence === null || activeChunkStart === null) return null; if (activeSequence === null || activeChunkStart === null) return null;
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let laboratoryRecordedEvidenceDemand;
let e47SemanticChunkWindowStarts;
let cancelE47SemanticRequestsOutsideWindow;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ laboratoryRecordedEvidenceDemand } = await server.ssrLoadModule(
"/src/core/laboratory/recordedEvidenceProfile.ts",
));
({
e47SemanticChunkWindowStarts,
cancelE47SemanticRequestsOutsideWindow,
} = await server.ssrLoadModule(
"/src/workspaces/laboratory/useE47SemanticTimeline.ts",
));
});
after(async () => {
await server?.close();
});
test("camera-only LAB presentation does not acquire hidden spatial or semantic payloads", () => {
assert.deepEqual(laboratoryRecordedEvidenceDemand({
mediaMode: "video",
spatialMode: null,
showMediaSemantic: false,
showSpatialSemantic: true,
showMediaPoints: false,
classifiedSpatialMode: "overlay",
}), {
sourceTimelineMetadata: true,
recordedVideo: true,
exactCameraFrame: false,
sourceSpatialPoints: false,
cameraPointOverlay: false,
selectedSemanticMask: false,
selectedSemanticPoints: false,
classifiedSpatial: false,
});
});
test("visible M4 layers acquire only their selected camera and spatial evidence", () => {
assert.deepEqual(laboratoryRecordedEvidenceDemand({
mediaMode: "camera",
spatialMode: "plan",
showMediaSemantic: true,
showSpatialSemantic: true,
showMediaPoints: true,
classifiedSpatialMode: "overlay",
}), {
sourceTimelineMetadata: true,
recordedVideo: false,
exactCameraFrame: true,
sourceSpatialPoints: true,
cameraPointOverlay: true,
selectedSemanticMask: true,
selectedSemanticPoints: true,
classifiedSpatial: true,
});
});
test("a classified replacement does not also load the hidden source point track", () => {
const demand = laboratoryRecordedEvidenceDemand({
mediaMode: null,
spatialMode: "3d",
showMediaSemantic: false,
showSpatialSemantic: false,
showMediaPoints: false,
classifiedSpatialMode: "replace-source",
});
assert.equal(demand.sourceSpatialPoints, false);
assert.equal(demand.classifiedSpatial, true);
});
test("semantic point chunks are ordered active-first and stale requests are cancelled", () => {
assert.deepEqual(e47SemanticChunkWindowStarts(48, 4_489), [48, 72, 96, 24]);
assert.deepEqual(e47SemanticChunkWindowStarts(0, 4_489), [0, 24, 48]);
const cancelled = [];
const requests = new Map([
[0, { abort: () => cancelled.push(0) }],
[24, { abort: () => cancelled.push(24) }],
[240, { abort: () => cancelled.push(240) }],
]);
cancelE47SemanticRequestsOutsideWindow(requests, [240, 264]);
assert.deepEqual(cancelled, [0, 24]);
assert.deepEqual([...requests.keys()], [240]);
});
@@ -7,6 +7,7 @@ let createLiveViewerDiagnosticLifecycle;
let createAbortFencedBuildVerifier; let createAbortFencedBuildVerifier;
let createUiBuildStaleCoordinator; let createUiBuildStaleCoordinator;
let liveViewerDiagnosticBody; let liveViewerDiagnosticBody;
let reloadRecordedViewerAfterStaleModuleFailure;
let server; let server;
let uiBuildIdFromModuleScripts; let uiBuildIdFromModuleScripts;
let verifyLiveViewerClientBuild; let verifyLiveViewerClientBuild;
@@ -22,6 +23,7 @@ before(async () => {
createLiveViewerDiagnosticLifecycle, createLiveViewerDiagnosticLifecycle,
createUiBuildStaleCoordinator, createUiBuildStaleCoordinator,
liveViewerDiagnosticBody, liveViewerDiagnosticBody,
reloadRecordedViewerAfterStaleModuleFailure,
uiBuildIdFromModuleScripts, uiBuildIdFromModuleScripts,
verifyLiveViewerClientBuild, verifyLiveViewerClientBuild,
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts")); } = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
@@ -204,6 +206,36 @@ test("build drift is reported once with the exact loaded build", async () => {
); );
}); });
test("a recorded viewer reloads only when its failed lazy module belongs to a stale build", async () => {
const reloads = [];
const loadedUiBuildId = "/assets/index-abcdefgh.js";
const currentUiBuildId = "/assets/index-ijklmnop.js";
const fetcher = async (_url, options) => {
assert.equal(options.headers["X-MissionCore-UI-Build"], loadedUiBuildId);
return new Response(JSON.stringify({}), {
status: 200,
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
});
};
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
loadedUiBuildId,
fetcher,
reload: () => reloads.push("reload"),
}), true);
assert.deepEqual(reloads, ["reload"]);
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
loadedUiBuildId: currentUiBuildId,
fetcher: async () => new Response(JSON.stringify({}), {
status: 200,
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
}),
reload: () => reloads.push("unexpected"),
}), false);
assert.deepEqual(reloads, ["reload"]);
});
test("last unsubscribe fences an already queued build verification callback", () => { test("last unsubscribe fences an already queued build verification callback", () => {
const controller = new AbortController(); const controller = new AbortController();
const observedSignals = []; const observedSignals = [];
@@ -932,7 +932,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /timelineFrame\.availableFrames\.find/); assert.match(visual, /timelineFrame\.availableFrames\.find/);
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/); assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/); assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/); assert.match(visual, /\{activeSemantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/); assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
assert.match( assert.match(
visual, visual,
@@ -11,7 +11,11 @@ let recordedMediaSeekableCoverage;
let recordedMediaFragmentUrl; let recordedMediaFragmentUrl;
let recordedMediaDecodeStartSequence; let recordedMediaDecodeStartSequence;
let recordedMediaSegmentAppendOrder; let recordedMediaSegmentAppendOrder;
let recordedMediaSegmentSequenceAtTime;
let recordedMediaCanRollTarget; let recordedMediaCanRollTarget;
let nextRecordedMediaRandomAccessSequence;
let recordedMediaRecoveryTargetSequence;
let selectRecordedMediaPreparationEpoch;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -26,7 +30,11 @@ before(async () => {
recordedMediaFragmentUrl, recordedMediaFragmentUrl,
recordedMediaDecodeStartSequence, recordedMediaDecodeStartSequence,
recordedMediaSegmentAppendOrder, recordedMediaSegmentAppendOrder,
recordedMediaSegmentSequenceAtTime,
recordedMediaCanRollTarget, recordedMediaCanRollTarget,
nextRecordedMediaRandomAccessSequence,
recordedMediaRecoveryTargetSequence,
selectRecordedMediaPreparationEpoch,
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx")); } = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
}); });
@@ -161,7 +169,7 @@ test("decoded duration and seekable range cover the complete declared epoch", ()
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false); assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
}); });
test("recorded player keeps full-archive range fallback and uses bounded generation-bound fragments", async () => { test("production replay derives bounded fragments and retains native range fallback", async () => {
const source = await readFile( const source = await readFile(
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
"utf8", "utf8",
@@ -176,6 +184,10 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
assert.match(source, /hasPresentedFrame/); assert.match(source, /hasPresentedFrame/);
assert.match(source, /retainForwardFrame/); assert.match(source, /retainForwardFrame/);
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/); assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
assert.match(source, /effectiveSegmentCount = segmentCount \?\? epoch\?\.segmentCount \?\? null/);
assert.match(source, /requestedSegmentSequence = segmentSequence \?\?/);
assert.match(source, /waitForRecordedVideoInitialFrame/);
assert.match(source, /setSegmentRecoveryGeneration/);
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491); assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
assert.deepEqual( assert.deepEqual(
@@ -209,6 +221,44 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
); );
}); });
test("production replay derives its bounded fragment directly from the source clock", () => {
const ends = [0.101, 0.185, 0.286, 0.401];
const epochStart = 39.215263458;
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart), 1);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.101), 1);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.103), 2);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.4), 4);
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 1), null);
});
test("a corrupt fragment advances recovery to the next random-access frame", () => {
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 1), 11);
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 20), 21);
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 49), null);
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 0), null);
});
test("paused seek displays the recovery keyframe until the source clock leaves the corrupt GOP", () => {
assert.equal(recordedMediaRecoveryTargetSequence(120, 120, 149), 149);
assert.equal(recordedMediaRecoveryTargetSequence(130, 120, 149), 149);
assert.equal(recordedMediaRecoveryTargetSequence(119, 120, 149), 119);
assert.equal(recordedMediaRecoveryTargetSequence(149, 120, 149), 149);
assert.equal(recordedMediaRecoveryTargetSequence(151, 120, 149), 151);
assert.equal(recordedMediaRecoveryTargetSequence(null, 120, 149), null);
assert.equal(recordedMediaRecoveryTargetSequence(120, null, null), 120);
});
test("camera admission selects one bounded epoch while the shared clock is outside video", () => {
const epochs = [
{ ordinal: 1, timelineStartSeconds: 39, timelineEndSeconds: 100 },
{ ordinal: 2, timelineStartSeconds: 120, timelineEndSeconds: 180 },
];
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 0).ordinal, 1);
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 70).ordinal, 1);
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 110).ordinal, 2);
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 200).ordinal, 2);
});
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => { test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true); assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true); assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
@@ -34,7 +34,7 @@ function camera(phase, byteLength = 1_024) {
return { phase, byteLength, message: null }; return { phase, byteLength, message: null };
} }
test("recorded session admits RRD and every declared camera as one atomic generation", () => { test("recorded session keeps camera and timeline atomic without hiding a ready spatial frame", () => {
const ids = ["camera.left", "camera.right"]; const ids = ["camera.left", "camera.right"];
const oneCamera = { const oneCamera = {
"camera.left": camera("ready"), "camera.left": camera("ready"),
@@ -42,7 +42,7 @@ test("recorded session admits RRD and every declared camera as one atomic genera
}; };
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera); const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
assert.equal(partialGate, "loading"); assert.equal(partialGate, "loading");
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading"); assert.equal(rerunPresentationStatus("ready", partialGate, true), "ready");
assert.equal( assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate), recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
"loading", "loading",
@@ -71,7 +71,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
...cameras, ...cameras,
"camera.right": camera("ready"), "camera.right": camera("ready"),
}), "error"); }), "error");
assert.equal(rerunPresentationStatus("ready", "error", true), "error"); assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
assert.equal( assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, "error"), recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
"error", "error",
@@ -257,6 +257,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
source, source,
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/, /if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
); );
assert.match(
source,
/reloadRecordedViewerAfterStaleModuleFailure\(\{[\s\S]*loadedUiBuildId: diagnosticLifecycle\.lineage\.uiBuildId/,
);
assert.match(
source,
/!recordedPerceptionUrl \|\| !recordedPerceptionLayers\.enabled/,
);
}); });
test("one live document owns one native Rerun receiver", async () => { test("one live document owns one native Rerun receiver", async () => {
@@ -287,8 +295,12 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
source, source,
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/, /const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
); );
assert.match(source, /followLive=\{liveRerunSource\}/); assert.match(
assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/); source,
/liveAcquisitionRerunProfile\(\{[\s\S]*liveActivitySequence: livePresentationActivitySequence/,
);
assert.match(source, /<RerunViewport[\s\S]*profile=\{rerunViewerProfile\}/);
assert.doesNotMatch(source, /followLive=\{liveRerunSource\}/);
assert.match( assert.match(
source, source,
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/, /sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
@@ -11,6 +11,7 @@ let isRecordedPlaybackPresentationReady;
let isUsableRecordedPlaybackRange; let isUsableRecordedPlaybackRange;
let recordedPlaybackBufferState; let recordedPlaybackBufferState;
let recordedPlaybackRangeWhenReady; let recordedPlaybackRangeWhenReady;
let rerunPresentationStatus;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -26,6 +27,7 @@ before(async () => {
isUsableRecordedPlaybackRange, isUsableRecordedPlaybackRange,
recordedPlaybackBufferState, recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady, recordedPlaybackRangeWhenReady,
rerunPresentationStatus,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx")); } = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
}); });
@@ -33,7 +35,7 @@ after(async () => {
await server?.close(); await server?.close();
}); });
test("a first frame reports buffer telemetry but is not ready for presentation", () => { test("a verified first frame is presentable while full-range controls stay closed", () => {
assert.equal(isUsableRecordedPlaybackRange(null), false); assert.equal(isUsableRecordedPlaybackRange(null), false);
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false); assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false); assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
@@ -47,8 +49,11 @@ test("a first frame reports buffer telemetry but is not ready for presentation",
bufferProgress: 0, bufferProgress: 0,
fullyBuffered: false, fullyBuffered: false,
}); });
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false); assert.equal(isRecordedPlaybackReady(true, true, firstFrame), true);
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null); assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
assert.equal(rerunPresentationStatus("ready", "loading", true), "ready");
assert.equal(rerunPresentationStatus("loading", "ready", true), "loading");
assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
}); });
test("host timeline remains unmounted until the verified recording is fully ready", () => { test("host timeline remains unmounted until the verified recording is fully ready", () => {
@@ -107,7 +112,7 @@ test("buffer progress grows independently and preserves the full-buffer toleranc
); );
assert.equal(missingDeclaredStart.bufferProgress, 1); assert.equal(missingDeclaredStart.bufferProgress, 1);
assert.equal(missingDeclaredStart.fullyBuffered, false); assert.equal(missingDeclaredStart.fullyBuffered, false);
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), false); assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), true);
}); });
test("a verified split boundary spill covers and clamps the declared LAB window", () => { test("a verified split boundary spill covers and clamps the declared LAB window", () => {
@@ -81,7 +81,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
); );
assert.match(source, /showMediaSemantic/); assert.match(source, /showMediaSemantic/);
assert.match(source, /showSpatialSemantic/); assert.match(source, /showSpatialSemantic/);
assert.match(source, /semantic && showMediaSemantic && frame/); assert.match(source, /activeSemantic && evidenceDemand\.selectedSemanticMask && frame/);
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/); assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
assert.match(source, /\|\| !showSpatialSemantic/); assert.match(source, /\|\| !showSpatialSemantic/);
assert.match(source, /aria-label="Слои камеры и видео"/); assert.match(source, /aria-label="Слои камеры и видео"/);
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
let liveAcquisitionRerunProfile;
let recordedSessionRerunProfile;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
liveAcquisitionRerunProfile,
recordedSessionRerunProfile,
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
});
after(async () => {
await server?.close();
});
test("live acquisition profile cannot acquire recorded playback policy", () => {
assert.deepEqual(liveAcquisitionRerunProfile({
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
liveActivitySequence: 12,
liveStreamId: "acquisition-1",
liveRecoveryAuthorityIdentity: "authority-1",
}), {
kind: "live-acquisition",
clock: "stream_time",
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
liveActivitySequence: 12,
liveStreamId: "acquisition-1",
liveRecoveryAuthorityIdentity: "authority-1",
});
});
test("recorded session profile owns progressive admission and on-demand layers", () => {
const artifact = {
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
viewerSourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd?generation=abc",
byteLength: 42,
sha256: "a".repeat(64),
};
const profile = recordedSessionRerunProfile({
sourceUrl: artifact.sourceUrl,
artifact,
autoplayWhenReady: true,
presentationGate: "loading",
expectedTimelineStartSeconds: 0,
expectedTimelineEndSeconds: 20,
initialPlaybackStartSeconds: 1,
view: "spatial",
viewResetGeneration: 0,
followTrajectory: false,
perceptionLayers: {
enabled: false,
detections2d: false,
segmentation: false,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
});
assert.equal(profile.kind, "recorded-session");
assert.equal(profile.clock, "session_time");
assert.equal(profile.artifact, artifact);
assert.equal(profile.perceptionLayers.enabled, false);
});
test("LAB recorded evidence remains outside native Rerun lifecycle", () => {
assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, {
kind: "lab-recorded-evidence",
clock: "source-sequence",
cameraTransport: "generation-bound-fmp4",
spatialTransport: "bounded-sealed-artifacts",
loadPolicy: "visible-evidence-only",
workerRequired: false,
});
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
});