fix(lab): restore canonical spatial replay

This commit is contained in:
DCCONSTRUCTIONS
2026-08-29 19:52:06 +03:00
parent c06b709fd7
commit 5179e93f4a
8 changed files with 263 additions and 53 deletions
@@ -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}
/>
@@ -29,7 +29,6 @@ export interface E31LaboratoryResult {
limitations: readonly string[];
access: "read-only";
}
export interface E32LaboratoryResult {
resultId: string;
createdAtUtc: string | null;
@@ -53,7 +52,6 @@ export interface E32LaboratoryResult {
};
access: "read-only";
}
export interface E33LaboratoryResult {
resultId: string;
createdAtUtc: string | null;
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
import { LaboratoryRecordedClipPlayer } from "../../components/laboratory/LaboratoryRecordedClipPlayer";
import { RerunViewport } from "../../components/RerunViewport";
import {
LaboratoryEvidence,
LaboratoryResultSummary,
@@ -24,6 +25,11 @@ import {
} from "../../core/laboratory/vegetationShadow";
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import {
recordedSessionRerunProfile,
type RerunPlaybackController,
} from "../../core/observation/viewerProfile";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
import {
fetchM49TgsFullShadowResult,
@@ -31,6 +37,10 @@ import {
} from "../../core/laboratory/m49TgsFullShadow";
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
import {
M48EvidenceModeRail,
type M48BlindEvidenceMode,
} from "./annotation/M48EvidenceModeControls";
function decimal(value: number, digits = 1): string {
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
@@ -76,8 +86,12 @@ function FullRouteReviewEvidence({
const [playbackRate, setPlaybackRate] = useState(1);
const [mode, setMode] = useState<typeof FULL_ROUTE_MODES[number]["value"]>("vegetation");
const [expanded, setExpanded] = useState(false);
const [evidenceMode, setEvidenceMode] = useState<M48BlindEvidenceMode>("3d");
const [cameraVisible, setCameraVisible] = useState(true);
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
const [replayLaunch, setReplayLaunch] = useState<ObservationSessionReplayLaunch | null>(null);
const [videoError, setVideoError] = useState<string | null>(null);
const spatialControllerRef = useRef<RerunPlaybackController | null>(null);
const frames = useMemo(
() => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({
sequence: index + 1,
@@ -97,6 +111,7 @@ function FullRouteReviewEvidence({
useEffect(() => {
const controller = new AbortController();
setVideoSource(null);
setReplayLaunch(null);
setVideoError(null);
void resolveObservationSessionReplay(review.sessionId, { signal: controller.signal })
.then((launch) => {
@@ -112,7 +127,10 @@ function FullRouteReviewEvidence({
if (!source) {
throw new Error("RIGHT-видео не совпало с sealed RAVNOVES004TREE timeline.");
}
if (!controller.signal.aborted) setVideoSource(source);
if (!controller.signal.aborted) {
setVideoSource(source);
setReplayLaunch(launch);
}
})
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
@@ -128,6 +146,55 @@ function FullRouteReviewEvidence({
review.timelineStartSeconds,
]);
const spatialProfile = useMemo(() => replayLaunch ? recordedSessionRerunProfile({
sourceUrl: replayLaunch.viewerSourceUrl,
artifact: {
sourceUrl: replayLaunch.sourceUrl,
viewerSourceUrl: replayLaunch.viewerSourceUrl,
byteLength: replayLaunch.byteLength,
sha256: replayLaunch.sha256,
},
autoplayWhenReady: false,
presentationGate: "ready",
expectedTimelineStartSeconds: replayLaunch.timelineStartSeconds,
expectedTimelineEndSeconds: replayLaunch.timelineEndSeconds,
initialPlaybackStartSeconds: review.timelineStartSeconds,
view: "spatial",
viewResetGeneration: 0,
followTrajectory: false,
perceptionLayers: {
enabled: false,
detections2d: false,
segmentation: false,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
}) : null, [replayLaunch, review.timelineStartSeconds]);
const activeFrame = frames.find((candidate) => candidate.sequence === sequence)
?? frames[0]
?? null;
const activeSourceTimeNsRef = useRef(activeFrame?.sourceTimeNs ?? null);
activeSourceTimeNsRef.current = activeFrame?.sourceTimeNs ?? null;
const handleSpatialControllerChange = useCallback((controller: RerunPlaybackController | null) => {
spatialControllerRef.current = controller;
const sourceTimeNs = activeSourceTimeNsRef.current;
if (!controller || sourceTimeNs === null) return;
controller.setPlaying(false);
controller.seek(sourceTimeNs);
}, []);
useEffect(() => {
const controller = spatialControllerRef.current;
if (!controller || !activeFrame) return;
controller.setPlaying(false);
controller.seek(activeFrame.sourceTimeNs);
}, [activeFrame]);
const cameraPresentation = evidenceMode === "camera"
? "primary"
: cameraVisible ? "companion" : "hidden";
return (
<LaboratoryEvidenceViewer
label="RAVNOVES004TREE full recorded review"
@@ -139,47 +206,69 @@ function FullRouteReviewEvidence({
onExpandedChange={setExpanded}
chromeLayout="stacked"
>
{videoSource ? (
<LaboratoryRecordedClipPlayer
source={videoSource}
segmentCount={review.frameCount}
frames={frames}
sequence={sequence}
playing={playing}
playbackRate={playbackRate}
cameraPresentation="primary"
continuousPlayback
sourceCount={1}
onSequenceChange={setSequence}
onPlayingChange={setPlaying}
onPlaybackRateChange={setPlaybackRate}
cameraOverlay={(
<>
<div className="m48-clip-player__pane-label" data-pane="camera">
{mode === "source" ? "SOURCE" : `${mode === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}`}
<div className="m48-evidence-stage">
{videoSource ? (
<LaboratoryRecordedClipPlayer
source={videoSource}
segmentCount={review.frameCount}
frames={frames}
sequence={sequence}
playing={playing}
playbackRate={playbackRate}
cameraPresentation={cameraPresentation}
continuousPlayback
sourceCount={2}
onSequenceChange={setSequence}
onPlayingChange={setPlaying}
onPlaybackRateChange={setPlaybackRate}
alternativeScene={spatialProfile ? (
<RerunViewport
profile={spatialProfile}
onPlaybackControllerChange={handleSpatialControllerChange}
/>
) : (
<div className="m4-replay-threat-visual__pane-status" role="status">
Открываем sealed RRD и point-cloud evidence
</div>
{layer && semantic ? (
<div className="m48-clip-player__overlay">
<RecordedEvidenceSemanticMaskOverlay
src={vegetationFullRouteMaskUrl(resultId, mode as "city" | "vegetation", maskSequence)}
prefetchSrcs={prefetchSrcs}
imageWidth={review.width}
imageHeight={review.height}
classes={semantic.classes}
palette={semantic.palette}
opacity={0.76}
ariaLabel={`${layer.name} semantic prediction`}
/>
)}
cameraOverlay={(
<>
<div className="m48-clip-player__pane-label" data-pane="camera">
{mode === "source" ? "SOURCE" : `${mode === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}`}
</div>
) : null}
</>
)}
/>
) : (
<div className="m4-replay-threat-visual__pane-status" role={videoError ? "alert" : "status"}>
{videoError ?? "Открываем автономный recorded source…"}
</div>
)}
{layer && semantic ? (
<div className="m48-clip-player__overlay">
<RecordedEvidenceSemanticMaskOverlay
src={vegetationFullRouteMaskUrl(resultId, mode as "city" | "vegetation", maskSequence)}
prefetchSrcs={prefetchSrcs}
imageWidth={review.width}
imageHeight={review.height}
classes={semantic.classes}
palette={semantic.palette}
opacity={0.76}
ariaLabel={`${layer.name} semantic prediction`}
/>
</div>
) : null}
</>
)}
/>
) : (
<div className="m4-replay-threat-visual__pane-status" role={videoError ? "alert" : "status"}>
{videoError ?? "Открываем автономный recorded source…"}
</div>
)}
{videoSource ? (
<M48EvidenceModeRail
mode={evidenceMode}
cameraVisible={cameraVisible}
spatialAvailable={Boolean(spatialProfile)}
planAvailable={false}
onModeChange={setEvidenceMode}
onCameraVisibleChange={setCameraVisible}
/>
) : null}
</div>
</LaboratoryEvidenceViewer>
);
}
@@ -228,7 +317,7 @@ function FullRouteReviewResult({
<LaboratoryEvidence
eyebrow="M4.7 TEMPLATE · RAVNOVES004TREE FULL VIDEO"
title="SOURCE / EoMT CITY / DDRNet NATURE · 6830/6830 · TRUTH отсутствует"
kind="diagnostic-model"
kind="recorded-replay"
resizable
>
<FullRouteReviewEvidence resultId={resultId} review={review} />
@@ -10,6 +10,7 @@ interface M48EvidenceModeControlProps {
mode: M48BlindEvidenceMode;
cameraVisible: boolean;
spatialAvailable: boolean;
planAvailable?: boolean;
onModeChange: (mode: M48BlindEvidenceMode) => void;
onCameraVisibleChange: (visible: boolean) => void;
}
@@ -34,6 +35,7 @@ export function M48EvidenceModeControls({
mode,
cameraVisible,
spatialAvailable,
planAvailable = spatialAvailable,
onModeChange,
onCameraVisibleChange,
}: M48EvidenceModeControlProps) {
@@ -68,9 +70,9 @@ export function M48EvidenceModeControls({
<IconButton
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
aria-pressed={spatialMode === "plan"}
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
disabled={!planAvailable || (!cameraVisible && spatialMode === "plan")}
onClick={() => {
if (!spatialAvailable) return;
if (!planAvailable) return;
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
}}
>
@@ -18,6 +18,7 @@ let nextM48ObjectId;
let laboratoryMetricLegendEntries;
let nearestLaboratoryRecordedClipFrame;
let laboratoryRecordedClipEndExclusiveNs;
let laboratoryRecordedClipClockGate;
let m48SpatialPlaybackWindow;
let trimM48SpatialPlaybackCache;
let nextM48CameraVisibility;
@@ -45,6 +46,7 @@ before(async () => {
({
nearestLaboratoryRecordedClipFrame,
laboratoryRecordedClipEndExclusiveNs,
laboratoryRecordedClipClockGate,
} = await server.ssrLoadModule(
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
));
@@ -342,6 +344,21 @@ test("shared recorded clip clock selects exact frames and one stable loop bounda
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
});
test("shared recorded clip rejects stale media callbacks until an explicit seek lands", () => {
assert.deepEqual(laboratoryRecordedClipClockGate(1, 123), {
accept: false,
pendingSequence: 1,
});
assert.deepEqual(laboratoryRecordedClipClockGate(1, 1), {
accept: true,
pendingSequence: null,
});
assert.deepEqual(laboratoryRecordedClipClockGate(null, 124), {
accept: true,
pendingSequence: null,
});
});
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
assert.equal(nextM48CameraVisibility("camera", true), true);
assert.equal(nextM48CameraVisibility("3d", true), false);
@@ -188,6 +188,9 @@ test("production replay derives bounded fragments and retains native range fallb
assert.match(source, /requestedSegmentSequence = segmentSequence \?\?/);
assert.match(source, /waitForRecordedVideoInitialFrame/);
assert.match(source, /setSegmentRecoveryGeneration/);
assert.match(source, /resumePlaybackIfRequested/);
assert.match(source, /!playbackPlayingRef\.current/);
assert.match(source, /await video\.play\(\)/);
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
assert.deepEqual(
@@ -384,6 +384,10 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
assert.match(resultSource, /RAVNOVES004TREE mixed route review/);
assert.match(resultSource, /RAVNOVES004TREE full recorded review/);
assert.match(resultSource, /LaboratoryRecordedClipPlayer/);
assert.match(resultSource, /RerunViewport/);
assert.match(resultSource, /M48EvidenceModeRail/);
assert.match(resultSource, /planAvailable=\{false\}/);
assert.match(resultSource, /kind="recorded-replay"/);
assert.match(resultSource, /className="m48-clip-player__overlay"/);
assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/);