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)
|
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 playbackRateRef = useRef(playbackRate);
|
||||||
|
playbackRateRef.current = playbackRate;
|
||||||
const presentationEpoch = useMemo(
|
const presentationEpoch = useMemo(
|
||||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||||
[archive?.manifest.epochs, currentSeconds],
|
[archive?.manifest.epochs, currentSeconds],
|
||||||
@@ -1183,13 +1185,42 @@ export function RecordedFmp4Player({
|
|||||||
message,
|
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) {
|
if (rollingTarget && previousTarget) {
|
||||||
runtime.target = {
|
runtime.target = {
|
||||||
...candidateTarget,
|
...candidateTarget,
|
||||||
revision: previousTarget.revision,
|
revision: previousTarget.revision,
|
||||||
};
|
};
|
||||||
runtime.onTargetBuffered = null;
|
runtime.onTargetBuffered = null;
|
||||||
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
|
void pumpRecordedSegmentWindow(runtime)
|
||||||
|
.then(() => resumePlaybackIfRequested(previousTarget.revision))
|
||||||
|
.catch(reportPumpError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1251,6 +1282,7 @@ export function RecordedFmp4Player({
|
|||||||
byteLength: archiveByteLength,
|
byteLength: archiveByteLength,
|
||||||
message: null,
|
message: null,
|
||||||
});
|
});
|
||||||
|
await resumePlaybackIfRequested(bufferedTarget.revision);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
targetReadyAbort.signal.aborted
|
targetReadyAbort.signal.aborted
|
||||||
@@ -1400,6 +1432,41 @@ export function RecordedFmp4Player({
|
|||||||
visualState,
|
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(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export function laboratoryRecordedClipEndExclusiveNs(
|
|||||||
return last.sourceTimeNs + typicalDelta;
|
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({
|
export function LaboratoryRecordedClipPlayer({
|
||||||
source,
|
source,
|
||||||
segmentCount,
|
segmentCount,
|
||||||
@@ -94,7 +104,8 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
}) {
|
}) {
|
||||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||||
const lastEmittedSequenceRef = useRef(sequence);
|
const lastEmittedSequenceRef = useRef(sequence);
|
||||||
lastEmittedSequenceRef.current = sequence;
|
const lastObservedSequenceRef = useRef<number | null>(sequence);
|
||||||
|
const pendingSequenceRef = useRef<number | null>(null);
|
||||||
const frame = useMemo(
|
const frame = useMemo(
|
||||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||||
[frames, sequence],
|
[frames, sequence],
|
||||||
@@ -113,23 +124,42 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||||
}, [continuousPlayback, onPlayingChange, playing]);
|
}, [continuousPlayback, onPlayingChange, playing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastObservedSequenceRef.current !== sequence) {
|
||||||
|
pendingSequenceRef.current = sequence;
|
||||||
|
}
|
||||||
|
lastEmittedSequenceRef.current = sequence;
|
||||||
|
}, [sequence]);
|
||||||
|
|
||||||
const emitSequence = useCallback((nextSequence: number) => {
|
const emitSequence = useCallback((nextSequence: number) => {
|
||||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||||
lastEmittedSequenceRef.current = nextSequence;
|
lastEmittedSequenceRef.current = nextSequence;
|
||||||
onSequenceChange(nextSequence);
|
onSequenceChange(nextSequence);
|
||||||
}, [onSequenceChange]);
|
}, [onSequenceChange]);
|
||||||
|
|
||||||
|
const requestSequence = useCallback((nextSequence: number) => {
|
||||||
|
pendingSequenceRef.current = nextSequence;
|
||||||
|
emitSequence(nextSequence);
|
||||||
|
}, [emitSequence]);
|
||||||
|
|
||||||
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
||||||
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
||||||
const first = frames[0];
|
const first = frames[0];
|
||||||
if (!first || endExclusiveNs === null) return;
|
if (!first || endExclusiveNs === null) return;
|
||||||
if (sourceTimeNs >= endExclusiveNs) {
|
if (sourceTimeNs >= endExclusiveNs) {
|
||||||
emitSequence(first.sequence);
|
requestSequence(first.sequence);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||||
if (nearest) emitSequence(nearest.sequence);
|
if (!nearest) return;
|
||||||
}, [emitSequence, endExclusiveNs, frames]);
|
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 timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
||||||
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||||
@@ -142,7 +172,7 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
className="laboratory-recorded-clip-player__spatial"
|
className="laboratory-recorded-clip-player__spatial"
|
||||||
aria-hidden={cameraPresentation === "primary"}
|
aria-hidden={cameraPresentation === "primary"}
|
||||||
>
|
>
|
||||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
{alternativeScene}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const cameraPane = (
|
const cameraPane = (
|
||||||
@@ -202,7 +232,7 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||||
onSeek={(timeNs) => {
|
onSeek={(timeNs) => {
|
||||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||||
if (nearest) emitSequence(nearest.sequence);
|
if (nearest) requestSequence(nearest.sequence);
|
||||||
}}
|
}}
|
||||||
showJumpToEnd={false}
|
showJumpToEnd={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export interface E31LaboratoryResult {
|
|||||||
limitations: readonly string[];
|
limitations: readonly string[];
|
||||||
access: "read-only";
|
access: "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E32LaboratoryResult {
|
export interface E32LaboratoryResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string | null;
|
createdAtUtc: string | null;
|
||||||
@@ -53,7 +52,6 @@ export interface E32LaboratoryResult {
|
|||||||
};
|
};
|
||||||
access: "read-only";
|
access: "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E33LaboratoryResult {
|
export interface E33LaboratoryResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string | null;
|
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 { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||||
import { LaboratoryRecordedClipPlayer } from "../../components/laboratory/LaboratoryRecordedClipPlayer";
|
import { LaboratoryRecordedClipPlayer } from "../../components/laboratory/LaboratoryRecordedClipPlayer";
|
||||||
|
import { RerunViewport } from "../../components/RerunViewport";
|
||||||
import {
|
import {
|
||||||
LaboratoryEvidence,
|
LaboratoryEvidence,
|
||||||
LaboratoryResultSummary,
|
LaboratoryResultSummary,
|
||||||
@@ -24,6 +25,11 @@ import {
|
|||||||
} from "../../core/laboratory/vegetationShadow";
|
} from "../../core/laboratory/vegetationShadow";
|
||||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
||||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
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 type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||||
import {
|
import {
|
||||||
fetchM49TgsFullShadowResult,
|
fetchM49TgsFullShadowResult,
|
||||||
@@ -31,6 +37,10 @@ import {
|
|||||||
} from "../../core/laboratory/m49TgsFullShadow";
|
} from "../../core/laboratory/m49TgsFullShadow";
|
||||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
||||||
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
||||||
|
import {
|
||||||
|
M48EvidenceModeRail,
|
||||||
|
type M48BlindEvidenceMode,
|
||||||
|
} from "./annotation/M48EvidenceModeControls";
|
||||||
|
|
||||||
function decimal(value: number, digits = 1): string {
|
function decimal(value: number, digits = 1): string {
|
||||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||||
@@ -76,8 +86,12 @@ function FullRouteReviewEvidence({
|
|||||||
const [playbackRate, setPlaybackRate] = useState(1);
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
const [mode, setMode] = useState<typeof FULL_ROUTE_MODES[number]["value"]>("vegetation");
|
const [mode, setMode] = useState<typeof FULL_ROUTE_MODES[number]["value"]>("vegetation");
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [evidenceMode, setEvidenceMode] = useState<M48BlindEvidenceMode>("3d");
|
||||||
|
const [cameraVisible, setCameraVisible] = useState(true);
|
||||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||||
|
const [replayLaunch, setReplayLaunch] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
const [videoError, setVideoError] = useState<string | null>(null);
|
const [videoError, setVideoError] = useState<string | null>(null);
|
||||||
|
const spatialControllerRef = useRef<RerunPlaybackController | null>(null);
|
||||||
const frames = useMemo(
|
const frames = useMemo(
|
||||||
() => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({
|
() => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({
|
||||||
sequence: index + 1,
|
sequence: index + 1,
|
||||||
@@ -97,6 +111,7 @@ function FullRouteReviewEvidence({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setVideoSource(null);
|
setVideoSource(null);
|
||||||
|
setReplayLaunch(null);
|
||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
void resolveObservationSessionReplay(review.sessionId, { signal: controller.signal })
|
void resolveObservationSessionReplay(review.sessionId, { signal: controller.signal })
|
||||||
.then((launch) => {
|
.then((launch) => {
|
||||||
@@ -112,7 +127,10 @@ function FullRouteReviewEvidence({
|
|||||||
if (!source) {
|
if (!source) {
|
||||||
throw new Error("RIGHT-видео не совпало с sealed RAVNOVES004TREE timeline.");
|
throw new Error("RIGHT-видео не совпало с sealed RAVNOVES004TREE timeline.");
|
||||||
}
|
}
|
||||||
if (!controller.signal.aborted) setVideoSource(source);
|
if (!controller.signal.aborted) {
|
||||||
|
setVideoSource(source);
|
||||||
|
setReplayLaunch(launch);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((caught: unknown) => {
|
.catch((caught: unknown) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -128,6 +146,55 @@ function FullRouteReviewEvidence({
|
|||||||
review.timelineStartSeconds,
|
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 (
|
return (
|
||||||
<LaboratoryEvidenceViewer
|
<LaboratoryEvidenceViewer
|
||||||
label="RAVNOVES004TREE full recorded review"
|
label="RAVNOVES004TREE full recorded review"
|
||||||
@@ -139,6 +206,7 @@ function FullRouteReviewEvidence({
|
|||||||
onExpandedChange={setExpanded}
|
onExpandedChange={setExpanded}
|
||||||
chromeLayout="stacked"
|
chromeLayout="stacked"
|
||||||
>
|
>
|
||||||
|
<div className="m48-evidence-stage">
|
||||||
{videoSource ? (
|
{videoSource ? (
|
||||||
<LaboratoryRecordedClipPlayer
|
<LaboratoryRecordedClipPlayer
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
@@ -147,12 +215,22 @@ function FullRouteReviewEvidence({
|
|||||||
sequence={sequence}
|
sequence={sequence}
|
||||||
playing={playing}
|
playing={playing}
|
||||||
playbackRate={playbackRate}
|
playbackRate={playbackRate}
|
||||||
cameraPresentation="primary"
|
cameraPresentation={cameraPresentation}
|
||||||
continuousPlayback
|
continuousPlayback
|
||||||
sourceCount={1}
|
sourceCount={2}
|
||||||
onSequenceChange={setSequence}
|
onSequenceChange={setSequence}
|
||||||
onPlayingChange={setPlaying}
|
onPlayingChange={setPlaying}
|
||||||
onPlaybackRateChange={setPlaybackRate}
|
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>
|
||||||
|
)}
|
||||||
cameraOverlay={(
|
cameraOverlay={(
|
||||||
<>
|
<>
|
||||||
<div className="m48-clip-player__pane-label" data-pane="camera">
|
<div className="m48-clip-player__pane-label" data-pane="camera">
|
||||||
@@ -180,6 +258,17 @@ function FullRouteReviewEvidence({
|
|||||||
{videoError ?? "Открываем автономный recorded source…"}
|
{videoError ?? "Открываем автономный recorded source…"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{videoSource ? (
|
||||||
|
<M48EvidenceModeRail
|
||||||
|
mode={evidenceMode}
|
||||||
|
cameraVisible={cameraVisible}
|
||||||
|
spatialAvailable={Boolean(spatialProfile)}
|
||||||
|
planAvailable={false}
|
||||||
|
onModeChange={setEvidenceMode}
|
||||||
|
onCameraVisibleChange={setCameraVisible}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</LaboratoryEvidenceViewer>
|
</LaboratoryEvidenceViewer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,7 +317,7 @@ function FullRouteReviewResult({
|
|||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="M4.7 TEMPLATE · RAVNOVES004TREE FULL VIDEO"
|
eyebrow="M4.7 TEMPLATE · RAVNOVES004TREE FULL VIDEO"
|
||||||
title="SOURCE / EoMT CITY / DDRNet NATURE · 6830/6830 · TRUTH отсутствует"
|
title="SOURCE / EoMT CITY / DDRNet NATURE · 6830/6830 · TRUTH отсутствует"
|
||||||
kind="diagnostic-model"
|
kind="recorded-replay"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
<FullRouteReviewEvidence resultId={resultId} review={review} />
|
<FullRouteReviewEvidence resultId={resultId} review={review} />
|
||||||
|
|||||||
+4
-2
@@ -10,6 +10,7 @@ interface M48EvidenceModeControlProps {
|
|||||||
mode: M48BlindEvidenceMode;
|
mode: M48BlindEvidenceMode;
|
||||||
cameraVisible: boolean;
|
cameraVisible: boolean;
|
||||||
spatialAvailable: boolean;
|
spatialAvailable: boolean;
|
||||||
|
planAvailable?: boolean;
|
||||||
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
||||||
onCameraVisibleChange: (visible: boolean) => void;
|
onCameraVisibleChange: (visible: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ export function M48EvidenceModeControls({
|
|||||||
mode,
|
mode,
|
||||||
cameraVisible,
|
cameraVisible,
|
||||||
spatialAvailable,
|
spatialAvailable,
|
||||||
|
planAvailable = spatialAvailable,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onCameraVisibleChange,
|
onCameraVisibleChange,
|
||||||
}: M48EvidenceModeControlProps) {
|
}: M48EvidenceModeControlProps) {
|
||||||
@@ -68,9 +70,9 @@ export function M48EvidenceModeControls({
|
|||||||
<IconButton
|
<IconButton
|
||||||
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
||||||
aria-pressed={spatialMode === "plan"}
|
aria-pressed={spatialMode === "plan"}
|
||||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
|
disabled={!planAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!spatialAvailable) return;
|
if (!planAvailable) return;
|
||||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ let nextM48ObjectId;
|
|||||||
let laboratoryMetricLegendEntries;
|
let laboratoryMetricLegendEntries;
|
||||||
let nearestLaboratoryRecordedClipFrame;
|
let nearestLaboratoryRecordedClipFrame;
|
||||||
let laboratoryRecordedClipEndExclusiveNs;
|
let laboratoryRecordedClipEndExclusiveNs;
|
||||||
|
let laboratoryRecordedClipClockGate;
|
||||||
let m48SpatialPlaybackWindow;
|
let m48SpatialPlaybackWindow;
|
||||||
let trimM48SpatialPlaybackCache;
|
let trimM48SpatialPlaybackCache;
|
||||||
let nextM48CameraVisibility;
|
let nextM48CameraVisibility;
|
||||||
@@ -45,6 +46,7 @@ before(async () => {
|
|||||||
({
|
({
|
||||||
nearestLaboratoryRecordedClipFrame,
|
nearestLaboratoryRecordedClipFrame,
|
||||||
laboratoryRecordedClipEndExclusiveNs,
|
laboratoryRecordedClipEndExclusiveNs,
|
||||||
|
laboratoryRecordedClipClockGate,
|
||||||
} = await server.ssrLoadModule(
|
} = await server.ssrLoadModule(
|
||||||
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
"/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);
|
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", () => {
|
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
|
||||||
assert.equal(nextM48CameraVisibility("camera", true), true);
|
assert.equal(nextM48CameraVisibility("camera", true), true);
|
||||||
assert.equal(nextM48CameraVisibility("3d", true), false);
|
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, /requestedSegmentSequence = segmentSequence \?\?/);
|
||||||
assert.match(source, /waitForRecordedVideoInitialFrame/);
|
assert.match(source, /waitForRecordedVideoInitialFrame/);
|
||||||
assert.match(source, /setSegmentRecoveryGeneration/);
|
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.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
||||||
assert.deepEqual(
|
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 mixed route review/);
|
||||||
assert.match(resultSource, /RAVNOVES004TREE full recorded review/);
|
assert.match(resultSource, /RAVNOVES004TREE full recorded review/);
|
||||||
assert.match(resultSource, /LaboratoryRecordedClipPlayer/);
|
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, /className="m48-clip-player__overlay"/);
|
||||||
assert.match(resultSource, /linkedTgsResultId/);
|
assert.match(resultSource, /linkedTgsResultId/);
|
||||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||||
|
|||||||
Reference in New Issue
Block a user