Стабилизация синхронного LAB-воспроизведения
This commit is contained in:
@@ -52,6 +52,7 @@ import {
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
workOptionsForExperiment,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
import {
|
||||
@@ -481,7 +482,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>(
|
||||
"l34-right-yolox-truth-island-freeze",
|
||||
);
|
||||
const initialWorkSelectedRef = useRef(false);
|
||||
const initialSelectionAppliedRef = useRef(false);
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
@@ -656,6 +657,15 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
if (!initialSelectionAppliedRef.current) {
|
||||
const freshest = freshestLaboratorySelection(catalog);
|
||||
if (!freshest) return;
|
||||
initialSelectionAppliedRef.current = true;
|
||||
setProfileId(freshest.profileId);
|
||||
setExperimentId(freshest.experimentId);
|
||||
setWorkId(freshest.workId);
|
||||
return;
|
||||
}
|
||||
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||
const firstProfile = profiles[0];
|
||||
if (!firstProfile) return;
|
||||
@@ -667,13 +677,6 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
if (firstExperiment) setExperimentId(firstExperiment.id);
|
||||
return;
|
||||
}
|
||||
if (!initialWorkSelectedRef.current) {
|
||||
const freshestWork = workOptions[0];
|
||||
if (!freshestWork) return;
|
||||
setWorkId(freshestWork.id);
|
||||
initialWorkSelectedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!workOptions.some((work) => work.id === workId)) {
|
||||
const firstWork = workOptions[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
@@ -681,6 +684,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
}, [
|
||||
evidenceLoading,
|
||||
advanced.indexLoading,
|
||||
catalog,
|
||||
experimentId,
|
||||
experimentOptions,
|
||||
profileId,
|
||||
@@ -692,7 +696,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
initialWorkSelectedRef.current = true;
|
||||
initialSelectionAppliedRef.current = true;
|
||||
const firstExperiment = experimentOptionsForProfile(next, catalog)[0];
|
||||
if (!firstExperiment) return;
|
||||
setExperimentId(firstExperiment.id);
|
||||
@@ -702,7 +706,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|
||||
const selectExperiment = (next: LaboratoryExperimentId) => {
|
||||
setExperimentId(next);
|
||||
initialWorkSelectedRef.current = true;
|
||||
initialSelectionAppliedRef.current = true;
|
||||
const firstWork = workOptionsForExperiment(profileId, next, catalog)[0];
|
||||
if (firstWork) selectWork(firstWork.id);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon, IconButton } from "@nodedc/ui-react";
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
SplitPane,
|
||||
type SplitPaneOrientation,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import {
|
||||
@@ -22,6 +29,7 @@ import { useRecordedEvidencePlayback } from "../../components/laboratory/useReco
|
||||
import {
|
||||
e47SemanticMaskUrl,
|
||||
type E47SemanticClass,
|
||||
type E47SemanticTimelineFrame,
|
||||
} from "../../core/laboratory/e47SemanticSlam";
|
||||
import type {
|
||||
M4ThreatCameraProposal,
|
||||
@@ -37,7 +45,9 @@ import {
|
||||
} from "./useM4ThreatTimeline";
|
||||
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
||||
|
||||
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
||||
type M4ThreatMediaMode = "video" | "camera";
|
||||
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
|
||||
type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none";
|
||||
|
||||
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||
if (proposal.threatDecision === "threat") return "danger";
|
||||
@@ -87,12 +97,19 @@ export function M4ReplayThreatVisual({
|
||||
resultId: string;
|
||||
semantic?: M4ReplayThreatSemanticLayer;
|
||||
}) {
|
||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
||||
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>(null);
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
const [showSemantic, setShowSemantic] = useState(true);
|
||||
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
|
||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
||||
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||
? "horizontal"
|
||||
: "vertical"
|
||||
));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||
const metadata = useM4ThreatTimelineMetadata(resultId);
|
||||
@@ -115,6 +132,14 @@ export function M4ReplayThreatVisual({
|
||||
setVideoError(null);
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(max-width: 900px)");
|
||||
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeline = metadata.timeline;
|
||||
if (!timeline || videoSource) return;
|
||||
@@ -162,6 +187,18 @@ export function M4ReplayThreatVisual({
|
||||
}, [resultId]);
|
||||
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
||||
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
|
||||
const lastSpatialFrameRef = useRef<{
|
||||
resultId: string;
|
||||
frame: M4ThreatTimelineFrame;
|
||||
} | null>(null);
|
||||
if (frame?.spatialAvailable) {
|
||||
lastSpatialFrameRef.current = { resultId, frame };
|
||||
}
|
||||
const spatialFrame = frame?.spatialAvailable
|
||||
? frame
|
||||
: lastSpatialFrameRef.current?.resultId === resultId
|
||||
? lastSpatialFrameRef.current.frame
|
||||
: null;
|
||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||
resultId: semantic?.resultId ?? null,
|
||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||
@@ -174,33 +211,6 @@ export function M4ReplayThreatVisual({
|
||||
&& frame.sequence !== timelineFrame.activeSequence,
|
||||
);
|
||||
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
|
||||
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
|
||||
id: obstacle.componentId,
|
||||
decision: obstacle.assessment.decision,
|
||||
state: obstacle.state,
|
||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||
})) ?? [], [frame]);
|
||||
const currentIncrementObstacles = frame?.metricObstacles.filter(
|
||||
(item) => item.state === "current",
|
||||
) ?? [];
|
||||
const rollingMapObstacles = frame?.metricObstacles.filter(
|
||||
(item) => item.state === "retained",
|
||||
) ?? [];
|
||||
const nearest = frame?.metricObstacles
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
||||
timelineFrame.availableFrames,
|
||||
frame,
|
||||
metadata.timeline?.localSurfaceVisualization ?? {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
},
|
||||
), [frame, metadata.timeline, timelineFrame.availableFrames]);
|
||||
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
id: item.classId,
|
||||
@@ -214,50 +224,95 @@ export function M4ReplayThreatVisual({
|
||||
color: item.disposition === "ambiguous"
|
||||
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||
opacity: item.disposition === "ambiguous" ? 0.22 : 0.56,
|
||||
opacity: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||
})) ?? [],
|
||||
[semantic?.taxonomy],
|
||||
);
|
||||
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||
? semanticTimeline.activeFrame
|
||||
: null;
|
||||
const semanticIntegrityError = semantic && frame?.spatialAvailable && semanticFrame && (
|
||||
semanticFrame.sourcePointCount !== frame.pointCloudSourceCount
|
||||
|| frame.pointCloudSampleCount !== frame.pointCloudSourceCount
|
||||
|| frame.pointCloudBodyXyzM.length !== frame.pointCloudSourceCount
|
||||
const lastSpatialSemanticFrameRef = useRef<{
|
||||
resultId: string;
|
||||
frame: E47SemanticTimelineFrame;
|
||||
} | null>(null);
|
||||
if (frame?.spatialAvailable && semanticFrame) {
|
||||
lastSpatialSemanticFrameRef.current = { resultId, frame: semanticFrame };
|
||||
}
|
||||
const spatialSemanticFrame = semanticFrame?.sequence === spatialFrame?.sequence
|
||||
? semanticFrame
|
||||
: lastSpatialSemanticFrameRef.current?.resultId === resultId
|
||||
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
|
||||
? lastSpatialSemanticFrameRef.current.frame
|
||||
: null;
|
||||
const semanticIntegrityError = semantic && spatialFrame && spatialSemanticFrame && (
|
||||
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|
||||
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|
||||
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
|
||||
)
|
||||
? "E47 semantic point index space не совпал с exact current increment M4.6."
|
||||
: null;
|
||||
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||
if (
|
||||
!semantic
|
||||
|| !showSemantic
|
||||
|| !frame
|
||||
|| !frame.spatialAvailable
|
||||
|| !semanticFrame
|
||||
|| !showSpatialSemantic
|
||||
|| !spatialFrame
|
||||
|| !spatialSemanticFrame
|
||||
|| semanticIntegrityError
|
||||
) return undefined;
|
||||
return semanticFrame.classIds.map((classId, index) => {
|
||||
const status = semanticFrame.statusCodes[index];
|
||||
return spatialSemanticFrame.classIds.map((classId, index) => {
|
||||
const status = spatialSemanticFrame.statusCodes[index];
|
||||
return status === 2 || status === 3 ? classId : null;
|
||||
});
|
||||
}, [frame, semantic, semanticFrame, semanticIntegrityError, showSemantic]);
|
||||
}, [semantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
||||
const sceneObstacles = useMemo(() => spatialFrame?.metricObstacles.map((obstacle) => ({
|
||||
id: obstacle.componentId,
|
||||
decision: obstacle.assessment.decision,
|
||||
state: obstacle.state,
|
||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||
})) ?? [], [spatialFrame]);
|
||||
const currentIncrementObstacles = spatialFrame?.metricObstacles.filter(
|
||||
(item) => item.state === "current",
|
||||
) ?? [];
|
||||
const rollingMapObstacles = spatialFrame?.metricObstacles.filter(
|
||||
(item) => item.state === "retained",
|
||||
) ?? [];
|
||||
const nearest = spatialFrame?.metricObstacles
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
||||
timelineFrame.availableFrames,
|
||||
spatialFrame,
|
||||
metadata.timeline?.localSurfaceVisualization ?? {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
},
|
||||
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||
semantic && showSemantic && frame
|
||||
semantic && showMediaSemantic && frame
|
||||
? {
|
||||
src: e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
||||
prefetchSrcs: Array.from({ length: 12 }, (_, index) => index + 1)
|
||||
.map((offset) => frame.sequence + offset)
|
||||
.filter((sequence) => sequence < (metadata.timeline?.frameCount ?? 0))
|
||||
.map((sequence) => e47SemanticMaskUrl(semantic.resultId, sequence)),
|
||||
classes: semanticClasses,
|
||||
palette: semanticPalette,
|
||||
opacity: 0.48,
|
||||
opacity: 0.9,
|
||||
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const seek = (seconds: number) => playbackController.seek(seconds);
|
||||
const handleModeChange = (next: M4ThreatViewMode) => {
|
||||
if (next === "camera") playbackController.setPlaying(false);
|
||||
if (next === "3d" || next === "plan") setSpatialMode(next);
|
||||
setMode(next);
|
||||
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
|
||||
if (next === "none") return;
|
||||
setMediaMode((current) => current === next ? null : next);
|
||||
};
|
||||
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
|
||||
if (next === "none") return;
|
||||
setSpatialMode((current) => current === next ? null : next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -266,86 +321,124 @@ export function M4ReplayThreatVisual({
|
||||
image.src = frame.cameraUrl;
|
||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
||||
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton
|
||||
label="Назад на 5 секунд"
|
||||
disabled={!metadata.timeline}
|
||||
onClick={() => seek(playbackController.playback.currentSeconds - 5)}
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
|
||||
const mediaModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
||||
<SegmentedControl
|
||||
value={mediaMode ?? "none"}
|
||||
items={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
label="Видео и камера"
|
||||
onChange={handleMediaModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const spatialModeControls = (
|
||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
||||
<SegmentedControl
|
||||
value={spatialMode ?? "none"}
|
||||
items={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
label="3D и план"
|
||||
onChange={handleSpatialModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const mediaLayerControls = semantic ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showMediaSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showMediaSemantic}
|
||||
onClick={() => setShowMediaSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои 3D и плана"
|
||||
>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showCurrentIncrement ? "primary" : "secondary"}
|
||||
aria-pressed={showCurrentIncrement}
|
||||
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
||||
>
|
||||
CURRENT
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showLocalSurface ? "primary" : "secondary"}
|
||||
aria-pressed={showLocalSurface}
|
||||
title="Bounded local SLAM surface · visual-derived"
|
||||
onClick={() => setShowLocalSurface((visible) => !visible)}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showRollingMap ? "primary" : "secondary"}
|
||||
aria-pressed={showRollingMap}
|
||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||
>
|
||||
ROLLING
|
||||
</Button>
|
||||
{semantic ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||
aria-pressed={showSpatialSemantic}
|
||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Вперёд на 5 секунд"
|
||||
disabled={!metadata.timeline}
|
||||
onClick={() => seek(playbackController.playback.currentSeconds + 5)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{mode === "3d" || mode === "plan" || semantic ? (
|
||||
<div
|
||||
className="nodedc-segmented m4-replay-threat-visual__layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои пространственного evidence"
|
||||
>
|
||||
{mode === "3d" || mode === "plan" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showCurrentIncrement ? "true" : undefined}
|
||||
aria-pressed={showCurrentIncrement}
|
||||
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
||||
>
|
||||
CURRENT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showLocalSurface ? "true" : undefined}
|
||||
aria-pressed={showLocalSurface}
|
||||
title="Bounded local SLAM surface · visual-derived"
|
||||
onClick={() => setShowLocalSurface((visible) => !visible)}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showRollingMap ? "true" : undefined}
|
||||
aria-pressed={showRollingMap}
|
||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||
>
|
||||
ROLLING
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{semantic ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showSemantic ? "true" : undefined}
|
||||
aria-pressed={showSemantic}
|
||||
onClick={() => setShowSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trailingActions = mode === "3d" || mode === "plan" ? (
|
||||
const resetSpatialView = (
|
||||
<IconButton
|
||||
label="Сбросить ракурс"
|
||||
onClick={() => metricSceneRef.current?.resetView()}
|
||||
>
|
||||
<Icon name="refresh" size={16} />
|
||||
</IconButton>
|
||||
) : null;
|
||||
);
|
||||
|
||||
const singlePaneControls = !splitView
|
||||
? spatialMode ? spatialLayerControls : mediaMode ? mediaLayerControls : null
|
||||
: null;
|
||||
|
||||
const actions = singlePaneControls ? (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="m4-replay-threat-visual__single-pane-controls">
|
||||
{singlePaneControls}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const trailingActions = !splitView && spatialMode ? resetSpatialView : null;
|
||||
|
||||
const overlay = metadata.timeline && frame ? (
|
||||
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
|
||||
@@ -365,18 +458,18 @@ export function M4ReplayThreatVisual({
|
||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||
</strong>
|
||||
<small>
|
||||
{frame.spatialAvailable
|
||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||
: "body frame / current increment unavailable"}
|
||||
{semantic && semanticFrame
|
||||
? ` · semantic L ${semanticFrame.counts.labeled} · A ${semanticFrame.counts.ambiguous} · U ${semanticFrame.counts.unprojected} · Ø ${semanticFrame.counts.absent}`
|
||||
{spatialFrame
|
||||
? `${spatialFrame.pointCloudSampleCount}/${spatialFrame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||
: "квалифицированный spatial frame ещё не получен"}
|
||||
{semantic && spatialSemanticFrame
|
||||
? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}`
|
||||
: semantic ? " · semantic buffer" : ""}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Virtual corridor</span>
|
||||
<strong>
|
||||
{frame.decisionCounts.threat} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
||||
{spatialFrame?.decisionCounts.threat ?? 0} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
||||
</strong>
|
||||
<small>
|
||||
{metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED
|
||||
@@ -397,74 +490,129 @@ export function M4ReplayThreatVisual({
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="m4-replay-threat-visual__deck">
|
||||
const mediaPane = (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="media"
|
||||
aria-label={mediaMode === "camera" ? "Камера" : "Видео"}
|
||||
hidden={!mediaMode}
|
||||
>
|
||||
{splitView ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="media"
|
||||
>
|
||||
{mediaLayerControls}
|
||||
{mediaModeControls}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "video" ? "true" : undefined}
|
||||
aria-hidden={mode !== "video"}
|
||||
className="m4-replay-threat-visual__media-layer"
|
||||
data-media="video"
|
||||
hidden={mediaMode !== "video"}
|
||||
>
|
||||
{videoSource ? (
|
||||
<RecordedEvidenceVideoScene
|
||||
source={videoSource}
|
||||
playback={playbackController.playback}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mode === "video" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||
interactive={false}
|
||||
/>
|
||||
) : videoError ? (
|
||||
<SpatialState message={videoError} />
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
||||
</div>
|
||||
<RecordedEvidenceVideoScene
|
||||
source={videoSource}
|
||||
playback={playbackController.playback}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||
interactive={false}
|
||||
/>
|
||||
) : videoError ? (
|
||||
<SpatialState message={videoError} />
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "camera" ? "true" : undefined}
|
||||
aria-hidden={mode !== "camera"}
|
||||
>
|
||||
{mode === "camera" && frame ? (
|
||||
<RecordedEvidenceImageScene
|
||||
src={frame.cameraUrl}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mode === "camera" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "3d" || mode === "plan" ? "true" : undefined}
|
||||
aria-hidden={mode !== "3d" && mode !== "plan"}
|
||||
>
|
||||
{frame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy"
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
pointSemanticClassIds={alignedSemanticPointIds}
|
||||
semanticClasses={semanticClasses}
|
||||
semanticPalette={semanticPalette}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{mediaMode === "camera" && frame ? (
|
||||
<RecordedEvidenceImageScene
|
||||
src={frame.cameraUrl}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={semanticOverlay}
|
||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
const spatialPane = spatialMode ? (
|
||||
<section
|
||||
className="m4-replay-threat-visual__pane"
|
||||
data-pane="spatial"
|
||||
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||
>
|
||||
{splitView ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="spatial"
|
||||
>
|
||||
{resetSpatialView}
|
||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||
{spatialLayerControls}
|
||||
{spatialModeControls}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{spatialFrame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={spatialFrame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy"
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
pointSemanticClassIds={alignedSemanticPointIds}
|
||||
semanticClasses={semanticClasses}
|
||||
semanticPalette={semanticPalette}
|
||||
/>
|
||||
) : null}
|
||||
{frame && !frame.spatialAvailable ? (
|
||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||
{spatialFrame
|
||||
? `На кадре ${frame.sequence + 1} нет body frame; держим spatial evidence кадра ${spatialFrame.sequence + 1}.`
|
||||
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
content = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__deck"
|
||||
data-split={splitView ? "true" : undefined}
|
||||
data-empty={!mediaMode && !spatialMode ? "true" : undefined}
|
||||
>
|
||||
<SplitPane
|
||||
primary={mediaPane}
|
||||
secondary={spatialPane ?? <div />}
|
||||
primarySize={splitView ? splitPrimarySize : mediaMode ? 100 : 0}
|
||||
onPrimarySizeChange={setSplitPrimarySize}
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView}
|
||||
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
|
||||
/>
|
||||
{!mediaMode && !spatialMode ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте.
|
||||
</div>
|
||||
) : null}
|
||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
@@ -495,11 +643,6 @@ export function M4ReplayThreatVisual({
|
||||
<span>{semanticIntegrityError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
На этом кадре нет квалифицированного body frame; сцена сохранена.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -533,16 +676,24 @@ export function M4ReplayThreatVisual({
|
||||
? "E47 semantic + SLAM diagnostic replay"
|
||||
: "M4.6 dual-evidence recorded-realtime replay"}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mode}
|
||||
mode={mediaMode ?? "none"}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
secondaryMode={{
|
||||
value: spatialMode ?? "none",
|
||||
modes: [
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
],
|
||||
label: "3D и план",
|
||||
onChange: handleSpatialModeChange,
|
||||
}}
|
||||
expanded={expanded}
|
||||
onModeChange={handleModeChange}
|
||||
onModeChange={handleMediaModeChange}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={!splitView}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface LaboratoryCatalogEntry {
|
||||
variantName: string;
|
||||
}
|
||||
|
||||
export interface LaboratorySelection {
|
||||
profileId: LaboratoryProfileId;
|
||||
experimentId: LaboratoryExperimentId;
|
||||
workId: LaboratoryWorkId;
|
||||
}
|
||||
|
||||
interface KnownWorkDefinition {
|
||||
profileId: Exclude<LaboratoryProfileId, `published:${string}`>;
|
||||
profileName: (rigLabel: string) => string;
|
||||
@@ -478,3 +484,19 @@ export function workOptionsForExperiment(
|
||||
label: `${laboratoryTimestamp(entry.createdAtUtc)} · ${entry.variantName}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function freshestLaboratorySelection(
|
||||
catalog: readonly LaboratoryCatalogEntry[],
|
||||
): LaboratorySelection | null {
|
||||
const profile = buildLaboratoryProfiles(catalog)[0];
|
||||
if (!profile) return null;
|
||||
const experiment = experimentOptionsForProfile(profile.id, catalog)[0];
|
||||
if (!experiment) return null;
|
||||
const work = workOptionsForExperiment(profile.id, experiment.id, catalog)[0];
|
||||
if (!work) return null;
|
||||
return {
|
||||
profileId: profile.id,
|
||||
experimentId: experiment.id,
|
||||
workId: work.id,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user