feat(ui): stabilize shared M4.8 review viewer

This commit is contained in:
DCCONSTRUCTIONS
2026-08-24 22:36:08 +03:00
parent 992c5a8b74
commit 4fa6597b18
44 changed files with 6463 additions and 147 deletions
@@ -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),
};
}
+4
View File
@@ -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} />;
}
@@ -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>
);
}
@@ -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>
)}
/>
);
}
@@ -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}
/>
),
};
}
@@ -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),