feat(ui): stabilize shared M4.8 review viewer
This commit is contained in:
@@ -101,13 +101,14 @@ export function ObservationTimeline({
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
className="observation-timeline__transport"
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
/>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
@@ -117,7 +118,7 @@ export function ObservationTimeline({
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="split"
|
||||
variant="inline"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
|
||||
@@ -90,6 +90,22 @@ export function recordedMediaSegmentAppendOrder(
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function recordedMediaCanRollTarget(
|
||||
previousSequence: number,
|
||||
nextSequence: number,
|
||||
playing: boolean,
|
||||
targetBuffered: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
playing
|
||||
&& targetBuffered
|
||||
&& Number.isInteger(previousSequence)
|
||||
&& Number.isInteger(nextSequence)
|
||||
&& previousSequence >= 1
|
||||
&& nextSequence >= previousSequence
|
||||
);
|
||||
}
|
||||
|
||||
function recordedMediaTimeRangesContain(
|
||||
ranges: TimeRanges,
|
||||
targetSeconds: number,
|
||||
@@ -1028,12 +1044,13 @@ export function RecordedFmp4Player({
|
||||
forceReset: false,
|
||||
resetAttempts: 0,
|
||||
};
|
||||
const rollingTarget = Boolean(
|
||||
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
|
||||
previousTarget.sequence,
|
||||
candidateTarget.sequence,
|
||||
playbackPlayingRef.current
|
||||
&& previousTarget
|
||||
&& runtime.notifiedRevision === previousTarget.revision
|
||||
&& recordedSegmentTargetBuffered(runtime, candidateTarget),
|
||||
);
|
||||
&& runtime.notifiedRevision === previousTarget.revision,
|
||||
recordedSegmentTargetBuffered(runtime, candidateTarget),
|
||||
));
|
||||
const reportPumpError = (error: unknown) => {
|
||||
if (
|
||||
runtime.disposed
|
||||
|
||||
@@ -32,6 +32,7 @@ export function LaboratoryEvidenceViewer<
|
||||
transport,
|
||||
trailingActions,
|
||||
modeControlsVisible = true,
|
||||
chromeLayout = "overlay",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -52,6 +53,7 @@ export function LaboratoryEvidenceViewer<
|
||||
transport?: ReactNode;
|
||||
trailingActions?: ReactNode;
|
||||
modeControlsVisible?: boolean;
|
||||
chromeLayout?: "overlay" | "stacked";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -80,6 +82,36 @@ export function LaboratoryEvidenceViewer<
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const controls = (
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{modeControlsVisible && secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
label={secondaryMode.label}
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
{modeControlsVisible ? (
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewer = (
|
||||
<dialog
|
||||
ref={viewerRef}
|
||||
@@ -90,44 +122,40 @@ export function LaboratoryEvidenceViewer<
|
||||
].filter(Boolean).join(" ")}
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
data-mode-controls={modeControlsVisible ? undefined : "content"}
|
||||
data-chrome-layout={chromeLayout}
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{modeControlsVisible && secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
label={secondaryMode.label}
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
{modeControlsVisible ? (
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{chromeLayout === "stacked" ? (
|
||||
<>
|
||||
<div className="laboratory-evidence-viewer__header">
|
||||
<div className="laboratory-evidence-viewer__header-context">
|
||||
{overlay}
|
||||
</div>
|
||||
{controls}
|
||||
</div>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
{controls}
|
||||
</>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
|
||||
|
||||
@@ -40,6 +40,55 @@ export interface LaboratoryMetricCorridorVisual {
|
||||
halfWidthM: number;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricLegendEntry {
|
||||
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function laboratoryMetricLegendEntries({
|
||||
pointCloudCount,
|
||||
localSurfaceCount,
|
||||
obstacles,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
}: {
|
||||
pointCloudCount: number;
|
||||
localSurfaceCount: number;
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
}): readonly LaboratoryMetricLegendEntry[] {
|
||||
const visibleObstacles = obstacles.filter((obstacle) => (
|
||||
obstacle.state === "current"
|
||||
? showCurrentIncrement
|
||||
: obstacle.state === "retained"
|
||||
? showRollingMap
|
||||
: false
|
||||
));
|
||||
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
|
||||
const entries: LaboratoryMetricLegendEntry[] = [];
|
||||
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
|
||||
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
|
||||
if (decisions.has("unknown")) entries.push({ id: "unknown", label: "Неизвестно" });
|
||||
if (showCurrentIncrement && pointCloudCount > 0) {
|
||||
entries.push({ id: "context", label: "Текущий кадр" });
|
||||
}
|
||||
if (showLocalSurface && localSurfaceCount > 0) {
|
||||
entries.push({ id: "local-surface", label: "Локальная SLAM-поверхность" });
|
||||
}
|
||||
if (
|
||||
showRollingMap
|
||||
&& visibleObstacles.some((obstacle) => (
|
||||
obstacle.state === "retained" && obstacle.cellCentersBodyXyzM.length > 0
|
||||
))
|
||||
) {
|
||||
entries.push({ id: "rolling", label: "Занято на накопленной карте" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
@@ -167,7 +216,6 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
|
||||
renderer.domElement.setAttribute("aria-label", label);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
@@ -221,6 +269,11 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = hostRef.current?.querySelector("canvas");
|
||||
if (canvas) canvas.setAttribute("aria-label", label);
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -478,6 +531,14 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
}];
|
||||
});
|
||||
})();
|
||||
const metricLegendEntries = laboratoryMetricLegendEntries({
|
||||
pointCloudCount: pointCloudBodyXyzM.length,
|
||||
localSurfaceCount: localSurfaceBodyXyzM.length,
|
||||
obstacles,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="laboratory-metric-evidence-scene">
|
||||
@@ -485,12 +546,9 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
{renderError ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
<span data-decision="threat">Угроза</span>
|
||||
<span data-decision="not-threat">Вне коридора</span>
|
||||
<span data-decision="unknown">Неизвестно</span>
|
||||
<span data-decision="context">Current increment</span>
|
||||
<span data-decision="local-surface">Local SLAM surface</span>
|
||||
<span data-decision="rolling">Rolling-map occupied</span>
|
||||
{metricLegendEntries.map((entry) => (
|
||||
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
|
||||
))}
|
||||
{semanticLegendEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Select, StatusBadge } from "@nodedc/ui-react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Icon, IconButton, Select, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
@@ -146,78 +146,94 @@ export function LaboratorySummary({
|
||||
method?: LaboratoryMethod | null;
|
||||
}) {
|
||||
const methodComplete = method?.completeness === "complete";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const detailsId = useId();
|
||||
return (
|
||||
<section className="laboratory-summary">
|
||||
<section className="laboratory-summary" data-expanded={expanded ? "true" : undefined}>
|
||||
<header>
|
||||
<div>
|
||||
<div className="laboratory-summary__heading">
|
||||
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
<div className="laboratory-summary__actions">
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
<IconButton
|
||||
label={expanded ? "Свернуть подробности лабораторной работы" : "Раскрыть подробности лабораторной работы"}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="laboratory-summary__toggle-glyph" aria-hidden="true">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<dl className="laboratory-summary__brief">
|
||||
<div>
|
||||
<dt>Задача</dt>
|
||||
<dd>{brief.question}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Как проверяли</dt>
|
||||
<dd>{brief.approach}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Главный результат</dt>
|
||||
<dd>{brief.principalResult}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ограничение</dt>
|
||||
<dd>{brief.limitation}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
<div id={detailsId} className="laboratory-summary__details" hidden={!expanded}>
|
||||
<p className="laboratory-summary__description">{description}</p>
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<dl className="laboratory-summary__brief">
|
||||
<div>
|
||||
<dt>Задача</dt>
|
||||
<dd>{brief.question}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Как проверяли</dt>
|
||||
<dd>{brief.approach}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Главный результат</dt>
|
||||
<dd>{brief.principalResult}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ограничение</dt>
|
||||
<dd>{brief.limitation}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { SplitPane } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
export const LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT =
|
||||
"missioncore.laboratory-recorded-clip-viewer/v1" as const;
|
||||
|
||||
export interface LaboratoryRecordedClipFrame {
|
||||
sequence: number;
|
||||
sourceTimeNs: number;
|
||||
}
|
||||
|
||||
export function nearestLaboratoryRecordedClipFrame(
|
||||
frames: readonly LaboratoryRecordedClipFrame[],
|
||||
sourceTimeNs: number,
|
||||
): LaboratoryRecordedClipFrame | null {
|
||||
if (!frames.length || !Number.isFinite(sourceTimeNs)) return null;
|
||||
let left = 0;
|
||||
let right = frames.length - 1;
|
||||
while (left < right) {
|
||||
const middle = Math.floor((left + right) / 2);
|
||||
if (frames[middle]!.sourceTimeNs < sourceTimeNs) left = middle + 1;
|
||||
else right = middle;
|
||||
}
|
||||
const next = frames[left]!;
|
||||
const previous = frames[Math.max(0, left - 1)]!;
|
||||
return Math.abs(previous.sourceTimeNs - sourceTimeNs)
|
||||
<= Math.abs(next.sourceTimeNs - sourceTimeNs)
|
||||
? previous
|
||||
: next;
|
||||
}
|
||||
|
||||
export function laboratoryRecordedClipEndExclusiveNs(
|
||||
frames: readonly LaboratoryRecordedClipFrame[],
|
||||
): number | null {
|
||||
const last = frames.at(-1);
|
||||
if (!last) return null;
|
||||
const deltas = frames.slice(1).flatMap((frame, index) => {
|
||||
const delta = frame.sourceTimeNs - frames[index]!.sourceTimeNs;
|
||||
return Number.isSafeInteger(delta) && delta > 0 ? [delta] : [];
|
||||
}).sort((left, right) => left - right);
|
||||
const typicalDelta = deltas.length
|
||||
? deltas[Math.floor(deltas.length / 2)]!
|
||||
: 100_000_000;
|
||||
return last.sourceTimeNs + typicalDelta;
|
||||
}
|
||||
|
||||
export function LaboratoryRecordedClipPlayer({
|
||||
source,
|
||||
segmentCount,
|
||||
frames,
|
||||
sequence,
|
||||
playing,
|
||||
playbackRate,
|
||||
cameraPresentation,
|
||||
continuousPlayback,
|
||||
sourceCount,
|
||||
cameraRef,
|
||||
cameraOverlay,
|
||||
alternativeScene,
|
||||
onSequenceChange,
|
||||
onPlayingChange,
|
||||
onPlaybackRateChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
segmentCount: number;
|
||||
frames: readonly LaboratoryRecordedClipFrame[];
|
||||
sequence: number;
|
||||
playing: boolean;
|
||||
playbackRate: number;
|
||||
cameraPresentation: "primary" | "companion" | "hidden";
|
||||
continuousPlayback: boolean;
|
||||
sourceCount: number;
|
||||
cameraRef?: RefObject<HTMLDivElement | null>;
|
||||
cameraOverlay?: ReactNode;
|
||||
alternativeScene?: ReactNode;
|
||||
onSequenceChange: (sequence: number) => void;
|
||||
onPlayingChange: (playing: boolean) => void;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
}) {
|
||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||
const lastEmittedSequenceRef = useRef(sequence);
|
||||
lastEmittedSequenceRef.current = sequence;
|
||||
const frame = useMemo(
|
||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||
[frames, sequence],
|
||||
);
|
||||
const endExclusiveNs = useMemo(
|
||||
() => laboratoryRecordedClipEndExclusiveNs(frames),
|
||||
[frames],
|
||||
);
|
||||
const playback = useMemo<RecordedObservationPlayback | null>(() => frame ? ({
|
||||
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
|
||||
playing: continuousPlayback && playing,
|
||||
rate: playbackRate,
|
||||
}) : null, [continuousPlayback, frame, playbackRate, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||
}, [continuousPlayback, onPlayingChange, playing]);
|
||||
|
||||
const emitSequence = useCallback((nextSequence: number) => {
|
||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||
lastEmittedSequenceRef.current = nextSequence;
|
||||
onSequenceChange(nextSequence);
|
||||
}, [onSequenceChange]);
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}, [emitSequence, endExclusiveNs, frames]);
|
||||
|
||||
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
||||
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||
const companionVisible = cameraPresentation === "companion";
|
||||
const spatialSize = companionVisible
|
||||
? companionSpatialSize
|
||||
: cameraPresentation === "primary" ? 0 : 100;
|
||||
const spatialPane = (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player__spatial"
|
||||
aria-hidden={cameraPresentation === "primary"}
|
||||
>
|
||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
||||
</div>
|
||||
);
|
||||
const cameraPane = (
|
||||
<div
|
||||
ref={cameraRef}
|
||||
className="laboratory-recorded-clip-player__camera"
|
||||
aria-hidden={cameraPresentation === "hidden"}
|
||||
>
|
||||
{playback ? (
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive={false}
|
||||
prepare
|
||||
segmentSequence={frame?.sequence}
|
||||
segmentCount={segmentCount}
|
||||
admissionKey={`${source.id}:${source.delivery?.id ?? "recorded"}`}
|
||||
onPlaybackChange={handlePlaybackChange}
|
||||
onPlayingRejected={() => onPlayingChange(false)}
|
||||
/>
|
||||
) : null}
|
||||
{cameraPresentation !== "hidden" ? cameraOverlay : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player"
|
||||
data-contract={LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT}
|
||||
data-camera-presentation={cameraPresentation}
|
||||
>
|
||||
<div className="laboratory-recorded-clip-player__stage">
|
||||
<SplitPane
|
||||
className="laboratory-recorded-clip-player__split"
|
||||
primary={spatialPane}
|
||||
secondary={cameraPane}
|
||||
primarySize={spatialSize}
|
||||
onPrimarySizeChange={setCompanionSpatialSize}
|
||||
orientation="vertical"
|
||||
minPrimarySize={companionVisible ? 24 : 0}
|
||||
minSecondarySize={companionVisible ? 24 : 0}
|
||||
resizable={companionVisible}
|
||||
separatorLabel="Изменить размер 3D/ПЛАН и правой камеры"
|
||||
/>
|
||||
</div>
|
||||
<ObservationTimeline
|
||||
className="laboratory-recorded-clip-player__timeline"
|
||||
active
|
||||
sourceCount={sourceCount}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="frame-accurate"
|
||||
rangeNs={{ min: timelineStart, max: Math.max(timelineEnd, timelineStart + 1) }}
|
||||
currentNs={frame?.sourceTimeNs ?? timelineStart}
|
||||
playing={continuousPlayback && playing}
|
||||
playbackRate={continuousPlayback ? playbackRate : undefined}
|
||||
onPlaybackRateChange={continuousPlayback ? onPlaybackRateChange : undefined}
|
||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||
onSeek={(timeNs) => {
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const FOCUSABLE = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
function keepFocusInside(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
frame: HTMLElement,
|
||||
): void {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(frame.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
frame.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function LaboratoryReviewWorkspaceFrame({
|
||||
ariaLabel,
|
||||
toolbar,
|
||||
stage,
|
||||
inspector,
|
||||
overlays,
|
||||
interactionEnabled = true,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
toolbar: ReactNode;
|
||||
stage: ReactNode;
|
||||
inspector?: ReactNode;
|
||||
overlays?: ReactNode;
|
||||
interactionEnabled?: boolean;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const frameRef = useRef<HTMLElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
const firstFocusable = frameRef.current?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(firstFocusable ?? frameRef.current)?.focus();
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
const target = returnFocusTarget?.isConnected ? returnFocusTarget : previousFocus;
|
||||
window.requestAnimationFrame(() => target?.focus());
|
||||
};
|
||||
}, [returnFocusTarget]);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
ref={frameRef}
|
||||
className="laboratory-review-workspace"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
data-has-inspector={inspector ? "true" : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (!interactionEnabled) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
keepFocusInside(event, event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<header className="laboratory-review-workspace__toolbar">{toolbar}</header>
|
||||
<div className="laboratory-review-workspace__stage">{stage}</div>
|
||||
{inspector ? <footer className="laboratory-review-workspace__inspector">{inspector}</footer> : null}
|
||||
{overlays}
|
||||
</section>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -38,8 +38,14 @@ import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||
import { fetchE47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import { fetchM47ReferenceGraphLab } from "./m47ReferenceGraph";
|
||||
import {
|
||||
fetchM48LifecycleResult,
|
||||
} from "./m48ObjectCentricQuality";
|
||||
import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "m48-object-centric-quality"
|
||||
| "m48-small-static-passage-regression"
|
||||
| "m47-reference-graph-shadow"
|
||||
| "m4-replay-threat"
|
||||
| "l3-pointpillars-visual-audit"
|
||||
@@ -82,6 +88,8 @@ export interface AdvancedLaboratoryIndexItem {
|
||||
}
|
||||
|
||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
"m47-reference-graph-shadow",
|
||||
"m4-replay-threat",
|
||||
"l3-pointpillars-visual-audit",
|
||||
@@ -119,6 +127,8 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
];
|
||||
|
||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"m48-object-centric-quality": "m48-object-quality-(?:pack|result)",
|
||||
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
|
||||
"m47-reference-graph-shadow": "m47-reference-graph-lab",
|
||||
"m4-replay-threat": "m4-threat-replay",
|
||||
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
||||
@@ -164,6 +174,8 @@ export function isAdvancedLaboratoryWorkId(
|
||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m47Graph: null,
|
||||
m48: null,
|
||||
m48SmallStatic: null,
|
||||
m4Threat: null,
|
||||
l3: null,
|
||||
l31: null,
|
||||
@@ -288,7 +300,9 @@ export function advancedLaboratoryResultAvailable(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
): boolean {
|
||||
return workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||
return workId === "m48-object-centric-quality" ? results.m48 !== null
|
||||
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
|
||||
: workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||
: workId === "m4-replay-threat" ? results.m4Threat !== null
|
||||
: workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
: workId === "l31-pointpillars-ravnoves" ? results.l31 !== null
|
||||
@@ -337,7 +351,13 @@ export async function fetchAdvancedLaboratoryResult(
|
||||
} = {},
|
||||
): Promise<AdvancedLaboratoryResults> {
|
||||
const results = emptyAdvancedLaboratoryResults();
|
||||
if (workId === "m47-reference-graph-shadow") {
|
||||
if (workId === "m48-object-centric-quality") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8 lifecycle evidence identity не выбрана.");
|
||||
results.m48 = await fetchM48LifecycleResult(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48-small-static-passage-regression") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R1 regression identity не выбрана.");
|
||||
results.m48SmallStatic = await fetchM48SmallStaticRegression(resultId, { fetcher, signal });
|
||||
} else if (workId === "m47-reference-graph-shadow") {
|
||||
if (!resultId) {
|
||||
throw new AdvancedLaboratoryContractError("M4.7 LAB identity не выбрана.");
|
||||
}
|
||||
|
||||
@@ -34,9 +34,13 @@ import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||
import type { E47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph";
|
||||
import type { M48AdvancedResult } from "./m48ObjectCentricQuality";
|
||||
import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression";
|
||||
|
||||
export interface AdvancedLaboratoryResults {
|
||||
m47Graph: M47ReferenceGraphLabResult | null;
|
||||
m48: M48AdvancedResult | null;
|
||||
m48SmallStatic: M48SmallStaticRegressionResult | null;
|
||||
m4Threat: M4ThreatReplayResult | null;
|
||||
l3: L3PointPillarsVisualAuditResult | null;
|
||||
l31: L31PointPillarsRavnovesResult | null;
|
||||
|
||||
@@ -967,7 +967,7 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
const e39 = settledCatalogValue(settled[7]);
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
m47Graph: null, m4Threat: null,
|
||||
m47Graph: null, m48: null, m48SmallStatic: null, m4Threat: null,
|
||||
l3: null, l31: null,
|
||||
l32: null,
|
||||
l33: null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,364 @@
|
||||
import type { LaboratoryFetch } from "./advancedResults";
|
||||
import type {
|
||||
M48Authority,
|
||||
M48Freshness,
|
||||
M48GeometryAssociation,
|
||||
M48Motion,
|
||||
M48Threat,
|
||||
M48Visibility,
|
||||
} from "./m48ObjectCentricQuality";
|
||||
|
||||
const RESULT_ID = /^m48-small-static-passage-regression-[a-f0-9]{64}$/;
|
||||
const PACK_ID = /^m48-object-quality-pack-[a-f0-9]{64}$/;
|
||||
const ANCHOR_ID = /^anchor-[a-f0-9]{24}$/;
|
||||
const SAFE_URL = /^\/api\/v1\/laboratory\/m48\/[A-Za-z0-9/_?&=.%:-]+$/;
|
||||
|
||||
export interface M48SmallStaticRegressionMetrics {
|
||||
assistedAnchorCount: number;
|
||||
assistedTrackletCount: number;
|
||||
anchorClipCount: number;
|
||||
requiresAvoidanceOrClearanceCount: number;
|
||||
workerRecalledAnchorCount: number;
|
||||
workerMissedAnchorCount: number;
|
||||
assistedAnchorRecall: number;
|
||||
extentIouThreshold: number;
|
||||
minimumAssistedAnchorRecall: number;
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionResult {
|
||||
resultId: string;
|
||||
packId: string;
|
||||
createdAtUtc: string;
|
||||
runLabel: "M4.8R1";
|
||||
pipelineId: "m48-class-free-object-quality/v1";
|
||||
experimentId: "m48-small-static-passage-regression/v1";
|
||||
accepted: boolean;
|
||||
metrics: M48SmallStaticRegressionMetrics;
|
||||
gates: {
|
||||
anchorSetNonEmpty: boolean;
|
||||
developmentAnchorRecallTarget: boolean;
|
||||
independentTruthAvailable: false;
|
||||
};
|
||||
decision: {
|
||||
state: "accepted-development-regression-baseline" | "failed-development-regression-baseline";
|
||||
summary: string;
|
||||
nextAction: string;
|
||||
};
|
||||
groundTruth: false;
|
||||
independentTruth: false;
|
||||
authority: M48Authority;
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionCaseSummary {
|
||||
anchorId: string;
|
||||
clipId: string;
|
||||
sequence: number;
|
||||
requiresAvoidanceOrClearance: boolean;
|
||||
workerCandidateCount: number;
|
||||
bestIou: number;
|
||||
matchedAtThreshold: boolean;
|
||||
outcome: "recalled" | "missed-assisted-anchor";
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionObject {
|
||||
predictionId: string;
|
||||
extentXyxy: readonly [number, number, number, number];
|
||||
geometryAssociation: M48GeometryAssociation;
|
||||
freshness: M48Freshness;
|
||||
motion: M48Motion;
|
||||
threat: M48Threat;
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionCase {
|
||||
resultId: string;
|
||||
packId: string;
|
||||
anchor: {
|
||||
anchorId: string;
|
||||
clipId: string;
|
||||
objectId: string;
|
||||
sequence: number;
|
||||
extentXyxy: readonly [number, number, number, number];
|
||||
visibility: M48Visibility;
|
||||
geometryAssociation: M48GeometryAssociation;
|
||||
freshness: M48Freshness;
|
||||
motion: M48Motion;
|
||||
threat: M48Threat;
|
||||
requiresAvoidanceOrClearance: boolean;
|
||||
};
|
||||
comparison: M48SmallStaticRegressionCaseSummary & {
|
||||
sourceTimeNs: number;
|
||||
anchorExtentXyxy: readonly [number, number, number, number];
|
||||
workerObjects: readonly M48SmallStaticRegressionObject[];
|
||||
bestPredictionId: string | null;
|
||||
extentIouThreshold: number;
|
||||
};
|
||||
cameraUrl: string | null;
|
||||
spatialUrl: string | null;
|
||||
groundTruth: false;
|
||||
authority: M48Authority;
|
||||
}
|
||||
|
||||
export class M48SmallStaticRegressionContractError extends Error {}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидался список.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact(value: unknown, expected: string | boolean, label: string): void {
|
||||
if (value !== expected) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
}
|
||||
|
||||
function textValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const result = numberValue(value, label);
|
||||
if (!Number.isInteger(result) || result < 0) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидался флаг.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extentValue(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): readonly [number, number, number, number] {
|
||||
const rows = arrayValue(value, label).map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
if (
|
||||
rows.length !== 4
|
||||
|| rows.some((item) => item < 0 || item > 1)
|
||||
|| rows[0]! >= rows[2]!
|
||||
|| rows[1]! >= rows[3]!
|
||||
) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: рамка недопустима.`);
|
||||
}
|
||||
return rows as unknown as readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
function authorityValue(value: unknown): M48Authority {
|
||||
const authority = objectValue(value, "M4.8R1.authority");
|
||||
exact(authority.mode, "replay-simulated", "M4.8R1.authority.mode");
|
||||
exact(authority.physical_live, false, "M4.8R1.authority.physical_live");
|
||||
exact(authority.commands_enabled, false, "M4.8R1.authority.commands_enabled");
|
||||
exact(authority.actuation_allowed, false, "M4.8R1.authority.actuation_allowed");
|
||||
exact(authority.navigation_or_safety_accepted, false, "M4.8R1.authority.navigation_or_safety_accepted");
|
||||
return {
|
||||
mode: "replay-simulated",
|
||||
physicalLive: false,
|
||||
commandsEnabled: false,
|
||||
actuationAllowed: false,
|
||||
navigationOrSafetyAccepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(value: unknown, allowed: readonly T[], label: string): T {
|
||||
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): M48SmallStaticRegressionCaseSummary {
|
||||
const row = objectValue(value, "M4.8R1.case");
|
||||
const anchorId = textValue(row.anchor_id, "M4.8R1.case.anchor_id");
|
||||
if (!ANCHOR_ID.test(anchorId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1.case.anchor_id: нарушена идентичность.");
|
||||
}
|
||||
return {
|
||||
anchorId,
|
||||
clipId: textValue(row.clip_id, "M4.8R1.case.clip_id"),
|
||||
sequence: integerValue(row.sequence, "M4.8R1.case.sequence"),
|
||||
requiresAvoidanceOrClearance: booleanValue(row.requires_avoidance_or_clearance, "M4.8R1.case.requires_avoidance_or_clearance"),
|
||||
workerCandidateCount: integerValue(row.worker_candidate_count, "M4.8R1.case.worker_candidate_count"),
|
||||
bestIou: numberValue(row.best_iou, "M4.8R1.case.best_iou"),
|
||||
matchedAtThreshold: booleanValue(row.matched_at_threshold, "M4.8R1.case.matched_at_threshold"),
|
||||
outcome: enumValue(row.outcome, ["recalled", "missed-assisted-anchor"] as const, "M4.8R1.case.outcome"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM48SmallStaticRegression(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M48SmallStaticRegressionResult> {
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 result identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(`/api/v1/laboratory/m48/regressions/small-static/${encodeURIComponent(resultId)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M48SmallStaticRegressionContractError(`M4.8R1 недоступен: HTTP ${response.status}.`);
|
||||
const payload = objectValue(await response.json(), "M4.8R1");
|
||||
exact(payload.schema_version, "missioncore.m48-small-static-passage-regression-result-view/v1", "M4.8R1.schema_version");
|
||||
const packId = textValue(payload.pack_id, "M4.8R1.pack_id");
|
||||
if (!PACK_ID.test(packId)) throw new M48SmallStaticRegressionContractError("M4.8R1.pack_id: нарушена идентичность.");
|
||||
const metrics = objectValue(payload.metrics, "M4.8R1.metrics");
|
||||
const gates = objectValue(payload.gates, "M4.8R1.gates");
|
||||
const decision = objectValue(payload.decision, "M4.8R1.decision");
|
||||
exact(payload.run_label, "M4.8R1", "M4.8R1.run_label");
|
||||
exact(payload.pipeline_id, "m48-class-free-object-quality/v1", "M4.8R1.pipeline_id");
|
||||
exact(payload.experiment_id, "m48-small-static-passage-regression/v1", "M4.8R1.experiment_id");
|
||||
exact(payload.ground_truth, false, "M4.8R1.ground_truth");
|
||||
exact(payload.independent_truth, false, "M4.8R1.independent_truth");
|
||||
exact(gates.independent_truth_available, false, "M4.8R1.gates.independent_truth_available");
|
||||
return {
|
||||
resultId,
|
||||
packId,
|
||||
createdAtUtc: textValue(payload.created_at_utc, "M4.8R1.created_at_utc"),
|
||||
runLabel: "M4.8R1",
|
||||
pipelineId: "m48-class-free-object-quality/v1",
|
||||
experimentId: "m48-small-static-passage-regression/v1",
|
||||
accepted: booleanValue(payload.accepted, "M4.8R1.accepted"),
|
||||
metrics: {
|
||||
assistedAnchorCount: integerValue(metrics.assisted_anchor_count, "M4.8R1.metrics.assisted_anchor_count"),
|
||||
assistedTrackletCount: integerValue(metrics.assisted_tracklet_count, "M4.8R1.metrics.assisted_tracklet_count"),
|
||||
anchorClipCount: integerValue(metrics.anchor_clip_count, "M4.8R1.metrics.anchor_clip_count"),
|
||||
requiresAvoidanceOrClearanceCount: integerValue(metrics.requires_avoidance_or_clearance_count, "M4.8R1.metrics.requires_avoidance_or_clearance_count"),
|
||||
workerRecalledAnchorCount: integerValue(metrics.worker_recalled_anchor_count, "M4.8R1.metrics.worker_recalled_anchor_count"),
|
||||
workerMissedAnchorCount: integerValue(metrics.worker_missed_anchor_count, "M4.8R1.metrics.worker_missed_anchor_count"),
|
||||
assistedAnchorRecall: numberValue(metrics.assisted_anchor_recall, "M4.8R1.metrics.assisted_anchor_recall"),
|
||||
extentIouThreshold: numberValue(metrics.extent_iou_threshold, "M4.8R1.metrics.extent_iou_threshold"),
|
||||
minimumAssistedAnchorRecall: numberValue(metrics.minimum_assisted_anchor_recall, "M4.8R1.metrics.minimum_assisted_anchor_recall"),
|
||||
},
|
||||
gates: {
|
||||
anchorSetNonEmpty: booleanValue(gates.anchor_set_non_empty, "M4.8R1.gates.anchor_set_non_empty"),
|
||||
developmentAnchorRecallTarget: booleanValue(gates.development_anchor_recall_target, "M4.8R1.gates.development_anchor_recall_target"),
|
||||
independentTruthAvailable: false,
|
||||
},
|
||||
decision: {
|
||||
state: enumValue(decision.state, ["accepted-development-regression-baseline", "failed-development-regression-baseline"] as const, "M4.8R1.decision.state"),
|
||||
summary: textValue(decision.summary, "M4.8R1.decision.summary"),
|
||||
nextAction: textValue(decision.next_action, "M4.8R1.decision.next_action"),
|
||||
},
|
||||
groundTruth: false,
|
||||
independentTruth: false,
|
||||
authority: authorityValue(payload.authority),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM48SmallStaticRegressionCases(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<readonly M48SmallStaticRegressionCaseSummary[]> {
|
||||
if (!RESULT_ID.test(resultId)) throw new M48SmallStaticRegressionContractError("M4.8R1 result identity недопустима.");
|
||||
const response = await fetcher(`/api/v1/laboratory/m48/regressions/small-static/${encodeURIComponent(resultId)}/cases`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M48SmallStaticRegressionContractError(`M4.8R1 cases недоступны: HTTP ${response.status}.`);
|
||||
const payload = objectValue(await response.json(), "M4.8R1 cases");
|
||||
exact(payload.schema_version, "missioncore.m48-small-static-passage-regression-case-catalog/v1", "M4.8R1 cases.schema_version");
|
||||
const cases = arrayValue(payload.cases, "M4.8R1 cases.items").map(parseSummary);
|
||||
if (integerValue(payload.case_count, "M4.8R1 cases.case_count") !== cases.length) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 cases: размер изменился.");
|
||||
}
|
||||
exact(payload.ground_truth, false, "M4.8R1 cases.ground_truth");
|
||||
authorityValue(payload.authority);
|
||||
return cases;
|
||||
}
|
||||
|
||||
export async function fetchM48SmallStaticRegressionCase(
|
||||
resultId: string,
|
||||
anchorId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M48SmallStaticRegressionCase> {
|
||||
if (!RESULT_ID.test(resultId) || !ANCHOR_ID.test(anchorId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(`/api/v1/laboratory/m48/regressions/small-static/${encodeURIComponent(resultId)}/cases/${encodeURIComponent(anchorId)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M48SmallStaticRegressionContractError(`M4.8R1 case недоступен: HTTP ${response.status}.`);
|
||||
const payload = objectValue(await response.json(), "M4.8R1 case");
|
||||
exact(payload.schema_version, "missioncore.m48-small-static-passage-regression-case-view/v1", "M4.8R1 case.schema_version");
|
||||
const anchor = objectValue(payload.anchor, "M4.8R1 case.anchor");
|
||||
const comparison = objectValue(payload.comparison, "M4.8R1 case.comparison");
|
||||
const summary = parseSummary(comparison);
|
||||
const workerObjects = arrayValue(comparison.worker_objects, "M4.8R1 case.worker_objects").map((raw) => {
|
||||
const object = objectValue(raw, "M4.8R1 worker object");
|
||||
return {
|
||||
predictionId: textValue(object.prediction_id, "M4.8R1 worker object.prediction_id"),
|
||||
extentXyxy: extentValue(object.extent_xyxy, "M4.8R1 worker object.extent_xyxy"),
|
||||
geometryAssociation: enumValue(object.geometry_association, ["associated", "unavailable", "ineligible", "unknown"] as const, "M4.8R1 worker object.geometry_association"),
|
||||
freshness: enumValue(object.freshness, ["current", "held", "stale", "unavailable"] as const, "M4.8R1 worker object.freshness"),
|
||||
motion: enumValue(object.motion, ["moving", "static", "unknown", "unsupported"] as const, "M4.8R1 worker object.motion"),
|
||||
threat: enumValue(object.threat, ["threat", "not-threat", "unknown"] as const, "M4.8R1 worker object.threat"),
|
||||
};
|
||||
});
|
||||
const cameraUrl = payload.camera_url === null ? null : textValue(payload.camera_url, "M4.8R1 case.camera_url");
|
||||
const spatialUrl = payload.spatial_url === null ? null : textValue(payload.spatial_url, "M4.8R1 case.spatial_url");
|
||||
if ((cameraUrl && !SAFE_URL.test(cameraUrl)) || (spatialUrl && !SAFE_URL.test(spatialUrl))) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case evidence URL недопустим.");
|
||||
}
|
||||
exact(payload.ground_truth, false, "M4.8R1 case.ground_truth");
|
||||
const packId = textValue(payload.pack_id, "M4.8R1 case.pack_id");
|
||||
if (!PACK_ID.test(packId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case.pack_id: нарушена идентичность.");
|
||||
}
|
||||
if (textValue(anchor.anchor_id, "M4.8R1 case.anchor.anchor_id") !== anchorId) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case.anchor_id: ответ не соответствует запросу.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
packId,
|
||||
anchor: {
|
||||
anchorId: textValue(anchor.anchor_id, "M4.8R1 case.anchor.anchor_id"),
|
||||
clipId: textValue(anchor.clip_id, "M4.8R1 case.anchor.clip_id"),
|
||||
objectId: textValue(anchor.object_id, "M4.8R1 case.anchor.object_id"),
|
||||
sequence: integerValue(anchor.sequence, "M4.8R1 case.anchor.sequence"),
|
||||
extentXyxy: extentValue(anchor.extent_xyxy, "M4.8R1 case.anchor.extent_xyxy"),
|
||||
visibility: enumValue(anchor.visibility, ["visible", "partial", "occluded"] as const, "M4.8R1 case.anchor.visibility"),
|
||||
geometryAssociation: enumValue(anchor.geometry_association, ["associated", "unavailable", "ineligible", "unknown"] as const, "M4.8R1 case.anchor.geometry_association"),
|
||||
freshness: enumValue(anchor.freshness, ["current", "held", "stale", "unavailable"] as const, "M4.8R1 case.anchor.freshness"),
|
||||
motion: enumValue(anchor.motion, ["moving", "static", "unknown", "unsupported"] as const, "M4.8R1 case.anchor.motion"),
|
||||
threat: enumValue(anchor.threat, ["threat", "not-threat", "unknown"] as const, "M4.8R1 case.anchor.threat"),
|
||||
requiresAvoidanceOrClearance: booleanValue(anchor.requires_avoidance_or_clearance, "M4.8R1 case.anchor.requires_avoidance_or_clearance"),
|
||||
},
|
||||
comparison: {
|
||||
...summary,
|
||||
sourceTimeNs: integerValue(comparison.source_time_ns, "M4.8R1 case.comparison.source_time_ns"),
|
||||
anchorExtentXyxy: extentValue(comparison.anchor_extent_xyxy, "M4.8R1 case.comparison.anchor_extent_xyxy"),
|
||||
workerObjects,
|
||||
bestPredictionId: comparison.best_prediction_id === null ? null : textValue(comparison.best_prediction_id, "M4.8R1 case.comparison.best_prediction_id"),
|
||||
extentIouThreshold: numberValue(comparison.extent_iou_threshold, "M4.8R1 case.comparison.extent_iou_threshold"),
|
||||
},
|
||||
cameraUrl,
|
||||
spatialUrl,
|
||||
groundTruth: false,
|
||||
authority: authorityValue(payload.authority),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
@import "./styles/shell.css";
|
||||
@import "./styles/workspaces.css";
|
||||
@import "./styles/laboratory.css";
|
||||
@import "./styles/laboratory-evidence-viewer.css";
|
||||
@import "./styles/laboratory-recorded-clip-player.css";
|
||||
@import "./styles/laboratory-review-workspace.css";
|
||||
@import "./styles/e40-case-review.css";
|
||||
@import "./styles/l3-pointpillars-visual-audit.css";
|
||||
@import "./styles/l34-annotation.css";
|
||||
@@ -10,6 +13,7 @@
|
||||
@import "./styles/laboratory-evidence-report.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/m4-replay-threat.css";
|
||||
@import "./styles/m48-object-centric-quality.css";
|
||||
@import "./styles/e35-degradation-recovery.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"] {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header-context {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__controls {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__stage {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__transport {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.laboratory-recorded-clip-player {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__stage,
|
||||
.laboratory-recorded-clip-player__split,
|
||||
.laboratory-recorded-clip-player__spatial,
|
||||
.laboratory-recorded-clip-player__camera {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__stage {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__split,
|
||||
.laboratory-recorded-clip-player__split > .nodedc-split-pane__panel,
|
||||
.laboratory-recorded-clip-player__spatial,
|
||||
.laboratory-recorded-clip-player__camera {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__camera {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__split
|
||||
> .nodedc-split-pane__separator::before {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__split
|
||||
> .nodedc-split-pane__separator:hover::before,
|
||||
.laboratory-recorded-clip-player__split
|
||||
> .nodedc-split-pane__separator:focus-visible::before,
|
||||
.laboratory-recorded-clip-player__split[data-dragging="true"]
|
||||
> .nodedc-split-pane__separator::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__camera > .recorded-media-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player
|
||||
> .laboratory-recorded-clip-player__timeline.observation-timeline {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
padding: 0.45rem 0.7rem;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player
|
||||
> .laboratory-recorded-clip-player__timeline
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto auto auto minmax(12rem, 1fr) auto;
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-recorded-clip-player {
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-recorded-clip-player__stage,
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-recorded-clip-player > .observation-timeline {
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.laboratory-recorded-clip-player
|
||||
> .laboratory-recorded-clip-player__timeline
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,32 @@
|
||||
.laboratory-summary > header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.laboratory-summary__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-summary__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--nodedc-space-3);
|
||||
}
|
||||
|
||||
.laboratory-summary__toggle-glyph {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
transition: transform var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.laboratory-summary[data-expanded="true"] .laboratory-summary__toggle-glyph {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.laboratory-summary__details[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.laboratory-summary .laboratory-summary__brief {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
.laboratory-review-workspace {
|
||||
position: fixed;
|
||||
z-index: var(--nodedc-layer-overlay);
|
||||
inset: 0;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
background: var(--nodedc-canvas);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.laboratory-review-workspace[data-has-inspector="true"] {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.laboratory-review-workspace__toolbar,
|
||||
.laboratory-review-workspace__inspector {
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.55rem 0.7rem;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.laboratory-review-workspace__stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-review-workspace__toolbar,
|
||||
.laboratory-review-workspace__inspector {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -93,11 +93,14 @@
|
||||
.laboratory-summary > header,
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary > header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.laboratory-summary h2,
|
||||
.laboratory-summary p,
|
||||
.laboratory-summary dl,
|
||||
@@ -115,10 +118,10 @@
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.laboratory-summary > header p,
|
||||
.laboratory-summary__description,
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
margin-top: 0.65rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.55;
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
.m48-review-workspace__header {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--nodedc-space-3);
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar,
|
||||
.m48-review-workspace__topbar-start,
|
||||
.m48-review-workspace__topbar-end,
|
||||
.m48-review-workspace__clip-navigation,
|
||||
.m48-review-workspace__source-heading,
|
||||
.m48-review-workspace__evidence-summary,
|
||||
.m48-review-workspace__object-tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-start,
|
||||
.m48-review-workspace__topbar-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-start {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-navigation {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-field {
|
||||
width: clamp(15rem, 19vw, 19rem);
|
||||
flex: 0 1 19rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-end {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-reviewed {
|
||||
width: clamp(14rem, 16vw, 20rem);
|
||||
flex: 0 1 clamp(14rem, 16vw, 20rem);
|
||||
}
|
||||
|
||||
.m48-review-workspace__state,
|
||||
.m48-atlas-visual__state,
|
||||
.m48-clip-player__state {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.m48-review-workspace__evidence-summary {
|
||||
flex-wrap: wrap;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__object-tools {
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__object-field {
|
||||
width: 8rem;
|
||||
min-width: 7.25rem;
|
||||
max-width: 9.25rem;
|
||||
flex: 1 1 8rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__object-field--wide {
|
||||
width: 11.5rem;
|
||||
min-width: 10.5rem;
|
||||
max-width: 13rem;
|
||||
flex-basis: 11.5rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__passage-field {
|
||||
width: clamp(15rem, 18vw, 18rem);
|
||||
min-width: 15rem;
|
||||
flex: 1 1 15rem;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__passage-field > .nodedc-checker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m48-review-workspace__stage-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m48-evidence-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m48-evidence-stage > .laboratory-recorded-clip-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m48-evidence-mode-rail {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 50%;
|
||||
left: var(--nodedc-space-4);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.m48-review-workspace__source-sticker {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 3.15rem;
|
||||
left: 0.75rem;
|
||||
display: grid;
|
||||
width: min(31rem, calc(100% - 1.5rem));
|
||||
gap: 0.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m48-review-workspace__source-sticker small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__source-sticker strong {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__freeze-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m48-clip-player__spatial-pane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m48-clip-player__spatial-pane > .laboratory-metric-evidence-scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m48-clip-player__pane-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0.6rem;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.38rem 0.52rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.52rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.m48-clip-player__pane-label[data-pane="spatial"] {
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.m48-clip-player__pane-label[data-pane="camera"] {
|
||||
right: 0.6rem;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player[data-camera-presentation="companion"]
|
||||
.m48-clip-player__pane-label[data-pane="camera"] {
|
||||
top: 4.35rem;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay[data-drawing="true"] {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay g rect,
|
||||
.m48-clip-player__draft-box {
|
||||
fill: transparent;
|
||||
stroke: rgb(var(--nodedc-accent-rgb));
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay g[data-selected="true"] rect {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay:not([data-drawing="true"]) g rect {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.m48-clip-player__resize-handle {
|
||||
fill: rgb(var(--nodedc-accent-rgb));
|
||||
stroke: var(--nodedc-canvas);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.m48-clip-player__resize-handle[data-handle="nw"],
|
||||
.m48-clip-player__resize-handle[data-handle="se"] {
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.m48-clip-player__resize-handle[data-handle="ne"],
|
||||
.m48-clip-player__resize-handle[data-handle="sw"] {
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.m48-clip-player__draft-box {
|
||||
stroke-dasharray: 7 5;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay text {
|
||||
fill: var(--nodedc-text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
paint-order: stroke;
|
||||
stroke: var(--nodedc-canvas);
|
||||
stroke-width: 3;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m48-evidence-mode-controls {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: var(--nodedc-space-2);
|
||||
}
|
||||
|
||||
.m48-evidence-mode-controls__text {
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene > img,
|
||||
.m48-atlas-visual__scene > canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene > img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__case {
|
||||
display: grid;
|
||||
max-width: min(34rem, 65%);
|
||||
gap: 0.15rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.55rem 0.7rem;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header-context
|
||||
.m48-atlas-visual__case {
|
||||
max-width: 38rem;
|
||||
background: transparent;
|
||||
padding: 0 0.15rem;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__case strong {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__case small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.m48-review-workspace__topbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-end {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-field {
|
||||
width: min(100%, 19rem);
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-start,
|
||||
.m48-review-workspace__object-tools {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-reviewed,
|
||||
.m48-review-workspace__passage-field {
|
||||
width: min(100%, 28rem);
|
||||
flex-basis: min(100%, 28rem);
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -400,10 +400,17 @@ i[data-availability="error"] {
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-select-anchor {
|
||||
width: 7.5rem;
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.observation-timeline__transport {
|
||||
width: var(--nodedc-control-height-compact);
|
||||
padding: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
@@ -42,6 +42,8 @@ import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult
|
||||
import { E47SemanticSlamResultView } from "./E47SemanticSlamResult";
|
||||
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
||||
import { M47ReferenceGraphResultView } from "./M47ReferenceGraphResult";
|
||||
import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult";
|
||||
import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult";
|
||||
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
@@ -84,6 +86,12 @@ export function AdvancedLaboratoryResult({
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "m48-object-centric-quality" && results.m48) {
|
||||
return <M48ObjectCentricQualityResultView rigLabel={rigLabel} result={results.m48} />;
|
||||
}
|
||||
if (workId === "m48-small-static-passage-regression" && results.m48SmallStatic) {
|
||||
return <M48SmallStaticPassageRegressionResultView rigLabel={rigLabel} result={results.m48SmallStatic} />;
|
||||
}
|
||||
if (workId === "m47-reference-graph-shadow" && results.m47Graph) {
|
||||
return <M47ReferenceGraphResultView rigLabel={rigLabel} result={results.m47Graph} />;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useLaboratoryValueReviewIndex } from "./useLaboratoryValueReviewIndex";
|
||||
import { useLaboratoryEvidenceReport } from "./useLaboratoryEvidenceReport";
|
||||
import { useLaboratoryViewMode } from "./useLaboratoryViewMode";
|
||||
import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability";
|
||||
import { useM48ReviewCapability } from "./annotation/useM48ReviewCapability";
|
||||
import {
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
@@ -526,6 +527,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
});
|
||||
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
const m48Review = useM48ReviewCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", initialGate: advancedResults.m48?.kind === "review" ? advancedResults.m48 : null, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
@@ -825,7 +827,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
<div className="laboratory-work-output">
|
||||
{!m48Review.active ? <div className="laboratory-work-output">
|
||||
{workId === "e28-local-surface" ? (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
@@ -947,8 +949,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div> : null}
|
||||
{annotationWorkspace}
|
||||
{m48Review.workspace}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
} from "../../components/laboratory/RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
fetchM48FailureAtlas,
|
||||
fetchM48FailureCase,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
type M48FailureCase,
|
||||
type M48FailureCaseSummary,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./annotation/M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./annotation/useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./annotation/M48EvidenceModeControls";
|
||||
|
||||
const ATLAS_MODES = [
|
||||
{ value: "source", label: "SOURCE" },
|
||||
{ value: "truth", label: "TRUTH" },
|
||||
{ value: "graph", label: "GRAPH" },
|
||||
{ value: "overlay", label: "OVERLAY" },
|
||||
] as const;
|
||||
type AtlasMode = typeof ATLAS_MODES[number]["value"];
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : "M4.8 evidence недоступно.";
|
||||
}
|
||||
|
||||
function FailureAtlasScene({ item, mode }: { item: M48FailureCase; mode: AtlasMode }) {
|
||||
const [size, setSize] = useState({ width: 1440, height: 1080 });
|
||||
const boxes = useMemo<RecordedEvidenceBox[]>(() => {
|
||||
const truth = mode === "truth" || mode === "overlay"
|
||||
? item.truth.map((object) => ({
|
||||
boxXyxy: [object.extentXyxy[0] * size.width, object.extentXyxy[1] * size.height, object.extentXyxy[2] * size.width, object.extentXyxy[3] * size.height] as const,
|
||||
label: `truth · ${object.objectId}`,
|
||||
tone: "success" as const,
|
||||
}))
|
||||
: [];
|
||||
const graph = mode === "graph" || mode === "overlay"
|
||||
? item.graph.map((object) => ({
|
||||
boxXyxy: [object.extentXyxy[0] * size.width, object.extentXyxy[1] * size.height, object.extentXyxy[2] * size.width, object.extentXyxy[3] * size.height] as const,
|
||||
label: `graph · ${object.objectId}`,
|
||||
tone: "danger" as const,
|
||||
dashed: mode === "overlay",
|
||||
}))
|
||||
: [];
|
||||
return [...truth, ...graph];
|
||||
}, [item.graph, item.truth, mode, size.height, size.width]);
|
||||
|
||||
if (!item.frame.cameraUrl) {
|
||||
return <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />Точный camera-кадр failure case недоступен.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="m48-atlas-visual__scene">
|
||||
<img
|
||||
src={item.frame.cameraUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={(event) => setSize({
|
||||
width: event.currentTarget.naturalWidth || 1440,
|
||||
height: event.currentTarget.naturalHeight || 1080,
|
||||
})}
|
||||
/>
|
||||
<RecordedEvidenceBoxOverlay imageWidth={size.width} imageHeight={size.height} boxes={boxes} ariaLabel="M4.8 truth/graph failure overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48FailureAtlasVisual({ resultId }: { resultId: string }) {
|
||||
const [cases, setCases] = useState<readonly M48FailureCaseSummary[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [item, setItem] = useState<M48FailureCase | null>(null);
|
||||
const [mode, setMode] = useState<AtlasMode>("overlay");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48FailureAtlas(resultId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setCases(next);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = cases[index];
|
||||
if (!selected) {
|
||||
setItem(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48FailureCase(resultId, selected.caseId, { signal: controller.signal })
|
||||
.then((next) => !controller.signal.aborted && setItem(next))
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [cases, index, resultId]);
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8 failure atlas"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={ATLAS_MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={(
|
||||
<>
|
||||
<IconButton label="Предыдущий failure case" disabled={!cases.length} onClick={() => setIndex((current) => (current - 1 + cases.length) % cases.length)}><Icon name="chevron-left" size={16} /></IconButton>
|
||||
<IconButton label="Следующий failure case" disabled={!cases.length} onClick={() => setIndex((current) => (current + 1) % cases.length)}><Icon name="chevron-right" size={16} /></IconButton>
|
||||
</>
|
||||
)}
|
||||
overlay={item ? <div className="m48-atlas-visual__case"><StatusBadge tone={item.split === "development" ? "neutral" : item.severity === "critical" ? "danger" : "warning"}>{item.split.toUpperCase()} · {item.severity}</StatusBadge><strong>{item.clipId} · frame {item.sequence}</strong><small>{item.split === "development" ? "Diagnostic only · " : "Validation acceptance evidence · "}{item.failures.join(" · ")}</small></div> : null}
|
||||
>
|
||||
{loading ? <div className="m48-atlas-visual__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем bounded failure case</div>
|
||||
: error ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
: item ? <FailureAtlasScene item={item} mode={mode} />
|
||||
: <div className="m48-atlas-visual__state" role="status"><Icon name="check" size={18} />Failure atlas пуст: ни один bounded failure case не зафиксирован.</div>}
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48ReviewPackVisual({ packId }: { packId: string }) {
|
||||
const [catalog, setCatalog] = useState<Awaited<ReturnType<typeof fetchM48ReviewSourceCatalog>> | null>(null);
|
||||
const [clipIndex, setClipIndex] = useState(0);
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchM48ReviewSourceCatalog(packId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
if (next.clips[0]) setSequence(next.clips[0].startSequence);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)));
|
||||
return () => controller.abort();
|
||||
}, [packId]);
|
||||
|
||||
const clip = catalog?.clips[clipIndex] ?? null;
|
||||
const spatialEnabled = Boolean(catalog?.evidenceCapabilities.currentPointCloudBodyXyzM && catalog.evidenceCapabilities.rig && catalog.evidenceCapabilities.virtualCorridor);
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8 neutral review pack"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={[]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={false}
|
||||
chromeLayout="stacked"
|
||||
actions={<><IconButton label="Предыдущий клип" disabled={!catalog} onClick={() => {
|
||||
if (!catalog) return;
|
||||
const next = (clipIndex - 1 + catalog.clips.length) % catalog.clips.length;
|
||||
setClipIndex(next);
|
||||
setSequence(catalog.clips[next]!.startSequence);
|
||||
}}><Icon name="chevron-left" size={16} /></IconButton><IconButton label="Следующий клип" disabled={!catalog} onClick={() => {
|
||||
if (!catalog) return;
|
||||
const next = (clipIndex + 1) % catalog.clips.length;
|
||||
setClipIndex(next);
|
||||
setSequence(catalog.clips[next]!.startSequence);
|
||||
}}><Icon name="chevron-right" size={16} /></IconButton></>}
|
||||
overlay={clip ? <div className="m48-atlas-visual__case"><StatusBadge tone="neutral">SOURCE ONLY</StatusBadge><strong>{clip.ordinal}/{catalog?.clipCount} · {clip.clipId}</strong><small>0 classes · 0 candidate identity · 0 model predictions</small></div> : null}
|
||||
>
|
||||
<div className="m48-evidence-stage">
|
||||
{error ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
: clip && catalog?.cameraPlayback ? <M48BlindClipPlayer cameraPlayback={catalog.cameraPlayback} clip={clip} sequence={sequence} mode={mode} cameraVisible={cameraVisible} tracklets={[]} selectedObjectId={null} editable={false} drawing={false} spatialFrame={spatial} spatialLoading={spatialLoading} spatialError={spatialError} spatialEvidenceAvailable={spatialEnabled} onSequenceChange={setSequence} onDrawingChange={() => undefined} onSelectedObjectIdChange={() => undefined} onTrackletsChange={() => undefined} />
|
||||
: catalog ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />Pack-bound camera playback недоступен.</div>
|
||||
: <div className="m48-atlas-visual__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем source-only clip pack</div>}
|
||||
{catalog ? <M48EvidenceModeRail mode={mode} cameraVisible={cameraVisible} spatialAvailable={spatialEnabled} onModeChange={setMode} onCameraVisibleChange={setCameraVisible} /> : null}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
M48AdvancedResult,
|
||||
M48GateStatus,
|
||||
M48QualityResult,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { M48FailureAtlasVisual, M48ReviewPackVisual } from "./M48FailureAtlasVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
function reviewStatus(result: M48GateStatus): { text: string; tone: "neutral" | "warning" | "success" } {
|
||||
if (result.evaluated) return { text: "Evaluation завершена · откройте финальный M4.8 result", tone: "success" };
|
||||
if (result.correctionState === "frozen") return { text: "24/24 клипов проверено · assisted evidence Worker 006 зафиксировано", tone: "success" };
|
||||
if (result.correctionState !== "not-started") return { text: `${result.correctionReviewedClipCount}/${result.clipCount} клипов проверено · исправляем авторазметку Worker 006`, tone: "warning" };
|
||||
if (result.adjudicationFrozen) return { text: "Truth seal зафиксирован · готово к evaluation", tone: "success" };
|
||||
if (result.adjudicationUnlocked) return { text: "2/2 независимых review · открыта adjudication", tone: "warning" };
|
||||
if (result.frozenReviewerCount > 0) return { text: `${result.frozenReviewerCount}/2 независимых review зафиксировано · quality verdict ещё отсутствует`, tone: "warning" };
|
||||
return { text: "Проверочный набор готов · корректность object graph ещё не измерена", tone: "warning" };
|
||||
}
|
||||
|
||||
function ReviewResult({ rigLabel, result }: { rigLabel: string; result: M48GateStatus }) {
|
||||
const status = reviewStatus(result);
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8 · проверяем, видит ли система реальные объекты"
|
||||
description="Worker 006 уже поставил рамки на связанных кадрах RIGHT-камеры. M4.8 показывает его ответ поверх синхронных CAMERA + LiDAR: оператор подтверждает правильные рамки и исправляет только false positive, miss и неточную геометрию."
|
||||
status={status.text}
|
||||
statusTone={status.tone}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · camera + prediction-free current spatial evidence` },
|
||||
{ label: "Покрытие", value: `${result.clipCount} клипов · ${result.frameCount} кадров` },
|
||||
{ label: "Авторазметка", value: `Worker 006 · ${result.seedObjectCount.toLocaleString("ru-RU")} frozen-рамок` },
|
||||
{ label: "Correction", value: `${result.correctionReviewedClipCount}/${result.clipCount} клипов · ${result.correctionState}` },
|
||||
{ label: "Authority", value: "REPLAY-SIMULATED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Не пропускает ли система реальный объект, не придумывает ли лишний, не теряет ли его между кадрами и правильно ли понимает геометрию, свежесть, движение и опасность в коридоре движения?",
|
||||
approach: "RIGHT-камера, рамки Worker 006 и синхронный LiDAR идут на одном таймлайне. На каждом из 24 клипов оператор удаляет лишнее, добавляет пропущенное, двигает или ресайзит неточную рамку и подтверждает клип.",
|
||||
principalResult: result.correctionState === "frozen"
|
||||
? "Проверка зафиксирована: исходный seed и все ручные изменения связаны одной immutable дельтой."
|
||||
: `Готовы ${result.seedObjectCount.toLocaleString("ru-RU")} автоматических рамок на ${result.frameCount} кадрах; ручная работа начинается с результата системы, а не с пустого кадра.`,
|
||||
limitation: "Correction выполнен с видимым ответом Worker 006 и потому не является independent ground truth. Отдельный двухрецензентный truth seal всё ещё нужен для формального quality gate; physical live, навигация и команды моторам запрещены.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "m48-class-free-object-quality/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "immutable connected clips", role: "camera/current-spatial evidence + frozen Worker 006 seed", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "frozen-candidate-seeded correction", version: "object-tracklet/v1", role: "editable assisted evidence without semantic classes", identitySha256: null },
|
||||
{ kind: "algorithm", name: "independent dual review + adjudication", version: "release-gate/v1", role: "separate formal truth-seal workflow", identitySha256: result.truthSealId?.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8 VISUAL EVIDENCE · CAMERA + 3D" title="Один объект в RIGHT-камере и LiDAR на общем времени" kind="recorded-replay" resizable>
|
||||
<M48ReviewPackVisual packId={result.packId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Что дал прогон: Worker 006 поставил рамки; оператор проверяет только ошибки"
|
||||
status={status.text}
|
||||
statusTone={status.tone}
|
||||
metrics={[
|
||||
{ label: "Worker boxes", value: result.seedObjectCount.toLocaleString("ru-RU"), hint: `${result.frameCount} кадров · immutable seed` },
|
||||
{ label: "Clips checked", value: `${result.correctionReviewedClipCount}/${result.clipCount}`, hint: "operator correction progress" },
|
||||
{ label: "Assisted evidence", value: result.correctionState === "frozen" ? "FROZEN" : "OPEN", hint: "candidate-visible · not independent truth" },
|
||||
{ label: "Authority", value: "OFF", hint: "physical live · commands · actuation" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Worker 006 воспроизводимо выдал ${result.seedObjectCount.toLocaleString("ru-RU")} рамок; CAMERA, 3D/PLAN и рамки связаны одним временем. После freeze будет сохранена точная дельта подтверждений, исправлений, false positive и miss.`,
|
||||
notProved: "Пока оператор не проверил 24/24 клипа, корректность этих рамок не подтверждена. Даже завершённый assisted correction не заменяет независимую ground truth и не даёт motor/planner authority.",
|
||||
decision: result.correctionState === "frozen" ? "Assisted regression evidence закрыто; для формального release gate отдельно выполнить два blind review и adjudication." : "Открыть проверку Worker 006, пройти все 24 клипа, исправить ошибки и зафиксировать evidence.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityResult({ rigLabel, result }: { rigLabel: string; result: M48QualityResult }) {
|
||||
const gateCount = Object.keys(result.gates).length;
|
||||
const passedGates = Object.values(result.gates).filter(Boolean).length;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8 · object-centric source quality gate"
|
||||
description="Frozen M4.7 graph сопоставлен с adjudicated class-free truth. Acceptance считается только на sealed validation split; development остаётся диагностическим и не может улучшить gate."
|
||||
status={result.accepted ? "Object-centric source quality принята" : "Quality gate не пройден · открыт bounded failure atlas"}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · frozen graph vs adjudicated truth` },
|
||||
{ label: "Pack", value: result.packId },
|
||||
{ label: "Truth seal", value: result.truthSealId },
|
||||
{ label: "Acceptance", value: "VALIDATION ONLY · development informational" },
|
||||
{ label: "Authority", value: "SOURCE-SCOPED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Проходит ли canonical graph минимальный object-centric quality gate на sealed validation split RAVNOVES00 source?",
|
||||
approach: "Per-frame class-free matching выполняется только после двух независимых reviews и adjudication. Gate берёт metrics только из validation; каждый отказ связан с конкретными клипами и кадрами в failure atlas.",
|
||||
principalResult: `${passedGates}/${gateCount} validation gates passed · presence P/R ${percent(result.metrics.obstaclePresencePrecision)} / ${percent(result.metrics.obstaclePresenceRecall)} · ${result.metrics.failureCaseCount} failure cases.`,
|
||||
limitation: "Результат ограничен recorded source и не доказывает realtime live, физическую collision safety, planning или motor control.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "m48-object-centric-quality/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "frozen-before-label-reveal", role: "candidate graph and connected source clips", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "source", name: result.truthSealId, version: "two reviewers + adjudication", role: "class-free object truth", identitySha256: result.truthSealId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "object-centric quality scorer", version: "v1", role: "per-frame matching, gates and failure atlas", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8 VISUAL EVIDENCE · FAILURE ATLAS" title="Точные bounded cases: SOURCE / TRUTH / GRAPH / OVERLAY" kind="diagnostic-model" resizable>
|
||||
<M48FailureAtlasVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title={result.accepted ? "Object-centric gate принят; можно готовить recorded realtime release candidate" : "Gate отклонён; исправления привязаны к bounded failure clusters"}
|
||||
status={`${passedGates}/${gateCount} validation gates · ${result.metrics.failureCaseCount} failure cases · ${result.metrics.unknownPredictionCount} unknown predictions`}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
metrics={[
|
||||
{ label: "Presence P / R", value: `${percent(result.metrics.obstaclePresencePrecision)} / ${percent(result.metrics.obstaclePresenceRecall)}`, hint: "class-free object presence" },
|
||||
{ label: "Critical recall", value: percent(result.metrics.criticalCorridorObstacleRecall), hint: "objects intersecting the virtual corridor" },
|
||||
{ label: "Geometry / freshness", value: `${percent(result.metrics.geometryAssociationCorrectness)} / ${percent(result.metrics.freshnessCorrectness)}`, hint: "adjudicated state correctness" },
|
||||
{ label: "Motion / false not-threat", value: `${percent(result.metrics.motionDecisionCorrectness)} / ${result.metrics.criticalNotThreatCount}`, hint: "critical corridor violations · conservative unknown retained" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `На sealed validation split граф прошёл ${passedGates}/${gateCount} object-centric gates; development metrics не участвовали в acceptance, каждый отказ и unknown имеет frame-level cause.`,
|
||||
notProved: "Не доказаны physical live, измеренная collision safety, navigation planner, motor commands или переносимость на другие маршруты.",
|
||||
decision: result.accepted ? "Открыть M4.9 recorded realtime release-candidate gate без расширения authority." : "Исправлять только кластеры из failure atlas, повторно заморозить candidate до label reveal и пересчитать M4.8.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48ObjectCentricQualityResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M48AdvancedResult;
|
||||
}) {
|
||||
return result.kind === "review"
|
||||
? <ReviewResult rigLabel={rigLabel} result={result} />
|
||||
: <QualityResult rigLabel={rigLabel} result={result} />;
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { M48SmallStaticRegressionResult } from "../../core/laboratory/m48SmallStaticRegression";
|
||||
import { M48SmallStaticRegressionVisual } from "./M48SmallStaticRegressionVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function M48SmallStaticPassageRegressionResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M48SmallStaticRegressionResult;
|
||||
}) {
|
||||
const status = result.accepted
|
||||
? "Development regression target пройден"
|
||||
: `Worker 006 пропустил ${result.metrics.workerMissedAnchorCount}/${result.metrics.assistedAnchorCount} assisted-якорей`;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8R1 · мелкие статические ограничения проезда"
|
||||
description="Отдельный append-only прогон внутри M4.8 проверяет, покрывает ли замороженный Worker 006 вручную добавленные столбики, полусферы, урны и другие малые статические ограничения. Текущий M4.8 correction не изменяется."
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · camera + prediction-free current spatial evidence` },
|
||||
{ label: "Пайплайн", value: result.pipelineId },
|
||||
{ label: "Эксперимент", value: result.experimentId },
|
||||
{ label: "Прогон", value: `${result.runLabel} · immutable ${result.resultId}` },
|
||||
{ label: "Authority", value: "REPLAY-SIMULATED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Находит ли текущий Worker 006 малые статические ограничения прохода, которые оператору пришлось добавить вручную при assisted correction?",
|
||||
approach: `Зафиксирован отдельный снимок ${result.metrics.assistedAnchorCount} operator-added якорей на ${result.metrics.anchorClipCount} клипах. На точном исходном кадре каждый якорь сопоставлен с frozen-ответом Worker 006 по IoU ≥ ${result.metrics.extentIouThreshold.toFixed(2)}.`,
|
||||
principalResult: `${result.metrics.workerRecalledAnchorCount}/${result.metrics.assistedAnchorCount} якорей покрыты Worker 006; ${result.metrics.workerMissedAnchorCount} не имеют совпадающей frozen-рамки.`,
|
||||
limitation: "Это candidate-visible assisted seed, намеренно собранный из ручных добавлений, поэтому он полезен как regression baseline, но не является independent ground truth. Camera bbox не является 3D-коллайдером и не выдаёт planner/safety authority.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "immutable Worker 006 pack", role: "frozen candidate output + exact camera/current-spatial evidence", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "source", name: "M4.8 assisted correction snapshot", version: "revision-bound", role: "operator-added anchors · not independent truth", identitySha256: null },
|
||||
{ kind: "algorithm", name: "small-static assisted-anchor comparator", version: "v1", role: "exact-frame class-free IoU regression", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8R1 VISUAL EVIDENCE · ASSISTED ANCHOR + WORKER" title="Точный кадр: ручной якорь и frozen-ответ Worker 006" kind="recorded-replay" resizable>
|
||||
<M48SmallStaticRegressionVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Что дал прогон: зафиксирован измеримый baseline пропусков малых ограничений"
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
metrics={[
|
||||
{ label: "Assisted recall", value: percent(result.metrics.assistedAnchorRecall), hint: `target ${percent(result.metrics.minimumAssistedAnchorRecall)} · diagnostic only` },
|
||||
{ label: "Recalled / missed", value: `${result.metrics.workerRecalledAnchorCount} / ${result.metrics.workerMissedAnchorCount}`, hint: `IoU ≥ ${result.metrics.extentIouThreshold.toFixed(2)}` },
|
||||
{ label: "Объезд или запас", value: result.metrics.requiresAvoidanceOrClearanceCount.toLocaleString("ru-RU"), hint: "operator-marked small static constraints" },
|
||||
{ label: "Independent truth", value: "НЕТ", hint: "assisted candidate-visible evidence" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Worker 006 детерминированно покрывает ${result.metrics.workerRecalledAnchorCount}/${result.metrics.assistedAnchorCount} вручную добавленных якорей на точных кадрах; результат сохранён отдельно и не перезаписывает correction-сессию.`,
|
||||
notProved: "Не измерены unbiased precision/recall, 3D clearance, проезжаемость конкретного шасси, realtime live или collision safety.",
|
||||
decision: result.accepted
|
||||
? "Сохранить прогон как development baseline и отдельно открыть independent truth evaluation."
|
||||
: "Использовать пропуски как bounded regression set для следующей версии Worker 006; после обновления выполнить новый append-only run на том же снимке и только затем — independent truth gate.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchM48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import {
|
||||
fetchM48SmallStaticRegressionCase,
|
||||
fetchM48SmallStaticRegressionCases,
|
||||
type M48SmallStaticRegressionCase,
|
||||
type M48SmallStaticRegressionCaseSummary,
|
||||
} from "../../core/laboratory/m48SmallStaticRegression";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./annotation/M48BlindClipPlayer";
|
||||
import { M48EvidenceModeRail } from "./annotation/M48EvidenceModeControls";
|
||||
import { useM48SpatialClipPlayback } from "./annotation/useM48SpatialClipPlayback";
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "M4.8R1 evidence недоступно.";
|
||||
}
|
||||
|
||||
function exactFrameTracklets(item: M48SmallStaticRegressionCase): readonly M48ReviewTracklet[] {
|
||||
const sequence = item.anchor.sequence;
|
||||
const state = (
|
||||
objectId: string,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
geometryAssociation: M48ReviewTracklet["stateSegments"][number]["geometryAssociation"],
|
||||
freshness: M48ReviewTracklet["stateSegments"][number]["freshness"],
|
||||
motion: M48ReviewTracklet["stateSegments"][number]["motion"],
|
||||
threat: M48ReviewTracklet["stateSegments"][number]["threat"],
|
||||
criticalCorridorObstacle: boolean,
|
||||
): M48ReviewTracklet => ({
|
||||
objectId,
|
||||
firstSequence: sequence,
|
||||
lastSequence: sequence,
|
||||
keyframes: [{ sequence, extentXyxy, visibility: "visible" }],
|
||||
stateSegments: [{
|
||||
startSequence: sequence,
|
||||
endSequence: sequence,
|
||||
geometryAssociation,
|
||||
freshness,
|
||||
motion,
|
||||
threat,
|
||||
criticalCorridorObstacle,
|
||||
}],
|
||||
notes: null,
|
||||
});
|
||||
|
||||
return [
|
||||
state(
|
||||
`ASSISTED · ${item.anchor.objectId}`,
|
||||
item.anchor.extentXyxy,
|
||||
item.anchor.geometryAssociation,
|
||||
item.anchor.freshness,
|
||||
item.anchor.motion,
|
||||
item.anchor.threat,
|
||||
item.anchor.requiresAvoidanceOrClearance,
|
||||
),
|
||||
...item.comparison.workerObjects.map((object) => state(
|
||||
`WORKER · ${object.predictionId}`,
|
||||
object.extentXyxy,
|
||||
object.geometryAssociation,
|
||||
object.freshness,
|
||||
object.motion,
|
||||
object.threat,
|
||||
false,
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
export function M48SmallStaticRegressionVisual({ resultId }: { resultId: string }) {
|
||||
const [cases, setCases] = useState<readonly M48SmallStaticRegressionCaseSummary[]>([]);
|
||||
const [caseIndex, setCaseIndex] = useState(0);
|
||||
const [item, setItem] = useState<M48SmallStaticRegressionCase | null>(null);
|
||||
const [catalog, setCatalog] = useState<Awaited<ReturnType<typeof fetchM48ReviewSourceCatalog>> | null>(null);
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchM48SmallStaticRegressionCases(resultId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setCases(next);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = cases[caseIndex];
|
||||
if (!selected) {
|
||||
setItem(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchM48SmallStaticRegressionCase(resultId, selected.anchorId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setItem(next);
|
||||
setSequence(next.anchor.sequence);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [caseIndex, cases, resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) return;
|
||||
const controller = new AbortController();
|
||||
setCatalog(null);
|
||||
void fetchM48ReviewSourceCatalog(item.packId, { signal: controller.signal })
|
||||
.then((next) => !controller.signal.aborted && setCatalog(next))
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)));
|
||||
return () => controller.abort();
|
||||
}, [item?.packId]);
|
||||
|
||||
const clip = item && catalog
|
||||
? catalog.clips.find((candidate) => candidate.clipId === item.anchor.clipId) ?? null
|
||||
: null;
|
||||
const spatialEnabled = Boolean(
|
||||
catalog?.evidenceCapabilities.currentPointCloudBodyXyzM
|
||||
&& catalog.evidenceCapabilities.rig
|
||||
&& catalog.evidenceCapabilities.virtualCorridor,
|
||||
);
|
||||
const spatial = useM48SpatialClipPlayback({
|
||||
packId: item?.packId ?? "",
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
const tracklets = useMemo(
|
||||
() => item ? exactFrameTracklets(item) : [],
|
||||
[item],
|
||||
);
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8R1 assisted-anchor regression"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={[]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={false}
|
||||
chromeLayout="stacked"
|
||||
actions={(
|
||||
<>
|
||||
<IconButton
|
||||
label="Предыдущий assisted-якорь"
|
||||
disabled={!cases.length}
|
||||
onClick={() => setCaseIndex((current) => (current - 1 + cases.length) % cases.length)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Следующий assisted-якорь"
|
||||
disabled={!cases.length}
|
||||
onClick={() => setCaseIndex((current) => (current + 1) % cases.length)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
overlay={item ? (
|
||||
<div className="m48-atlas-visual__case">
|
||||
<StatusBadge tone={item.comparison.matchedAtThreshold ? "success" : "warning"}>
|
||||
{item.comparison.matchedAtThreshold ? "WORKER RECALL" : "WORKER MISS"}
|
||||
</StatusBadge>
|
||||
<strong>{caseIndex + 1}/{cases.length} · {item.anchor.clipId} · кадр {item.anchor.sequence}</strong>
|
||||
<small>ASSISTED-якорь, не independent truth · best IoU {item.comparison.bestIou.toFixed(3)}</small>
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<div className="m48-evidence-stage">
|
||||
{loading ? (
|
||||
<div className="m48-atlas-visual__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
Загружаем M4.8R1 bounded case
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
) : clip && catalog?.cameraPlayback && item ? (
|
||||
<M48BlindClipPlayer
|
||||
cameraPlayback={catalog.cameraPlayback}
|
||||
clip={clip}
|
||||
sequence={sequence}
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
tracklets={tracklets}
|
||||
selectedObjectId={`ASSISTED · ${item.anchor.objectId}`}
|
||||
editable={false}
|
||||
drawing={false}
|
||||
spatialFrame={spatial.frame}
|
||||
spatialLoading={spatial.loading}
|
||||
spatialError={spatial.error}
|
||||
spatialEvidenceAvailable={spatialEnabled}
|
||||
onSequenceChange={setSequence}
|
||||
onDrawingChange={() => undefined}
|
||||
onSelectedObjectIdChange={() => undefined}
|
||||
onTrackletsChange={() => undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="m48-atlas-visual__state" role="status">
|
||||
<Icon name="alert" size={18} />Точный источник M4.8R1 недоступен.
|
||||
</div>
|
||||
)}
|
||||
{catalog ? (
|
||||
<M48EvidenceModeRail
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
spatialAvailable={spatialEnabled}
|
||||
onModeChange={setMode}
|
||||
onCameraVisibleChange={setCameraVisible}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createM48AdjudicationSession,
|
||||
evaluateM48Adjudication,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
freezeM48AdjudicationSession,
|
||||
saveM48AdjudicationSession,
|
||||
type M48AdjudicationSession,
|
||||
type M48GateStatus,
|
||||
type M48ReviewClipDraft,
|
||||
type M48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { LaboratoryReviewWorkspaceFrame } from "../../../components/laboratory/LaboratoryReviewWorkspaceFrame";
|
||||
import { M48BlindClipPlayer, type M48BlindEvidenceMode } from "./M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./M48EvidenceModeControls";
|
||||
|
||||
const REVIEW_LAYERS = [
|
||||
{ value: "reviewer-a", label: "REVIEWER A" },
|
||||
{ value: "reviewer-b", label: "REVIEWER B" },
|
||||
{ value: "decision", label: "РЕШЕНИЕ" },
|
||||
] as const;
|
||||
type ReviewLayer = typeof REVIEW_LAYERS[number]["value"];
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : "M4.8 adjudication не выполнена.";
|
||||
}
|
||||
|
||||
function operationKey(packId: string): string {
|
||||
const storageKey = `missioncore:m48:${packId}:adjudication-operation-key`;
|
||||
const current = localStorage.getItem(storageKey);
|
||||
if (current) return current;
|
||||
const created = `adjudication-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(storageKey, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function decisionCopy(clip: M48ReviewClipDraft): M48ReviewClipDraft {
|
||||
return { ...clip, reviewState: "adjudicated", tracklets: clip.tracklets.map((tracklet) => ({ ...tracklet, keyframes: tracklet.keyframes.map((keyframe) => ({ ...keyframe })), stateSegments: tracklet.stateSegments.map((segment) => ({ ...segment })) })) };
|
||||
}
|
||||
|
||||
export function M48AdjudicationWorkspace({
|
||||
gate,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
gate: M48GateStatus;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<M48ReviewSourceCatalog | null>(null);
|
||||
const [session, setSession] = useState<M48AdjudicationSession | null>(null);
|
||||
const [drafts, setDrafts] = useState<ReadonlyMap<string, M48ReviewClipDraft>>(new Map());
|
||||
const [clipId, setClipId] = useState("");
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [reviewLayer, setReviewLayer] = useState<ReviewLayer>("reviewer-a");
|
||||
const [evidenceMode, setEvidenceMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [freezeOpen, setFreezeOpen] = useState(false);
|
||||
const [adjudicatorId, setAdjudicatorId] = useState("");
|
||||
const [resolvedAttested, setResolvedAttested] = useState(false);
|
||||
const [blindAttested, setBlindAttested] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const notify = useCallback((toast: Omit<ToastItem, "id">) => setToasts((current) => [...current, { ...toast, id: crypto.randomUUID() }]), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48ReviewSourceCatalog(gate.packId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
const first = next.clips[0];
|
||||
if (first) {
|
||||
setClipId(first.clipId);
|
||||
setSequence(first.startSequence);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [gate.packId]);
|
||||
|
||||
const clip = useMemo(() => catalog?.clips.find((item) => item.clipId === clipId) ?? null, [catalog, clipId]);
|
||||
const reviewerA = session?.reviewInputs.find(({ reviewerSlot }) => reviewerSlot === 1)?.clips.find((item) => item.clipId === clipId) ?? null;
|
||||
const reviewerB = session?.reviewInputs.find(({ reviewerSlot }) => reviewerSlot === 2)?.clips.find((item) => item.clipId === clipId) ?? null;
|
||||
const decision = drafts.get(clipId) ?? null;
|
||||
const visibleDraft = reviewLayer === "reviewer-a" ? reviewerA : reviewLayer === "reviewer-b" ? reviewerB : decision;
|
||||
const selectedDecisionTracklet = decision?.tracklets.find(({ objectId }) => objectId === selectedObjectId) ?? null;
|
||||
const editable = Boolean(session && reviewLayer === "decision" && !["adjudication-frozen", "evaluated"].includes(session.state));
|
||||
const spatialEnabled = Boolean(catalog?.evidenceCapabilities.currentPointCloudBodyXyzM && catalog.evidenceCapabilities.rig && catalog.evidenceCapabilities.virtualCorridor);
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId: gate.packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: evidenceMode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
const createSession = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await createM48AdjudicationSession(gate.packId, operationKey(gate.packId));
|
||||
setSession(next);
|
||||
setDrafts(new Map(next.clips.map((item) => [item.clipId, item])));
|
||||
notify({ tone: "success", title: "Adjudication открыта", description: "Reviewer A/B видны без model predictions." });
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDecision = (next: M48ReviewClipDraft) => {
|
||||
setDrafts((current) => new Map(current).set(next.clipId, next));
|
||||
setDirty(true);
|
||||
setReviewLayer("decision");
|
||||
};
|
||||
|
||||
const updateDecisionTrackState = (patch: Partial<M48ReviewTracklet["stateSegments"][number]>) => {
|
||||
if (!decision || !selectedDecisionTracklet) return;
|
||||
setDecision({
|
||||
...decision,
|
||||
reviewState: "pending",
|
||||
noObject: null,
|
||||
tracklets: decision.tracklets.map((tracklet) => tracklet.objectId === selectedDecisionTracklet.objectId
|
||||
? { ...tracklet, stateSegments: tracklet.stateSegments.map((segment) => ({ ...segment, ...patch })) }
|
||||
: tracklet),
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!session) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const clips = session.clips.map((item) => drafts.get(item.clipId) ?? item);
|
||||
const saved = await saveM48AdjudicationSession(session, session.title, clips, `save-${session.revision + 1}-${crypto.randomUUID()}`);
|
||||
setSession(saved);
|
||||
setDrafts(new Map(saved.clips.map((item) => [item.clipId, item])));
|
||||
setDirty(false);
|
||||
notify({ tone: "success", title: "Решения сохранены", description: `${saved.resolvedClipCount}/${saved.clipCount} клипов согласовано.` });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = async () => {
|
||||
if (!session || dirty || !session.complete || !adjudicatorId.trim() || !resolvedAttested || !blindAttested) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const frozen = await freezeM48AdjudicationSession(session, adjudicatorId.trim());
|
||||
setSession(frozen);
|
||||
setFreezeOpen(false);
|
||||
notify({ tone: "success", title: "Truth seal создан", description: "Frozen adjudication готова к одноразовой оценке." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluate = async () => {
|
||||
if (!session || session.state !== "adjudication-frozen") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const evaluated = await evaluateM48Adjudication(session, `evaluate-${crypto.randomUUID()}`);
|
||||
setSession(evaluated);
|
||||
notify({ tone: "success", title: "M4.8 рассчитана", description: evaluated.qualityResultId ?? "Результат зарегистрирован." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (!dirty || window.confirm("Закрыть без сохранения?")) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryReviewWorkspaceFrame
|
||||
ariaLabel="M4.8 adjudication"
|
||||
onClose={requestClose}
|
||||
interactionEnabled={!freezeOpen}
|
||||
returnFocusTarget={returnFocusTarget}
|
||||
toolbar={(
|
||||
<>
|
||||
<div className="m48-review-workspace__toolbar-group">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="plus" size={16} />} disabled={busy || Boolean(session)} onClick={() => void createSession()}>Открыть adjudication</Button>
|
||||
<Button size="compact" variant={drawing ? "accent" : "secondary"} icon={<Icon name="edit" size={16} />} disabled={!editable} onClick={() => setDrawing((value) => !value)}>Объект</Button>
|
||||
<Button size="compact" variant="primary" icon={<Icon name="save" size={16} />} disabled={!editable || !dirty || busy} onClick={() => void save()}>Сохранить</Button>
|
||||
<Button size="compact" variant="accent" icon={<Icon name="check" size={16} />} disabled={!session?.complete || dirty || busy || session.state !== "saved"} onClick={() => setFreezeOpen(true)}>Truth seal</Button>
|
||||
<Button size="compact" variant="accent" disabled={session?.state !== "adjudication-frozen" || busy} onClick={() => void evaluate()}>Рассчитать gate</Button>
|
||||
</div>
|
||||
<div className="m48-review-workspace__toolbar-group">
|
||||
<Select label="Клип" value={clipId} options={(catalog?.clips ?? []).map((item) => ({ value: item.clipId, label: `${item.ordinal}/${catalog?.clipCount ?? 0} · ${item.clipId} · ${drafts.get(item.clipId)?.reviewState ?? "pending"}` }))} disabled={!catalog} searchable menuWidth={380} onChange={(value) => {
|
||||
const next = catalog?.clips.find((item) => item.clipId === value);
|
||||
if (!next) return;
|
||||
setClipId(value);
|
||||
setSequence(next.startSequence);
|
||||
setSelectedObjectId(null);
|
||||
}} />
|
||||
<StatusBadge tone={session?.state === "evaluated" ? "success" : dirty ? "warning" : session ? "neutral" : "warning"}>{session ? `${session.state} · ${session.resolvedClipCount}/${session.clipCount}` : "Adjudication не создана"}</StatusBadge>
|
||||
<IconButton label="Закрыть M4.8 adjudication" onClick={requestClose}><Icon name="close" size={16} /></IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
stage={(
|
||||
<div className="m48-evidence-stage">
|
||||
{loading ? <div className="m48-review-workspace__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем source-only клипы</div>
|
||||
: !catalog || !catalog.cameraPlayback || !clip ? <div className="m48-review-workspace__state" role="alert"><Icon name="alert" size={18} />{error ?? "Источник недоступен."}</div>
|
||||
: <M48BlindClipPlayer cameraPlayback={catalog.cameraPlayback} clip={clip} sequence={sequence} mode={evidenceMode} cameraVisible={cameraVisible} tracklets={visibleDraft?.tracklets ?? []} selectedObjectId={selectedObjectId} editable={editable} drawing={drawing} spatialFrame={spatial} spatialLoading={spatialLoading} spatialError={spatialError} spatialEvidenceAvailable={spatialEnabled} onSequenceChange={setSequence} onDrawingChange={setDrawing} onSelectedObjectIdChange={setSelectedObjectId} onTrackletsChange={(tracklets) => {
|
||||
if (!decision) return;
|
||||
setDecision({ ...decision, reviewState: "pending", noObject: null, tracklets });
|
||||
}} />}
|
||||
{catalog ? <M48EvidenceModeRail mode={evidenceMode} cameraVisible={cameraVisible} spatialAvailable={spatialEnabled} onModeChange={setEvidenceMode} onCameraVisibleChange={setCameraVisible} /> : null}
|
||||
</div>
|
||||
)}
|
||||
inspector={(
|
||||
<>
|
||||
<div className="m48-review-workspace__source-state"><span>M4.8 · reviewer disagreement</span><strong>{clip ? `${clip.clipId} · frame ${sequence}` : "Источник проверяется"}</strong><small>Два независимых class-free review; frozen graph output остаётся скрыт.</small></div>
|
||||
{(error || spatialError) && catalog ? <StatusBadge tone="danger">{error ?? spatialError}</StatusBadge> : null}
|
||||
<SegmentedControl value={reviewLayer} items={[...REVIEW_LAYERS]} label="Reviewer inputs" onChange={setReviewLayer} />
|
||||
{session && reviewerA && reviewerB && !["adjudication-frozen", "evaluated"].includes(session.state) ? (
|
||||
<div className="m48-review-workspace__review-actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => setDecision(decisionCopy(reviewerA))}>Принять A</Button>
|
||||
<Button size="compact" variant="secondary" onClick={() => setDecision(decisionCopy(reviewerB))}>Принять B</Button>
|
||||
{decision ? <Checker checked={decision.reviewState === "adjudicated"} label={decision.noObject ? "Согласовано: объектов нет" : `Согласовано: ${decision.tracklets.length} tracklet`} onChange={(checked) => setDecision({ ...decision, reviewState: checked ? "adjudicated" : "pending", noObject: checked ? decision.tracklets.length === 0 : null })} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{editable && selectedDecisionTracklet ? (
|
||||
<div className="m48-review-workspace__object-tools">
|
||||
<strong>{selectedDecisionTracklet.objectId}</strong>
|
||||
<Select disabled={!catalog?.evidenceCapabilities.geometryAssociation} label="Геометрия" value={selectedDecisionTracklet.stateSegments[0]?.geometryAssociation ?? "unknown"} options={[{ value: "associated", label: "Связана" }, { value: "unavailable", label: "Недоступна" }, { value: "ineligible", label: "Неприменима" }, { value: "unknown", label: "Неизвестно" }]} onChange={(value) => updateDecisionTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.freshness} label="Актуальность" value={selectedDecisionTracklet.stateSegments[0]?.freshness ?? "unavailable"} options={[{ value: "current", label: "Актуальна" }, { value: "held", label: "Удержана" }, { value: "stale", label: "Устарела" }, { value: "unavailable", label: "Недоступна" }]} onChange={(value) => updateDecisionTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.motion} label="Движение" value={selectedDecisionTracklet.stateSegments[0]?.motion ?? "unknown"} options={[{ value: "moving", label: "Движется" }, { value: "static", label: "Стоит" }, { value: "unknown", label: "Неизвестно" }, { value: "unsupported", label: "Не поддержано" }]} onChange={(value) => updateDecisionTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.threat} label="Угроза" value={selectedDecisionTracklet.stateSegments[0]?.threat ?? "unknown"} options={[{ value: "threat", label: "Угроза" }, { value: "not-threat", label: "Не угроза" }, { value: "unknown", label: "Неизвестно" }]} onChange={(value) => updateDecisionTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} />
|
||||
<Checker disabled={!catalog?.evidenceCapabilities.criticalCorridorObstacle} checked={selectedDecisionTracklet.stateSegments[0]?.criticalCorridorObstacle ?? false} label="Критический объект" onChange={(criticalCorridorObstacle) => updateDecisionTrackState({ criticalCorridorObstacle })} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
overlays={(
|
||||
<>
|
||||
<Window open={freezeOpen} title="Создать M4.8 truth seal" subtitle="После freeze Reviewer A/B и adjudication станут immutable входом quality gate." size="md" closeOnBackdrop={!busy} closeOnEscape={!busy} onClose={() => !busy && setFreezeOpen(false)} footer={<WindowFooterActions><Button disabled={busy} onClick={() => setFreezeOpen(false)}>Отмена</Button><Button variant="accent" disabled={busy || !adjudicatorId.trim() || !resolvedAttested || !blindAttested} onClick={() => void freeze()}>Freeze adjudication</Button></WindowFooterActions>}>
|
||||
<div className="m48-review-workspace__freeze-form">
|
||||
<TextField label="Opaque adjudicator ID" value={adjudicatorId} maxLength={96} placeholder="adjudicator-1" onChange={(event) => setAdjudicatorId(event.target.value)} />
|
||||
<Checker checked={resolvedAttested} label="Все 20–30 клипов согласованы" onChange={setResolvedAttested} />
|
||||
<Checker checked={blindAttested} label="Model predictions до truth seal не просматривались" onChange={setBlindAttested} />
|
||||
</div>
|
||||
</Window>
|
||||
<ToastStack items={toasts} onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { ActivityIndicator, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryRecordedClipPlayer } from "../../../components/laboratory/LaboratoryRecordedClipPlayer";
|
||||
import { LaboratoryMetricEvidenceScene } from "../../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||
import type {
|
||||
M48RecordedCameraPlayback,
|
||||
M48ReviewClipSource,
|
||||
M48ReviewSpatialFrame,
|
||||
M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { m48RecordedCameraSourceDescriptor } from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import type { M48BlindEvidenceMode } from "./M48EvidenceModeControls";
|
||||
|
||||
export type { M48BlindEvidenceMode } from "./M48EvidenceModeControls";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type ResizeHandle = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
interface BoxInteraction {
|
||||
kind: "move" | "resize";
|
||||
pointerId: number;
|
||||
objectId: string;
|
||||
start: readonly [number, number];
|
||||
startClient: readonly [number, number];
|
||||
current: readonly [number, number];
|
||||
originalExtent: readonly [number, number, number, number];
|
||||
handle?: ResizeHandle;
|
||||
}
|
||||
|
||||
function interpolate(left: number, right: number, progress: number): number {
|
||||
return left + (right - left) * progress;
|
||||
}
|
||||
|
||||
export function interpolateM48Extent(
|
||||
tracklet: M48ReviewTracklet,
|
||||
sequence: number,
|
||||
): readonly [number, number, number, number] | null {
|
||||
if (sequence < tracklet.firstSequence || sequence > tracklet.lastSequence) return null;
|
||||
const rightIndex = tracklet.keyframes.findIndex((keyframe) => keyframe.sequence >= sequence);
|
||||
const right = tracklet.keyframes[rightIndex < 0 ? tracklet.keyframes.length - 1 : rightIndex];
|
||||
if (!right) return null;
|
||||
const left = tracklet.keyframes[Math.max(0, (rightIndex < 0 ? tracklet.keyframes.length : rightIndex) - 1)] ?? right;
|
||||
if (left.sequence === right.sequence) return right.extentXyxy;
|
||||
const progress = (sequence - left.sequence) / (right.sequence - left.sequence);
|
||||
return right.extentXyxy.map((value, index) => interpolate(left.extentXyxy[index]!, value, progress)) as unknown as readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
export function createM48Tracklet(
|
||||
objectId: string,
|
||||
clip: M48ReviewClipSource,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
spatialEvidenceAvailable = true,
|
||||
sequence = clip.startSequence,
|
||||
): M48ReviewTracklet {
|
||||
return {
|
||||
objectId,
|
||||
firstSequence: sequence,
|
||||
lastSequence: sequence,
|
||||
keyframes: [{ sequence, extentXyxy, visibility: "visible" as const }],
|
||||
stateSegments: [{
|
||||
startSequence: sequence,
|
||||
endSequence: sequence,
|
||||
geometryAssociation: spatialEvidenceAvailable ? "unknown" : "unavailable",
|
||||
freshness: "unavailable",
|
||||
motion: spatialEvidenceAvailable ? "unknown" : "unsupported",
|
||||
threat: "unknown",
|
||||
criticalCorridorObstacle: false,
|
||||
}],
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function nextM48ObjectId(tracklets: readonly M48ReviewTracklet[]): string {
|
||||
const occupied = new Set(tracklets.map(({ objectId }) => objectId));
|
||||
let ordinal = 1;
|
||||
while (occupied.has(`object-${String(ordinal).padStart(2, "0")}`)) ordinal += 1;
|
||||
return `object-${String(ordinal).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function upsertM48Extent(
|
||||
tracklet: M48ReviewTracklet,
|
||||
sequence: number,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
): M48ReviewTracklet {
|
||||
const visibility = tracklet.keyframes.find((keyframe) => keyframe.sequence === sequence)?.visibility
|
||||
?? tracklet.keyframes.filter((keyframe) => keyframe.sequence <= sequence).at(-1)?.visibility
|
||||
?? "visible";
|
||||
return {
|
||||
...tracklet,
|
||||
keyframes: [
|
||||
...tracklet.keyframes.filter((keyframe) => keyframe.sequence !== sequence),
|
||||
{ sequence, extentXyxy, visibility },
|
||||
].sort((left, right) => left.sequence - right.sequence),
|
||||
};
|
||||
}
|
||||
|
||||
function useHostSize(ref: RefObject<HTMLDivElement | null>): Rect {
|
||||
const [rect, setRect] = useState<Rect>({ x: 0, y: 0, width: 1, height: 1 });
|
||||
useEffect(() => {
|
||||
const host = ref.current;
|
||||
if (!host) return;
|
||||
const update = () => setRect({ x: 0, y: 0, width: Math.max(host.clientWidth, 1), height: Math.max(host.clientHeight, 1) });
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return rect;
|
||||
}
|
||||
|
||||
function imagePlane(host: Rect, naturalWidth: number, naturalHeight: number): Rect {
|
||||
const scale = Math.min(host.width / Math.max(naturalWidth, 1), host.height / Math.max(naturalHeight, 1));
|
||||
const width = naturalWidth * scale;
|
||||
const height = naturalHeight * scale;
|
||||
return { x: (host.width - width) / 2, y: (host.height - height) / 2, width, height };
|
||||
}
|
||||
|
||||
function normalizedPoint(event: ReactPointerEvent<SVGSVGElement>, plane: Rect): readonly [number, number] | null {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const x = (event.clientX - bounds.left - plane.x) / Math.max(plane.width, 1);
|
||||
const y = (event.clientY - bounds.top - plane.y) / Math.max(plane.height, 1);
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
function boundedNormalizedPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
svg: SVGSVGElement,
|
||||
plane: Rect,
|
||||
): readonly [number, number] {
|
||||
const bounds = svg.getBoundingClientRect();
|
||||
return [
|
||||
Math.max(0, Math.min(1, (clientX - bounds.left - plane.x) / Math.max(plane.width, 1))),
|
||||
Math.max(0, Math.min(1, (clientY - bounds.top - plane.y) / Math.max(plane.height, 1))),
|
||||
];
|
||||
}
|
||||
|
||||
function movedExtent(
|
||||
original: readonly [number, number, number, number],
|
||||
start: readonly [number, number],
|
||||
current: readonly [number, number],
|
||||
): readonly [number, number, number, number] {
|
||||
const width = original[2] - original[0];
|
||||
const height = original[3] - original[1];
|
||||
const left = Math.max(0, Math.min(1 - width, original[0] + current[0] - start[0]));
|
||||
const top = Math.max(0, Math.min(1 - height, original[1] + current[1] - start[1]));
|
||||
return [left, top, left + width, top + height];
|
||||
}
|
||||
|
||||
function resizedExtent(
|
||||
original: readonly [number, number, number, number],
|
||||
handle: ResizeHandle,
|
||||
current: readonly [number, number],
|
||||
): readonly [number, number, number, number] {
|
||||
const minimum = 0.005;
|
||||
let [left, top, right, bottom] = original;
|
||||
if (handle.includes("n")) top = Math.min(current[1], bottom - minimum);
|
||||
if (handle.includes("s")) bottom = Math.max(current[1], top + minimum);
|
||||
if (handle.includes("w")) left = Math.min(current[0], right - minimum);
|
||||
if (handle.includes("e")) right = Math.max(current[0], left + minimum);
|
||||
return [left, top, right, bottom];
|
||||
}
|
||||
|
||||
function interactionExtent(interaction: BoxInteraction) {
|
||||
return interaction.kind === "move"
|
||||
? movedExtent(interaction.originalExtent, interaction.start, interaction.current)
|
||||
: resizedExtent(
|
||||
interaction.originalExtent,
|
||||
interaction.handle ?? "se",
|
||||
interaction.current,
|
||||
);
|
||||
}
|
||||
|
||||
function extentsDiffer(
|
||||
left: readonly [number, number, number, number],
|
||||
right: readonly [number, number, number, number],
|
||||
): boolean {
|
||||
return left.some((value, index) => Math.abs(value - right[index]!) > 1e-6);
|
||||
}
|
||||
|
||||
function interactionMoved(
|
||||
start: readonly [number, number],
|
||||
current: readonly [number, number],
|
||||
): boolean {
|
||||
return Math.hypot(current[0] - start[0], current[1] - start[1]) >= 3;
|
||||
}
|
||||
|
||||
export function M48BlindClipPlayer({
|
||||
cameraPlayback,
|
||||
clip,
|
||||
sequence,
|
||||
mode,
|
||||
cameraVisible,
|
||||
tracklets,
|
||||
selectedObjectId,
|
||||
editable,
|
||||
drawing,
|
||||
spatialFrame,
|
||||
spatialLoading,
|
||||
spatialError,
|
||||
spatialEvidenceAvailable = true,
|
||||
onSequenceChange,
|
||||
onDrawingChange,
|
||||
onSelectedObjectIdChange,
|
||||
onTrackletsChange,
|
||||
}: {
|
||||
cameraPlayback: M48RecordedCameraPlayback;
|
||||
clip: M48ReviewClipSource;
|
||||
sequence: number;
|
||||
mode: M48BlindEvidenceMode;
|
||||
cameraVisible: boolean;
|
||||
tracklets: readonly M48ReviewTracklet[];
|
||||
selectedObjectId: string | null;
|
||||
editable: boolean;
|
||||
drawing: boolean;
|
||||
spatialFrame: M48ReviewSpatialFrame | null;
|
||||
spatialLoading: boolean;
|
||||
spatialError?: string | null;
|
||||
spatialEvidenceAvailable?: boolean;
|
||||
onSequenceChange: (sequence: number) => void;
|
||||
onDrawingChange: (drawing: boolean) => void;
|
||||
onSelectedObjectIdChange: (objectId: string | null) => void;
|
||||
onTrackletsChange: (tracklets: readonly M48ReviewTracklet[]) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const host = useHostSize(hostRef);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [drawStart, setDrawStart] = useState<readonly [number, number] | null>(null);
|
||||
const [drawCurrent, setDrawCurrent] = useState<readonly [number, number] | null>(null);
|
||||
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null);
|
||||
const cameraSource = useMemo(
|
||||
() => m48RecordedCameraSourceDescriptor(cameraPlayback),
|
||||
[cameraPlayback],
|
||||
);
|
||||
const spatialReady = Boolean(
|
||||
spatialFrame?.sequence === sequence
|
||||
&& spatialFrame.sourceAvailable
|
||||
&& spatialFrame.bodyFrameAvailable
|
||||
&& spatialFrame.pointCloudBodyXyzM.length > 0,
|
||||
);
|
||||
const spatialVisible = mode !== "camera" && spatialEvidenceAvailable;
|
||||
const effectiveCameraVisible = cameraVisible || !spatialVisible;
|
||||
const plane = imagePlane(host, 1440, 1080);
|
||||
|
||||
useEffect(() => setPlaying(false), [clip.clipId]);
|
||||
useEffect(() => {
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
setBoxInteraction(null);
|
||||
}, [clip.clipId, sequence]);
|
||||
|
||||
const boxes = useMemo(() => tracklets.flatMap((tracklet) => {
|
||||
const extent = interpolateM48Extent(tracklet, sequence);
|
||||
return extent ? [{ tracklet, extent }] : [];
|
||||
}), [sequence, tracklets]);
|
||||
|
||||
const finishDrawing = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (!editable || !drawing || !drawStart) return;
|
||||
const end = normalizedPoint(event, plane) ?? drawCurrent;
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
if (!end) return;
|
||||
const extent = [
|
||||
Math.min(drawStart[0], end[0]),
|
||||
Math.min(drawStart[1], end[1]),
|
||||
Math.max(drawStart[0], end[0]),
|
||||
Math.max(drawStart[1], end[1]),
|
||||
] as const;
|
||||
if (extent[2] - extent[0] < 0.01 || extent[3] - extent[1] < 0.01) return;
|
||||
const selected = selectedObjectId
|
||||
? tracklets.find((tracklet) => (
|
||||
tracklet.objectId === selectedObjectId
|
||||
&& sequence >= tracklet.firstSequence
|
||||
&& sequence <= tracklet.lastSequence
|
||||
))
|
||||
: null;
|
||||
if (selected) {
|
||||
onTrackletsChange(tracklets.map((tracklet) => (
|
||||
tracklet.objectId === selected.objectId
|
||||
? upsertM48Extent(tracklet, sequence, extent)
|
||||
: tracklet
|
||||
)));
|
||||
onDrawingChange(false);
|
||||
return;
|
||||
}
|
||||
const objectId = nextM48ObjectId(tracklets);
|
||||
onTrackletsChange([...tracklets, createM48Tracklet(objectId, clip, extent, spatialEvidenceAvailable, sequence)]);
|
||||
onSelectedObjectIdChange(objectId);
|
||||
onDrawingChange(false);
|
||||
};
|
||||
|
||||
const finishBoxInteraction = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (!boxInteraction || boxInteraction.pointerId !== event.pointerId) return;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const current = boundedNormalizedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
plane,
|
||||
);
|
||||
const extent = interactionExtent({ ...boxInteraction, current });
|
||||
if (
|
||||
interactionMoved(
|
||||
boxInteraction.startClient,
|
||||
[event.clientX, event.clientY],
|
||||
)
|
||||
&& extentsDiffer(boxInteraction.originalExtent, extent)
|
||||
) {
|
||||
onTrackletsChange(tracklets.map((tracklet) => (
|
||||
tracklet.objectId === boxInteraction.objectId
|
||||
? upsertM48Extent(tracklet, sequence, extent)
|
||||
: tracklet
|
||||
)));
|
||||
}
|
||||
setBoxInteraction(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryRecordedClipPlayer
|
||||
source={cameraSource}
|
||||
segmentCount={cameraPlayback.segmentCount}
|
||||
frames={clip.frames}
|
||||
sequence={sequence}
|
||||
playing={playing}
|
||||
playbackRate={playbackRate}
|
||||
cameraPresentation={spatialVisible
|
||||
? effectiveCameraVisible ? "companion" : "hidden"
|
||||
: "primary"}
|
||||
continuousPlayback
|
||||
sourceCount={Number(effectiveCameraVisible) + Number(spatialVisible)}
|
||||
cameraRef={hostRef}
|
||||
onSequenceChange={onSequenceChange}
|
||||
onPlayingChange={setPlaying}
|
||||
onPlaybackRateChange={setPlaybackRate}
|
||||
cameraOverlay={(<>
|
||||
<div className="m48-clip-player__pane-label" data-pane="camera">
|
||||
ПРАВАЯ КАМЕРА · СИНХРОННО · КАДР {sequence}
|
||||
</div>
|
||||
<svg
|
||||
className="m48-clip-player__overlay"
|
||||
viewBox={`0 0 ${host.width} ${host.height}`}
|
||||
aria-label="Объектные tracklet-рамки без классов"
|
||||
data-drawing={editable && drawing ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (!drawing) {
|
||||
onSelectedObjectIdChange(null);
|
||||
return;
|
||||
}
|
||||
if (!editable) return;
|
||||
setPlaying(false);
|
||||
const point = normalizedPoint(event, plane);
|
||||
if (point) {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDrawStart(point);
|
||||
setDrawCurrent(point);
|
||||
}
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drawStart) setDrawCurrent(normalizedPoint(event, plane));
|
||||
if (boxInteraction?.pointerId === event.pointerId) {
|
||||
setBoxInteraction({
|
||||
...boxInteraction,
|
||||
current: boundedNormalizedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
plane,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (boxInteraction) finishBoxInteraction(event);
|
||||
else finishDrawing(event);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
setBoxInteraction(null);
|
||||
}}
|
||||
>
|
||||
{boxes.map(({ tracklet, extent }) => {
|
||||
const displayedExtent = boxInteraction?.objectId === tracklet.objectId
|
||||
? interactionExtent(boxInteraction)
|
||||
: extent;
|
||||
const [left, top, right, bottom] = displayedExtent;
|
||||
return (
|
||||
<g
|
||||
key={tracklet.objectId}
|
||||
data-selected={tracklet.objectId === selectedObjectId ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (drawing) return;
|
||||
event.stopPropagation();
|
||||
onSelectedObjectIdChange(tracklet.objectId);
|
||||
if (!editable || event.button !== 0) return;
|
||||
setPlaying(false);
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
const point = boundedNormalizedPoint(event.clientX, event.clientY, svg, plane);
|
||||
setBoxInteraction({
|
||||
kind: "move",
|
||||
pointerId: event.pointerId,
|
||||
objectId: tracklet.objectId,
|
||||
start: point,
|
||||
startClient: [event.clientX, event.clientY],
|
||||
current: point,
|
||||
originalExtent: extent,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<rect x={plane.x + left * plane.width} y={plane.y + top * plane.height} width={(right - left) * plane.width} height={(bottom - top) * plane.height} />
|
||||
<text x={plane.x + left * plane.width} y={Math.max(14, plane.y + top * plane.height - 6)}>{tracklet.objectId}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{drawStart && drawCurrent ? (
|
||||
<rect
|
||||
className="m48-clip-player__draft-box"
|
||||
x={plane.x + Math.min(drawStart[0], drawCurrent[0]) * plane.width}
|
||||
y={plane.y + Math.min(drawStart[1], drawCurrent[1]) * plane.height}
|
||||
width={Math.abs(drawCurrent[0] - drawStart[0]) * plane.width}
|
||||
height={Math.abs(drawCurrent[1] - drawStart[1]) * plane.height}
|
||||
/>
|
||||
) : null}
|
||||
{editable && !drawing && selectedObjectId ? boxes
|
||||
.filter(({ tracklet }) => tracklet.objectId === selectedObjectId)
|
||||
.flatMap(({ tracklet, extent }) => {
|
||||
const displayedExtent = boxInteraction?.objectId === tracklet.objectId
|
||||
? interactionExtent(boxInteraction)
|
||||
: extent;
|
||||
const [left, top, right, bottom] = displayedExtent;
|
||||
return ([
|
||||
["nw", left, top],
|
||||
["ne", right, top],
|
||||
["sw", left, bottom],
|
||||
["se", right, bottom],
|
||||
] as const).map(([handle, x, y]) => (
|
||||
<circle
|
||||
className="m48-clip-player__resize-handle"
|
||||
data-handle={handle}
|
||||
key={`${tracklet.objectId}-${handle}`}
|
||||
cx={plane.x + x * plane.width}
|
||||
cy={plane.y + y * plane.height}
|
||||
r={6}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
setPlaying(false);
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
const point = boundedNormalizedPoint(event.clientX, event.clientY, svg, plane);
|
||||
setBoxInteraction({
|
||||
kind: "resize",
|
||||
pointerId: event.pointerId,
|
||||
objectId: tracklet.objectId,
|
||||
start: point,
|
||||
startClient: [event.clientX, event.clientY],
|
||||
current: point,
|
||||
originalExtent: extent,
|
||||
handle,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}) : null}
|
||||
</svg>
|
||||
</>)}
|
||||
alternativeScene={(
|
||||
<div
|
||||
className="m48-clip-player__spatial-pane"
|
||||
data-spatial-sequence={spatialReady ? sequence : undefined}
|
||||
>
|
||||
<div className="m48-clip-player__pane-label" data-pane="spatial">
|
||||
{mode === "3d" ? "3D LIDAR" : "ПЛАН LIDAR"} · КАДР {sequence}
|
||||
</div>
|
||||
{spatialReady && spatialFrame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
pointCloudBodyXyzM={spatialFrame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={[]}
|
||||
obstacles={[]}
|
||||
rig={spatialFrame.rig}
|
||||
corridor={spatialFrame.corridor}
|
||||
occupiedVoxelSizeM={spatialFrame.occupiedVoxelSizeM}
|
||||
mode={mode === "3d" ? "3d" : "plan"}
|
||||
label={`M4.8 пространственные данные источника · кадр ${sequence}`}
|
||||
showCurrentIncrement
|
||||
showLocalSurface={false}
|
||||
showRollingMap={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="m48-clip-player__state" role={spatialLoading ? "status" : "alert"}>
|
||||
{spatialLoading ? <ActivityIndicator size="compact" /> : <Icon name="alert" size={18} />}
|
||||
{spatialLoading
|
||||
? "Подготавливаем синхронный LiDAR-кадр"
|
||||
: spatialError
|
||||
? spatialError
|
||||
: spatialFrame && !spatialFrame.sourceAvailable
|
||||
? "Текущий LiDAR-кадр недоступен"
|
||||
: spatialFrame && !spatialFrame.bodyFrameAvailable
|
||||
? "LiDAR в системе координат корпуса для этого кадра недоступен"
|
||||
: "Исходные пространственные данные для этого кадра недоступны"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
FieldFrame,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createM48CorrectionSession,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
freezeM48CorrectionSession,
|
||||
saveM48CorrectionSession,
|
||||
type M48CorrectionSession,
|
||||
type M48GateStatus,
|
||||
type M48ReviewClipDraft,
|
||||
type M48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { LaboratoryReviewWorkspaceFrame } from "../../../components/laboratory/LaboratoryReviewWorkspaceFrame";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
interpolateM48Extent,
|
||||
upsertM48Extent,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./M48EvidenceModeControls";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция M4.8 не выполнена.";
|
||||
}
|
||||
|
||||
function operationKey(packId: string): string {
|
||||
const key = `missioncore:m48:${packId}:correction-operation-key`;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) return stored;
|
||||
const created = `correction-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function draftMap(session: M48CorrectionSession): Map<string, M48ReviewClipDraft> {
|
||||
return new Map(session.clips.map((clip) => [clip.clipId, clip]));
|
||||
}
|
||||
|
||||
function stateLabel(session: M48CorrectionSession | null): string {
|
||||
if (!session) return "Проверка загружается";
|
||||
if (session.state === "frozen") return "Проверка завершена";
|
||||
return `Проверено клипов: ${session.reviewedClipCount} из ${session.clipCount}`;
|
||||
}
|
||||
|
||||
function clipOptionLabel(
|
||||
ordinal: number,
|
||||
clipCount: number,
|
||||
clipId: string,
|
||||
reviewState: M48ReviewClipDraft["reviewState"] | undefined,
|
||||
): string {
|
||||
const progress = `${String(ordinal).padStart(2, "0")}/${String(clipCount).padStart(2, "0")}`;
|
||||
return `${progress} · ${clipId} · ${reviewState === "reviewed" ? "проверен" : "не проверен"}`;
|
||||
}
|
||||
|
||||
type M48CorrectionSaveReason = "clip-status" | "object-edit";
|
||||
|
||||
interface M48CorrectionSaveRollback {
|
||||
drafts: ReadonlyMap<string, M48ReviewClipDraft>;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export function M48CorrectionWorkspace({
|
||||
gate,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
gate: M48GateStatus;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<M48ReviewSourceCatalog | null>(null);
|
||||
const [session, setSession] = useState<M48CorrectionSession | null>(null);
|
||||
const [drafts, setDrafts] = useState<ReadonlyMap<string, M48ReviewClipDraft>>(new Map());
|
||||
const [selectedClipId, setSelectedClipId] = useState("");
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [freezeOpen, setFreezeOpen] = useState(false);
|
||||
const [reviewerId, setReviewerId] = useState("");
|
||||
const [candidateVisible, setCandidateVisible] = useState(false);
|
||||
const [classFree, setClassFree] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const savingRef = useRef(false);
|
||||
|
||||
const notify = useCallback((toast: Omit<ToastItem, "id">) => {
|
||||
setToasts((current) => [...current, { ...toast, id: crypto.randomUUID() }]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void Promise.all([
|
||||
fetchM48ReviewSourceCatalog(gate.packId, { signal: controller.signal }),
|
||||
createM48CorrectionSession(gate.packId, operationKey(gate.packId), { signal: controller.signal }),
|
||||
])
|
||||
.then(([next, correction]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
setSession(correction);
|
||||
setDrafts(draftMap(correction));
|
||||
const first = next.clips[0];
|
||||
if (first) {
|
||||
setSelectedClipId(first.clipId);
|
||||
setSequence(first.startSequence);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(caught));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [gate.packId]);
|
||||
|
||||
const clip = useMemo(
|
||||
() => catalog?.clips.find((item) => item.clipId === selectedClipId) ?? null,
|
||||
[catalog, selectedClipId],
|
||||
);
|
||||
const selectedClipIndex = useMemo(
|
||||
() => catalog?.clips.findIndex((item) => item.clipId === selectedClipId) ?? -1,
|
||||
[catalog, selectedClipId],
|
||||
);
|
||||
const currentDraft = clip ? drafts.get(clip.clipId) ?? null : null;
|
||||
const selectedTracklet = currentDraft?.tracklets.find(({ objectId }) => objectId === selectedObjectId) ?? null;
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTracklet
|
||||
&& (sequence < selectedTracklet.firstSequence || sequence > selectedTracklet.lastSequence)
|
||||
) {
|
||||
setSelectedObjectId(null);
|
||||
}
|
||||
}, [selectedTracklet, sequence]);
|
||||
const spatialEnabled = Boolean(
|
||||
catalog?.evidenceCapabilities.currentPointCloudBodyXyzM
|
||||
&& catalog.evidenceCapabilities.rig
|
||||
&& catalog.evidenceCapabilities.virtualCorridor,
|
||||
);
|
||||
const extentEnabled = Boolean(catalog?.evidenceCapabilities.obstaclePresenceAndExtent);
|
||||
const editable = Boolean(session && session.state !== "frozen");
|
||||
const editingEnabled = editable && !busy;
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId: gate.packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
const setCurrentDraft = useCallback((next: M48ReviewClipDraft) => {
|
||||
setDrafts((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(next.clipId, next);
|
||||
return updated;
|
||||
});
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const save = async (
|
||||
nextDrafts: ReadonlyMap<string, M48ReviewClipDraft> = drafts,
|
||||
reason: M48CorrectionSaveReason = "object-edit",
|
||||
rollback?: M48CorrectionSaveRollback,
|
||||
) => {
|
||||
if (!session || nextDrafts.size !== session.clipCount || savingRef.current) return false;
|
||||
savingRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const clips = session.clips.map((item) => nextDrafts.get(item.clipId) ?? item);
|
||||
const saved = await saveM48CorrectionSession(
|
||||
session,
|
||||
session.title,
|
||||
clips,
|
||||
`save-${session.revision + 1}-${crypto.randomUUID()}`,
|
||||
);
|
||||
setSession(saved);
|
||||
setDrafts(draftMap(saved));
|
||||
setDirty(false);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: reason === "clip-status" ? "Статус клипа сохранён" : "Изменения объекта сохранены",
|
||||
description: `${saved.reviewedClipCount}/${saved.clipCount} клипов проверено.`,
|
||||
});
|
||||
onChanged?.();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
const message = errorMessage(caught);
|
||||
setError(message);
|
||||
if (rollback) {
|
||||
setDrafts(rollback.drafts);
|
||||
setDirty(rollback.dirty);
|
||||
} else {
|
||||
setDirty(true);
|
||||
}
|
||||
notify({
|
||||
tone: "error",
|
||||
title: reason === "clip-status" ? "Статус клипа не сохранён" : "Изменения объекта не сохранены",
|
||||
description: message,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = async () => {
|
||||
if (!session || !reviewerId.trim() || dirty || !session.complete || !candidateVisible || !classFree) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const frozen = await freezeM48CorrectionSession(session, reviewerId.trim());
|
||||
setSession(frozen);
|
||||
setDrafts(draftMap(frozen));
|
||||
setFreezeOpen(false);
|
||||
setDrawing(false);
|
||||
notify({ tone: "success", title: "Проверка Worker 006 зафиксирована", description: "Дельта correction сохранена как assisted evidence, не independent truth." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectedTracklet = (patch: Partial<M48ReviewTracklet>) => {
|
||||
if (!currentDraft || !selectedTracklet) return;
|
||||
setCurrentDraft({
|
||||
...currentDraft,
|
||||
reviewState: "pending",
|
||||
noObject: null,
|
||||
tracklets: currentDraft.tracklets.map((tracklet) => (
|
||||
tracklet.objectId === selectedTracklet.objectId ? { ...tracklet, ...patch } : tracklet
|
||||
)),
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrackState = (patch: Partial<M48ReviewTracklet["stateSegments"][number]>) => {
|
||||
if (!selectedTracklet) return;
|
||||
updateSelectedTracklet({
|
||||
stateSegments: selectedTracklet.stateSegments.map((segment) => ({ ...segment, ...patch })),
|
||||
});
|
||||
};
|
||||
|
||||
const updateVisibility = (visibility: M48ReviewTracklet["keyframes"][number]["visibility"]) => {
|
||||
if (!selectedTracklet) return;
|
||||
const extent = interpolateM48Extent(selectedTracklet, sequence);
|
||||
if (!extent) return;
|
||||
const withKeyframe = upsertM48Extent(selectedTracklet, sequence, extent);
|
||||
updateSelectedTracklet({
|
||||
keyframes: withKeyframe.keyframes.map((keyframe) => (
|
||||
keyframe.sequence === sequence ? { ...keyframe, visibility } : keyframe
|
||||
)),
|
||||
});
|
||||
};
|
||||
|
||||
const markReviewed = (reviewed: boolean) => {
|
||||
if (!currentDraft || !editingEnabled) return;
|
||||
const nextDraft: M48ReviewClipDraft = reviewed
|
||||
? {
|
||||
...currentDraft,
|
||||
reviewState: "reviewed",
|
||||
noObject: currentDraft.tracklets.length === 0,
|
||||
}
|
||||
: { ...currentDraft, reviewState: "pending", noObject: null };
|
||||
const nextDrafts = new Map(drafts);
|
||||
nextDrafts.set(nextDraft.clipId, nextDraft);
|
||||
setDrafts(nextDrafts);
|
||||
setDirty(true);
|
||||
void save(nextDrafts, "clip-status", { drafts, dirty });
|
||||
};
|
||||
|
||||
const selectClip = (clipId: string) => {
|
||||
const next = catalog?.clips.find((item) => item.clipId === clipId);
|
||||
if (!next) return;
|
||||
setSelectedClipId(next.clipId);
|
||||
setSequence(next.startSequence);
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(false);
|
||||
};
|
||||
|
||||
const selectAdjacentClip = (offset: -1 | 1) => {
|
||||
const next = catalog?.clips[selectedClipIndex + offset];
|
||||
if (next) selectClip(next.clipId);
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (dirty) {
|
||||
if (!window.confirm("Закрыть рабочую область без сохранения черновика?")) return;
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (session?.complete && session.state !== "frozen") {
|
||||
setFreezeOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryReviewWorkspaceFrame
|
||||
ariaLabel="M4.8 проверка авторазметки Worker 006"
|
||||
onClose={requestClose}
|
||||
interactionEnabled={!freezeOpen}
|
||||
returnFocusTarget={returnFocusTarget}
|
||||
toolbar={(
|
||||
<div className="m48-review-workspace__header">
|
||||
<div className="m48-review-workspace__topbar">
|
||||
<div className="m48-review-workspace__topbar-start">
|
||||
<div className="m48-review-workspace__clip-navigation">
|
||||
<IconButton
|
||||
label="Предыдущий клип"
|
||||
disabled={selectedClipIndex <= 0 || busy}
|
||||
onClick={() => selectAdjacentClip(-1)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Следующий клип"
|
||||
disabled={!catalog || selectedClipIndex < 0 || selectedClipIndex >= catalog.clips.length - 1 || busy}
|
||||
onClick={() => selectAdjacentClip(1)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<FieldFrame label="Выбор клипа" className="m48-review-workspace__clip-field">
|
||||
<Select
|
||||
label="Клип для проверки"
|
||||
value={selectedClipId}
|
||||
options={(catalog?.clips ?? []).map((item) => ({
|
||||
value: item.clipId,
|
||||
label: clipOptionLabel(
|
||||
item.ordinal,
|
||||
catalog?.clipCount ?? 0,
|
||||
item.clipId,
|
||||
drafts.get(item.clipId)?.reviewState,
|
||||
),
|
||||
}))}
|
||||
disabled={!catalog || busy}
|
||||
searchable
|
||||
menuWidth={380}
|
||||
onChange={selectClip}
|
||||
/>
|
||||
</FieldFrame>
|
||||
{currentDraft && editable ? (
|
||||
<Checker
|
||||
className="m48-review-workspace__clip-reviewed"
|
||||
checked={currentDraft.reviewState === "reviewed"}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
aria-label={currentDraft.tracklets.length
|
||||
? `Клип проверен · ${currentDraft.tracklets.length} объектов`
|
||||
: "Клип проверен · без объектов"}
|
||||
label={currentDraft.tracklets.length
|
||||
? `Проверен · ${currentDraft.tracklets.length} объектов`
|
||||
: "Проверен · без объектов"}
|
||||
onChange={markReviewed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="m48-review-workspace__topbar-end">
|
||||
<IconButton
|
||||
label="Добавить объект"
|
||||
aria-pressed={drawing}
|
||||
disabled={!editingEnabled || !extentEnabled}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing((value) => !value);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Закрыть проверку M4.8" disabled={busy} onClick={requestClose}>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTracklet && editable ? (
|
||||
<div className="m48-review-workspace__object-tools">
|
||||
<FieldFrame label="Видимость" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy} label="Видимость объекта" value={selectedTracklet.keyframes.filter((keyframe) => keyframe.sequence <= sequence).at(-1)?.visibility ?? "visible"} options={[{ value: "visible", label: "Виден" }, { value: "partial", label: "Виден частично" }, { value: "occluded", label: "Перекрыт" }]} onChange={(value) => updateVisibility(value as M48ReviewTracklet["keyframes"][number]["visibility"])} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Связь с LiDAR" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.geometryAssociation} label="Связь объекта с LiDAR" value={selectedTracklet.stateSegments[0]?.geometryAssociation ?? "unknown"} options={[{ value: "associated", label: "Связана" }, { value: "unavailable", label: "Недоступна" }, { value: "ineligible", label: "Не применяется" }, { value: "unknown", label: "Не определена" }]} onChange={(value) => updateTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Актуальность" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.freshness} label="Актуальность объекта" value={selectedTracklet.stateSegments[0]?.freshness ?? "unavailable"} options={[{ value: "current", label: "Актуальна" }, { value: "held", label: "Удержана" }, { value: "stale", label: "Устарела" }, { value: "unavailable", label: "Недоступна" }]} onChange={(value) => updateTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Движение" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.motion} label="Движение объекта" value={selectedTracklet.stateSegments[0]?.motion ?? "unknown"} options={[{ value: "moving", label: "Движется" }, { value: "static", label: "Стоит" }, { value: "unknown", label: "Не определено" }, { value: "unsupported", label: "Не поддерживается" }]} onChange={(value) => updateTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Непосредственная опасность" className="m48-review-workspace__object-field m48-review-workspace__object-field--wide">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.threat} label="Непосредственная опасность объекта" value={selectedTracklet.stateSegments[0]?.threat ?? "unknown"} options={[{ value: "threat", label: "Опасен сейчас" }, { value: "not-threat", label: "Не опасен сейчас" }, { value: "unknown", label: "Не определено" }]} onChange={(value) => updateTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Проезд" className="m48-review-workspace__passage-field">
|
||||
<Checker disabled={busy || !catalog?.evidenceCapabilities.criticalCorridorObstacle} checked={selectedTracklet.stateSegments[0]?.criticalCorridorObstacle ?? false} label="Объезд или запас" onChange={(criticalCorridorObstacle) => updateTrackState({ criticalCorridorObstacle })} />
|
||||
</FieldFrame>
|
||||
<IconButton
|
||||
label="Добавить ещё один объект"
|
||||
disabled={busy || !extentEnabled}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Сохранить изменения объекта"
|
||||
disabled={!dirty || busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => void save(drafts, "object-edit")}
|
||||
>
|
||||
<Icon name="save" size={16} />
|
||||
</IconButton>
|
||||
<IconButton disabled={busy} label="Удалить объект" onClick={() => {
|
||||
if (!currentDraft) return;
|
||||
setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets: currentDraft.tracklets.filter(({ objectId }) => objectId !== selectedTracklet.objectId) });
|
||||
setSelectedObjectId(null);
|
||||
}}><Icon name="trash" size={16} /></IconButton>
|
||||
<IconButton
|
||||
label="Закрыть редактор объекта"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
stage={(
|
||||
<div className="m48-review-workspace__stage-shell">
|
||||
{loading ? (
|
||||
<div className="m48-review-workspace__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем клипы и frozen-candidate seed Worker 006</div>
|
||||
) : !catalog || !catalog.cameraPlayback || !clip ? (
|
||||
<div className="m48-review-workspace__state" role="alert"><Icon name="alert" size={18} />{error ?? "M4.8 источник недоступен."}</div>
|
||||
) : (
|
||||
<M48BlindClipPlayer
|
||||
cameraPlayback={catalog.cameraPlayback}
|
||||
clip={clip}
|
||||
sequence={sequence}
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
tracklets={currentDraft?.tracklets ?? []}
|
||||
selectedObjectId={selectedObjectId}
|
||||
editable={editingEnabled && extentEnabled}
|
||||
drawing={drawing}
|
||||
spatialFrame={spatial}
|
||||
spatialLoading={spatialLoading}
|
||||
spatialError={spatialError}
|
||||
spatialEvidenceAvailable={spatialEnabled}
|
||||
onSequenceChange={setSequence}
|
||||
onDrawingChange={setDrawing}
|
||||
onSelectedObjectIdChange={setSelectedObjectId}
|
||||
onTrackletsChange={(tracklets) => {
|
||||
if (!currentDraft) return;
|
||||
setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{catalog ? (
|
||||
<M48EvidenceModeRail
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
spatialAvailable={spatialEnabled}
|
||||
onModeChange={setMode}
|
||||
onCameraVisibleChange={setCameraVisible}
|
||||
/>
|
||||
) : null}
|
||||
{catalog ? (
|
||||
<GlassSurface
|
||||
className="m48-review-workspace__source-sticker"
|
||||
tone="strong"
|
||||
padding="sm"
|
||||
materialRim={false}
|
||||
>
|
||||
<div className="m48-review-workspace__source-heading">
|
||||
<StatusBadge tone={session ? "success" : "warning"}>
|
||||
{session
|
||||
? `Worker 006 · ${session.seedObjectCount.toLocaleString("ru-RU")} авторамок`
|
||||
: "Загружаем авторазметку"}
|
||||
</StatusBadge>
|
||||
<strong>{clip ? `${clip.clipId} · кадр ${sequence}` : "Источник проверяется"}</strong>
|
||||
</div>
|
||||
<small>Исправьте авторамки; новая ручная рамка относится только к текущему кадру и не имитирует трекинг.</small>
|
||||
<small>«Опасность» — немедленная угроза. «Проезд» — статическое ограничение, которое требует объезда или геометрического запаса.</small>
|
||||
<StatusBadge tone={dirty ? "warning" : session?.state === "frozen" ? "success" : session ? "neutral" : "warning"}>
|
||||
{busy ? "Сохраняем изменения" : dirty ? "Есть несохранённые изменения" : stateLabel(session)}
|
||||
</StatusBadge>
|
||||
{(error || spatialError) ? <StatusBadge tone="danger">{error ?? spatialError}</StatusBadge> : null}
|
||||
{session?.evidenceSummary ? (
|
||||
<div className="m48-review-workspace__evidence-summary">
|
||||
<strong>Результат проверки Worker 006</strong>
|
||||
<span>Подтверждено: {session.evidenceSummary.confirmedCandidateCount}/{session.evidenceSummary.seedObjectCount}</span>
|
||||
<span>Исправлено: {session.evidenceSummary.modifiedCandidateCount}</span>
|
||||
<span>Удалено лишних: {session.evidenceSummary.falsePositiveRemovedCount}</span>
|
||||
<span>Добавлено пропущенных: {session.evidenceSummary.missedObjectAddedCount}</span>
|
||||
<small>Это проверка авторазметки, а не независимая контрольная разметка.</small>
|
||||
</div>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
overlays={(
|
||||
<>
|
||||
<Window
|
||||
open={freezeOpen}
|
||||
title="Завершить проверку Worker 006"
|
||||
subtitle="Будут сохранены исходные авторамки, ваши исправления и итоговая разница. Результат не является независимой контрольной разметкой."
|
||||
size="md"
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
onClose={() => !busy && setFreezeOpen(false)}
|
||||
footer={<WindowFooterActions><Button disabled={busy} onClick={() => setFreezeOpen(false)}>Отмена</Button><Button variant="accent" disabled={busy || !reviewerId.trim() || !candidateVisible || !classFree} onClick={() => void freeze()}>{busy ? "Завершаем" : "Завершить проверку"}</Button></WindowFooterActions>}
|
||||
>
|
||||
<div className="m48-review-workspace__freeze-form">
|
||||
<TextField label="Кто проверил" value={reviewerId} maxLength={96} placeholder="Имя или ID проверяющего" onChange={(event) => setReviewerId(event.target.value)} />
|
||||
<Checker checked={candidateVisible} label="Все авторамки Worker 006 просмотрены, найденные ошибки исправлены" onChange={setCandidateVisible} />
|
||||
<Checker checked={classFree} label="Проверка не назначает объектам семантические классы" onChange={setClassFree} />
|
||||
</div>
|
||||
</Window>
|
||||
<ToastStack items={toasts} onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export type M48BlindEvidenceMode = "camera" | "3d" | "plan";
|
||||
|
||||
interface M48EvidenceModeControlProps {
|
||||
mode: M48BlindEvidenceMode;
|
||||
cameraVisible: boolean;
|
||||
spatialAvailable: boolean;
|
||||
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
||||
onCameraVisibleChange: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export function nextM48CameraVisibility(
|
||||
mode: M48BlindEvidenceMode,
|
||||
cameraVisible: boolean,
|
||||
): boolean {
|
||||
return mode === "camera" ? true : !cameraVisible;
|
||||
}
|
||||
|
||||
export function nextM48SpatialMode(
|
||||
mode: M48BlindEvidenceMode,
|
||||
cameraVisible: boolean,
|
||||
selected: Exclude<M48BlindEvidenceMode, "camera">,
|
||||
): M48BlindEvidenceMode {
|
||||
if (mode !== selected) return selected;
|
||||
return cameraVisible ? "camera" : mode;
|
||||
}
|
||||
|
||||
export function M48EvidenceModeControls({
|
||||
mode,
|
||||
cameraVisible,
|
||||
spatialAvailable,
|
||||
onModeChange,
|
||||
onCameraVisibleChange,
|
||||
}: M48EvidenceModeControlProps) {
|
||||
const spatialMode = mode === "camera" ? null : mode;
|
||||
return (
|
||||
<div
|
||||
className="m48-evidence-mode-controls"
|
||||
role="group"
|
||||
aria-label="Каналы доказательства"
|
||||
>
|
||||
<IconButton
|
||||
label={cameraVisible ? "Скрыть правую камеру" : "Показать правую камеру"}
|
||||
aria-pressed={cameraVisible}
|
||||
disabled={spatialMode === null}
|
||||
onClick={() => onCameraVisibleChange(
|
||||
nextM48CameraVisibility(mode, cameraVisible),
|
||||
)}
|
||||
>
|
||||
<Icon name="video" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={spatialMode === "3d" ? "Скрыть 3D" : "Показать 3D"}
|
||||
aria-pressed={spatialMode === "3d"}
|
||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "3d")}
|
||||
onClick={() => {
|
||||
if (!spatialAvailable) return;
|
||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "3d"));
|
||||
}}
|
||||
>
|
||||
<span className="m48-evidence-mode-controls__text" aria-hidden="true">3D</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
||||
aria-pressed={spatialMode === "plan"}
|
||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||
onClick={() => {
|
||||
if (!spatialAvailable) return;
|
||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
||||
}}
|
||||
>
|
||||
<Icon name="plan" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48EvidenceModeRail(props: M48EvidenceModeControlProps) {
|
||||
return (
|
||||
<GlassSurface
|
||||
className="m48-evidence-mode-rail"
|
||||
tone="strong"
|
||||
radius="pill"
|
||||
padding="sm"
|
||||
materialRim={false}
|
||||
role="toolbar"
|
||||
aria-label="Режимы CAMERA, 3D и план"
|
||||
>
|
||||
<M48EvidenceModeControls {...props} />
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
fetchM48GateStatus,
|
||||
type M48GateStatus,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import type { LaboratoryAnnotationAction } from "../../contracts";
|
||||
import { M48CorrectionWorkspace } from "./M48BlindReviewWorkspace";
|
||||
|
||||
export function useM48ReviewCapability({
|
||||
selectedWorkId,
|
||||
initialGate,
|
||||
onActionChange,
|
||||
}: {
|
||||
selectedWorkId: string;
|
||||
initialGate: M48GateStatus | null;
|
||||
onActionChange: (action: LaboratoryAnnotationAction | null) => void;
|
||||
}): { workspace: ReactNode; active: boolean } {
|
||||
const [gate, setGate] = useState(initialGate);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ownsAction = useRef(false);
|
||||
const actionTrigger = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => setGate(initialGate), [initialGate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!gate) return;
|
||||
void fetchM48GateStatus(gate.packId).then(setGate).catch(() => undefined);
|
||||
}, [gate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedWorkId !== "m48-object-centric-quality" || !gate) {
|
||||
if (ownsAction.current) {
|
||||
onActionChange(null);
|
||||
ownsAction.current = false;
|
||||
}
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
ownsAction.current = true;
|
||||
onActionChange({
|
||||
label: open
|
||||
? "Рабочая область открыта"
|
||||
: "Проверить Worker 006",
|
||||
disabled: Boolean(open) || gate.evaluated,
|
||||
onClick: () => {
|
||||
actionTrigger.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
setOpen(true);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
if (ownsAction.current) {
|
||||
onActionChange(null);
|
||||
ownsAction.current = false;
|
||||
}
|
||||
};
|
||||
}, [gate, onActionChange, open, selectedWorkId]);
|
||||
|
||||
if (!gate || !open) return { workspace: null, active: false };
|
||||
return {
|
||||
active: true,
|
||||
workspace: (
|
||||
<M48CorrectionWorkspace
|
||||
gate={gate}
|
||||
returnFocusTarget={actionTrigger.current}
|
||||
onClose={() => setOpen(false)}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchM48ReviewSpatialFrame,
|
||||
type M48ReviewClipSource,
|
||||
type M48ReviewSpatialFrame,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
|
||||
export const M48_SPATIAL_PREFETCH_FRAME_COUNT = 14;
|
||||
export const M48_SPATIAL_CACHE_FRAME_LIMIT = 24;
|
||||
|
||||
export function m48SpatialPlaybackWindow(
|
||||
frames: readonly { sequence: number }[],
|
||||
sequence: number,
|
||||
frameCount = M48_SPATIAL_PREFETCH_FRAME_COUNT,
|
||||
): readonly number[] {
|
||||
if (!frames.length || frameCount <= 0) return [];
|
||||
const currentIndex = Math.max(0, frames.findIndex((frame) => frame.sequence === sequence));
|
||||
const count = Math.min(frameCount, frames.length);
|
||||
return Array.from({ length: count }, (_, offset) => (
|
||||
frames[(currentIndex + offset) % frames.length]!.sequence
|
||||
));
|
||||
}
|
||||
|
||||
export function trimM48SpatialPlaybackCache<T>(
|
||||
cache: Map<number, T>,
|
||||
protectedSequences: readonly number[],
|
||||
limit = M48_SPATIAL_CACHE_FRAME_LIMIT,
|
||||
): void {
|
||||
const protectedSet = new Set(protectedSequences);
|
||||
for (const key of cache.keys()) {
|
||||
if (cache.size <= limit) return;
|
||||
if (!protectedSet.has(key)) cache.delete(key);
|
||||
}
|
||||
for (const key of cache.keys()) {
|
||||
if (cache.size <= limit) return;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Spatial evidence для текущего кадра недоступно.";
|
||||
}
|
||||
|
||||
function aborted(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useM48SpatialClipPlayback({
|
||||
packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled,
|
||||
}: {
|
||||
packId: string;
|
||||
clip: M48ReviewClipSource | null;
|
||||
sequence: number;
|
||||
enabled: boolean;
|
||||
}): {
|
||||
frame: M48ReviewSpatialFrame | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} {
|
||||
const cacheRef = useRef(new Map<number, M48ReviewSpatialFrame>());
|
||||
const errorsRef = useRef(new Map<number, string>());
|
||||
const inFlightRef = useRef(new Map<number, Promise<void>>());
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const [, setRevision] = useState(0);
|
||||
const sourceKey = enabled && clip ? `${packId}:${clip.clipId}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = sourceKey ? new AbortController() : null;
|
||||
cacheRef.current.clear();
|
||||
errorsRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
setRevision((value) => value + 1);
|
||||
return () => controllerRef.current?.abort();
|
||||
}, [sourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = controllerRef.current;
|
||||
if (!sourceKey || !clip || !controller || controller.signal.aborted) return;
|
||||
const generation = generationRef.current;
|
||||
const wanted = m48SpatialPlaybackWindow(clip.frames, sequence);
|
||||
|
||||
const load = (nextSequence: number): Promise<void> => {
|
||||
const existing = inFlightRef.current.get(nextSequence);
|
||||
if (existing) return existing;
|
||||
if (cacheRef.current.has(nextSequence)) return Promise.resolve();
|
||||
const request = fetchM48ReviewSpatialFrame(
|
||||
packId,
|
||||
clip.clipId,
|
||||
nextSequence,
|
||||
{ signal: controller.signal },
|
||||
).then((next) => {
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return;
|
||||
cacheRef.current.set(nextSequence, next);
|
||||
errorsRef.current.delete(nextSequence);
|
||||
trimM48SpatialPlaybackCache(cacheRef.current, wanted);
|
||||
setRevision((value) => value + 1);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted || aborted(caught) || generation !== generationRef.current) return;
|
||||
errorsRef.current.set(nextSequence, errorMessage(caught));
|
||||
setRevision((value) => value + 1);
|
||||
}).finally(() => {
|
||||
if (generation === generationRef.current) inFlightRef.current.delete(nextSequence);
|
||||
});
|
||||
inFlightRef.current.set(nextSequence, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
for (const nextSequence of wanted) void load(nextSequence);
|
||||
}, [clip, packId, sequence, sourceKey]);
|
||||
|
||||
const frame = sourceKey ? cacheRef.current.get(sequence) ?? null : null;
|
||||
return {
|
||||
frame,
|
||||
loading: Boolean(sourceKey && !frame && !errorsRef.current.has(sequence)),
|
||||
error: sourceKey ? errorsRef.current.get(sequence) ?? null : null,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,20 @@ interface KnownWorkDefinition {
|
||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||
|
||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
||||
"m48-object-centric-quality": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + prediction-free spatial evidence`,
|
||||
experimentId: "m48-object-centric-source-quality",
|
||||
experimentName: "RAVNOVES00 class-free object-centric source quality",
|
||||
variantName: "M4.8 · Worker 006 assisted correction → evidence delta",
|
||||
},
|
||||
"m48-small-static-passage-regression": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + prediction-free spatial evidence`,
|
||||
experimentId: "m48-small-static-passage-regression",
|
||||
experimentName: "M4.8 · small static passage regression",
|
||||
variantName: "M4.8R1 · Worker 006 small-static assisted baseline",
|
||||
},
|
||||
"m47-reference-graph-shadow": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
|
||||
|
||||
@@ -19,6 +19,8 @@ function mergeResults(
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m47Graph: next.m47Graph ?? current.m47Graph,
|
||||
m48: next.m48 ?? current.m48,
|
||||
m48SmallStatic: next.m48SmallStatic ?? current.m48SmallStatic,
|
||||
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||
l3: next.l3 ?? current.l3,
|
||||
l31: next.l31 ?? current.l31,
|
||||
@@ -111,7 +113,14 @@ export function useAdvancedLaboratoryCatalog({
|
||||
|| advancedLaboratoryResultAvailable(selectedWorkId, results)
|
||||
) return;
|
||||
const indexedResultId = index.find((item) => item.workId === selectedWorkId)?.resultId;
|
||||
if (selectedWorkId === "m47-reference-graph-shadow" && !indexedResultId) return;
|
||||
if (
|
||||
[
|
||||
"m47-reference-graph-shadow",
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
].includes(selectedWorkId)
|
||||
&& !indexedResultId
|
||||
) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingWorkId(selectedWorkId);
|
||||
setFailedWorkId(null);
|
||||
|
||||
@@ -963,6 +963,46 @@ test("LAB entry defaults atomically to the freshest pipeline, experiment and run
|
||||
});
|
||||
});
|
||||
|
||||
test("M4.8R1 stays in the current pipeline as a separate experiment and run", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
knownWorks: [],
|
||||
advancedIndex: [
|
||||
{
|
||||
workId: "m48-object-centric-quality",
|
||||
resultId: `m48-object-quality-pack-${"8".repeat(64)}`,
|
||||
createdAtUtc: "2026-08-24T12:00:00Z",
|
||||
},
|
||||
{
|
||||
workId: "m48-small-static-passage-regression",
|
||||
resultId: `m48-small-static-passage-regression-${"9".repeat(64)}`,
|
||||
createdAtUtc: "2026-08-24T18:30:00Z",
|
||||
},
|
||||
],
|
||||
publishedWorks: [],
|
||||
});
|
||||
|
||||
const profiles = buildLaboratoryProfiles(catalog);
|
||||
assert.deepEqual(profiles.map(({ id }) => id), [
|
||||
"rig-dual-evidence-virtual-corridor-v1",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
experimentOptionsForProfile(profiles[0].id, catalog).map(({ id }) => id),
|
||||
[
|
||||
"m48-small-static-passage-regression",
|
||||
"m48-object-centric-source-quality",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
freshestLaboratorySelection(catalog),
|
||||
{
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
experimentId: "m48-small-static-passage-regression",
|
||||
workId: "m48-small-static-passage-regression",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("E46E is exposed as the newest independent NVIDIA pipeline", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
|
||||
@@ -264,6 +264,11 @@ test("LAB product surface has a compact canonical summary and no roadmap footer"
|
||||
]);
|
||||
|
||||
assert.match(presentationSource, /export function LaboratorySummary/);
|
||||
assert.match(presentationSource, /const \[expanded, setExpanded\] = useState\(false\)/);
|
||||
assert.match(presentationSource, /aria-expanded=\{expanded\}/);
|
||||
assert.match(presentationSource, /className="laboratory-summary__details" hidden=\{!expanded\}/);
|
||||
assert.match(presentationSource, /className="laboratory-summary__actions"/);
|
||||
assert.match(presentationSource, /name="chevron-down"/);
|
||||
assert.match(presentationSource, /export function LaboratoryWorkTemplate/);
|
||||
assert.match(
|
||||
presentationSource,
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeM48GateStatus;
|
||||
let decodeM48ReviewSourceCatalog;
|
||||
let decodeM48CorrectionSession;
|
||||
let decodeM48SpatialFrame;
|
||||
let decodeM48QualityResult;
|
||||
let decodeM48FailureAtlas;
|
||||
let decodeM48FailureCase;
|
||||
let assertM48BlindPayload;
|
||||
let interpolateM48Extent;
|
||||
let createM48Tracklet;
|
||||
let nextM48ObjectId;
|
||||
let laboratoryMetricLegendEntries;
|
||||
let nearestLaboratoryRecordedClipFrame;
|
||||
let laboratoryRecordedClipEndExclusiveNs;
|
||||
let m48SpatialPlaybackWindow;
|
||||
let trimM48SpatialPlaybackCache;
|
||||
let nextM48CameraVisibility;
|
||||
let nextM48SpatialMode;
|
||||
|
||||
const packId = `m48-object-quality-pack-${"a".repeat(64)}`;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } });
|
||||
({
|
||||
decodeM48GateStatus,
|
||||
decodeM48ReviewSourceCatalog,
|
||||
decodeM48CorrectionSession,
|
||||
decodeM48SpatialFrame,
|
||||
decodeM48QualityResult,
|
||||
decodeM48FailureAtlas,
|
||||
decodeM48FailureCase,
|
||||
assertM48BlindPayload,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/m48ObjectCentricQuality.ts"));
|
||||
({
|
||||
interpolateM48Extent,
|
||||
createM48Tracklet,
|
||||
nextM48ObjectId,
|
||||
} = await server.ssrLoadModule("/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx"));
|
||||
({
|
||||
nearestLaboratoryRecordedClipFrame,
|
||||
laboratoryRecordedClipEndExclusiveNs,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
||||
));
|
||||
({ laboratoryMetricLegendEntries } = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx",
|
||||
));
|
||||
({
|
||||
m48SpatialPlaybackWindow,
|
||||
trimM48SpatialPlaybackCache,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts",
|
||||
));
|
||||
({
|
||||
nextM48CameraVisibility,
|
||||
nextM48SpatialMode,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => server?.close());
|
||||
|
||||
function authority() {
|
||||
return {
|
||||
mode: "replay-simulated",
|
||||
physical_live: false,
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function source() {
|
||||
const clips = Array.from({ length: 20 }, (_, index) => {
|
||||
const start = index * 2 + 1;
|
||||
return {
|
||||
clip_id: `clip-${String(index + 1).padStart(2, "0")}`,
|
||||
start_sequence: start,
|
||||
end_sequence: start + 1,
|
||||
frames: [start, start + 1].map((sequence) => ({
|
||||
sequence,
|
||||
source_time_ns: (sequence - 1) * 100_000_000,
|
||||
camera_fragment_sha256: String(index % 10).repeat(64),
|
||||
camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-${String(index + 1).padStart(2, "0")}/frames/${sequence}/camera`,
|
||||
spatial_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-${String(index + 1).padStart(2, "0")}/frames/${sequence}/spatial`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return {
|
||||
schema_version: "missioncore.m48-neutral-object-review-source/v2",
|
||||
pack_id: packId,
|
||||
state: "prediction-blind-neutral-source-projection",
|
||||
contract: {
|
||||
contract_id: "m48-class-free-object-tracklet/v1",
|
||||
label_unit: "clip-local-object-tracklet",
|
||||
semantic_classes_allowed: false,
|
||||
extent: "normalized-xyxy-sparse-keyframes",
|
||||
extent_interpolation: "linear-between-bounding-keyframes",
|
||||
visibility: ["occluded", "partial", "visible"],
|
||||
state_segments: {
|
||||
coverage: "contiguous-full-tracklet-lifetime",
|
||||
geometry_association: ["associated", "ineligible", "unavailable", "unknown"],
|
||||
freshness: ["current", "held", "stale", "unavailable"],
|
||||
motion: ["moving", "static", "unknown", "unsupported"],
|
||||
threat: ["not-threat", "threat", "unknown"],
|
||||
critical_corridor_obstacle: "boolean",
|
||||
},
|
||||
},
|
||||
camera_playback: {
|
||||
schema_version: "missioncore.laboratory-recorded-clip-camera/v1",
|
||||
source_id: "recorded.camera.test",
|
||||
label: "Записанная RIGHT камера",
|
||||
manifest_url: "/api/v1/observation-sessions/recorded-session/media/recorded-video-test/manifest",
|
||||
manifest_generation_sha256: "f".repeat(64),
|
||||
byte_length: 123456,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 4,
|
||||
segment_count: 40,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
transport: "recorded-fmp4-manifest",
|
||||
fragment_binding: "pack-frozen-sha256-verified",
|
||||
},
|
||||
clips,
|
||||
clip_count: clips.length,
|
||||
frame_count: 40,
|
||||
strata_included: false,
|
||||
split_included: false,
|
||||
candidate_identity_included: false,
|
||||
frozen_predictions_included: false,
|
||||
model_scores_included: false,
|
||||
semantic_class_task_included: false,
|
||||
evidence_capabilities: {
|
||||
state: "prediction-free-spatial-evidence-available",
|
||||
camera_epoch_time: true,
|
||||
current_point_cloud_body_xyz_m: true,
|
||||
rig: true,
|
||||
virtual_corridor: true,
|
||||
raw_lidar: false,
|
||||
graph_output: false,
|
||||
graph_boxes_ids_scores: false,
|
||||
label_authority: {
|
||||
obstacle_presence_and_extent: true,
|
||||
geometry_association: true,
|
||||
freshness: true,
|
||||
motion: true,
|
||||
threat: true,
|
||||
critical_corridor_obstacle: true,
|
||||
},
|
||||
fail_closed_reason: null,
|
||||
},
|
||||
access: "prediction-free-strata-free-source-read-only",
|
||||
};
|
||||
}
|
||||
|
||||
test("M4.8 pack status stays fail-closed and command-free", () => {
|
||||
const result = decodeM48GateStatus({
|
||||
schema_version: "missioncore.m48-object-quality-pack-status/v1",
|
||||
pack_id: packId,
|
||||
created_at_utc: "2026-08-24T10:00:00Z",
|
||||
state: "prepared",
|
||||
metrics: { clip_count: 20, frame_count: 1020, seed_object_count: 5236, correction_state: "not-started", correction_reviewed_clip_count: 0, correction_complete: false, review_slot_count: 0, frozen_reviewer_count: 0, required_frozen_reviewer_count: 2 },
|
||||
decision: { review_collection_ready: true, two_distinct_reviews_frozen: false, adjudication_unlocked: false, adjudication_frozen: false, evaluated: false, next_action: "freeze two reviews" },
|
||||
truth_seal_id: null,
|
||||
quality_result_id: null,
|
||||
blindness: { candidate_identity_included: false, frozen_predictions_included: false, model_scores_included: false, semantic_class_task_included: false, strata_included: false },
|
||||
authority: authority(),
|
||||
access: "neutral-workflow-status-read-only",
|
||||
});
|
||||
assert.equal(result.packId, packId);
|
||||
assert.equal(result.authority.commandsEnabled, false);
|
||||
assert.equal(result.correctionState, "not-started");
|
||||
assert.equal(result.seedObjectCount, 5236);
|
||||
assert.equal(result.requiredFrozenReviewerCount, 2);
|
||||
});
|
||||
|
||||
test("blind source admits fragment identity and structurally rejects candidate material", () => {
|
||||
const result = decodeM48ReviewSourceCatalog(source());
|
||||
assert.equal(result.clips.length, 20);
|
||||
assert.equal(result.cameraPlayback.segmentCount, 40);
|
||||
assert.equal(result.cameraPlayback.manifestGenerationSha256, "f".repeat(64));
|
||||
assert.equal(result.clips[0].frames[0].cameraFragmentSha256.length, 64);
|
||||
assert.equal(result.evidenceCapabilities.currentPointCloudBodyXyzM, true);
|
||||
|
||||
for (const leak of [
|
||||
{ predictions: [] },
|
||||
{ strata: ["no-object"] },
|
||||
{ model: { id: "candidate" } },
|
||||
{ graph: [] },
|
||||
{ split: "validation" },
|
||||
{ semantic_class: "car" },
|
||||
]) assert.throws(() => assertM48BlindPayload(leak), /blind|candidate|stratum/i);
|
||||
|
||||
const staleName = source();
|
||||
staleName.clips[0].frames[0].camera_frame_sha256 = staleName.clips[0].frames[0].camera_fragment_sha256;
|
||||
delete staleName.clips[0].frames[0].camera_fragment_sha256;
|
||||
assert.throws(() => decodeM48ReviewSourceCatalog(staleName), /состав полей/);
|
||||
});
|
||||
|
||||
test("candidate-assisted correction admits frozen boxes without claiming independent truth", () => {
|
||||
const candidate = {
|
||||
schema_version: "missioncore.m48-assisted-object-correction-session/v1",
|
||||
pack_id: packId,
|
||||
session_id: `m48-correction-session-${"b".repeat(64)}`,
|
||||
title: "Worker 006 correction",
|
||||
revision: 0,
|
||||
state: "draft",
|
||||
created_at_utc: "2026-08-24T12:00:00Z",
|
||||
updated_at_utc: "2026-08-24T12:00:00Z",
|
||||
clips: [{
|
||||
clip_id: "clip-01",
|
||||
start_sequence: 1,
|
||||
end_sequence: 2,
|
||||
review_state: "pending",
|
||||
no_object: null,
|
||||
tracklets: [{
|
||||
object_id: "proposal-0-0",
|
||||
first_sequence: 1,
|
||||
last_sequence: 1,
|
||||
keyframes: [{ sequence: 1, extent_xyxy: [0.1, 0.2, 0.3, 0.4], visibility: "visible" }],
|
||||
state_segments: [{ start_sequence: 1, end_sequence: 1, geometry_association: "associated", freshness: "current", motion: "unknown", threat: "unknown", critical_corridor_obstacle: false }],
|
||||
notes: null,
|
||||
}],
|
||||
notes: null,
|
||||
}],
|
||||
progress: { reviewed_clip_count: 0, clip_count: 1, complete: false },
|
||||
seed_summary: { worker_id: "006", clip_count: 1, frame_count: 2, object_count: 1, prediction_rows_sha256: "c".repeat(64) },
|
||||
evidence_summary: null,
|
||||
reviewer_id: null,
|
||||
submitted_at_utc: null,
|
||||
submission_sha256: null,
|
||||
assistance: { mode: "frozen-candidate-seeded", candidate_predictions_seen: true, model_scores_seen: false, semantic_class_task_seen: false, independent_truth_eligible: false },
|
||||
authority: authority(),
|
||||
access: "capability-protected-candidate-assisted-correction",
|
||||
};
|
||||
const decoded = decodeM48CorrectionSession(candidate);
|
||||
assert.equal(decoded.seedWorkerId, "006");
|
||||
assert.equal(decoded.seedObjectCount, 1);
|
||||
assert.equal(decoded.clips[0].tracklets[0].objectId, "proposal-0-0");
|
||||
|
||||
candidate.assistance.independent_truth_eligible = true;
|
||||
assert.throws(() => decodeM48CorrectionSession(candidate), /assistance|truth/i);
|
||||
});
|
||||
|
||||
test("spatial frame accepts only current points, virtual rig and corridor", () => {
|
||||
const result = decodeM48SpatialFrame({
|
||||
schema_version: "missioncore.m48-neutral-object-review-spatial-frame/v1",
|
||||
pack_id: packId,
|
||||
clip_id: "clip-01",
|
||||
sequence: 1,
|
||||
source_time_ns: 0,
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.25]],
|
||||
rig: { profile_id: "virtual-rig", length_m: 1, width_m: 0.6, lidar_reference: "rear", nominal_sensor_height_m: 1.25, physical_mount_claimed: false },
|
||||
corridor: { profile_id: "corridor", forward_length_m: 8, rear_margin_m: 0.2, lateral_clearance_m: 0.25, half_width_m: 0.55, prediction_horizon_seconds: 5 },
|
||||
occupied_voxel_size_m: 0.2,
|
||||
source_available: true,
|
||||
body_frame_available: true,
|
||||
candidate_identity_included: false,
|
||||
graph_boxes_ids_scores_included: false,
|
||||
frozen_predictions_included: false,
|
||||
strata_included: false,
|
||||
authority: authority(),
|
||||
access: "prediction-free-current-spatial-evidence-read-only",
|
||||
});
|
||||
assert.deepEqual(result.pointCloudBodyXyzM, [[1, 0, 0.25]]);
|
||||
assert.equal(result.sourceAvailable, true);
|
||||
assert.equal(result.bodyFrameAvailable, true);
|
||||
assert.equal(result.corridor.forwardLengthM, 8);
|
||||
|
||||
const unavailable = {
|
||||
schema_version: "missioncore.m48-neutral-object-review-spatial-frame/v1",
|
||||
pack_id: packId,
|
||||
clip_id: "clip-01",
|
||||
sequence: 1,
|
||||
source_time_ns: 0,
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.25]],
|
||||
rig: { profile_id: "virtual-rig", length_m: 1, width_m: 0.6, lidar_reference: "rear", nominal_sensor_height_m: 1.25, physical_mount_claimed: false },
|
||||
corridor: { profile_id: "corridor", forward_length_m: 8, rear_margin_m: 0.2, lateral_clearance_m: 0.25, half_width_m: 0.55, prediction_horizon_seconds: 5 },
|
||||
occupied_voxel_size_m: 0.2,
|
||||
source_available: true,
|
||||
body_frame_available: false,
|
||||
candidate_identity_included: false,
|
||||
graph_boxes_ids_scores_included: false,
|
||||
frozen_predictions_included: false,
|
||||
strata_included: false,
|
||||
authority: authority(),
|
||||
access: "prediction-free-current-spatial-evidence-read-only",
|
||||
};
|
||||
assert.throws(() => decodeM48SpatialFrame(unavailable), /unavailable spatial frame/);
|
||||
});
|
||||
|
||||
test("tracklet extents interpolate on the shared clip timeline", () => {
|
||||
const tracklet = {
|
||||
objectId: "object-01",
|
||||
firstSequence: 10,
|
||||
lastSequence: 20,
|
||||
keyframes: [
|
||||
{ sequence: 10, extentXyxy: [0.1, 0.2, 0.3, 0.4], visibility: "visible" },
|
||||
{ sequence: 20, extentXyxy: [0.2, 0.3, 0.4, 0.5], visibility: "visible" },
|
||||
],
|
||||
stateSegments: [],
|
||||
notes: null,
|
||||
};
|
||||
const midpoint = interpolateM48Extent(tracklet, 15);
|
||||
assert.ok(midpoint.every((value, index) => Math.abs(value - [0.15, 0.25, 0.35, 0.45][index]) < 1e-12));
|
||||
assert.equal(interpolateM48Extent(tracklet, 9), null);
|
||||
});
|
||||
|
||||
test("manual correction is frame-local, fails closed without spatial authority and never reuses a deleted id", () => {
|
||||
const clip = decodeM48ReviewSourceCatalog(source()).clips[0];
|
||||
const tracklet = createM48Tracklet("object-01", clip, [0.1, 0.2, 0.3, 0.4], false, clip.endSequence);
|
||||
assert.equal(tracklet.firstSequence, clip.endSequence);
|
||||
assert.equal(tracklet.lastSequence, clip.endSequence);
|
||||
assert.equal(tracklet.stateSegments[0].startSequence, clip.endSequence);
|
||||
assert.equal(tracklet.stateSegments[0].endSequence, clip.endSequence);
|
||||
assert.equal(interpolateM48Extent(tracklet, clip.startSequence), null);
|
||||
assert.deepEqual(interpolateM48Extent(tracklet, clip.endSequence), [0.1, 0.2, 0.3, 0.4]);
|
||||
assert.equal(tracklet.stateSegments[0].geometryAssociation, "unavailable");
|
||||
assert.equal(tracklet.stateSegments[0].motion, "unsupported");
|
||||
assert.equal(nextM48ObjectId([
|
||||
tracklet,
|
||||
{ ...tracklet, objectId: "object-03" },
|
||||
]), "object-02");
|
||||
});
|
||||
|
||||
test("shared recorded clip clock selects exact frames and one stable loop boundary", () => {
|
||||
const frames = [
|
||||
{ sequence: 11, sourceTimeNs: 1_000_000_000 },
|
||||
{ sequence: 12, sourceTimeNs: 1_100_000_000 },
|
||||
{ sequence: 13, sourceTimeNs: 1_200_000_000 },
|
||||
];
|
||||
assert.equal(nearestLaboratoryRecordedClipFrame(frames, 1_049_000_000).sequence, 11);
|
||||
assert.equal(nearestLaboratoryRecordedClipFrame(frames, 1_051_000_000).sequence, 12);
|
||||
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(nextM48CameraVisibility("3d", false), true);
|
||||
assert.equal(nextM48SpatialMode("camera", true, "3d"), "3d");
|
||||
assert.equal(nextM48SpatialMode("3d", true, "3d"), "camera");
|
||||
assert.equal(nextM48SpatialMode("3d", false, "3d"), "3d");
|
||||
assert.equal(nextM48SpatialMode("3d", false, "plan"), "plan");
|
||||
});
|
||||
|
||||
test("M4.8 spatial playback prefetches across the loop and remains bounded", () => {
|
||||
const frames = [11, 12, 13, 14, 15].map((sequence) => ({ sequence }));
|
||||
assert.deepEqual(m48SpatialPlaybackWindow(frames, 14, 4), [14, 15, 11, 12]);
|
||||
|
||||
const cache = new Map(Array.from({ length: 30 }, (_, index) => [index + 1, index]));
|
||||
trimM48SpatialPlaybackCache(cache, [28, 29, 30, 1], 24);
|
||||
assert.equal(cache.size, 24);
|
||||
for (const sequence of [28, 29, 30, 1]) assert.equal(cache.has(sequence), true);
|
||||
});
|
||||
|
||||
test("post-seal result and atlas reveal bounded graph material only after evaluation", () => {
|
||||
const resultId = `m48-object-quality-result-${"b".repeat(64)}`;
|
||||
const truthId = `m48-object-truth-seal-${"c".repeat(64)}`;
|
||||
const metricNames = [
|
||||
"terminal_outcome_accounting",
|
||||
"false_free_space_claims",
|
||||
"obstacle_presence_precision",
|
||||
"obstacle_presence_recall",
|
||||
"critical_corridor_obstacle_recall",
|
||||
"geometry_association_correctness",
|
||||
"freshness_correctness",
|
||||
"motion_decision_correctness",
|
||||
"critical_threat_not_threat",
|
||||
"unknown_prediction_count",
|
||||
"failure_case_count",
|
||||
];
|
||||
const metrics = Object.fromEntries(metricNames.map((name) => [name, name.endsWith("_count") || name === "false_free_space_claims" ? 0 : 1]));
|
||||
const quality = decodeM48QualityResult({
|
||||
schema_version: "missioncore.m48-object-centric-quality-result-view/v1",
|
||||
result_id: resultId,
|
||||
pack_id: packId,
|
||||
truth_seal_id: truthId,
|
||||
created_at_utc: "2026-08-24T12:00:00Z",
|
||||
status: "accepted-object-centric-source-quality",
|
||||
accepted: true,
|
||||
metrics,
|
||||
gates: { obstacle_presence_precision: true },
|
||||
unknown_causes: {},
|
||||
prediction_material_release: "post-adjudication-seal-evaluation-only",
|
||||
authority: authority(),
|
||||
ground_truth: false,
|
||||
access: "evaluated-object-quality-summary-read-only",
|
||||
});
|
||||
assert.equal(quality.accepted, true);
|
||||
|
||||
const caseId = `m48-failure-${"d".repeat(64)}`;
|
||||
const atlas = decodeM48FailureAtlas({
|
||||
schema_version: "missioncore.m48-object-quality-failure-atlas-view/v1",
|
||||
result_id: resultId,
|
||||
cases: [{ schema_version: "missioncore.m48-object-quality-failure/v1", failure_case_id: caseId, clip_id: "clip-01", split: "validation", sequence: 1, causes: ["presence-false-negative"], severity: "high", terminal_outcome: "delivered", unmatched_prediction_ids: [], unmatched_truth_object_ids: ["object-01"] }],
|
||||
case_count: 1,
|
||||
prediction_material_release: "post-adjudication-seal-evaluation-only",
|
||||
authority: authority(),
|
||||
access: "evaluated-bounded-failure-atlas-read-only",
|
||||
});
|
||||
assert.equal(atlas[0].caseId, caseId);
|
||||
assert.equal(atlas[0].split, "validation");
|
||||
|
||||
const failure = decodeM48FailureCase({
|
||||
schema_version: "missioncore.m48-object-quality-failure-case-view/v1",
|
||||
result_id: resultId,
|
||||
case: { failure_case_id: caseId, clip_id: "clip-01", split: "validation", sequence: 1, causes: ["presence-false-negative"], severity: "high" },
|
||||
frame: { sequence: 1, source_time_ns: 0, camera_fragment_sha256: "e".repeat(64), camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-01/frames/1/camera`, spatial_url: null },
|
||||
truth: [{ object_id: "object-01", extent_xyxy: [0.1, 0.1, 0.3, 0.4], geometry_association: "associated", freshness: "current", motion: "static", threat: "threat" }],
|
||||
graph: [],
|
||||
prediction_material_release: "post-adjudication-seal-evaluation-only",
|
||||
authority: authority(),
|
||||
access: "evaluated-failure-case-read-only",
|
||||
});
|
||||
assert.equal(failure.truth[0].objectId, "object-01");
|
||||
assert.equal(failure.frame.cameraFragmentSha256, "e".repeat(64));
|
||||
});
|
||||
|
||||
test("M4.8 evidence keeps the shared viewer stage stretched over the visual frame", () => {
|
||||
const stylesheet = readFileSync(
|
||||
new URL("../src/styles/laboratory-recorded-clip-player.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const player = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const shared = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryRecordedClipPlayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const visual = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/M48FailureAtlasVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const evidenceViewer = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryEvidenceViewer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const laboratoryStyles = readFileSync(
|
||||
new URL("../src/styles/laboratory-evidence-viewer.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const modeControls = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
stylesheet,
|
||||
/\.laboratory-recorded-clip-player__camera\s*>\s*\.recorded-media-player\s*\{[^}]*position:\s*absolute/s,
|
||||
);
|
||||
assert.match(player, /<LaboratoryRecordedClipPlayer/);
|
||||
assert.doesNotMatch(player, /fetch\(|image\/jpeg|createM48LatestFrameLoader|Следующий camera frame/);
|
||||
assert.match(shared, /missioncore\.laboratory-recorded-clip-viewer\/v1/);
|
||||
assert.match(shared, /<RecordedFmp4Player/);
|
||||
assert.match(shared, /<SplitPane/);
|
||||
assert.equal((shared.match(/<RecordedFmp4Player/g) ?? []).length, 1);
|
||||
assert.match(shared, /orientation="vertical"/);
|
||||
assert.match(shared, /resizable=\{companionVisible\}/);
|
||||
assert.match(shared, /separatorLabel="Изменить размер 3D\/ПЛАН и правой камеры"/);
|
||||
assert.match(shared, /segmentSequence=\{frame\?\.sequence\}/);
|
||||
assert.match(player, /cameraPresentation=\{spatialVisible/);
|
||||
assert.match(player, /effectiveCameraVisible \? "companion" : "hidden"/);
|
||||
assert.match(player, /cameraVisible: boolean/);
|
||||
assert.match(player, /continuousPlayback/);
|
||||
assert.doesNotMatch(player, /continuousPlayback=\{mode === "camera"\}/);
|
||||
assert.match(visual, /chromeLayout="stacked"/);
|
||||
assert.match(visual, /modeControlsVisible=\{false\}/);
|
||||
assert.match(visual, /<M48EvidenceModeRail/);
|
||||
assert.match(modeControls, /<GlassSurface/);
|
||||
assert.match(modeControls, /className="m48-evidence-mode-rail"/);
|
||||
assert.match(modeControls, /radius="pill"/);
|
||||
assert.match(modeControls, /padding="sm"/);
|
||||
assert.match(modeControls, /materialRim=\{false\}/);
|
||||
assert.match(modeControls, /<Icon name="video" size=\{16\}/);
|
||||
assert.match(modeControls, /<Icon name="plan" size=\{16\}/);
|
||||
assert.match(modeControls, />3D<\/span>/);
|
||||
assert.doesNotMatch(modeControls, /<SegmentedControl|<Button/);
|
||||
assert.doesNotMatch(modeControls, /value: "camera"[\s\S]*value: "3d"/);
|
||||
assert.match(evidenceViewer, /chromeLayout\?: "overlay" \| "stacked"/);
|
||||
assert.match(evidenceViewer, /laboratory-evidence-viewer__header/);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/data-chrome-layout="stacked"[\s\S]*?grid-template-rows:\s*auto minmax\(0, 1fr\) auto/,
|
||||
);
|
||||
assert.match(
|
||||
stylesheet,
|
||||
/data-chrome-layout="stacked"[\s\S]*?laboratory-recorded-clip-player\s*\{[\s\S]*?gap:\s*0/,
|
||||
);
|
||||
assert.doesNotMatch(stylesheet, /grid-template-columns:[^;]*31%/);
|
||||
assert.doesNotMatch(
|
||||
stylesheet,
|
||||
/border-(?:left|top):\s*1px solid var\(--nodedc-glass-outline\)/,
|
||||
);
|
||||
assert.match(
|
||||
stylesheet,
|
||||
/nodedc-split-pane__separator::before\s*\{[^}]*background:\s*transparent/s,
|
||||
);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/laboratory-evidence-viewer__header\s*\{[^}]*border:\s*0/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("LAB workspaces cannot fork the frozen recorded clip transport", () => {
|
||||
const root = new URL("../src/workspaces/laboratory/", import.meta.url);
|
||||
const sourceFiles = readdirSync(root, { recursive: true })
|
||||
.filter((name) => /\.(?:ts|tsx)$/.test(String(name)));
|
||||
for (const name of sourceFiles) {
|
||||
const sourceText = readFileSync(new URL(String(name), root), "utf8");
|
||||
assert.doesNotMatch(
|
||||
sourceText,
|
||||
/new\s+MediaSource|requestVideoFrameCallback|URL\.createObjectURL|image\/jpeg|setTimeout\s*\(|<RecordedFmp4Player/,
|
||||
`${name} forks missioncore.laboratory-recorded-clip-viewer/v1`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("M4.8 review and adjudication share one focus-owning workspace frame", () => {
|
||||
const review = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const adjudication = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48AdjudicationWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const frame = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryReviewWorkspaceFrame.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const frameStyles = readFileSync(
|
||||
new URL("../src/styles/laboratory-review-workspace.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const m48Styles = readFileSync(
|
||||
new URL("../src/styles/m48-object-centric-quality.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const recordedStyles = readFileSync(
|
||||
new URL("../src/styles/laboratory-recorded-clip-player.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
for (const workspace of [review, adjudication]) {
|
||||
assert.match(workspace, /LaboratoryReviewWorkspaceFrame/);
|
||||
assert.doesNotMatch(workspace, /createPortal|className="m48-review-workspace"/);
|
||||
assert.doesNotMatch(
|
||||
workspace,
|
||||
/@rerun-io|RerunViewer|Blueprint|view_id|blueprint_id/,
|
||||
"M4.8 must reuse shared viewers instead of defining a per-LAB Rerun layout",
|
||||
);
|
||||
}
|
||||
assert.match(frame, /document\.body\.style\.overflow = "hidden"/);
|
||||
assert.match(frame, /event\.key === "Escape"/);
|
||||
assert.match(frame, /keepFocusInside/);
|
||||
assert.match(frame, /returnFocusTarget\?\.isConnected/);
|
||||
assert.match(frame, /requestAnimationFrame\(\(\) => target\?\.focus\(\)\)/);
|
||||
|
||||
const player = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(player, /spatialFrame\.bodyFrameAvailable/);
|
||||
assert.match(player, /LiDAR в системе координат корпуса для этого кадра недоступен/);
|
||||
assert.match(player, /Number\(effectiveCameraVisible\) \+ Number\(spatialVisible\)/);
|
||||
assert.match(player, /data-spatial-sequence=\{spatialReady \? sequence : undefined\}/);
|
||||
assert.match(review, /<M48EvidenceModeRail/);
|
||||
assert.match(adjudication, /<M48EvidenceModeRail/);
|
||||
assert.match(review, /<FieldFrame label="Выбор клипа"/);
|
||||
assert.match(review, /label="Предыдущий клип"/);
|
||||
assert.match(review, /label="Следующий клип"/);
|
||||
assert.match(review, /<IconButton\s+label="Добавить объект"/);
|
||||
assert.doesNotMatch(review, />\s*Добавить объект\s*<\/Button>/);
|
||||
assert.ok(review.indexOf('label="Предыдущий клип"') < review.indexOf('<FieldFrame label="Выбор клипа"'));
|
||||
assert.ok(review.indexOf('className="m48-review-workspace__clip-reviewed"') < review.indexOf('className="m48-review-workspace__topbar-end"'));
|
||||
assert.doesNotMatch(review, /m48-review-workspace__topbar-center/);
|
||||
assert.ok(review.indexOf('label="Добавить объект"') < review.indexOf('label="Закрыть проверку M4.8"'));
|
||||
assert.doesNotMatch(review, /label="Сохранить всю проверку"/);
|
||||
assert.match(review, /void save\(nextDrafts, "clip-status", \{ drafts, dirty \}\)/);
|
||||
assert.match(review, /title: reason === "clip-status" \? "Статус клипа сохранён"/);
|
||||
assert.match(review, /setDrafts\(rollback\.drafts\);\s*setDirty\(rollback\.dirty\);/);
|
||||
assert.match(review, /className="m48-review-workspace__stage-shell"/);
|
||||
assert.match(review, /className="m48-review-workspace__source-sticker"/);
|
||||
assert.match(review, /materialRim=\{false\}/);
|
||||
assert.match(review, /label="Добавить ещё один объект"/);
|
||||
assert.match(review, /label="Сохранить изменения объекта"/);
|
||||
assert.match(review, /<FieldFrame label="Проезд"/);
|
||||
assert.match(review, /label="Объезд или запас"/);
|
||||
assert.match(review, /<FieldFrame label="Непосредственная опасность"/);
|
||||
assert.match(review, /label="Закрыть редактор объекта"/);
|
||||
assert.doesNotMatch(review, /className="m48-review-workspace__(?:reviewbar|review-actions|source-state)"/);
|
||||
assert.doesNotMatch(review, />\s*Сохранить изменения\s*<\/Button>/);
|
||||
assert.doesNotMatch(review, /Выбранный объект|Рамка здесь|Начало здесь|Конец здесь|trimM48Tracklet|рамка переносится по клипу/);
|
||||
assert.match(review, /sequence < selectedTracklet\.firstSequence \|\| sequence > selectedTracklet\.lastSequence/);
|
||||
assert.match(player, /interactionMoved\([\s\S]*?boxInteraction\.startClient,[\s\S]*?event\.clientX/);
|
||||
assert.match(player, /extentsDiffer\(boxInteraction\.originalExtent, extent\)/);
|
||||
assert.match(player, /firstSequence: sequence/);
|
||||
assert.match(player, /lastSequence: sequence/);
|
||||
assert.doesNotMatch(review, /inspector=\{/);
|
||||
assert.match(frame, /\{inspector \? <footer/);
|
||||
assert.doesNotMatch(frameStyles, /border-(?:top|bottom):/);
|
||||
assert.match(m48Styles, /\.m48-clip-player__pane-label\s*\{[^}]*border:\s*0/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__object-tools\s*\{[^}]*justify-content:\s*flex-start/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__topbar\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto/s);
|
||||
assert.match(m48Styles, /\.m48-evidence-mode-rail\s*\{[^}]*position:\s*absolute[^}]*left:\s*var\(--nodedc-space-4\)[^}]*translateY\(-50%\)/s);
|
||||
assert.match(m48Styles, /\.m48-evidence-mode-controls\s*\{[^}]*flex-direction:\s*column[^}]*gap:\s*var\(--nodedc-space-2\)/s);
|
||||
assert.match(m48Styles, /\.m48-evidence-mode-controls__text\s*\{[^}]*font-size:\s*var\(--nodedc-font-size-xs\)/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__clip-field\s*\{[^}]*19vw/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__clip-reviewed\s*\{[^}]*16vw/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__passage-field\s*\{[^}]*18vw/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__source-sticker\s*\{[^}]*position:\s*absolute[^}]*pointer-events:\s*none/s);
|
||||
assert.match(
|
||||
recordedStyles,
|
||||
/\.laboratory-recorded-clip-player__timeline\.observation-timeline\s*\{[^}]*border:\s*0/s,
|
||||
);
|
||||
assert.match(
|
||||
recordedStyles,
|
||||
/grid-template-columns:\s*auto auto auto minmax\(12rem, 1fr\) auto/,
|
||||
);
|
||||
});
|
||||
|
||||
test("spatial legend exposes only layers present in the current evidence contract", () => {
|
||||
const sourceOnly = laboratoryMetricLegendEntries({
|
||||
pointCloudCount: 128,
|
||||
localSurfaceCount: 0,
|
||||
obstacles: [],
|
||||
showCurrentIncrement: true,
|
||||
showLocalSurface: false,
|
||||
showRollingMap: false,
|
||||
});
|
||||
assert.deepEqual(sourceOnly, [{ id: "context", label: "Текущий кадр" }]);
|
||||
|
||||
const threat = laboratoryMetricLegendEntries({
|
||||
pointCloudCount: 128,
|
||||
localSurfaceCount: 64,
|
||||
obstacles: [{
|
||||
id: "obstacle-1",
|
||||
decision: "threat",
|
||||
state: "retained",
|
||||
centroidBodyXyzM: [1, 0, 0],
|
||||
cellCentersBodyXyzM: [[1, 0, 0]],
|
||||
}],
|
||||
showCurrentIncrement: true,
|
||||
showLocalSurface: true,
|
||||
showRollingMap: true,
|
||||
});
|
||||
assert.deepEqual(threat.map(({ id }) => id), [
|
||||
"threat",
|
||||
"context",
|
||||
"local-surface",
|
||||
"rolling",
|
||||
]);
|
||||
});
|
||||
|
||||
test("M4.8 full-screen workflow suspends the background evidence owner", () => {
|
||||
const capability = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const archive = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(capability, /\{ workspace: ReactNode; active: boolean \}/);
|
||||
assert.match(archive, /!m48Review\.active \? <div className="laboratory-work-output">/);
|
||||
assert.match(archive, /\{m48Review\.workspace\}/);
|
||||
});
|
||||
|
||||
test("metric evidence keeps one WebGL renderer while frame labels advance", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(source, /renderer\.domElement\.setAttribute\("aria-label", label\)/);
|
||||
assert.match(source, /renderer\.dispose\(\);[\s\S]{0,600}?\}, \[\]\);/);
|
||||
assert.match(
|
||||
source,
|
||||
/querySelector\("canvas"\)[\s\S]*?setAttribute\("aria-label", label\)[\s\S]*?\}, \[label\]\);/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchM48SmallStaticRegression;
|
||||
let fetchM48SmallStaticRegressionCases;
|
||||
let fetchM48SmallStaticRegressionCase;
|
||||
|
||||
const resultId = `m48-small-static-passage-regression-${"a".repeat(64)}`;
|
||||
const packId = `m48-object-quality-pack-${"b".repeat(64)}`;
|
||||
const anchorId = `anchor-${"c".repeat(24)}`;
|
||||
const authority = {
|
||||
mode: "replay-simulated",
|
||||
physical_live: false,
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } });
|
||||
({
|
||||
fetchM48SmallStaticRegression,
|
||||
fetchM48SmallStaticRegressionCases,
|
||||
fetchM48SmallStaticRegressionCase,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/m48SmallStaticRegression.ts"));
|
||||
});
|
||||
|
||||
after(async () => server?.close());
|
||||
|
||||
function response(value) {
|
||||
return { ok: true, status: 200, json: async () => value };
|
||||
}
|
||||
|
||||
test("M4.8R1 keeps pipeline identity and assisted evidence authority explicit", async () => {
|
||||
const summary = await fetchM48SmallStaticRegression(resultId, {
|
||||
fetcher: async () => response({
|
||||
schema_version: "missioncore.m48-small-static-passage-regression-result-view/v1",
|
||||
result_id: resultId,
|
||||
pack_id: packId,
|
||||
created_at_utc: "2026-08-24T12:00:00Z",
|
||||
run_label: "M4.8R1",
|
||||
pipeline_id: "m48-class-free-object-quality/v1",
|
||||
experiment_id: "m48-small-static-passage-regression/v1",
|
||||
accepted: false,
|
||||
metrics: {
|
||||
assisted_anchor_count: 14,
|
||||
assisted_tracklet_count: 14,
|
||||
anchor_clip_count: 8,
|
||||
requires_avoidance_or_clearance_count: 12,
|
||||
worker_recalled_anchor_count: 0,
|
||||
worker_missed_anchor_count: 14,
|
||||
assisted_anchor_recall: 0,
|
||||
extent_iou_threshold: 0.5,
|
||||
minimum_assisted_anchor_recall: 0.9,
|
||||
},
|
||||
gates: {
|
||||
anchor_set_non_empty: true,
|
||||
development_anchor_recall_target: false,
|
||||
independent_truth_available: false,
|
||||
},
|
||||
decision: {
|
||||
state: "failed-development-regression-baseline",
|
||||
summary: "Worker 006 matched 0/14.",
|
||||
next_action: "Publish another immutable run.",
|
||||
},
|
||||
ground_truth: false,
|
||||
independent_truth: false,
|
||||
authority,
|
||||
}),
|
||||
});
|
||||
assert.equal(summary.pipelineId, "m48-class-free-object-quality/v1");
|
||||
assert.equal(summary.experimentId, "m48-small-static-passage-regression/v1");
|
||||
assert.equal(summary.metrics.workerMissedAnchorCount, 14);
|
||||
assert.equal(summary.independentTruth, false);
|
||||
});
|
||||
|
||||
test("M4.8R1 bounded case binds one exact assisted anchor and frozen objects", async () => {
|
||||
const cases = await fetchM48SmallStaticRegressionCases(resultId, {
|
||||
fetcher: async () => response({
|
||||
schema_version: "missioncore.m48-small-static-passage-regression-case-catalog/v1",
|
||||
result_id: resultId,
|
||||
cases: [{
|
||||
anchor_id: anchorId,
|
||||
clip_id: "m48-clip-03",
|
||||
sequence: 256,
|
||||
requires_avoidance_or_clearance: true,
|
||||
worker_candidate_count: 1,
|
||||
best_iou: 0.01,
|
||||
matched_at_threshold: false,
|
||||
outcome: "missed-assisted-anchor",
|
||||
}],
|
||||
case_count: 1,
|
||||
ground_truth: false,
|
||||
authority,
|
||||
}),
|
||||
});
|
||||
assert.equal(cases[0].outcome, "missed-assisted-anchor");
|
||||
|
||||
const item = await fetchM48SmallStaticRegressionCase(resultId, anchorId, {
|
||||
fetcher: async () => response({
|
||||
schema_version: "missioncore.m48-small-static-passage-regression-case-view/v1",
|
||||
result_id: resultId,
|
||||
pack_id: packId,
|
||||
anchor: {
|
||||
anchor_id: anchorId,
|
||||
clip_id: "m48-clip-03",
|
||||
object_id: "object-01",
|
||||
sequence: 256,
|
||||
extent_xyxy: [0.2, 0.3, 0.4, 0.7],
|
||||
visibility: "visible",
|
||||
geometry_association: "unknown",
|
||||
freshness: "current",
|
||||
motion: "static",
|
||||
threat: "not-threat",
|
||||
requires_avoidance_or_clearance: true,
|
||||
},
|
||||
comparison: {
|
||||
anchor_id: anchorId,
|
||||
clip_id: "m48-clip-03",
|
||||
sequence: 256,
|
||||
requires_avoidance_or_clearance: true,
|
||||
worker_candidate_count: 1,
|
||||
best_iou: 0.01,
|
||||
matched_at_threshold: false,
|
||||
outcome: "missed-assisted-anchor",
|
||||
source_time_ns: 25_600_000_000,
|
||||
anchor_extent_xyxy: [0.2, 0.3, 0.4, 0.7],
|
||||
worker_objects: [{
|
||||
prediction_id: "proposal-256-0",
|
||||
extent_xyxy: [0.7, 0.2, 0.9, 0.6],
|
||||
geometry_association: "associated",
|
||||
freshness: "current",
|
||||
motion: "static",
|
||||
threat: "not-threat",
|
||||
}],
|
||||
best_prediction_id: "proposal-256-0",
|
||||
extent_iou_threshold: 0.5,
|
||||
},
|
||||
camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/m48-clip-03/frames/256/camera`,
|
||||
spatial_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/m48-clip-03/frames/256/spatial`,
|
||||
ground_truth: false,
|
||||
authority,
|
||||
}),
|
||||
});
|
||||
assert.equal(item.anchor.sequence, 256);
|
||||
assert.equal(item.comparison.workerObjects[0].predictionId, "proposal-256-0");
|
||||
assert.equal(item.groundTruth, false);
|
||||
});
|
||||
|
||||
test("M4.8R1 visual reuses the held viewer and keeps manual boxes exact-frame only", () => {
|
||||
const visual = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(visual, /<LaboratoryEvidenceViewer/);
|
||||
assert.match(visual, /<M48BlindClipPlayer/);
|
||||
assert.match(visual, /<M48EvidenceModeRail/);
|
||||
assert.match(visual, /firstSequence: sequence,[\s\S]*lastSequence: sequence/);
|
||||
assert.doesNotMatch(visual, /Rerun|MediaSource|setInterval/);
|
||||
});
|
||||
@@ -508,7 +508,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visual, /LOCAL SLAM/);
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(visual, /pointCloudBodyXyzM=\{spatialFrame\.pointCloudBodyXyzM\}/);
|
||||
assert.match(metricScene, /Local SLAM surface/);
|
||||
assert.match(metricScene, /Локальная SLAM-поверхность/);
|
||||
assert.match(visual, /showJumpToEnd=\{false\}/);
|
||||
assert.doesNotMatch(visual, /Назад на 5 секунд/);
|
||||
assert.doesNotMatch(visual, /Вперёд на 5 секунд/);
|
||||
|
||||
@@ -326,6 +326,47 @@ test("spatial timeline renders synchronized accumulation and playback controls",
|
||||
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("recorded observation timeline uses compact icon transport and one inline rate trigger", () => {
|
||||
const idleMarkup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
playing: false,
|
||||
playbackRate: 1,
|
||||
onSeek: () => undefined,
|
||||
onPlayingChange: () => undefined,
|
||||
onPlaybackRateChange: () => undefined,
|
||||
}));
|
||||
const playingMarkup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
onSeek: () => undefined,
|
||||
onPlayingChange: () => undefined,
|
||||
onPlaybackRateChange: () => undefined,
|
||||
}));
|
||||
|
||||
assert.match(idleMarkup, /aria-label="Воспроизвести"/);
|
||||
assert.match(idleMarkup, /lucide-play/);
|
||||
assert.match(idleMarkup, /fill="currentColor"/);
|
||||
assert.match(idleMarkup, /stroke-width="0"/);
|
||||
assert.match(playingMarkup, /aria-label="Пауза"/);
|
||||
assert.match(playingMarkup, /lucide-square/);
|
||||
assert.match(playingMarkup, /fill="currentColor"/);
|
||||
assert.match(idleMarkup, /nodedc-select-inline/);
|
||||
assert.match(idleMarkup, />1×<\/span>/);
|
||||
assert.doesNotMatch(idleMarkup, /nodedc-select-trigger__chevron/);
|
||||
assert.doesNotMatch(idleMarkup, />Воспроизвести<|>Пауза</);
|
||||
});
|
||||
|
||||
test("Rerun expands only root-relative session recordings onto the current origin", () => {
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl(
|
||||
|
||||
@@ -11,6 +11,7 @@ let recordedMediaSeekableCoverage;
|
||||
let recordedMediaFragmentUrl;
|
||||
let recordedMediaDecodeStartSequence;
|
||||
let recordedMediaSegmentAppendOrder;
|
||||
let recordedMediaCanRollTarget;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -25,6 +26,7 @@ before(async () => {
|
||||
recordedMediaFragmentUrl,
|
||||
recordedMediaDecodeStartSequence,
|
||||
recordedMediaSegmentAppendOrder,
|
||||
recordedMediaCanRollTarget,
|
||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||
});
|
||||
|
||||
@@ -204,6 +206,14 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
|
||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 1, true, true), false);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 21, false, true), false);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
||||
});
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation.css", import.meta.url),
|
||||
|
||||
@@ -5,7 +5,11 @@ from .active import (
|
||||
ActiveSessionLeaseError,
|
||||
recover_stale_active_session_marker,
|
||||
)
|
||||
from .camera_frame import RecordedCameraFrame, RecordedCameraFrameService
|
||||
from .camera_frame import (
|
||||
RecordedCameraFrame,
|
||||
RecordedCameraFrameService,
|
||||
RecordedCameraPlaybackSource,
|
||||
)
|
||||
from .lab_cache import publish_lab_replay_cache
|
||||
from .media import (
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
@@ -68,6 +72,7 @@ __all__ = [
|
||||
"RecordedMediaArtifact",
|
||||
"RecordedCameraFrame",
|
||||
"RecordedCameraFrameService",
|
||||
"RecordedCameraPlaybackSource",
|
||||
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||
"RecordedMediaFile",
|
||||
"RecordedMediaInspector",
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,8 @@ from .store import SessionStore
|
||||
|
||||
_MAX_KEYFRAME_DISTANCE = 120
|
||||
_FFMPEG_TIMEOUT_SECONDS = 15.0
|
||||
_DEFAULT_MAX_DECODE_LANES = 32
|
||||
_DEFAULT_MAX_SOURCE_MANIFESTS = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -24,6 +27,34 @@ class RecordedCameraFrame:
|
||||
width: int
|
||||
height: int
|
||||
sha256: str
|
||||
source_fragment_sha256: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedCameraPlaybackSource:
|
||||
"""Immutable single-epoch camera source for bounded review playback."""
|
||||
|
||||
session_id: str
|
||||
public_source_id: str
|
||||
artifact_id: str
|
||||
synchronization: str
|
||||
generation_sha256: str
|
||||
timeline_start_seconds: float
|
||||
timeline_end_seconds: float
|
||||
byte_length: int
|
||||
media_type: str
|
||||
segment_sha256s: tuple[str, ...]
|
||||
segment_start_times_ns: tuple[int, ...]
|
||||
|
||||
@property
|
||||
def segment_count(self) -> int:
|
||||
return len(self.segment_sha256s)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _CameraDecodeLane:
|
||||
active: bool = False
|
||||
latest_ticket: int = 0
|
||||
|
||||
|
||||
class RecordedCameraFrameService:
|
||||
@@ -42,6 +73,8 @@ class RecordedCameraFrameService:
|
||||
*,
|
||||
ffmpeg_path: Path,
|
||||
cache_root: Path,
|
||||
max_decode_lanes: int = _DEFAULT_MAX_DECODE_LANES,
|
||||
max_source_manifests: int = _DEFAULT_MAX_SOURCE_MANIFESTS,
|
||||
) -> None:
|
||||
resolved_ffmpeg = ffmpeg_path.expanduser().resolve(strict=True)
|
||||
if not resolved_ffmpeg.is_file() or not os.access(resolved_ffmpeg, os.X_OK):
|
||||
@@ -53,7 +86,16 @@ class RecordedCameraFrameService:
|
||||
self._cache_root.mkdir(parents=True, exist_ok=True)
|
||||
if self._cache_root.is_symlink() or not self._cache_root.is_dir():
|
||||
raise SessionIntegrityError("camera frame cache root is invalid")
|
||||
self._lock = threading.Lock()
|
||||
if max_decode_lanes < 1 or max_source_manifests < 1:
|
||||
raise SessionIntegrityError("camera frame memory cache bounds are invalid")
|
||||
self._max_decode_lanes = max_decode_lanes
|
||||
self._max_source_manifests = max_source_manifests
|
||||
self._coordination = threading.Condition(threading.Lock())
|
||||
self._lanes: OrderedDict[tuple[str, str], _CameraDecodeLane] = OrderedDict()
|
||||
self._source_manifests: OrderedDict[
|
||||
tuple[str, str],
|
||||
RecordedMediaManifest,
|
||||
] = OrderedDict()
|
||||
|
||||
def extract(
|
||||
self,
|
||||
@@ -64,6 +106,108 @@ class RecordedCameraFrameService:
|
||||
) -> RecordedCameraFrame:
|
||||
if frame_index < 0:
|
||||
raise SessionIntegrityError("camera frame index is invalid")
|
||||
source_key = (session_id, expected_source_name)
|
||||
lane, ticket = self._acquire_lane(source_key)
|
||||
try:
|
||||
manifest = self._source_manifest(
|
||||
source_key,
|
||||
session_id=session_id,
|
||||
expected_source_name=expected_source_name,
|
||||
)
|
||||
self._require_latest(lane, ticket)
|
||||
epoch, sequence = _frame_location(manifest, frame_index)
|
||||
cache_key = hashlib.sha256(
|
||||
(
|
||||
f"{manifest.generation_sha256}\0{manifest.artifact_id}\0"
|
||||
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
|
||||
).encode()
|
||||
).hexdigest()
|
||||
cache_path = self._cache_root / f"{cache_key}.jpg"
|
||||
|
||||
cached = _read_cached_jpeg(
|
||||
cache_path,
|
||||
source_fragment_sha256=epoch.segments[sequence - 1].sha256,
|
||||
)
|
||||
if cached is not None:
|
||||
self._require_latest(lane, ticket)
|
||||
return cached
|
||||
self._require_latest(lane, ticket)
|
||||
frame = self._decode(manifest, epoch, sequence)
|
||||
_publish_cached_jpeg(cache_path, frame.payload)
|
||||
self._require_latest(lane, ticket)
|
||||
return frame
|
||||
finally:
|
||||
self._release_lane(source_key, lane)
|
||||
|
||||
def playback_source(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
expected_source_name: str = "sensor.camera.right",
|
||||
) -> RecordedCameraPlaybackSource:
|
||||
"""Return the generation-bound fMP4 source used by the shared LAB viewer.
|
||||
|
||||
LAB playback is intentionally admitted from the same cached immutable
|
||||
manifest as exact JPEG extraction. The contract is limited to one
|
||||
codec epoch because LAB frame sequence is a direct one-based segment
|
||||
sequence; a future multi-epoch source must add an explicit mapping
|
||||
contract instead of guessing across epoch boundaries.
|
||||
"""
|
||||
|
||||
manifest = self._source_manifest(
|
||||
(session_id, expected_source_name),
|
||||
session_id=session_id,
|
||||
expected_source_name=expected_source_name,
|
||||
)
|
||||
if manifest.synchronization != "host-arrival-best-effort" or len(manifest.epochs) != 1:
|
||||
raise SessionIntegrityError("recorded camera playback source is incompatible")
|
||||
epoch = manifest.epochs[0]
|
||||
if (
|
||||
epoch.ordinal != 1
|
||||
or epoch.timeline_start_seconds != manifest.timeline_start_seconds
|
||||
or epoch.timeline_end_seconds != manifest.timeline_end_seconds
|
||||
or not epoch.media_type.startswith("video/mp4;")
|
||||
or not epoch.segments
|
||||
or tuple(segment.sequence for segment in epoch.segments)
|
||||
!= tuple(range(1, len(epoch.segments) + 1))
|
||||
):
|
||||
raise SessionIntegrityError("recorded camera playback epoch is incompatible")
|
||||
starts_ns: list[int] = []
|
||||
previous_end_seconds = 0.0
|
||||
for segment in epoch.segments:
|
||||
starts_ns.append(
|
||||
round((epoch.timeline_start_seconds + previous_end_seconds) * 1_000_000_000)
|
||||
)
|
||||
previous_end_seconds = segment.end_time_seconds
|
||||
return RecordedCameraPlaybackSource(
|
||||
session_id=manifest.session_id,
|
||||
public_source_id=manifest.public_source_id,
|
||||
artifact_id=manifest.artifact_id,
|
||||
synchronization=manifest.synchronization,
|
||||
generation_sha256=manifest.generation_sha256,
|
||||
timeline_start_seconds=manifest.timeline_start_seconds,
|
||||
timeline_end_seconds=manifest.timeline_end_seconds,
|
||||
byte_length=manifest.byte_length,
|
||||
media_type=epoch.media_type,
|
||||
segment_sha256s=tuple(segment.sha256 for segment in epoch.segments),
|
||||
segment_start_times_ns=tuple(starts_ns),
|
||||
)
|
||||
|
||||
def _source_manifest(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
*,
|
||||
session_id: str,
|
||||
expected_source_name: str,
|
||||
) -> RecordedMediaManifest:
|
||||
"""Bind one immutable recorded source without rescanning it per frame."""
|
||||
|
||||
with self._coordination:
|
||||
cached = self._source_manifests.get(source_key)
|
||||
if cached is not None:
|
||||
self._source_manifests.move_to_end(source_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
replay = self._store.prepare_replay(session_id, speed=1.0, loop=False)
|
||||
matches = tuple(
|
||||
artifact
|
||||
@@ -74,22 +218,66 @@ class RecordedCameraFrameService:
|
||||
raise SessionIntegrityError("recorded camera source is unavailable")
|
||||
artifact = matches[0]
|
||||
manifest = self._inspector.inspect(artifact, replay)
|
||||
epoch, sequence = _frame_location(manifest, frame_index)
|
||||
cache_key = hashlib.sha256(
|
||||
(
|
||||
f"{manifest.generation_sha256}\0{artifact.artifact_id}\0"
|
||||
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
|
||||
).encode()
|
||||
).hexdigest()
|
||||
cache_path = self._cache_root / f"{cache_key}.jpg"
|
||||
with self._coordination:
|
||||
bound = self._source_manifests.get(source_key)
|
||||
if bound is None:
|
||||
bound = manifest
|
||||
self._source_manifests[source_key] = manifest
|
||||
self._source_manifests.move_to_end(source_key)
|
||||
while len(self._source_manifests) > self._max_source_manifests:
|
||||
self._source_manifests.popitem(last=False)
|
||||
return bound
|
||||
|
||||
with self._lock:
|
||||
cached = _read_cached_jpeg(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
frame = self._decode(manifest, epoch, sequence)
|
||||
_publish_cached_jpeg(cache_path, frame.payload)
|
||||
return frame
|
||||
def _acquire_lane(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
) -> tuple[_CameraDecodeLane, int]:
|
||||
"""Admit only the newest waiter behind one active source decode."""
|
||||
|
||||
with self._coordination:
|
||||
lane = self._lanes.get(source_key)
|
||||
if lane is None:
|
||||
lane = _CameraDecodeLane()
|
||||
self._lanes[source_key] = lane
|
||||
self._lanes.move_to_end(source_key)
|
||||
self._evict_inactive_lanes(exclude=source_key)
|
||||
lane.latest_ticket += 1
|
||||
ticket = lane.latest_ticket
|
||||
self._coordination.notify_all()
|
||||
while lane.active:
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
self._coordination.wait()
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
lane.active = True
|
||||
return lane, ticket
|
||||
|
||||
def _evict_inactive_lanes(self, *, exclude: tuple[str, str] | None = None) -> None:
|
||||
if len(self._lanes) <= self._max_decode_lanes:
|
||||
return
|
||||
for source_key, lane in tuple(self._lanes.items()):
|
||||
if len(self._lanes) <= self._max_decode_lanes:
|
||||
break
|
||||
if source_key != exclude and not lane.active:
|
||||
del self._lanes[source_key]
|
||||
|
||||
def _require_latest(self, lane: _CameraDecodeLane, ticket: int) -> None:
|
||||
with self._coordination:
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
|
||||
def _release_lane(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
lane: _CameraDecodeLane,
|
||||
) -> None:
|
||||
with self._coordination:
|
||||
lane.active = False
|
||||
if self._lanes.get(source_key) is lane:
|
||||
self._lanes.move_to_end(source_key)
|
||||
self._evict_inactive_lanes()
|
||||
self._coordination.notify_all()
|
||||
|
||||
def _decode(
|
||||
self,
|
||||
@@ -156,6 +344,7 @@ class RecordedCameraFrameService:
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=digest,
|
||||
source_fragment_sha256=target.sha256,
|
||||
)
|
||||
|
||||
|
||||
@@ -196,7 +385,11 @@ def _jpeg_dimensions(payload: bytes) -> tuple[int, int]:
|
||||
raise SessionIntegrityError("camera frame JPEG dimensions are unavailable")
|
||||
|
||||
|
||||
def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
|
||||
def _read_cached_jpeg(
|
||||
path: Path,
|
||||
*,
|
||||
source_fragment_sha256: str,
|
||||
) -> RecordedCameraFrame | None:
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return None
|
||||
@@ -210,6 +403,7 @@ def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
source_fragment_sha256=source_fragment_sha256,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.sessions.camera_frame import RecordedCameraFrame, RecordedCameraFrameService
|
||||
from k1link.sessions.media import (
|
||||
RecordedMediaEpoch,
|
||||
RecordedMediaManifest,
|
||||
RecordedMediaSegment,
|
||||
)
|
||||
from k1link.sessions.models import RecordedMediaArtifact, SessionIntegrityError
|
||||
|
||||
_JPEG = b"\xff\xd8\xff\xc0\x00\x07\x08\x00\x01\x00\x01\xff\xd9"
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *, block_prepare: bool = False) -> None:
|
||||
self.prepare_calls = 0
|
||||
self.list_calls = 0
|
||||
self.prepare_started = threading.Event()
|
||||
self.release_prepare = threading.Event()
|
||||
if not block_prepare:
|
||||
self.release_prepare.set()
|
||||
self.artifact = RecordedMediaArtifact(
|
||||
session_id="recorded-session",
|
||||
public_source_id="camera",
|
||||
artifact_id="camera-artifact",
|
||||
source_path=Path("/sealed/sensor.camera.right"),
|
||||
byte_length=123,
|
||||
)
|
||||
|
||||
def prepare_replay(self, session_id: str, **_: Any) -> SimpleNamespace:
|
||||
self.prepare_calls += 1
|
||||
self.prepare_started.set()
|
||||
assert self.release_prepare.wait(timeout=2.0)
|
||||
return SimpleNamespace(session_id=session_id)
|
||||
|
||||
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
|
||||
self.list_calls += 1
|
||||
return (self.artifact,)
|
||||
|
||||
|
||||
class _Inspector:
|
||||
def __init__(self, manifest: RecordedMediaManifest) -> None:
|
||||
self.manifest = manifest
|
||||
self.inspect_calls = 0
|
||||
|
||||
def inspect(self, artifact: RecordedMediaArtifact, replay: object) -> RecordedMediaManifest:
|
||||
assert artifact.artifact_id == "camera-artifact"
|
||||
assert replay is not None
|
||||
self.inspect_calls += 1
|
||||
return self.manifest
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path) -> RecordedMediaManifest:
|
||||
epoch_path = tmp_path / "epoch-1"
|
||||
segments = tuple(
|
||||
RecordedMediaSegment(
|
||||
sequence=sequence,
|
||||
path=epoch_path / "segments" / f"{sequence:08d}.m4s",
|
||||
byte_length=10,
|
||||
sha256=hashlib.sha256(str(sequence).encode()).hexdigest(),
|
||||
random_access=True,
|
||||
end_time_seconds=float(sequence),
|
||||
)
|
||||
for sequence in range(1, 4)
|
||||
)
|
||||
return RecordedMediaManifest(
|
||||
session_id="recorded-session",
|
||||
public_source_id="camera",
|
||||
artifact_id="camera-artifact",
|
||||
synchronization="recorded",
|
||||
generation_sha256="a" * 64,
|
||||
timeline_start_seconds=0.0,
|
||||
timeline_end_seconds=3.0,
|
||||
byte_length=123,
|
||||
epochs=(
|
||||
RecordedMediaEpoch(
|
||||
ordinal=1,
|
||||
path=epoch_path,
|
||||
init_path=epoch_path / "init.mp4",
|
||||
init_byte_length=10,
|
||||
init_sha256="b" * 64,
|
||||
media_type="video/mp4",
|
||||
timeline_start_seconds=0.0,
|
||||
timeline_end_seconds=3.0,
|
||||
segments=segments,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
tmp_path: Path,
|
||||
store: _Store,
|
||||
inspector: _Inspector,
|
||||
*,
|
||||
max_decode_lanes: int = 32,
|
||||
max_source_manifests: int = 32,
|
||||
) -> RecordedCameraFrameService:
|
||||
return RecordedCameraFrameService(
|
||||
store, # type: ignore[arg-type]
|
||||
inspector, # type: ignore[arg-type]
|
||||
ffmpeg_path=Path(sys.executable),
|
||||
cache_root=tmp_path / "cache",
|
||||
max_decode_lanes=max_decode_lanes,
|
||||
max_source_manifests=max_source_manifests,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_latest(service: RecordedCameraFrameService, ticket: int) -> None:
|
||||
deadline = time.monotonic() + 2.0
|
||||
source_key = ("recorded-session", "sensor.camera.right")
|
||||
while time.monotonic() < deadline:
|
||||
with service._coordination: # noqa: SLF001
|
||||
lane = service._lanes.get(source_key) # noqa: SLF001
|
||||
if lane is not None and lane.latest_ticket == ticket:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise AssertionError(f"camera request ticket {ticket} was not registered")
|
||||
|
||||
|
||||
def _frame(epoch: RecordedMediaEpoch, sequence: int) -> RecordedCameraFrame:
|
||||
return RecordedCameraFrame(
|
||||
payload=_JPEG,
|
||||
media_type="image/jpeg",
|
||||
width=1,
|
||||
height=1,
|
||||
sha256=hashlib.sha256(_JPEG).hexdigest(),
|
||||
source_fragment_sha256=epoch.segments[sequence - 1].sha256,
|
||||
)
|
||||
|
||||
|
||||
def test_camera_frame_burst_scans_manifest_once_and_decodes_only_latest(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _Store(block_prepare=True)
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
service = _service(tmp_path, store, inspector)
|
||||
decoded: list[int] = []
|
||||
|
||||
def decode(
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch: RecordedMediaEpoch,
|
||||
sequence: int,
|
||||
) -> RecordedCameraFrame:
|
||||
assert manifest is inspector.manifest
|
||||
decoded.append(sequence)
|
||||
return _frame(epoch, sequence)
|
||||
|
||||
monkeypatch.setattr(service, "_decode", decode)
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
first = pool.submit(service.extract, "recorded-session", 0)
|
||||
assert store.prepare_started.wait(timeout=2.0)
|
||||
second = pool.submit(service.extract, "recorded-session", 1)
|
||||
_wait_for_latest(service, 2)
|
||||
third = pool.submit(service.extract, "recorded-session", 2)
|
||||
_wait_for_latest(service, 3)
|
||||
store.release_prepare.set()
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
first.result(timeout=2.0)
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
second.result(timeout=2.0)
|
||||
assert third.result(timeout=2.0).source_fragment_sha256 == (
|
||||
inspector.manifest.epochs[0].segments[2].sha256
|
||||
)
|
||||
|
||||
assert store.prepare_calls == 1
|
||||
assert store.list_calls == 1
|
||||
assert inspector.inspect_calls == 1
|
||||
assert decoded == [3]
|
||||
|
||||
|
||||
def test_camera_frame_lane_keeps_only_latest_waiter_behind_active_decode(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _Store()
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
service = _service(tmp_path, store, inspector)
|
||||
decode_started = threading.Event()
|
||||
release_decode = threading.Event()
|
||||
decoded: list[int] = []
|
||||
|
||||
def decode(
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch: RecordedMediaEpoch,
|
||||
sequence: int,
|
||||
) -> RecordedCameraFrame:
|
||||
assert manifest is inspector.manifest
|
||||
decoded.append(sequence)
|
||||
if sequence == 1:
|
||||
decode_started.set()
|
||||
assert release_decode.wait(timeout=2.0)
|
||||
return _frame(epoch, sequence)
|
||||
|
||||
monkeypatch.setattr(service, "_decode", decode)
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
first = pool.submit(service.extract, "recorded-session", 0)
|
||||
assert decode_started.wait(timeout=2.0)
|
||||
second = pool.submit(service.extract, "recorded-session", 1)
|
||||
_wait_for_latest(service, 2)
|
||||
third = pool.submit(service.extract, "recorded-session", 2)
|
||||
_wait_for_latest(service, 3)
|
||||
release_decode.set()
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
first.result(timeout=2.0)
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
second.result(timeout=2.0)
|
||||
assert third.result(timeout=2.0).width == 1
|
||||
|
||||
assert decoded == [1, 3]
|
||||
assert store.prepare_calls == 1
|
||||
assert inspector.inspect_calls == 1
|
||||
|
||||
|
||||
def test_camera_frame_memory_caches_are_lru_bounded(tmp_path: Path) -> None:
|
||||
store = _Store()
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
service = _service(
|
||||
tmp_path,
|
||||
store,
|
||||
inspector,
|
||||
max_decode_lanes=2,
|
||||
max_source_manifests=2,
|
||||
)
|
||||
|
||||
for ordinal in range(4):
|
||||
source_key = (f"session-{ordinal}", "sensor.camera.right")
|
||||
lane, _ = service._acquire_lane(source_key) # noqa: SLF001
|
||||
service._release_lane(source_key, lane) # noqa: SLF001
|
||||
assert tuple(service._lanes) == ( # noqa: SLF001
|
||||
("session-2", "sensor.camera.right"),
|
||||
("session-3", "sensor.camera.right"),
|
||||
)
|
||||
|
||||
for ordinal in range(3):
|
||||
source_key = (f"session-{ordinal}", "sensor.camera.right")
|
||||
service._source_manifest( # noqa: SLF001
|
||||
source_key,
|
||||
session_id=source_key[0],
|
||||
expected_source_name=source_key[1],
|
||||
)
|
||||
assert tuple(service._source_manifests) == ( # noqa: SLF001
|
||||
("session-1", "sensor.camera.right"),
|
||||
("session-2", "sensor.camera.right"),
|
||||
)
|
||||
|
||||
service._source_manifest( # noqa: SLF001
|
||||
("session-1", "sensor.camera.right"),
|
||||
session_id="session-1",
|
||||
expected_source_name="sensor.camera.right",
|
||||
)
|
||||
service._source_manifest( # noqa: SLF001
|
||||
("session-3", "sensor.camera.right"),
|
||||
session_id="session-3",
|
||||
expected_source_name="sensor.camera.right",
|
||||
)
|
||||
assert tuple(service._source_manifests) == ( # noqa: SLF001
|
||||
("session-1", "sensor.camera.right"),
|
||||
("session-3", "sensor.camera.right"),
|
||||
)
|
||||
assert store.prepare_calls == 4
|
||||
assert inspector.inspect_calls == 4
|
||||
|
||||
|
||||
def test_camera_playback_source_reuses_manifest_and_exposes_exact_segment_clock(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = _Store()
|
||||
manifest = _manifest(tmp_path)
|
||||
manifest = RecordedMediaManifest(
|
||||
session_id=manifest.session_id,
|
||||
public_source_id=manifest.public_source_id,
|
||||
artifact_id=manifest.artifact_id,
|
||||
synchronization="host-arrival-best-effort",
|
||||
generation_sha256=manifest.generation_sha256,
|
||||
timeline_start_seconds=manifest.timeline_start_seconds,
|
||||
timeline_end_seconds=manifest.timeline_end_seconds,
|
||||
byte_length=manifest.byte_length,
|
||||
epochs=(
|
||||
RecordedMediaEpoch(
|
||||
ordinal=1,
|
||||
path=manifest.epochs[0].path,
|
||||
init_path=manifest.epochs[0].init_path,
|
||||
init_byte_length=manifest.epochs[0].init_byte_length,
|
||||
init_sha256=manifest.epochs[0].init_sha256,
|
||||
media_type='video/mp4; codecs="avc1.640028"',
|
||||
timeline_start_seconds=0.0,
|
||||
timeline_end_seconds=3.0,
|
||||
segments=manifest.epochs[0].segments,
|
||||
),
|
||||
),
|
||||
)
|
||||
inspector = _Inspector(manifest)
|
||||
service = _service(tmp_path, store, inspector)
|
||||
|
||||
first = service.playback_source("recorded-session")
|
||||
second = service.playback_source("recorded-session")
|
||||
|
||||
assert first == second
|
||||
assert first.segment_count == 3
|
||||
assert first.segment_sha256s == tuple(
|
||||
segment.sha256 for segment in manifest.epochs[0].segments
|
||||
)
|
||||
assert first.segment_start_times_ns == (0, 1_000_000_000, 2_000_000_000)
|
||||
assert store.prepare_calls == 1
|
||||
assert inspector.inspect_calls == 1
|
||||
|
||||
|
||||
def test_camera_frame_rejects_unbounded_memory_cache_configuration(tmp_path: Path) -> None:
|
||||
store = _Store()
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
with pytest.raises(SessionIntegrityError, match="cache bounds"):
|
||||
_service(tmp_path, store, inspector, max_decode_lanes=0)
|
||||
Reference in New Issue
Block a user