diff --git a/apps/control-station/src/components/RecordedFmp4Player.tsx b/apps/control-station/src/components/RecordedFmp4Player.tsx index d4ef085..34bb56a 100644 --- a/apps/control-station/src/components/RecordedFmp4Player.tsx +++ b/apps/control-station/src/components/RecordedFmp4Player.tsx @@ -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; diff --git a/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx index fdb5c63..50cc27b 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx @@ -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(sequence); + const pendingSequenceRef = useRef(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} ); 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} /> diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 12be3e0..e3ee9d9 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -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; diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx index 7b7840b..73128de 100644 --- a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx @@ -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("vegetation"); const [expanded, setExpanded] = useState(false); + const [evidenceMode, setEvidenceMode] = useState("3d"); + const [cameraVisible, setCameraVisible] = useState(true); const [videoSource, setVideoSource] = useState(null); + const [replayLaunch, setReplayLaunch] = useState(null); const [videoError, setVideoError] = useState(null); + const spatialControllerRef = useRef(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 ( - {videoSource ? ( - -
- {mode === "source" ? "SOURCE" : `${mode === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}`} +
+ {videoSource ? ( + + ) : ( +
+ Открываем sealed RRD и point-cloud evidence…
- {layer && semantic ? ( -
- + )} + cameraOverlay={( + <> +
+ {mode === "source" ? "SOURCE" : `${mode === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}`}
- ) : null} - - )} - /> - ) : ( -
- {videoError ?? "Открываем автономный recorded source…"} -
- )} + {layer && semantic ? ( +
+ +
+ ) : null} + + )} + /> + ) : ( +
+ {videoError ?? "Открываем автономный recorded source…"} +
+ )} + {videoSource ? ( + + ) : null} +
); } @@ -228,7 +317,7 @@ function FullRouteReviewResult({ diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx index 20ba4b0..c65042b 100644 --- a/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx @@ -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({ { - if (!spatialAvailable) return; + if (!planAvailable) return; onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan")); }} > diff --git a/apps/control-station/test/m48ObjectCentricQuality.test.mjs b/apps/control-station/test/m48ObjectCentricQuality.test.mjs index 8e85f5a..e921688 100644 --- a/apps/control-station/test/m48ObjectCentricQuality.test.mjs +++ b/apps/control-station/test/m48ObjectCentricQuality.test.mjs @@ -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); diff --git a/apps/control-station/test/recordedCameraBuffering.test.mjs b/apps/control-station/test/recordedCameraBuffering.test.mjs index 1376e99..ce393ed 100644 --- a/apps/control-station/test/recordedCameraBuffering.test.mjs +++ b/apps/control-station/test/recordedCameraBuffering.test.mjs @@ -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( diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs index 133ecd8..16c952f 100644 --- a/apps/control-station/test/vegetationShadow.test.mjs +++ b/apps/control-station/test/vegetationShadow.test.mjs @@ -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/);