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
@@ -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);