fix(lab): restore canonical spatial replay
This commit is contained in:
@@ -850,6 +850,8 @@ export function RecordedFmp4Player({
|
||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||
? Math.min(4, Math.max(0.25, playback.rate))
|
||||
: 1;
|
||||
const playbackRateRef = useRef(playbackRate);
|
||||
playbackRateRef.current = playbackRate;
|
||||
const presentationEpoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
@@ -1183,13 +1185,42 @@ export function RecordedFmp4Player({
|
||||
message,
|
||||
});
|
||||
};
|
||||
const resumePlaybackIfRequested = async (revision: number) => {
|
||||
if (
|
||||
runtime.disposed
|
||||
|| segmentedRuntimeRef.current !== runtime
|
||||
|| runtime.target?.revision !== revision
|
||||
|| !playbackPlayingRef.current
|
||||
) return;
|
||||
video.playbackRate = playbackRateRef.current;
|
||||
try {
|
||||
await video.play();
|
||||
} catch {
|
||||
if (
|
||||
runtime.disposed
|
||||
|| segmentedRuntimeRef.current !== runtime
|
||||
|| runtime.target?.revision !== revision
|
||||
) return;
|
||||
onPlayingRejectedRef.current?.();
|
||||
setReadyGeneration(null);
|
||||
setErrorMessage("Запуск записанной камеры отклонён браузером.");
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archiveByteLength,
|
||||
message: "Запуск записанной камеры отклонён браузером.",
|
||||
});
|
||||
}
|
||||
};
|
||||
if (rollingTarget && previousTarget) {
|
||||
runtime.target = {
|
||||
...candidateTarget,
|
||||
revision: previousTarget.revision,
|
||||
};
|
||||
runtime.onTargetBuffered = null;
|
||||
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
|
||||
void pumpRecordedSegmentWindow(runtime)
|
||||
.then(() => resumePlaybackIfRequested(previousTarget.revision))
|
||||
.catch(reportPumpError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1251,6 +1282,7 @@ export function RecordedFmp4Player({
|
||||
byteLength: archiveByteLength,
|
||||
message: null,
|
||||
});
|
||||
await resumePlaybackIfRequested(bufferedTarget.revision);
|
||||
} catch (error) {
|
||||
if (
|
||||
targetReadyAbort.signal.aborted
|
||||
@@ -1400,6 +1432,41 @@ export function RecordedFmp4Player({
|
||||
visualState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !segmented || !playback?.playing || visualState !== "ready") return;
|
||||
let cancelled = false;
|
||||
const resumeIfDecoderReady = () => {
|
||||
if (cancelled || !playbackPlayingRef.current || !video.paused) return;
|
||||
const runtime = segmentedRuntimeRef.current;
|
||||
const target = runtime?.target;
|
||||
if (
|
||||
!runtime
|
||||
|| !target
|
||||
|| runtime.disposed
|
||||
|| video.seeking
|
||||
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
|| Math.abs(video.currentTime - target.targetSeconds) > 0.25
|
||||
|| !recordedMediaTimeRangesContain(video.buffered, target.targetSeconds)
|
||||
) return;
|
||||
playAttemptRevisionRef.current += 1;
|
||||
const playAttemptRevision = playAttemptRevisionRef.current;
|
||||
video.playbackRate = playbackRateRef.current;
|
||||
void video.play().catch(() => {
|
||||
if (cancelled || playAttemptRevisionRef.current !== playAttemptRevision) return;
|
||||
onPlayingRejectedRef.current?.();
|
||||
});
|
||||
};
|
||||
const queueResume = () => window.queueMicrotask(resumeIfDecoderReady);
|
||||
const events = ["pause", "canplay", "seeked"] as const;
|
||||
for (const event of events) video.addEventListener(event, queueResume);
|
||||
resumeIfDecoderReady();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const event of events) video.removeEventListener(event, queueResume);
|
||||
};
|
||||
}, [bufferRevision, playback?.playing, segmented, visualState]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
||||
|
||||
@@ -59,6 +59,16 @@ export function laboratoryRecordedClipEndExclusiveNs(
|
||||
return last.sourceTimeNs + typicalDelta;
|
||||
}
|
||||
|
||||
export function laboratoryRecordedClipClockGate(
|
||||
pendingSequence: number | null,
|
||||
observedSequence: number,
|
||||
): { accept: boolean; pendingSequence: number | null } {
|
||||
if (pendingSequence !== null && pendingSequence !== observedSequence) {
|
||||
return { accept: false, pendingSequence };
|
||||
}
|
||||
return { accept: true, pendingSequence: null };
|
||||
}
|
||||
|
||||
export function LaboratoryRecordedClipPlayer({
|
||||
source,
|
||||
segmentCount,
|
||||
@@ -94,7 +104,8 @@ export function LaboratoryRecordedClipPlayer({
|
||||
}) {
|
||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||
const lastEmittedSequenceRef = useRef(sequence);
|
||||
lastEmittedSequenceRef.current = sequence;
|
||||
const lastObservedSequenceRef = useRef<number | null>(sequence);
|
||||
const pendingSequenceRef = useRef<number | null>(null);
|
||||
const frame = useMemo(
|
||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||
[frames, sequence],
|
||||
@@ -113,23 +124,42 @@ export function LaboratoryRecordedClipPlayer({
|
||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||
}, [continuousPlayback, onPlayingChange, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastObservedSequenceRef.current !== sequence) {
|
||||
pendingSequenceRef.current = sequence;
|
||||
}
|
||||
lastEmittedSequenceRef.current = sequence;
|
||||
}, [sequence]);
|
||||
|
||||
const emitSequence = useCallback((nextSequence: number) => {
|
||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||
lastEmittedSequenceRef.current = nextSequence;
|
||||
onSequenceChange(nextSequence);
|
||||
}, [onSequenceChange]);
|
||||
|
||||
const requestSequence = useCallback((nextSequence: number) => {
|
||||
pendingSequenceRef.current = nextSequence;
|
||||
emitSequence(nextSequence);
|
||||
}, [emitSequence]);
|
||||
|
||||
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
||||
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
||||
const first = frames[0];
|
||||
if (!first || endExclusiveNs === null) return;
|
||||
if (sourceTimeNs >= endExclusiveNs) {
|
||||
emitSequence(first.sequence);
|
||||
requestSequence(first.sequence);
|
||||
return;
|
||||
}
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}, [emitSequence, endExclusiveNs, frames]);
|
||||
if (!nearest) return;
|
||||
lastObservedSequenceRef.current = nearest.sequence;
|
||||
const gate = laboratoryRecordedClipClockGate(
|
||||
pendingSequenceRef.current,
|
||||
nearest.sequence,
|
||||
);
|
||||
pendingSequenceRef.current = gate.pendingSequence;
|
||||
if (gate.accept) emitSequence(nearest.sequence);
|
||||
}, [emitSequence, endExclusiveNs, frames, requestSequence]);
|
||||
|
||||
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
||||
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||
@@ -142,7 +172,7 @@ export function LaboratoryRecordedClipPlayer({
|
||||
className="laboratory-recorded-clip-player__spatial"
|
||||
aria-hidden={cameraPresentation === "primary"}
|
||||
>
|
||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
||||
{alternativeScene}
|
||||
</div>
|
||||
);
|
||||
const cameraPane = (
|
||||
@@ -202,7 +232,7 @@ export function LaboratoryRecordedClipPlayer({
|
||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||
onSeek={(timeNs) => {
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
if (nearest) requestSequence(nearest.sequence);
|
||||
}}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user