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),
+6 -1
View File
@@ -5,7 +5,11 @@ from .active import (
ActiveSessionLeaseError,
recover_stale_active_session_marker,
)
from .camera_frame import RecordedCameraFrame, RecordedCameraFrameService
from .camera_frame import (
RecordedCameraFrame,
RecordedCameraFrameService,
RecordedCameraPlaybackSource,
)
from .lab_cache import publish_lab_replay_cache
from .media import (
RECORDED_MEDIA_MANIFEST_SCHEMA,
@@ -68,6 +72,7 @@ __all__ = [
"RecordedMediaArtifact",
"RecordedCameraFrame",
"RecordedCameraFrameService",
"RecordedCameraPlaybackSource",
"RECORDED_MEDIA_MANIFEST_SCHEMA",
"RecordedMediaFile",
"RecordedMediaInspector",
+211 -17
View File
@@ -5,6 +5,7 @@ import os
import struct
import subprocess
import threading
from collections import OrderedDict
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
@@ -15,6 +16,8 @@ from .store import SessionStore
_MAX_KEYFRAME_DISTANCE = 120
_FFMPEG_TIMEOUT_SECONDS = 15.0
_DEFAULT_MAX_DECODE_LANES = 32
_DEFAULT_MAX_SOURCE_MANIFESTS = 32
@dataclass(frozen=True, slots=True)
@@ -24,6 +27,34 @@ class RecordedCameraFrame:
width: int
height: int
sha256: str
source_fragment_sha256: str | None = None
@dataclass(frozen=True, slots=True)
class RecordedCameraPlaybackSource:
"""Immutable single-epoch camera source for bounded review playback."""
session_id: str
public_source_id: str
artifact_id: str
synchronization: str
generation_sha256: str
timeline_start_seconds: float
timeline_end_seconds: float
byte_length: int
media_type: str
segment_sha256s: tuple[str, ...]
segment_start_times_ns: tuple[int, ...]
@property
def segment_count(self) -> int:
return len(self.segment_sha256s)
@dataclass(slots=True)
class _CameraDecodeLane:
active: bool = False
latest_ticket: int = 0
class RecordedCameraFrameService:
@@ -42,6 +73,8 @@ class RecordedCameraFrameService:
*,
ffmpeg_path: Path,
cache_root: Path,
max_decode_lanes: int = _DEFAULT_MAX_DECODE_LANES,
max_source_manifests: int = _DEFAULT_MAX_SOURCE_MANIFESTS,
) -> None:
resolved_ffmpeg = ffmpeg_path.expanduser().resolve(strict=True)
if not resolved_ffmpeg.is_file() or not os.access(resolved_ffmpeg, os.X_OK):
@@ -53,7 +86,16 @@ class RecordedCameraFrameService:
self._cache_root.mkdir(parents=True, exist_ok=True)
if self._cache_root.is_symlink() or not self._cache_root.is_dir():
raise SessionIntegrityError("camera frame cache root is invalid")
self._lock = threading.Lock()
if max_decode_lanes < 1 or max_source_manifests < 1:
raise SessionIntegrityError("camera frame memory cache bounds are invalid")
self._max_decode_lanes = max_decode_lanes
self._max_source_manifests = max_source_manifests
self._coordination = threading.Condition(threading.Lock())
self._lanes: OrderedDict[tuple[str, str], _CameraDecodeLane] = OrderedDict()
self._source_manifests: OrderedDict[
tuple[str, str],
RecordedMediaManifest,
] = OrderedDict()
def extract(
self,
@@ -64,6 +106,108 @@ class RecordedCameraFrameService:
) -> RecordedCameraFrame:
if frame_index < 0:
raise SessionIntegrityError("camera frame index is invalid")
source_key = (session_id, expected_source_name)
lane, ticket = self._acquire_lane(source_key)
try:
manifest = self._source_manifest(
source_key,
session_id=session_id,
expected_source_name=expected_source_name,
)
self._require_latest(lane, ticket)
epoch, sequence = _frame_location(manifest, frame_index)
cache_key = hashlib.sha256(
(
f"{manifest.generation_sha256}\0{manifest.artifact_id}\0"
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
).encode()
).hexdigest()
cache_path = self._cache_root / f"{cache_key}.jpg"
cached = _read_cached_jpeg(
cache_path,
source_fragment_sha256=epoch.segments[sequence - 1].sha256,
)
if cached is not None:
self._require_latest(lane, ticket)
return cached
self._require_latest(lane, ticket)
frame = self._decode(manifest, epoch, sequence)
_publish_cached_jpeg(cache_path, frame.payload)
self._require_latest(lane, ticket)
return frame
finally:
self._release_lane(source_key, lane)
def playback_source(
self,
session_id: str,
*,
expected_source_name: str = "sensor.camera.right",
) -> RecordedCameraPlaybackSource:
"""Return the generation-bound fMP4 source used by the shared LAB viewer.
LAB playback is intentionally admitted from the same cached immutable
manifest as exact JPEG extraction. The contract is limited to one
codec epoch because LAB frame sequence is a direct one-based segment
sequence; a future multi-epoch source must add an explicit mapping
contract instead of guessing across epoch boundaries.
"""
manifest = self._source_manifest(
(session_id, expected_source_name),
session_id=session_id,
expected_source_name=expected_source_name,
)
if manifest.synchronization != "host-arrival-best-effort" or len(manifest.epochs) != 1:
raise SessionIntegrityError("recorded camera playback source is incompatible")
epoch = manifest.epochs[0]
if (
epoch.ordinal != 1
or epoch.timeline_start_seconds != manifest.timeline_start_seconds
or epoch.timeline_end_seconds != manifest.timeline_end_seconds
or not epoch.media_type.startswith("video/mp4;")
or not epoch.segments
or tuple(segment.sequence for segment in epoch.segments)
!= tuple(range(1, len(epoch.segments) + 1))
):
raise SessionIntegrityError("recorded camera playback epoch is incompatible")
starts_ns: list[int] = []
previous_end_seconds = 0.0
for segment in epoch.segments:
starts_ns.append(
round((epoch.timeline_start_seconds + previous_end_seconds) * 1_000_000_000)
)
previous_end_seconds = segment.end_time_seconds
return RecordedCameraPlaybackSource(
session_id=manifest.session_id,
public_source_id=manifest.public_source_id,
artifact_id=manifest.artifact_id,
synchronization=manifest.synchronization,
generation_sha256=manifest.generation_sha256,
timeline_start_seconds=manifest.timeline_start_seconds,
timeline_end_seconds=manifest.timeline_end_seconds,
byte_length=manifest.byte_length,
media_type=epoch.media_type,
segment_sha256s=tuple(segment.sha256 for segment in epoch.segments),
segment_start_times_ns=tuple(starts_ns),
)
def _source_manifest(
self,
source_key: tuple[str, str],
*,
session_id: str,
expected_source_name: str,
) -> RecordedMediaManifest:
"""Bind one immutable recorded source without rescanning it per frame."""
with self._coordination:
cached = self._source_manifests.get(source_key)
if cached is not None:
self._source_manifests.move_to_end(source_key)
if cached is not None:
return cached
replay = self._store.prepare_replay(session_id, speed=1.0, loop=False)
matches = tuple(
artifact
@@ -74,22 +218,66 @@ class RecordedCameraFrameService:
raise SessionIntegrityError("recorded camera source is unavailable")
artifact = matches[0]
manifest = self._inspector.inspect(artifact, replay)
epoch, sequence = _frame_location(manifest, frame_index)
cache_key = hashlib.sha256(
(
f"{manifest.generation_sha256}\0{artifact.artifact_id}\0"
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
).encode()
).hexdigest()
cache_path = self._cache_root / f"{cache_key}.jpg"
with self._coordination:
bound = self._source_manifests.get(source_key)
if bound is None:
bound = manifest
self._source_manifests[source_key] = manifest
self._source_manifests.move_to_end(source_key)
while len(self._source_manifests) > self._max_source_manifests:
self._source_manifests.popitem(last=False)
return bound
with self._lock:
cached = _read_cached_jpeg(cache_path)
if cached is not None:
return cached
frame = self._decode(manifest, epoch, sequence)
_publish_cached_jpeg(cache_path, frame.payload)
return frame
def _acquire_lane(
self,
source_key: tuple[str, str],
) -> tuple[_CameraDecodeLane, int]:
"""Admit only the newest waiter behind one active source decode."""
with self._coordination:
lane = self._lanes.get(source_key)
if lane is None:
lane = _CameraDecodeLane()
self._lanes[source_key] = lane
self._lanes.move_to_end(source_key)
self._evict_inactive_lanes(exclude=source_key)
lane.latest_ticket += 1
ticket = lane.latest_ticket
self._coordination.notify_all()
while lane.active:
if ticket != lane.latest_ticket:
raise SessionIntegrityError("camera frame request was superseded")
self._coordination.wait()
if ticket != lane.latest_ticket:
raise SessionIntegrityError("camera frame request was superseded")
lane.active = True
return lane, ticket
def _evict_inactive_lanes(self, *, exclude: tuple[str, str] | None = None) -> None:
if len(self._lanes) <= self._max_decode_lanes:
return
for source_key, lane in tuple(self._lanes.items()):
if len(self._lanes) <= self._max_decode_lanes:
break
if source_key != exclude and not lane.active:
del self._lanes[source_key]
def _require_latest(self, lane: _CameraDecodeLane, ticket: int) -> None:
with self._coordination:
if ticket != lane.latest_ticket:
raise SessionIntegrityError("camera frame request was superseded")
def _release_lane(
self,
source_key: tuple[str, str],
lane: _CameraDecodeLane,
) -> None:
with self._coordination:
lane.active = False
if self._lanes.get(source_key) is lane:
self._lanes.move_to_end(source_key)
self._evict_inactive_lanes()
self._coordination.notify_all()
def _decode(
self,
@@ -156,6 +344,7 @@ class RecordedCameraFrameService:
width=width,
height=height,
sha256=digest,
source_fragment_sha256=target.sha256,
)
@@ -196,7 +385,11 @@ def _jpeg_dimensions(payload: bytes) -> tuple[int, int]:
raise SessionIntegrityError("camera frame JPEG dimensions are unavailable")
def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
def _read_cached_jpeg(
path: Path,
*,
source_fragment_sha256: str,
) -> RecordedCameraFrame | None:
try:
if path.is_symlink() or not path.is_file():
return None
@@ -210,6 +403,7 @@ def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
width=width,
height=height,
sha256=hashlib.sha256(payload).hexdigest(),
source_fragment_sha256=source_fragment_sha256,
)
+327
View File
@@ -0,0 +1,327 @@
from __future__ import annotations
import hashlib
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from k1link.sessions.camera_frame import RecordedCameraFrame, RecordedCameraFrameService
from k1link.sessions.media import (
RecordedMediaEpoch,
RecordedMediaManifest,
RecordedMediaSegment,
)
from k1link.sessions.models import RecordedMediaArtifact, SessionIntegrityError
_JPEG = b"\xff\xd8\xff\xc0\x00\x07\x08\x00\x01\x00\x01\xff\xd9"
class _Store:
def __init__(self, *, block_prepare: bool = False) -> None:
self.prepare_calls = 0
self.list_calls = 0
self.prepare_started = threading.Event()
self.release_prepare = threading.Event()
if not block_prepare:
self.release_prepare.set()
self.artifact = RecordedMediaArtifact(
session_id="recorded-session",
public_source_id="camera",
artifact_id="camera-artifact",
source_path=Path("/sealed/sensor.camera.right"),
byte_length=123,
)
def prepare_replay(self, session_id: str, **_: Any) -> SimpleNamespace:
self.prepare_calls += 1
self.prepare_started.set()
assert self.release_prepare.wait(timeout=2.0)
return SimpleNamespace(session_id=session_id)
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
self.list_calls += 1
return (self.artifact,)
class _Inspector:
def __init__(self, manifest: RecordedMediaManifest) -> None:
self.manifest = manifest
self.inspect_calls = 0
def inspect(self, artifact: RecordedMediaArtifact, replay: object) -> RecordedMediaManifest:
assert artifact.artifact_id == "camera-artifact"
assert replay is not None
self.inspect_calls += 1
return self.manifest
def _manifest(tmp_path: Path) -> RecordedMediaManifest:
epoch_path = tmp_path / "epoch-1"
segments = tuple(
RecordedMediaSegment(
sequence=sequence,
path=epoch_path / "segments" / f"{sequence:08d}.m4s",
byte_length=10,
sha256=hashlib.sha256(str(sequence).encode()).hexdigest(),
random_access=True,
end_time_seconds=float(sequence),
)
for sequence in range(1, 4)
)
return RecordedMediaManifest(
session_id="recorded-session",
public_source_id="camera",
artifact_id="camera-artifact",
synchronization="recorded",
generation_sha256="a" * 64,
timeline_start_seconds=0.0,
timeline_end_seconds=3.0,
byte_length=123,
epochs=(
RecordedMediaEpoch(
ordinal=1,
path=epoch_path,
init_path=epoch_path / "init.mp4",
init_byte_length=10,
init_sha256="b" * 64,
media_type="video/mp4",
timeline_start_seconds=0.0,
timeline_end_seconds=3.0,
segments=segments,
),
),
)
def _service(
tmp_path: Path,
store: _Store,
inspector: _Inspector,
*,
max_decode_lanes: int = 32,
max_source_manifests: int = 32,
) -> RecordedCameraFrameService:
return RecordedCameraFrameService(
store, # type: ignore[arg-type]
inspector, # type: ignore[arg-type]
ffmpeg_path=Path(sys.executable),
cache_root=tmp_path / "cache",
max_decode_lanes=max_decode_lanes,
max_source_manifests=max_source_manifests,
)
def _wait_for_latest(service: RecordedCameraFrameService, ticket: int) -> None:
deadline = time.monotonic() + 2.0
source_key = ("recorded-session", "sensor.camera.right")
while time.monotonic() < deadline:
with service._coordination: # noqa: SLF001
lane = service._lanes.get(source_key) # noqa: SLF001
if lane is not None and lane.latest_ticket == ticket:
return
time.sleep(0.005)
raise AssertionError(f"camera request ticket {ticket} was not registered")
def _frame(epoch: RecordedMediaEpoch, sequence: int) -> RecordedCameraFrame:
return RecordedCameraFrame(
payload=_JPEG,
media_type="image/jpeg",
width=1,
height=1,
sha256=hashlib.sha256(_JPEG).hexdigest(),
source_fragment_sha256=epoch.segments[sequence - 1].sha256,
)
def test_camera_frame_burst_scans_manifest_once_and_decodes_only_latest(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = _Store(block_prepare=True)
inspector = _Inspector(_manifest(tmp_path))
service = _service(tmp_path, store, inspector)
decoded: list[int] = []
def decode(
manifest: RecordedMediaManifest,
epoch: RecordedMediaEpoch,
sequence: int,
) -> RecordedCameraFrame:
assert manifest is inspector.manifest
decoded.append(sequence)
return _frame(epoch, sequence)
monkeypatch.setattr(service, "_decode", decode)
with ThreadPoolExecutor(max_workers=3) as pool:
first = pool.submit(service.extract, "recorded-session", 0)
assert store.prepare_started.wait(timeout=2.0)
second = pool.submit(service.extract, "recorded-session", 1)
_wait_for_latest(service, 2)
third = pool.submit(service.extract, "recorded-session", 2)
_wait_for_latest(service, 3)
store.release_prepare.set()
with pytest.raises(SessionIntegrityError, match="superseded"):
first.result(timeout=2.0)
with pytest.raises(SessionIntegrityError, match="superseded"):
second.result(timeout=2.0)
assert third.result(timeout=2.0).source_fragment_sha256 == (
inspector.manifest.epochs[0].segments[2].sha256
)
assert store.prepare_calls == 1
assert store.list_calls == 1
assert inspector.inspect_calls == 1
assert decoded == [3]
def test_camera_frame_lane_keeps_only_latest_waiter_behind_active_decode(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = _Store()
inspector = _Inspector(_manifest(tmp_path))
service = _service(tmp_path, store, inspector)
decode_started = threading.Event()
release_decode = threading.Event()
decoded: list[int] = []
def decode(
manifest: RecordedMediaManifest,
epoch: RecordedMediaEpoch,
sequence: int,
) -> RecordedCameraFrame:
assert manifest is inspector.manifest
decoded.append(sequence)
if sequence == 1:
decode_started.set()
assert release_decode.wait(timeout=2.0)
return _frame(epoch, sequence)
monkeypatch.setattr(service, "_decode", decode)
with ThreadPoolExecutor(max_workers=3) as pool:
first = pool.submit(service.extract, "recorded-session", 0)
assert decode_started.wait(timeout=2.0)
second = pool.submit(service.extract, "recorded-session", 1)
_wait_for_latest(service, 2)
third = pool.submit(service.extract, "recorded-session", 2)
_wait_for_latest(service, 3)
release_decode.set()
with pytest.raises(SessionIntegrityError, match="superseded"):
first.result(timeout=2.0)
with pytest.raises(SessionIntegrityError, match="superseded"):
second.result(timeout=2.0)
assert third.result(timeout=2.0).width == 1
assert decoded == [1, 3]
assert store.prepare_calls == 1
assert inspector.inspect_calls == 1
def test_camera_frame_memory_caches_are_lru_bounded(tmp_path: Path) -> None:
store = _Store()
inspector = _Inspector(_manifest(tmp_path))
service = _service(
tmp_path,
store,
inspector,
max_decode_lanes=2,
max_source_manifests=2,
)
for ordinal in range(4):
source_key = (f"session-{ordinal}", "sensor.camera.right")
lane, _ = service._acquire_lane(source_key) # noqa: SLF001
service._release_lane(source_key, lane) # noqa: SLF001
assert tuple(service._lanes) == ( # noqa: SLF001
("session-2", "sensor.camera.right"),
("session-3", "sensor.camera.right"),
)
for ordinal in range(3):
source_key = (f"session-{ordinal}", "sensor.camera.right")
service._source_manifest( # noqa: SLF001
source_key,
session_id=source_key[0],
expected_source_name=source_key[1],
)
assert tuple(service._source_manifests) == ( # noqa: SLF001
("session-1", "sensor.camera.right"),
("session-2", "sensor.camera.right"),
)
service._source_manifest( # noqa: SLF001
("session-1", "sensor.camera.right"),
session_id="session-1",
expected_source_name="sensor.camera.right",
)
service._source_manifest( # noqa: SLF001
("session-3", "sensor.camera.right"),
session_id="session-3",
expected_source_name="sensor.camera.right",
)
assert tuple(service._source_manifests) == ( # noqa: SLF001
("session-1", "sensor.camera.right"),
("session-3", "sensor.camera.right"),
)
assert store.prepare_calls == 4
assert inspector.inspect_calls == 4
def test_camera_playback_source_reuses_manifest_and_exposes_exact_segment_clock(
tmp_path: Path,
) -> None:
store = _Store()
manifest = _manifest(tmp_path)
manifest = RecordedMediaManifest(
session_id=manifest.session_id,
public_source_id=manifest.public_source_id,
artifact_id=manifest.artifact_id,
synchronization="host-arrival-best-effort",
generation_sha256=manifest.generation_sha256,
timeline_start_seconds=manifest.timeline_start_seconds,
timeline_end_seconds=manifest.timeline_end_seconds,
byte_length=manifest.byte_length,
epochs=(
RecordedMediaEpoch(
ordinal=1,
path=manifest.epochs[0].path,
init_path=manifest.epochs[0].init_path,
init_byte_length=manifest.epochs[0].init_byte_length,
init_sha256=manifest.epochs[0].init_sha256,
media_type='video/mp4; codecs="avc1.640028"',
timeline_start_seconds=0.0,
timeline_end_seconds=3.0,
segments=manifest.epochs[0].segments,
),
),
)
inspector = _Inspector(manifest)
service = _service(tmp_path, store, inspector)
first = service.playback_source("recorded-session")
second = service.playback_source("recorded-session")
assert first == second
assert first.segment_count == 3
assert first.segment_sha256s == tuple(
segment.sha256 for segment in manifest.epochs[0].segments
)
assert first.segment_start_times_ns == (0, 1_000_000_000, 2_000_000_000)
assert store.prepare_calls == 1
assert inspector.inspect_calls == 1
def test_camera_frame_rejects_unbounded_memory_cache_configuration(tmp_path: Path) -> None:
store = _Store()
inspector = _Inspector(_manifest(tmp_path))
with pytest.raises(SessionIntegrityError, match="cache bounds"):
_service(tmp_path, store, inspector, max_decode_lanes=0)