From 4fa6597b188a7770f29bdde2f5784e382cece1da Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 24 Aug 2026 22:36:08 +0300 Subject: [PATCH] feat(ui): stabilize shared M4.8 review viewer --- .../src/components/ObservationTimeline.tsx | 11 +- .../src/components/RecordedFmp4Player.tsx | 27 +- .../laboratory/LaboratoryEvidenceViewer.tsx | 100 +- .../LaboratoryMetricEvidenceScene.tsx | 72 +- .../laboratory/LaboratoryPresentation.tsx | 146 +-- .../LaboratoryRecordedClipPlayer.tsx | 211 ++++ .../LaboratoryReviewWorkspaceFrame.tsx | 111 ++ .../src/core/laboratory/advancedIndex.ts | 24 +- .../laboratory/advancedLaboratoryResults.ts | 4 + .../src/core/laboratory/advancedResults.ts | 2 +- .../laboratory/m48ObjectCentricQuality.ts | 1007 +++++++++++++++++ .../laboratory/m48SmallStaticRegression.ts | 364 ++++++ apps/control-station/src/styles.css | 4 + .../src/styles/laboratory-evidence-viewer.css | 46 + .../laboratory-recorded-clip-player.css | 94 ++ .../src/styles/laboratory-reporting.css | 29 + .../styles/laboratory-review-workspace.css | 44 + .../control-station/src/styles/laboratory.css | 9 +- .../src/styles/m48-object-centric-quality.css | 370 ++++++ .../src/styles/observation.css | 9 +- .../laboratory/AdvancedLaboratoryResult.tsx | 8 + .../laboratory/LaboratoryArchiveWorkspace.tsx | 7 +- .../laboratory/M48FailureAtlasVisual.tsx | 203 ++++ .../M48ObjectCentricQualityResult.tsx | 165 +++ .../M48SmallStaticPassageRegressionResult.tsx | 84 ++ .../M48SmallStaticRegressionVisual.tsx | 235 ++++ .../annotation/M48AdjudicationWorkspace.tsx | 291 +++++ .../annotation/M48BlindClipPlayer.tsx | 528 +++++++++ .../annotation/M48BlindReviewWorkspace.tsx | 568 ++++++++++ .../annotation/M48EvidenceModeControls.tsx | 97 ++ .../annotation/useM48ReviewCapability.tsx | 73 ++ .../annotation/useM48SpatialClipPlayback.ts | 126 +++ .../laboratory/laboratoryArchiveProfiles.ts | 14 + .../useAdvancedLaboratoryCatalog.ts | 11 +- .../test/advancedLaboratoryResults.test.mjs | 40 + .../test/laboratoryProductUi.test.mjs | 5 + .../test/m48ObjectCentricQuality.test.mjs | 693 ++++++++++++ .../test/m48SmallStaticRegression.test.mjs | 163 +++ .../test/m4ReplayThreat.test.mjs | 2 +- .../test/observationSources.test.mjs | 41 + .../test/recordedCameraBuffering.test.mjs | 10 + src/k1link/sessions/__init__.py | 7 +- src/k1link/sessions/camera_frame.py | 228 +++- tests/test_camera_frame.py | 327 ++++++ 44 files changed, 6463 insertions(+), 147 deletions(-) create mode 100644 apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx create mode 100644 apps/control-station/src/components/laboratory/LaboratoryReviewWorkspaceFrame.tsx create mode 100644 apps/control-station/src/core/laboratory/m48ObjectCentricQuality.ts create mode 100644 apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts create mode 100644 apps/control-station/src/styles/laboratory-evidence-viewer.css create mode 100644 apps/control-station/src/styles/laboratory-recorded-clip-player.css create mode 100644 apps/control-station/src/styles/laboratory-review-workspace.css create mode 100644 apps/control-station/src/styles/m48-object-centric-quality.css create mode 100644 apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/M48ObjectCentricQualityResult.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/annotation/M48AdjudicationWorkspace.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts create mode 100644 apps/control-station/test/m48ObjectCentricQuality.test.mjs create mode 100644 apps/control-station/test/m48SmallStaticRegression.test.mjs create mode 100644 tests/test_camera_frame.py diff --git a/apps/control-station/src/components/ObservationTimeline.tsx b/apps/control-station/src/components/ObservationTimeline.tsx index 4f2d2e3..f7e63e3 100644 --- a/apps/control-station/src/components/ObservationTimeline.tsx +++ b/apps/control-station/src/components/ObservationTimeline.tsx @@ -101,13 +101,14 @@ export function ObservationTimeline({ + /> {buffered && playbackRate !== undefined && onPlaybackRateChange ? ( ({ 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); + }} /> + {session ? `${session.state} · ${session.resolvedClipCount}/${session.clipCount}` : "Adjudication не создана"} + + + + )} + stage={( +
+ {loading ?
+ : !catalog || !catalog.cameraPlayback || !clip ?
{error ?? "Источник недоступен."}
+ : { + if (!decision) return; + setDecision({ ...decision, reviewState: "pending", noObject: null, tracklets }); + }} />} + {catalog ? : null} +
+ )} + inspector={( + <> +
M4.8 · reviewer disagreement{clip ? `${clip.clipId} · frame ${sequence}` : "Источник проверяется"}Два независимых class-free review; frozen graph output остаётся скрыт.
+ {(error || spatialError) && catalog ? {error ?? spatialError} : null} + + {session && reviewerA && reviewerB && !["adjudication-frozen", "evaluated"].includes(session.state) ? ( +
+ + + {decision ? setDecision({ ...decision, reviewState: checked ? "adjudicated" : "pending", noObject: checked ? decision.tracklets.length === 0 : null })} /> : null} +
+ ) : null} + {editable && selectedDecisionTracklet ? ( +
+ {selectedDecisionTracklet.objectId} + updateDecisionTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} /> + updateDecisionTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} /> + updateDecisionTrackState({ criticalCorridorObstacle })} /> +
+ ) : null} + + )} + overlays={( + <> + !busy && setFreezeOpen(false)} footer={}> +
+ setAdjudicatorId(event.target.value)} /> + + +
+
+ setToasts((current) => current.filter((item) => item.id !== id))} /> + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx new file mode 100644 index 0000000..094d1eb --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx @@ -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): Rect { + const [rect, setRect] = useState({ 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, 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(null); + const host = useHostSize(hostRef); + const [playing, setPlaying] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1); + const [drawStart, setDrawStart] = useState(null); + const [drawCurrent, setDrawCurrent] = useState(null); + const [boxInteraction, setBoxInteraction] = useState(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) => { + 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) => { + 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 ( + +
+ ПРАВАЯ КАМЕРА · СИНХРОННО · КАДР {sequence} +
+ { + 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 ( + { + 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, + }); + }} + > + + {tracklet.objectId} + + ); + })} + {drawStart && drawCurrent ? ( + + ) : 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]) => ( + { + 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} + + )} + alternativeScene={( +
+
+ {mode === "3d" ? "3D LIDAR" : "ПЛАН LIDAR"} · КАДР {sequence} +
+ {spatialReady && spatialFrame ? ( + + ) : ( +
+ {spatialLoading ? : } + {spatialLoading + ? "Подготавливаем синхронный LiDAR-кадр" + : spatialError + ? spatialError + : spatialFrame && !spatialFrame.sourceAvailable + ? "Текущий LiDAR-кадр недоступен" + : spatialFrame && !spatialFrame.bodyFrameAvailable + ? "LiDAR в системе координат корпуса для этого кадра недоступен" + : "Исходные пространственные данные для этого кадра недоступны"} +
+ )} +
+ )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx new file mode 100644 index 0000000..8f5a7d0 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx @@ -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 { + 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; + dirty: boolean; +} + +export function M48CorrectionWorkspace({ + gate, + returnFocusTarget, + onClose, + onChanged, +}: { + gate: M48GateStatus; + returnFocusTarget?: HTMLElement | null; + onClose: () => void; + onChanged?: () => void; +}) { + const [catalog, setCatalog] = useState(null); + const [session, setSession] = useState(null); + const [drafts, setDrafts] = useState>(new Map()); + const [selectedClipId, setSelectedClipId] = useState(""); + const [sequence, setSequence] = useState(1); + const [mode, setMode] = useState("camera"); + const [cameraVisible, setCameraVisible] = useState(true); + const [selectedObjectId, setSelectedObjectId] = useState(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(null); + const [freezeOpen, setFreezeOpen] = useState(false); + const [reviewerId, setReviewerId] = useState(""); + const [candidateVisible, setCandidateVisible] = useState(false); + const [classFree, setClassFree] = useState(false); + const [toasts, setToasts] = useState([]); + const savingRef = useRef(false); + + const notify = useCallback((toast: Omit) => { + 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 = 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) => { + 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) => { + 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 ( + +
+
+
+ selectAdjacentClip(-1)} + > + + + = catalog.clips.length - 1 || busy} + onClick={() => selectAdjacentClip(1)} + > + + +
+ + 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"])} /> + + + updateTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} /> + + + updateTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} /> + + + updateTrackState({ criticalCorridorObstacle })} /> + + { + setSelectedObjectId(null); + setDrawing(true); + }} + > + + + void save(drafts, "object-edit")} + > + + + { + if (!currentDraft) return; + setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets: currentDraft.tracklets.filter(({ objectId }) => objectId !== selectedTracklet.objectId) }); + setSelectedObjectId(null); + }}> + { + setSelectedObjectId(null); + setDrawing(false); + }} + > + + +
+ ) : null} +
+ )} + stage={( +
+ {loading ? ( +
+ ) : !catalog || !catalog.cameraPlayback || !clip ? ( +
{error ?? "M4.8 источник недоступен."}
+ ) : ( + { + if (!currentDraft) return; + setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets }); + }} + /> + )} + {catalog ? ( + + ) : null} + {catalog ? ( + +
+ + {session + ? `Worker 006 · ${session.seedObjectCount.toLocaleString("ru-RU")} авторамок` + : "Загружаем авторазметку"} + + {clip ? `${clip.clipId} · кадр ${sequence}` : "Источник проверяется"} +
+ Исправьте авторамки; новая ручная рамка относится только к текущему кадру и не имитирует трекинг. + «Опасность» — немедленная угроза. «Проезд» — статическое ограничение, которое требует объезда или геометрического запаса. + + {busy ? "Сохраняем изменения" : dirty ? "Есть несохранённые изменения" : stateLabel(session)} + + {(error || spatialError) ? {error ?? spatialError} : null} + {session?.evidenceSummary ? ( +
+ Результат проверки Worker 006 + Подтверждено: {session.evidenceSummary.confirmedCandidateCount}/{session.evidenceSummary.seedObjectCount} + Исправлено: {session.evidenceSummary.modifiedCandidateCount} + Удалено лишних: {session.evidenceSummary.falsePositiveRemovedCount} + Добавлено пропущенных: {session.evidenceSummary.missedObjectAddedCount} + Это проверка авторазметки, а не независимая контрольная разметка. +
+ ) : null} +
+ ) : null} +
+ )} + overlays={( + <> + !busy && setFreezeOpen(false)} + footer={} + > +
+ setReviewerId(event.target.value)} /> + + +
+
+ setToasts((current) => current.filter((item) => item.id !== id))} /> + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx new file mode 100644 index 0000000..20ba4b0 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx @@ -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 { + 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 ( +
+ onCameraVisibleChange( + nextM48CameraVisibility(mode, cameraVisible), + )} + > + + + { + if (!spatialAvailable) return; + onModeChange(nextM48SpatialMode(mode, cameraVisible, "3d")); + }} + > + + + { + if (!spatialAvailable) return; + onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan")); + }} + > + + +
+ ); +} + +export function M48EvidenceModeRail(props: M48EvidenceModeControlProps) { + return ( + + + + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx b/apps/control-station/src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx new file mode 100644 index 0000000..0ad230e --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx @@ -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(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: ( + setOpen(false)} + onChanged={refresh} + /> + ), + }; +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts b/apps/control-station/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts new file mode 100644 index 0000000..6d72f53 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts @@ -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( + cache: Map, + 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()); + const errorsRef = useRef(new Map()); + const inFlightRef = useRef(new Map>()); + const controllerRef = useRef(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 => { + 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, + }; +} diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index 06fe8e7..f0c8a6b 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -63,6 +63,20 @@ interface KnownWorkDefinition { const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг"; const KNOWN_WORKS: Readonly, 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`, diff --git a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts index 5412bdb..fa99040 100644 --- a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts +++ b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts @@ -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); diff --git a/apps/control-station/test/advancedLaboratoryResults.test.mjs b/apps/control-station/test/advancedLaboratoryResults.test.mjs index 9b2cd88..032c547 100644 --- a/apps/control-station/test/advancedLaboratoryResults.test.mjs +++ b/apps/control-station/test/advancedLaboratoryResults.test.mjs @@ -963,6 +963,46 @@ test("LAB entry defaults atomically to the freshest pipeline, experiment and run }); }); +test("M4.8R1 stays in the current pipeline as a separate experiment and run", () => { + const catalog = buildLaboratoryCatalog({ + rigLabel: "K1", + knownWorks: [], + advancedIndex: [ + { + workId: "m48-object-centric-quality", + resultId: `m48-object-quality-pack-${"8".repeat(64)}`, + createdAtUtc: "2026-08-24T12:00:00Z", + }, + { + workId: "m48-small-static-passage-regression", + resultId: `m48-small-static-passage-regression-${"9".repeat(64)}`, + createdAtUtc: "2026-08-24T18:30:00Z", + }, + ], + publishedWorks: [], + }); + + const profiles = buildLaboratoryProfiles(catalog); + assert.deepEqual(profiles.map(({ id }) => id), [ + "rig-dual-evidence-virtual-corridor-v1", + ]); + assert.deepEqual( + experimentOptionsForProfile(profiles[0].id, catalog).map(({ id }) => id), + [ + "m48-small-static-passage-regression", + "m48-object-centric-source-quality", + ], + ); + assert.deepEqual( + freshestLaboratorySelection(catalog), + { + profileId: "rig-dual-evidence-virtual-corridor-v1", + experimentId: "m48-small-static-passage-regression", + workId: "m48-small-static-passage-regression", + }, + ); +}); + test("E46E is exposed as the newest independent NVIDIA pipeline", () => { const catalog = buildLaboratoryCatalog({ rigLabel: "K1", diff --git a/apps/control-station/test/laboratoryProductUi.test.mjs b/apps/control-station/test/laboratoryProductUi.test.mjs index 7f96729..462fcf1 100644 --- a/apps/control-station/test/laboratoryProductUi.test.mjs +++ b/apps/control-station/test/laboratoryProductUi.test.mjs @@ -264,6 +264,11 @@ test("LAB product surface has a compact canonical summary and no roadmap footer" ]); assert.match(presentationSource, /export function LaboratorySummary/); + assert.match(presentationSource, /const \[expanded, setExpanded\] = useState\(false\)/); + assert.match(presentationSource, /aria-expanded=\{expanded\}/); + assert.match(presentationSource, /className="laboratory-summary__details" hidden=\{!expanded\}/); + assert.match(presentationSource, /className="laboratory-summary__actions"/); + assert.match(presentationSource, /name="chevron-down"/); assert.match(presentationSource, /export function LaboratoryWorkTemplate/); assert.match( presentationSource, diff --git a/apps/control-station/test/m48ObjectCentricQuality.test.mjs b/apps/control-station/test/m48ObjectCentricQuality.test.mjs new file mode 100644 index 0000000..4693109 --- /dev/null +++ b/apps/control-station/test/m48ObjectCentricQuality.test.mjs @@ -0,0 +1,693 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { after, before, test } from "node:test"; +import { createServer } from "vite"; + +let server; +let decodeM48GateStatus; +let decodeM48ReviewSourceCatalog; +let decodeM48CorrectionSession; +let decodeM48SpatialFrame; +let decodeM48QualityResult; +let decodeM48FailureAtlas; +let decodeM48FailureCase; +let assertM48BlindPayload; +let interpolateM48Extent; +let createM48Tracklet; +let nextM48ObjectId; +let laboratoryMetricLegendEntries; +let nearestLaboratoryRecordedClipFrame; +let laboratoryRecordedClipEndExclusiveNs; +let m48SpatialPlaybackWindow; +let trimM48SpatialPlaybackCache; +let nextM48CameraVisibility; +let nextM48SpatialMode; + +const packId = `m48-object-quality-pack-${"a".repeat(64)}`; + +before(async () => { + server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } }); + ({ + decodeM48GateStatus, + decodeM48ReviewSourceCatalog, + decodeM48CorrectionSession, + decodeM48SpatialFrame, + decodeM48QualityResult, + decodeM48FailureAtlas, + decodeM48FailureCase, + assertM48BlindPayload, + } = await server.ssrLoadModule("/src/core/laboratory/m48ObjectCentricQuality.ts")); + ({ + interpolateM48Extent, + createM48Tracklet, + nextM48ObjectId, + } = await server.ssrLoadModule("/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx")); + ({ + nearestLaboratoryRecordedClipFrame, + laboratoryRecordedClipEndExclusiveNs, + } = await server.ssrLoadModule( + "/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx", + )); + ({ laboratoryMetricLegendEntries } = await server.ssrLoadModule( + "/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", + )); + ({ + m48SpatialPlaybackWindow, + trimM48SpatialPlaybackCache, + } = await server.ssrLoadModule( + "/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts", + )); + ({ + nextM48CameraVisibility, + nextM48SpatialMode, + } = await server.ssrLoadModule( + "/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx", + )); +}); + +after(async () => server?.close()); + +function authority() { + return { + mode: "replay-simulated", + physical_live: false, + commands_enabled: false, + actuation_allowed: false, + navigation_or_safety_accepted: false, + }; +} + +function source() { + const clips = Array.from({ length: 20 }, (_, index) => { + const start = index * 2 + 1; + return { + clip_id: `clip-${String(index + 1).padStart(2, "0")}`, + start_sequence: start, + end_sequence: start + 1, + frames: [start, start + 1].map((sequence) => ({ + sequence, + source_time_ns: (sequence - 1) * 100_000_000, + camera_fragment_sha256: String(index % 10).repeat(64), + camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-${String(index + 1).padStart(2, "0")}/frames/${sequence}/camera`, + spatial_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-${String(index + 1).padStart(2, "0")}/frames/${sequence}/spatial`, + })), + }; + }); + return { + schema_version: "missioncore.m48-neutral-object-review-source/v2", + pack_id: packId, + state: "prediction-blind-neutral-source-projection", + contract: { + contract_id: "m48-class-free-object-tracklet/v1", + label_unit: "clip-local-object-tracklet", + semantic_classes_allowed: false, + extent: "normalized-xyxy-sparse-keyframes", + extent_interpolation: "linear-between-bounding-keyframes", + visibility: ["occluded", "partial", "visible"], + state_segments: { + coverage: "contiguous-full-tracklet-lifetime", + geometry_association: ["associated", "ineligible", "unavailable", "unknown"], + freshness: ["current", "held", "stale", "unavailable"], + motion: ["moving", "static", "unknown", "unsupported"], + threat: ["not-threat", "threat", "unknown"], + critical_corridor_obstacle: "boolean", + }, + }, + camera_playback: { + schema_version: "missioncore.laboratory-recorded-clip-camera/v1", + source_id: "recorded.camera.test", + label: "Записанная RIGHT камера", + manifest_url: "/api/v1/observation-sessions/recorded-session/media/recorded-video-test/manifest", + manifest_generation_sha256: "f".repeat(64), + byte_length: 123456, + media_type: "video/mp4", + timeline_start_seconds: 0, + timeline_end_seconds: 4, + segment_count: 40, + seekable: true, + synchronization: "host-arrival-best-effort", + transport: "recorded-fmp4-manifest", + fragment_binding: "pack-frozen-sha256-verified", + }, + clips, + clip_count: clips.length, + frame_count: 40, + strata_included: false, + split_included: false, + candidate_identity_included: false, + frozen_predictions_included: false, + model_scores_included: false, + semantic_class_task_included: false, + evidence_capabilities: { + state: "prediction-free-spatial-evidence-available", + camera_epoch_time: true, + current_point_cloud_body_xyz_m: true, + rig: true, + virtual_corridor: true, + raw_lidar: false, + graph_output: false, + graph_boxes_ids_scores: false, + label_authority: { + obstacle_presence_and_extent: true, + geometry_association: true, + freshness: true, + motion: true, + threat: true, + critical_corridor_obstacle: true, + }, + fail_closed_reason: null, + }, + access: "prediction-free-strata-free-source-read-only", + }; +} + +test("M4.8 pack status stays fail-closed and command-free", () => { + const result = decodeM48GateStatus({ + schema_version: "missioncore.m48-object-quality-pack-status/v1", + pack_id: packId, + created_at_utc: "2026-08-24T10:00:00Z", + state: "prepared", + metrics: { clip_count: 20, frame_count: 1020, seed_object_count: 5236, correction_state: "not-started", correction_reviewed_clip_count: 0, correction_complete: false, review_slot_count: 0, frozen_reviewer_count: 0, required_frozen_reviewer_count: 2 }, + decision: { review_collection_ready: true, two_distinct_reviews_frozen: false, adjudication_unlocked: false, adjudication_frozen: false, evaluated: false, next_action: "freeze two reviews" }, + truth_seal_id: null, + quality_result_id: null, + blindness: { candidate_identity_included: false, frozen_predictions_included: false, model_scores_included: false, semantic_class_task_included: false, strata_included: false }, + authority: authority(), + access: "neutral-workflow-status-read-only", + }); + assert.equal(result.packId, packId); + assert.equal(result.authority.commandsEnabled, false); + assert.equal(result.correctionState, "not-started"); + assert.equal(result.seedObjectCount, 5236); + assert.equal(result.requiredFrozenReviewerCount, 2); +}); + +test("blind source admits fragment identity and structurally rejects candidate material", () => { + const result = decodeM48ReviewSourceCatalog(source()); + assert.equal(result.clips.length, 20); + assert.equal(result.cameraPlayback.segmentCount, 40); + assert.equal(result.cameraPlayback.manifestGenerationSha256, "f".repeat(64)); + assert.equal(result.clips[0].frames[0].cameraFragmentSha256.length, 64); + assert.equal(result.evidenceCapabilities.currentPointCloudBodyXyzM, true); + + for (const leak of [ + { predictions: [] }, + { strata: ["no-object"] }, + { model: { id: "candidate" } }, + { graph: [] }, + { split: "validation" }, + { semantic_class: "car" }, + ]) assert.throws(() => assertM48BlindPayload(leak), /blind|candidate|stratum/i); + + const staleName = source(); + staleName.clips[0].frames[0].camera_frame_sha256 = staleName.clips[0].frames[0].camera_fragment_sha256; + delete staleName.clips[0].frames[0].camera_fragment_sha256; + assert.throws(() => decodeM48ReviewSourceCatalog(staleName), /состав полей/); +}); + +test("candidate-assisted correction admits frozen boxes without claiming independent truth", () => { + const candidate = { + schema_version: "missioncore.m48-assisted-object-correction-session/v1", + pack_id: packId, + session_id: `m48-correction-session-${"b".repeat(64)}`, + title: "Worker 006 correction", + revision: 0, + state: "draft", + created_at_utc: "2026-08-24T12:00:00Z", + updated_at_utc: "2026-08-24T12:00:00Z", + clips: [{ + clip_id: "clip-01", + start_sequence: 1, + end_sequence: 2, + review_state: "pending", + no_object: null, + tracklets: [{ + object_id: "proposal-0-0", + first_sequence: 1, + last_sequence: 1, + keyframes: [{ sequence: 1, extent_xyxy: [0.1, 0.2, 0.3, 0.4], visibility: "visible" }], + state_segments: [{ start_sequence: 1, end_sequence: 1, geometry_association: "associated", freshness: "current", motion: "unknown", threat: "unknown", critical_corridor_obstacle: false }], + notes: null, + }], + notes: null, + }], + progress: { reviewed_clip_count: 0, clip_count: 1, complete: false }, + seed_summary: { worker_id: "006", clip_count: 1, frame_count: 2, object_count: 1, prediction_rows_sha256: "c".repeat(64) }, + evidence_summary: null, + reviewer_id: null, + submitted_at_utc: null, + submission_sha256: null, + assistance: { mode: "frozen-candidate-seeded", candidate_predictions_seen: true, model_scores_seen: false, semantic_class_task_seen: false, independent_truth_eligible: false }, + authority: authority(), + access: "capability-protected-candidate-assisted-correction", + }; + const decoded = decodeM48CorrectionSession(candidate); + assert.equal(decoded.seedWorkerId, "006"); + assert.equal(decoded.seedObjectCount, 1); + assert.equal(decoded.clips[0].tracklets[0].objectId, "proposal-0-0"); + + candidate.assistance.independent_truth_eligible = true; + assert.throws(() => decodeM48CorrectionSession(candidate), /assistance|truth/i); +}); + +test("spatial frame accepts only current points, virtual rig and corridor", () => { + const result = decodeM48SpatialFrame({ + schema_version: "missioncore.m48-neutral-object-review-spatial-frame/v1", + pack_id: packId, + clip_id: "clip-01", + sequence: 1, + source_time_ns: 0, + point_cloud_body_xyz_m: [[1, 0, 0.25]], + rig: { profile_id: "virtual-rig", length_m: 1, width_m: 0.6, lidar_reference: "rear", nominal_sensor_height_m: 1.25, physical_mount_claimed: false }, + corridor: { profile_id: "corridor", forward_length_m: 8, rear_margin_m: 0.2, lateral_clearance_m: 0.25, half_width_m: 0.55, prediction_horizon_seconds: 5 }, + occupied_voxel_size_m: 0.2, + source_available: true, + body_frame_available: true, + candidate_identity_included: false, + graph_boxes_ids_scores_included: false, + frozen_predictions_included: false, + strata_included: false, + authority: authority(), + access: "prediction-free-current-spatial-evidence-read-only", + }); + assert.deepEqual(result.pointCloudBodyXyzM, [[1, 0, 0.25]]); + assert.equal(result.sourceAvailable, true); + assert.equal(result.bodyFrameAvailable, true); + assert.equal(result.corridor.forwardLengthM, 8); + + const unavailable = { + schema_version: "missioncore.m48-neutral-object-review-spatial-frame/v1", + pack_id: packId, + clip_id: "clip-01", + sequence: 1, + source_time_ns: 0, + point_cloud_body_xyz_m: [[1, 0, 0.25]], + rig: { profile_id: "virtual-rig", length_m: 1, width_m: 0.6, lidar_reference: "rear", nominal_sensor_height_m: 1.25, physical_mount_claimed: false }, + corridor: { profile_id: "corridor", forward_length_m: 8, rear_margin_m: 0.2, lateral_clearance_m: 0.25, half_width_m: 0.55, prediction_horizon_seconds: 5 }, + occupied_voxel_size_m: 0.2, + source_available: true, + body_frame_available: false, + candidate_identity_included: false, + graph_boxes_ids_scores_included: false, + frozen_predictions_included: false, + strata_included: false, + authority: authority(), + access: "prediction-free-current-spatial-evidence-read-only", + }; + assert.throws(() => decodeM48SpatialFrame(unavailable), /unavailable spatial frame/); +}); + +test("tracklet extents interpolate on the shared clip timeline", () => { + const tracklet = { + objectId: "object-01", + firstSequence: 10, + lastSequence: 20, + keyframes: [ + { sequence: 10, extentXyxy: [0.1, 0.2, 0.3, 0.4], visibility: "visible" }, + { sequence: 20, extentXyxy: [0.2, 0.3, 0.4, 0.5], visibility: "visible" }, + ], + stateSegments: [], + notes: null, + }; + const midpoint = interpolateM48Extent(tracklet, 15); + assert.ok(midpoint.every((value, index) => Math.abs(value - [0.15, 0.25, 0.35, 0.45][index]) < 1e-12)); + assert.equal(interpolateM48Extent(tracklet, 9), null); +}); + +test("manual correction is frame-local, fails closed without spatial authority and never reuses a deleted id", () => { + const clip = decodeM48ReviewSourceCatalog(source()).clips[0]; + const tracklet = createM48Tracklet("object-01", clip, [0.1, 0.2, 0.3, 0.4], false, clip.endSequence); + assert.equal(tracklet.firstSequence, clip.endSequence); + assert.equal(tracklet.lastSequence, clip.endSequence); + assert.equal(tracklet.stateSegments[0].startSequence, clip.endSequence); + assert.equal(tracklet.stateSegments[0].endSequence, clip.endSequence); + assert.equal(interpolateM48Extent(tracklet, clip.startSequence), null); + assert.deepEqual(interpolateM48Extent(tracklet, clip.endSequence), [0.1, 0.2, 0.3, 0.4]); + assert.equal(tracklet.stateSegments[0].geometryAssociation, "unavailable"); + assert.equal(tracklet.stateSegments[0].motion, "unsupported"); + assert.equal(nextM48ObjectId([ + tracklet, + { ...tracklet, objectId: "object-03" }, + ]), "object-02"); +}); + +test("shared recorded clip clock selects exact frames and one stable loop boundary", () => { + const frames = [ + { sequence: 11, sourceTimeNs: 1_000_000_000 }, + { sequence: 12, sourceTimeNs: 1_100_000_000 }, + { sequence: 13, sourceTimeNs: 1_200_000_000 }, + ]; + assert.equal(nearestLaboratoryRecordedClipFrame(frames, 1_049_000_000).sequence, 11); + assert.equal(nearestLaboratoryRecordedClipFrame(frames, 1_051_000_000).sequence, 12); + assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000); +}); + +test("M4.8 camera and spatial visibility are independent without an empty viewer", () => { + assert.equal(nextM48CameraVisibility("camera", true), true); + assert.equal(nextM48CameraVisibility("3d", true), false); + assert.equal(nextM48CameraVisibility("3d", false), true); + assert.equal(nextM48SpatialMode("camera", true, "3d"), "3d"); + assert.equal(nextM48SpatialMode("3d", true, "3d"), "camera"); + assert.equal(nextM48SpatialMode("3d", false, "3d"), "3d"); + assert.equal(nextM48SpatialMode("3d", false, "plan"), "plan"); +}); + +test("M4.8 spatial playback prefetches across the loop and remains bounded", () => { + const frames = [11, 12, 13, 14, 15].map((sequence) => ({ sequence })); + assert.deepEqual(m48SpatialPlaybackWindow(frames, 14, 4), [14, 15, 11, 12]); + + const cache = new Map(Array.from({ length: 30 }, (_, index) => [index + 1, index])); + trimM48SpatialPlaybackCache(cache, [28, 29, 30, 1], 24); + assert.equal(cache.size, 24); + for (const sequence of [28, 29, 30, 1]) assert.equal(cache.has(sequence), true); +}); + +test("post-seal result and atlas reveal bounded graph material only after evaluation", () => { + const resultId = `m48-object-quality-result-${"b".repeat(64)}`; + const truthId = `m48-object-truth-seal-${"c".repeat(64)}`; + const metricNames = [ + "terminal_outcome_accounting", + "false_free_space_claims", + "obstacle_presence_precision", + "obstacle_presence_recall", + "critical_corridor_obstacle_recall", + "geometry_association_correctness", + "freshness_correctness", + "motion_decision_correctness", + "critical_threat_not_threat", + "unknown_prediction_count", + "failure_case_count", + ]; + const metrics = Object.fromEntries(metricNames.map((name) => [name, name.endsWith("_count") || name === "false_free_space_claims" ? 0 : 1])); + const quality = decodeM48QualityResult({ + schema_version: "missioncore.m48-object-centric-quality-result-view/v1", + result_id: resultId, + pack_id: packId, + truth_seal_id: truthId, + created_at_utc: "2026-08-24T12:00:00Z", + status: "accepted-object-centric-source-quality", + accepted: true, + metrics, + gates: { obstacle_presence_precision: true }, + unknown_causes: {}, + prediction_material_release: "post-adjudication-seal-evaluation-only", + authority: authority(), + ground_truth: false, + access: "evaluated-object-quality-summary-read-only", + }); + assert.equal(quality.accepted, true); + + const caseId = `m48-failure-${"d".repeat(64)}`; + const atlas = decodeM48FailureAtlas({ + schema_version: "missioncore.m48-object-quality-failure-atlas-view/v1", + result_id: resultId, + cases: [{ schema_version: "missioncore.m48-object-quality-failure/v1", failure_case_id: caseId, clip_id: "clip-01", split: "validation", sequence: 1, causes: ["presence-false-negative"], severity: "high", terminal_outcome: "delivered", unmatched_prediction_ids: [], unmatched_truth_object_ids: ["object-01"] }], + case_count: 1, + prediction_material_release: "post-adjudication-seal-evaluation-only", + authority: authority(), + access: "evaluated-bounded-failure-atlas-read-only", + }); + assert.equal(atlas[0].caseId, caseId); + assert.equal(atlas[0].split, "validation"); + + const failure = decodeM48FailureCase({ + schema_version: "missioncore.m48-object-quality-failure-case-view/v1", + result_id: resultId, + case: { failure_case_id: caseId, clip_id: "clip-01", split: "validation", sequence: 1, causes: ["presence-false-negative"], severity: "high" }, + frame: { sequence: 1, source_time_ns: 0, camera_fragment_sha256: "e".repeat(64), camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-01/frames/1/camera`, spatial_url: null }, + truth: [{ object_id: "object-01", extent_xyxy: [0.1, 0.1, 0.3, 0.4], geometry_association: "associated", freshness: "current", motion: "static", threat: "threat" }], + graph: [], + prediction_material_release: "post-adjudication-seal-evaluation-only", + authority: authority(), + access: "evaluated-failure-case-read-only", + }); + assert.equal(failure.truth[0].objectId, "object-01"); + assert.equal(failure.frame.cameraFragmentSha256, "e".repeat(64)); +}); + +test("M4.8 evidence keeps the shared viewer stage stretched over the visual frame", () => { + const stylesheet = readFileSync( + new URL("../src/styles/laboratory-recorded-clip-player.css", import.meta.url), + "utf8", + ); + const player = readFileSync( + new URL("../src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx", import.meta.url), + "utf8", + ); + const shared = readFileSync( + new URL("../src/components/laboratory/LaboratoryRecordedClipPlayer.tsx", import.meta.url), + "utf8", + ); + const visual = readFileSync( + new URL("../src/workspaces/laboratory/M48FailureAtlasVisual.tsx", import.meta.url), + "utf8", + ); + const evidenceViewer = readFileSync( + new URL("../src/components/laboratory/LaboratoryEvidenceViewer.tsx", import.meta.url), + "utf8", + ); + const laboratoryStyles = readFileSync( + new URL("../src/styles/laboratory-evidence-viewer.css", import.meta.url), + "utf8", + ); + const modeControls = readFileSync( + new URL("../src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx", import.meta.url), + "utf8", + ); + + assert.match( + stylesheet, + /\.laboratory-recorded-clip-player__camera\s*>\s*\.recorded-media-player\s*\{[^}]*position:\s*absolute/s, + ); + assert.match(player, /3D<\/span>/); + assert.doesNotMatch(modeControls, / { + const root = new URL("../src/workspaces/laboratory/", import.meta.url); + const sourceFiles = readdirSync(root, { recursive: true }) + .filter((name) => /\.(?:ts|tsx)$/.test(String(name))); + for (const name of sourceFiles) { + const sourceText = readFileSync(new URL(String(name), root), "utf8"); + assert.doesNotMatch( + sourceText, + /new\s+MediaSource|requestVideoFrameCallback|URL\.createObjectURL|image\/jpeg|setTimeout\s*\(| { + const review = readFileSync( + new URL("../src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx", import.meta.url), + "utf8", + ); + const adjudication = readFileSync( + new URL("../src/workspaces/laboratory/annotation/M48AdjudicationWorkspace.tsx", import.meta.url), + "utf8", + ); + const frame = readFileSync( + new URL("../src/components/laboratory/LaboratoryReviewWorkspaceFrame.tsx", import.meta.url), + "utf8", + ); + const frameStyles = readFileSync( + new URL("../src/styles/laboratory-review-workspace.css", import.meta.url), + "utf8", + ); + const m48Styles = readFileSync( + new URL("../src/styles/m48-object-centric-quality.css", import.meta.url), + "utf8", + ); + const recordedStyles = readFileSync( + new URL("../src/styles/laboratory-recorded-clip-player.css", import.meta.url), + "utf8", + ); + + for (const workspace of [review, adjudication]) { + assert.match(workspace, /LaboratoryReviewWorkspaceFrame/); + assert.doesNotMatch(workspace, /createPortal|className="m48-review-workspace"/); + assert.doesNotMatch( + workspace, + /@rerun-io|RerunViewer|Blueprint|view_id|blueprint_id/, + "M4.8 must reuse shared viewers instead of defining a per-LAB Rerun layout", + ); + } + assert.match(frame, /document\.body\.style\.overflow = "hidden"/); + assert.match(frame, /event\.key === "Escape"/); + assert.match(frame, /keepFocusInside/); + assert.match(frame, /returnFocusTarget\?\.isConnected/); + assert.match(frame, /requestAnimationFrame\(\(\) => target\?\.focus\(\)\)/); + + const player = readFileSync( + new URL("../src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx", import.meta.url), + "utf8", + ); + assert.match(player, /spatialFrame\.bodyFrameAvailable/); + assert.match(player, /LiDAR в системе координат корпуса для этого кадра недоступен/); + assert.match(player, /Number\(effectiveCameraVisible\) \+ Number\(spatialVisible\)/); + assert.match(player, /data-spatial-sequence=\{spatialReady \? sequence : undefined\}/); + assert.match(review, /\s*Добавить объект\s*<\/Button>/); + assert.ok(review.indexOf('label="Предыдущий клип"') < review.indexOf('\s*Сохранить изменения\s*<\/Button>/); + assert.doesNotMatch(review, /Выбранный объект|Рамка здесь|Начало здесь|Конец здесь|trimM48Tracklet|рамка переносится по клипу/); + assert.match(review, /sequence < selectedTracklet\.firstSequence \|\| sequence > selectedTracklet\.lastSequence/); + assert.match(player, /interactionMoved\([\s\S]*?boxInteraction\.startClient,[\s\S]*?event\.clientX/); + assert.match(player, /extentsDiffer\(boxInteraction\.originalExtent, extent\)/); + assert.match(player, /firstSequence: sequence/); + assert.match(player, /lastSequence: sequence/); + assert.doesNotMatch(review, /inspector=\{/); + assert.match(frame, /\{inspector \?