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