refactor(viewer): profile rerun loading by playback stage
This commit is contained in:
@@ -29,7 +29,9 @@ export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "
|
||||
|
||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
|
||||
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000;
|
||||
// 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_REQUIRED_AHEAD_SEGMENTS = 12;
|
||||
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
|
||||
@@ -72,6 +74,39 @@ export function recordedMediaDecodeStartSequence(
|
||||
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(
|
||||
appended: ReadonlySet<number>,
|
||||
decodeStartSequence: number,
|
||||
@@ -90,6 +125,32 @@ export function recordedMediaSegmentAppendOrder(
|
||||
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(
|
||||
previousSequence: number,
|
||||
nextSequence: number,
|
||||
@@ -151,6 +212,24 @@ export function selectRecordedMediaEpoch(
|
||||
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(
|
||||
durationSeconds: number,
|
||||
seekableEndSeconds: number,
|
||||
@@ -231,29 +310,14 @@ export async function fetchRecordedMediaArchive(
|
||||
return { manifest, byteLength: totalBytes };
|
||||
}
|
||||
|
||||
function videoHasSeekableArchive(
|
||||
function waitForRecordedVideoInitialFrame(
|
||||
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,
|
||||
): Promise<void> {
|
||||
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) => {
|
||||
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
|
||||
const events = ["loadeddata", "canplay", "progress"] as const;
|
||||
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
const armStallTimer = () => {
|
||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||
@@ -270,7 +334,7 @@ function waitForSeekableArchive(
|
||||
};
|
||||
const onProgress = () => {
|
||||
armStallTimer();
|
||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
||||
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
@@ -313,11 +377,7 @@ async function mountRecordedEpochStream(
|
||||
};
|
||||
video.load();
|
||||
try {
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
signal,
|
||||
);
|
||||
await waitForRecordedVideoInitialFrame(video, signal);
|
||||
return cleanup;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
@@ -336,6 +396,11 @@ interface RecordedSegmentTarget {
|
||||
resetAttempts: number;
|
||||
}
|
||||
|
||||
interface RecordedSegmentRecovery {
|
||||
readonly failedSequence: number;
|
||||
readonly recoverySequence: number;
|
||||
}
|
||||
|
||||
interface RecordedSegmentStreamRuntime {
|
||||
readonly generation: string;
|
||||
readonly mediaSource: MediaSource;
|
||||
@@ -768,12 +833,16 @@ export function RecordedFmp4Player({
|
||||
);
|
||||
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const [segmentRecoveryGeneration, setSegmentRecoveryGeneration] = useState(0);
|
||||
const [segmentRecovery, setSegmentRecovery] = useState<RecordedSegmentRecovery | null>(null);
|
||||
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
|
||||
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
|
||||
const targetRevisionRef = useRef(0);
|
||||
const targetReadyAbortRef = useRef<AbortController | null>(null);
|
||||
const lastSegmentRecoveryRef = useRef<string | null>(null);
|
||||
const playAttemptRevisionRef = useRef(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const playbackPlayingRef = useRef(Boolean(playback?.playing));
|
||||
@@ -781,25 +850,60 @@ export function RecordedFmp4Player({
|
||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||
? Math.min(4, Math.max(0.25, playback.rate))
|
||||
: 1;
|
||||
const epoch = useMemo(
|
||||
const presentationEpoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(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(
|
||||
segmentCount !== null
|
||||
&& Number.isInteger(segmentCount)
|
||||
&& segmentCount >= 1
|
||||
requestedSegmentSequence !== null
|
||||
&& Number.isInteger(requestedSegmentSequence)
|
||||
&& requestedSegmentSequence >= 1
|
||||
&&
|
||||
effectiveSegmentCount !== null
|
||||
&& Number.isInteger(effectiveSegmentCount)
|
||||
&& effectiveSegmentCount >= 1
|
||||
&& typeof MediaSource !== "undefined"
|
||||
&& epoch
|
||||
&& epoch.segmentCount === segmentCount
|
||||
&& epoch.segmentCount === effectiveSegmentCount
|
||||
&& epoch.randomAccessSequences.length > 0
|
||||
&& epoch.segmentEndTimesSeconds.length === segmentCount
|
||||
&& epoch.segmentEndTimesSeconds.length === effectiveSegmentCount
|
||||
&& MediaSource.isTypeSupported(epoch.mediaType),
|
||||
);
|
||||
const directPlaybackSeconds = segmented ? null : currentSeconds;
|
||||
const waitingForEpoch = Boolean(archive && !epoch);
|
||||
const selectedGeneration = contract && epoch
|
||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
||||
const waitingForEpoch = Boolean(archive && !presentationEpoch);
|
||||
const selectedGeneration = contract && presentationEpoch
|
||||
? `${contract.manifestGenerationSha256}:${presentationEpoch.ordinal}:${presentationEpoch.timelineStartSeconds}:${presentationEpoch.timelineEndSeconds}`
|
||||
: null;
|
||||
const visualState = recordedMediaPresentationState(
|
||||
state,
|
||||
@@ -813,6 +917,7 @@ export function RecordedFmp4Player({
|
||||
if (!contract) {
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Некорректный descriptor записанной камеры.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -825,6 +930,9 @@ export function RecordedFmp4Player({
|
||||
const abort = new AbortController();
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setSegmentRecovery(null);
|
||||
lastSegmentRecoveryRef.current = null;
|
||||
setErrorMessage(null);
|
||||
setState("loading");
|
||||
reportAdmission({
|
||||
phase: "loading",
|
||||
@@ -842,6 +950,7 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Архив записанной камеры не прошёл проверку.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -853,43 +962,14 @@ export function RecordedFmp4Player({
|
||||
}, [admissionKey, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!archive || !contract || !prepare || segmented) return;
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
for (const candidate of archive.manifest.epochs) {
|
||||
const probe = document.createElement("video");
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
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]);
|
||||
if (!segmentRecovery || requestedSegmentSequence === null) return;
|
||||
if (
|
||||
requestedSegmentSequence >= segmentRecovery.failedSequence
|
||||
&& requestedSegmentSequence < segmentRecovery.recoverySequence
|
||||
) return;
|
||||
lastSegmentRecoveryRef.current = null;
|
||||
setSegmentRecovery(null);
|
||||
}, [requestedSegmentSequence, segmentRecovery]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -910,6 +990,7 @@ export function RecordedFmp4Player({
|
||||
|
||||
setReadyGeneration(null);
|
||||
setSegmentedRuntimeGeneration(null);
|
||||
setErrorMessage(null);
|
||||
setState("loading");
|
||||
video.pause();
|
||||
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
|
||||
@@ -957,6 +1038,7 @@ export function RecordedFmp4Player({
|
||||
return;
|
||||
}
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Покадровый буфер записанной камеры не открылся.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -982,7 +1064,14 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [archive, contract, epoch, segmentCount, segmented]);
|
||||
}, [
|
||||
archive,
|
||||
contract,
|
||||
effectiveSegmentCount,
|
||||
epoch,
|
||||
segmented,
|
||||
segmentRecoveryGeneration,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = segmentedRuntimeRef.current;
|
||||
@@ -993,18 +1082,19 @@ export function RecordedFmp4Player({
|
||||
|| !archive
|
||||
|| !segmented
|
||||
|| segmentedRuntimeGeneration !== runtime.generation
|
||||
|| segmentSequence === null
|
||||
|| !Number.isInteger(segmentSequence)
|
||||
|| segmentSequence < 1
|
||||
|| segmentSequence > runtime.segmentCount
|
||||
|| effectiveSegmentSequence === null
|
||||
|| !Number.isInteger(effectiveSegmentSequence)
|
||||
|| effectiveSegmentSequence < 1
|
||||
|| effectiveSegmentSequence > runtime.segmentCount
|
||||
) return;
|
||||
const archiveByteLength = archive.byteLength;
|
||||
const decodeStart = recordedMediaDecodeStartSequence(
|
||||
runtime.randomAccessSequences,
|
||||
segmentSequence,
|
||||
effectiveSegmentSequence,
|
||||
);
|
||||
if (decodeStart === null) {
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Для кадра записанной камеры нет random-access фрагмента.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1015,10 +1105,11 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
const targetSeconds = recordedSegmentStartSeconds(
|
||||
runtime.segmentEndTimesSeconds,
|
||||
segmentSequence,
|
||||
effectiveSegmentSequence,
|
||||
);
|
||||
if (targetSeconds === null) {
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Для кадра записанной камеры нет точной media timestamp.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1030,15 +1121,15 @@ export function RecordedFmp4Player({
|
||||
const previousTarget = runtime.target;
|
||||
const readyEnd = Math.min(
|
||||
runtime.segmentCount,
|
||||
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
||||
effectiveSegmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
||||
);
|
||||
const desiredEnd = Math.min(
|
||||
runtime.segmentCount,
|
||||
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
||||
effectiveSegmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
||||
);
|
||||
const candidateTarget: RecordedSegmentTarget = {
|
||||
revision: previousTarget?.revision ?? 0,
|
||||
sequence: segmentSequence,
|
||||
sequence: effectiveSegmentSequence,
|
||||
decodeStart,
|
||||
readyEnd,
|
||||
desiredEnd,
|
||||
@@ -1046,6 +1137,24 @@ export function RecordedFmp4Player({
|
||||
forceReset: false,
|
||||
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(
|
||||
previousTarget.sequence,
|
||||
candidateTarget.sequence,
|
||||
@@ -1059,12 +1168,19 @@ export function RecordedFmp4Player({
|
||||
|| runtime.abort.signal.aborted
|
||||
|| (error instanceof DOMException && error.name === "AbortError")
|
||||
) 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);
|
||||
setErrorMessage(message);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archiveByteLength,
|
||||
message: "Покадровый фрагмент записанной камеры недоступен.",
|
||||
message,
|
||||
});
|
||||
};
|
||||
if (rollingTarget && previousTarget) {
|
||||
@@ -1128,6 +1244,7 @@ export function RecordedFmp4Player({
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
runtime.hasPresentedFrame = true;
|
||||
setReadyGeneration(runtime.generation);
|
||||
setErrorMessage(null);
|
||||
setState("ready");
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
@@ -1139,7 +1256,9 @@ export function RecordedFmp4Player({
|
||||
targetReadyAbort.signal.aborted
|
||||
|| (error instanceof DOMException && error.name === "AbortError")
|
||||
) return;
|
||||
if (recoverFromSegmentFailure(bufferedTarget.sequence)) return;
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Кадр записанной камеры не стал decoder-ready.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1160,7 +1279,7 @@ export function RecordedFmp4Player({
|
||||
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
|
||||
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
|
||||
};
|
||||
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]);
|
||||
}, [archive?.byteLength, effectiveSegmentSequence, segmented, segmentedRuntimeGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -1170,6 +1289,7 @@ export function RecordedFmp4Player({
|
||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||
: null;
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage(null);
|
||||
setState("loading");
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
@@ -1185,7 +1305,13 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
setReadyGeneration(generation);
|
||||
setErrorMessage(null);
|
||||
setState("ready");
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
disposed ||
|
||||
@@ -1195,11 +1321,12 @@ export function RecordedFmp4Player({
|
||||
return;
|
||||
}
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Записанная камера не открыла первый декодируемый кадр.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Записанная камера не стала seekable.",
|
||||
message: "Записанная камера не открыла первый декодируемый кадр.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1234,6 +1361,7 @@ export function RecordedFmp4Player({
|
||||
video.currentTime = target;
|
||||
} catch {
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Seek записанной камеры завершился ошибкой.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1244,11 +1372,12 @@ export function RecordedFmp4Player({
|
||||
}
|
||||
}
|
||||
video.playbackRate = playbackRate;
|
||||
if (playback?.playing) {
|
||||
if (playback?.playing && !holdingForSegmentRecovery) {
|
||||
void video.play().catch(() => {
|
||||
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
|
||||
onPlayingRejectedRef.current?.();
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Запуск записанной камеры отклонён браузером.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
@@ -1264,6 +1393,7 @@ export function RecordedFmp4Player({
|
||||
bufferRevision,
|
||||
directPlaybackSeconds,
|
||||
epoch,
|
||||
holdingForSegmentRecovery,
|
||||
playback?.playing,
|
||||
playbackRate,
|
||||
segmented,
|
||||
@@ -1327,7 +1457,9 @@ export function RecordedFmp4Player({
|
||||
{visualState === "waiting"
|
||||
? "Камера на этой позиции ещё не записывалась"
|
||||
: visualState === "error"
|
||||
? "Записанное видео недоступно"
|
||||
? errorMessage ?? "Записанное видео недоступно"
|
||||
: errorMessage
|
||||
? errorMessage
|
||||
: archive
|
||||
? "Проверяем seek и codec записанного видео…"
|
||||
: "Читаем manifest записанного видео…"}
|
||||
|
||||
@@ -11,10 +11,18 @@ import {
|
||||
LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS,
|
||||
requestLiveReceiverRecovery,
|
||||
} from "../core/observation/liveReceiverWatchdog";
|
||||
import {
|
||||
claimExclusiveLiveViewer,
|
||||
createReentrantViewerDisposer,
|
||||
isLiveRerunPresentationReady,
|
||||
liveRerunReceiverBindingIdentity,
|
||||
liveTimelineNeedsSynchronization,
|
||||
} from "../core/observation/liveRerunLifecycle";
|
||||
import {
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createLiveViewerInstanceId,
|
||||
createLiveViewerLineage,
|
||||
reloadRecordedViewerAfterStaleModuleFailure,
|
||||
subscribeToLiveViewerBuildFence,
|
||||
type LiveViewerFailureStage,
|
||||
} from "../core/observation/liveViewerDiagnostics";
|
||||
@@ -22,10 +30,63 @@ import {
|
||||
fetchPerceptionPreparationStatus,
|
||||
perceptionPreparationMessage,
|
||||
} 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 =
|
||||
| "idle"
|
||||
| "loading"
|
||||
@@ -46,55 +107,14 @@ export type RecordedPointColorLoadState = Pick<
|
||||
"phase" | "receivedBytes" | "totalBytes" | "progress" | "message"
|
||||
>;
|
||||
|
||||
export interface RecordedPerceptionLayers {
|
||||
enabled: boolean;
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
}
|
||||
|
||||
export interface RerunSelection {
|
||||
entityPath: string;
|
||||
viewName?: string;
|
||||
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 {
|
||||
sourceUrl: string;
|
||||
recordedArtifact?: RecordedRrdArtifactDescriptor | null;
|
||||
followLive?: boolean;
|
||||
liveActivitySequence?: number | null;
|
||||
liveStreamId?: string | null;
|
||||
liveRecoveryAuthorityIdentity?: string | null;
|
||||
autoplayWhenReady?: boolean;
|
||||
presentationGate?: RecordedAdmissionPhase;
|
||||
expectedTimelineStartSeconds?: number;
|
||||
expectedTimelineEndSeconds?: number;
|
||||
initialPlaybackStartSeconds?: number;
|
||||
profile: RerunViewerProfile;
|
||||
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
||||
onSelectionChange?: (selection: RerunSelection | null) => void;
|
||||
onPlaybackChange?: (state: RerunPlaybackState | null) => void;
|
||||
@@ -110,23 +130,10 @@ export interface RerunViewportProps {
|
||||
| "palette"
|
||||
| "customColor"
|
||||
>;
|
||||
recordedView?: RecordedRerunView;
|
||||
recordedViewResetGeneration?: 0 | 1;
|
||||
recordedFollowTrajectory?: boolean;
|
||||
recordedPerceptionLayers?: RecordedPerceptionLayers;
|
||||
recordedPerceptionRetryGeneration?: number;
|
||||
lockPerceptionCameraInteraction?: boolean;
|
||||
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
|
||||
onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void;
|
||||
}
|
||||
|
||||
export interface RecordedRrdArtifactDescriptor {
|
||||
sourceUrl: string;
|
||||
viewerSourceUrl: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
interface RerunBlueprintChannel {
|
||||
endpointUrl: string;
|
||||
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 MAX_BLUEPRINT_BYTES = 1_048_576;
|
||||
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>({
|
||||
emit,
|
||||
@@ -917,36 +555,43 @@ export async function fetchRecordedPerceptionRrd(
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
sourceUrl,
|
||||
recordedArtifact = null,
|
||||
followLive = false,
|
||||
liveActivitySequence = null,
|
||||
liveStreamId = null,
|
||||
liveRecoveryAuthorityIdentity = null,
|
||||
autoplayWhenReady = false,
|
||||
presentationGate = "ready",
|
||||
expectedTimelineStartSeconds,
|
||||
expectedTimelineEndSeconds,
|
||||
initialPlaybackStartSeconds,
|
||||
profile,
|
||||
onStatusChange,
|
||||
onSelectionChange,
|
||||
onPlaybackChange,
|
||||
onPlaybackControllerChange,
|
||||
sceneSettings,
|
||||
recordedView = "spatial",
|
||||
recordedViewResetGeneration = 0,
|
||||
recordedFollowTrajectory = false,
|
||||
recordedPerceptionLayers = {
|
||||
onPerceptionLoadChange,
|
||||
onPointColorLoadChange,
|
||||
}: RerunViewportProps) {
|
||||
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,
|
||||
detections2d: false,
|
||||
segmentation: false,
|
||||
cuboids3d: false,
|
||||
},
|
||||
recordedPerceptionRetryGeneration = 0,
|
||||
lockPerceptionCameraInteraction = false,
|
||||
onPerceptionLoadChange,
|
||||
onPointColorLoadChange,
|
||||
}: RerunViewportProps) {
|
||||
};
|
||||
const recordedPerceptionRetryGeneration =
|
||||
recordedProfile?.perceptionRetryGeneration ?? 0;
|
||||
const lockPerceptionCameraInteraction =
|
||||
recordedProfile?.lockPerceptionCameraInteraction ?? false;
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null);
|
||||
@@ -1588,7 +1233,7 @@ export function RerunViewport({
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
}
|
||||
if (!followLive) clearRecordedAdmissionWatchdog();
|
||||
if (!followLive) setRecordingBufferProgress(1);
|
||||
if (!followLive) setRecordingBufferProgress(recordedBuffer.bufferProgress);
|
||||
recordedSceneAdmitted = true;
|
||||
if (!followLive) onPlaybackChange?.(playbackState);
|
||||
setStatus("ready");
|
||||
@@ -1597,7 +1242,10 @@ export function RerunViewport({
|
||||
if (
|
||||
!followLive &&
|
||||
!playbackControllerPublished &&
|
||||
canPublishRecordedPlaybackController(readyToRender, presentationGateRef.current)
|
||||
canPublishRecordedPlaybackController(
|
||||
recordedBuffer.fullyBuffered,
|
||||
presentationGateRef.current,
|
||||
)
|
||||
) {
|
||||
playbackControllerPublished = true;
|
||||
onPlaybackControllerChange?.(playbackController);
|
||||
@@ -1740,9 +1388,16 @@ export function RerunViewport({
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(async () => {
|
||||
if (disposed) return;
|
||||
disposeViewer?.();
|
||||
if (
|
||||
isRecordedSource &&
|
||||
await reloadRecordedViewerAfterStaleModuleFailure({
|
||||
loadedUiBuildId: diagnosticLifecycle.lineage.uiBuildId,
|
||||
signal: diagnosticLifecycle.signal,
|
||||
})
|
||||
) return;
|
||||
if (requestLiveRecovery("module-load")) return;
|
||||
reportError("Не удалось загрузить модуль визуализатора.");
|
||||
});
|
||||
@@ -1786,7 +1441,18 @@ export function RerunViewport({
|
||||
}, [recordedPointColorsUrl]);
|
||||
|
||||
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 identity = recordedIdentityRef.current;
|
||||
if (
|
||||
@@ -1931,6 +1597,7 @@ export function RerunViewport({
|
||||
}, [
|
||||
onPerceptionLoadChange,
|
||||
perceptionChannelRevision,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionRetryGeneration,
|
||||
recordedPerceptionUrl,
|
||||
]);
|
||||
|
||||
@@ -849,12 +849,14 @@ export async function fetchM4ThreatTimelineChunk(
|
||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||
cameraObstacleProjectionDelivery = null,
|
||||
playbackPointPack,
|
||||
includePoints = true,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
endpointRoot?: string;
|
||||
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
||||
playbackPointPack?: M4ThreatPlaybackPointPack;
|
||||
includePoints?: boolean;
|
||||
} = {},
|
||||
): Promise<M4ThreatTimelineChunk> {
|
||||
const params = new URLSearchParams({
|
||||
@@ -864,7 +866,7 @@ export async function fetchM4ThreatTimelineChunk(
|
||||
if (cameraObstacleProjectionDelivery !== null) {
|
||||
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
||||
}
|
||||
if (playbackPointPack) params.set("include_points", "false");
|
||||
if (playbackPointPack || !includePoints) params.set("include_points", "false");
|
||||
const response = await fetcher(
|
||||
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
||||
{ 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 {
|
||||
if (buildMonitorAbort || typeof window === "undefined") return;
|
||||
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,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
||||
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
@@ -46,7 +47,6 @@ function statusTone(status: CapabilityStatus): "success" | "accent" | "warning"
|
||||
if (status === "contract") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||
return (
|
||||
<div className="feature-inventory">
|
||||
@@ -76,7 +76,6 @@ function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||
return (
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
@@ -89,7 +88,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
@@ -181,7 +179,7 @@ function SpatialWorkspace({
|
||||
);
|
||||
const recordedPerceptionSupported =
|
||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready";
|
||||
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||
const recordedPerceptionEnabled =
|
||||
showDetections2d || showSegmentation || showCuboids3d;
|
||||
// The native recorded camera remains the authoritative original. Only 2D
|
||||
@@ -261,7 +259,7 @@ function SpatialWorkspace({
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
@@ -381,6 +379,35 @@ function SpatialWorkspace({
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "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 (
|
||||
<div
|
||||
@@ -405,7 +432,7 @@ function SpatialWorkspace({
|
||||
variant={detections2dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={detections2dActive}
|
||||
disabled={recordedSource && !recordedPerceptionReady}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowDetections2d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
@@ -420,7 +447,7 @@ function SpatialWorkspace({
|
||||
variant={segmentationActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="image" />}
|
||||
aria-pressed={segmentationActive}
|
||||
disabled={recordedSource && !recordedPerceptionReady}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowSegmentation((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
@@ -435,7 +462,7 @@ function SpatialWorkspace({
|
||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="apps" />}
|
||||
aria-pressed={cuboids3dActive}
|
||||
disabled={recordedSource && !recordedPerceptionReady}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowCuboids3d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
@@ -489,35 +516,8 @@ function SpatialWorkspace({
|
||||
>
|
||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
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}
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
recordedViewResetGeneration={recordedViewResetGeneration}
|
||||
recordedFollowTrajectory={followRecordedTrajectory}
|
||||
recordedPerceptionLayers={{
|
||||
enabled:
|
||||
recordedPerceptionSupported &&
|
||||
recordedPerceptionReady &&
|
||||
recordedPerceptionEnabled,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
}}
|
||||
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
|
||||
lockPerceptionCameraInteraction={unifiedPerception}
|
||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||
onPointColorLoadChange={onPointColorLoadChange}
|
||||
onStatusChange={onStatusChange}
|
||||
@@ -905,7 +905,7 @@ function CamerasWorkspace({
|
||||
if (!recordedReplay) return true;
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedReplay, recordedSessionAdmission]);
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type E47SemanticClass,
|
||||
type E47SemanticTimelineFrame,
|
||||
} from "../../core/laboratory/e47SemanticSlam";
|
||||
import { laboratoryRecordedEvidenceDemand } from "../../core/laboratory/recordedEvidenceProfile";
|
||||
import type {
|
||||
M4ThreatCameraProposal,
|
||||
M4ThreatTimelineFrame,
|
||||
@@ -234,6 +235,26 @@ export function M4ReplayThreatVisual({
|
||||
const activeSemantic = availableSemanticLayers.find(
|
||||
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
|
||||
) ?? 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 metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
|
||||
const playbackRange = useMemo(() => metadata.timeline ? ({
|
||||
@@ -249,6 +270,7 @@ export function M4ReplayThreatVisual({
|
||||
resultId,
|
||||
timeline: metadata.timeline,
|
||||
currentSeconds: playbackController.playback.currentSeconds,
|
||||
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
|
||||
endpointRoot: timelineEndpointRoot,
|
||||
});
|
||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||
@@ -270,6 +292,10 @@ export function M4ReplayThreatVisual({
|
||||
|
||||
useEffect(() => {
|
||||
const timeline = metadata.timeline;
|
||||
if (!evidenceDemand.recordedVideo) {
|
||||
setVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!timeline || videoSource) return;
|
||||
const controller = new AbortController();
|
||||
setVideoLoading(true);
|
||||
@@ -304,7 +330,7 @@ export function M4ReplayThreatVisual({
|
||||
if (!controller.signal.aborted) setVideoLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [metadata.timeline, videoSource]);
|
||||
}, [evidenceDemand.recordedVideo, metadata.timeline, videoSource]);
|
||||
|
||||
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -319,6 +345,9 @@ export function M4ReplayThreatVisual({
|
||||
resultId: string;
|
||||
frame: M4ThreatTimelineFrame;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
lastSpatialFrameRef.current = null;
|
||||
}, [evidenceDemand.sourceSpatialPoints, resultId]);
|
||||
if (frame?.spatialAvailable) {
|
||||
lastSpatialFrameRef.current = { resultId, frame };
|
||||
}
|
||||
@@ -328,7 +357,7 @@ export function M4ReplayThreatVisual({
|
||||
? lastSpatialFrameRef.current.frame
|
||||
: null;
|
||||
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
||||
enabled: showReferenceMediaLayers && showMediaPoints,
|
||||
enabled: showReferenceMediaLayers && evidenceDemand.cameraPointOverlay,
|
||||
resultId,
|
||||
sequence: frame?.sequence ?? null,
|
||||
endpointRoot: timelineEndpointRoot,
|
||||
@@ -354,6 +383,7 @@ export function M4ReplayThreatVisual({
|
||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||
frameCount: metadata.timeline?.frameCount ?? 0,
|
||||
taxonomy: spatialSemanticTaxonomy,
|
||||
enabled: evidenceDemand.selectedSemanticPoints,
|
||||
});
|
||||
const displayingBufferedFrame = Boolean(
|
||||
frame
|
||||
@@ -647,7 +677,7 @@ export function M4ReplayThreatVisual({
|
||||
},
|
||||
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||
activeSemantic && showMediaSemantic && frame
|
||||
activeSemantic && evidenceDemand.selectedSemanticMask && frame
|
||||
? {
|
||||
src: activeSemantic.maskUrl?.(frame.sequence)
|
||||
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
|
||||
@@ -694,10 +724,14 @@ export function M4ReplayThreatVisual({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackController.playback.playing || !frame) return;
|
||||
if (
|
||||
playbackController.playback.playing
|
||||
|| !evidenceDemand.exactCameraFrame
|
||||
|| !frame
|
||||
) return;
|
||||
const image = new Image();
|
||||
image.src = frame.cameraUrl;
|
||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
||||
}, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]);
|
||||
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
|
||||
|
||||
@@ -11,11 +11,30 @@ const CHUNK_SIZE = 24;
|
||||
const RETAINED_CHUNK_COUNT = 8;
|
||||
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||
|
||||
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] {
|
||||
return Array.from(
|
||||
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
||||
(_, index) => activeStart + (index - 1) * CHUNK_SIZE,
|
||||
).filter((start) => start >= 0 && start < frameCount);
|
||||
export function e47SemanticChunkWindowStarts(
|
||||
activeStart: number,
|
||||
frameCount: number,
|
||||
): readonly number[] {
|
||||
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 {
|
||||
@@ -29,11 +48,13 @@ export function useE47SemanticTimelineFrame({
|
||||
activeSequence,
|
||||
frameCount,
|
||||
taxonomy,
|
||||
enabled = true,
|
||||
}: {
|
||||
resultId: string | null;
|
||||
activeSequence: number | null;
|
||||
frameCount: number;
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
||||
() => new Map(),
|
||||
@@ -55,16 +76,21 @@ export function useE47SemanticTimelineFrame({
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
};
|
||||
}, [resultId]);
|
||||
}, [enabled, resultId]);
|
||||
|
||||
const activeStart = activeSequence === null
|
||||
const activeStart = !enabled || activeSequence === null
|
||||
? null
|
||||
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
||||
activeStartRef.current = activeStart;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resultId || activeStart === null || frameCount < 1) return;
|
||||
for (const start of chunkWindowStarts(activeStart, frameCount)) {
|
||||
if (!enabled || !resultId || activeStart === null || frameCount < 1) {
|
||||
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;
|
||||
const controller = new AbortController();
|
||||
inFlight.current.set(start, controller);
|
||||
@@ -95,8 +121,11 @@ export function useE47SemanticTimelineFrame({
|
||||
.finally(() => {
|
||||
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(() => {
|
||||
if (activeSequence === null || activeStart === null) return null;
|
||||
@@ -107,7 +136,7 @@ export function useE47SemanticTimelineFrame({
|
||||
|
||||
return {
|
||||
activeFrame,
|
||||
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||
loading: enabled && Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,11 +76,13 @@ export function useM4ThreatTimelineFrame({
|
||||
resultId,
|
||||
timeline,
|
||||
currentSeconds,
|
||||
includeSpatialPoints = true,
|
||||
endpointRoot,
|
||||
}: {
|
||||
resultId: string;
|
||||
timeline: M4ThreatTimeline | null;
|
||||
currentSeconds: number;
|
||||
includeSpatialPoints?: boolean;
|
||||
endpointRoot?: string;
|
||||
}) {
|
||||
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
||||
@@ -107,7 +109,7 @@ export function useM4ThreatTimelineFrame({
|
||||
setPlaybackError(null);
|
||||
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||
if (!timeline) return () => controller.abort();
|
||||
if (!binaryPlayback) {
|
||||
if (!binaryPlayback || !includeSpatialPoints) {
|
||||
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||
return () => controller.abort();
|
||||
}
|
||||
@@ -127,7 +129,7 @@ export function useM4ThreatTimelineFrame({
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [binaryPlayback, endpointRoot, resultId, timeline]);
|
||||
}, [binaryPlayback, endpointRoot, includeSpatialPoints, resultId, timeline]);
|
||||
|
||||
useEffect(() => {
|
||||
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();
|
||||
inFlight.current.clear();
|
||||
};
|
||||
}, [resultId, timeline]);
|
||||
}, [includeSpatialPoints, resultId, timeline]);
|
||||
|
||||
const activeSequence = useMemo(
|
||||
() => timeline
|
||||
@@ -158,7 +160,11 @@ export function useM4ThreatTimelineFrame({
|
||||
activeChunkStartRef.current = activeChunkStart;
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return;
|
||||
if (
|
||||
!timeline
|
||||
|| activeChunkStart === null
|
||||
|| (binaryPlayback && includeSpatialPoints && !playbackManifest)
|
||||
) return;
|
||||
const starts = m4ThreatChunkWindowStarts(
|
||||
activeChunkStart,
|
||||
chunkSize,
|
||||
@@ -170,7 +176,7 @@ export function useM4ThreatTimelineFrame({
|
||||
const controller = new AbortController();
|
||||
inFlight.current.set(start, controller);
|
||||
void (async () => {
|
||||
const playbackPointPack = binaryPlayback && playbackManifest
|
||||
const playbackPointPack = binaryPlayback && includeSpatialPoints && playbackManifest
|
||||
? await fetchM4ThreatPlaybackPointChunk(
|
||||
playbackManifest,
|
||||
Math.floor(start / playbackManifest.chunkFrameCount),
|
||||
@@ -189,6 +195,7 @@ export function useM4ThreatTimelineFrame({
|
||||
endpointRoot,
|
||||
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||
playbackPointPack,
|
||||
includePoints: includeSpatialPoints,
|
||||
});
|
||||
})()
|
||||
.then((chunk) => {
|
||||
@@ -220,7 +227,7 @@ export function useM4ThreatTimelineFrame({
|
||||
// loaded first, then the next chunk is prefetched on the following render.
|
||||
break;
|
||||
}
|
||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]);
|
||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, includeSpatialPoints, playbackManifest, resultId, timeline]);
|
||||
|
||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||
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 createUiBuildStaleCoordinator;
|
||||
let liveViewerDiagnosticBody;
|
||||
let reloadRecordedViewerAfterStaleModuleFailure;
|
||||
let server;
|
||||
let uiBuildIdFromModuleScripts;
|
||||
let verifyLiveViewerClientBuild;
|
||||
@@ -22,6 +23,7 @@ before(async () => {
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createUiBuildStaleCoordinator,
|
||||
liveViewerDiagnosticBody,
|
||||
reloadRecordedViewerAfterStaleModuleFailure,
|
||||
uiBuildIdFromModuleScripts,
|
||||
verifyLiveViewerClientBuild,
|
||||
} = 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", () => {
|
||||
const controller = new AbortController();
|
||||
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, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
||||
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,
|
||||
|
||||
@@ -11,7 +11,11 @@ let recordedMediaSeekableCoverage;
|
||||
let recordedMediaFragmentUrl;
|
||||
let recordedMediaDecodeStartSequence;
|
||||
let recordedMediaSegmentAppendOrder;
|
||||
let recordedMediaSegmentSequenceAtTime;
|
||||
let recordedMediaCanRollTarget;
|
||||
let nextRecordedMediaRandomAccessSequence;
|
||||
let recordedMediaRecoveryTargetSequence;
|
||||
let selectRecordedMediaPreparationEpoch;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -26,7 +30,11 @@ before(async () => {
|
||||
recordedMediaFragmentUrl,
|
||||
recordedMediaDecodeStartSequence,
|
||||
recordedMediaSegmentAppendOrder,
|
||||
recordedMediaSegmentSequenceAtTime,
|
||||
recordedMediaCanRollTarget,
|
||||
nextRecordedMediaRandomAccessSequence,
|
||||
recordedMediaRecoveryTargetSequence,
|
||||
selectRecordedMediaPreparationEpoch,
|
||||
} = 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);
|
||||
});
|
||||
|
||||
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(
|
||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -176,6 +184,10 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
||||
assert.match(source, /hasPresentedFrame/);
|
||||
assert.match(source, /retainForwardFrame/);
|
||||
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.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", () => {
|
||||
assert.equal(recordedMediaCanRollTarget(20, 21, 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 };
|
||||
}
|
||||
|
||||
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 oneCamera = {
|
||||
"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);
|
||||
assert.equal(partialGate, "loading");
|
||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
|
||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "ready");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
||||
"loading",
|
||||
@@ -71,7 +71,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
|
||||
...cameras,
|
||||
"camera.right": camera("ready"),
|
||||
}), "error");
|
||||
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
|
||||
assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
||||
"error",
|
||||
|
||||
@@ -257,6 +257,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
source,
|
||||
/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 () => {
|
||||
@@ -287,8 +295,12 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
||||
source,
|
||||
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
|
||||
);
|
||||
assert.match(source, /followLive=\{liveRerunSource\}/);
|
||||
assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/);
|
||||
assert.match(
|
||||
source,
|
||||
/liveAcquisitionRerunProfile\(\{[\s\S]*liveActivitySequence: livePresentationActivitySequence/,
|
||||
);
|
||||
assert.match(source, /<RerunViewport[\s\S]*profile=\{rerunViewerProfile\}/);
|
||||
assert.doesNotMatch(source, /followLive=\{liveRerunSource\}/);
|
||||
assert.match(
|
||||
source,
|
||||
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
||||
|
||||
@@ -11,6 +11,7 @@ let isRecordedPlaybackPresentationReady;
|
||||
let isUsableRecordedPlaybackRange;
|
||||
let recordedPlaybackBufferState;
|
||||
let recordedPlaybackRangeWhenReady;
|
||||
let rerunPresentationStatus;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -26,6 +27,7 @@ before(async () => {
|
||||
isUsableRecordedPlaybackRange,
|
||||
recordedPlaybackBufferState,
|
||||
recordedPlaybackRangeWhenReady,
|
||||
rerunPresentationStatus,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
@@ -33,7 +35,7 @@ after(async () => {
|
||||
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({ min: Number.NaN, max: 0 }), 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,
|
||||
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(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", () => {
|
||||
@@ -107,7 +112,7 @@ test("buffer progress grows independently and preserves the full-buffer toleranc
|
||||
);
|
||||
assert.equal(missingDeclaredStart.bufferProgress, 1);
|
||||
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", () => {
|
||||
|
||||
@@ -81,7 +81,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
|
||||
);
|
||||
assert.match(source, /showMediaSemantic/);
|
||||
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, /\|\| !showSpatialSemantic/);
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user