From 525ab741687cc6307365d4f71abe7c5c1979bc2f Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 29 Aug 2026 21:08:49 +0300 Subject: [PATCH] fix(lab): restore canonical RAV004 replay --- .../LaboratoryRecordedClipPlayer.tsx | 20 + .../src/core/laboratory/vegetationShadow.ts | 11 + .../laboratory-recorded-clip-player.css | 23 + .../laboratory/VegetationShadowResult.tsx | 495 +++++++++++++++++- .../test/vegetationShadow.test.mjs | 14 +- .../mixed_route_vegetation_review.py | 1 + src/k1link/web/advanced_laboratory_api.py | 16 + tests/test_advanced_laboratory_api.py | 28 +- 8 files changed, 594 insertions(+), 14 deletions(-) diff --git a/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx index 50cc27b..0fd9b59 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx @@ -81,7 +81,9 @@ export function LaboratoryRecordedClipPlayer({ sourceCount, cameraRef, cameraOverlay, + cameraControls, alternativeScene, + spatialControls, onSequenceChange, onPlayingChange, onPlaybackRateChange, @@ -97,7 +99,9 @@ export function LaboratoryRecordedClipPlayer({ sourceCount: number; cameraRef?: RefObject; cameraOverlay?: ReactNode; + cameraControls?: ReactNode; alternativeScene?: ReactNode; + spatialControls?: ReactNode; onSequenceChange: (sequence: number) => void; onPlayingChange: (playing: boolean) => void; onPlaybackRateChange: (rate: number) => void; @@ -173,6 +177,14 @@ export function LaboratoryRecordedClipPlayer({ aria-hidden={cameraPresentation === "primary"} > {alternativeScene} + {spatialControls ? ( +
+ {spatialControls} +
+ ) : null} ); const cameraPane = ( @@ -195,6 +207,14 @@ export function LaboratoryRecordedClipPlayer({ /> ) : null} {cameraPresentation !== "hidden" ? cameraOverlay : null} + {cameraPresentation !== "hidden" && cameraControls ? ( +
+ {cameraControls} +
+ ) : null} ); return ( diff --git a/apps/control-station/src/core/laboratory/vegetationShadow.ts b/apps/control-station/src/core/laboratory/vegetationShadow.ts index 380ad58..42e6bf8 100644 --- a/apps/control-station/src/core/laboratory/vegetationShadow.ts +++ b/apps/control-station/src/core/laboratory/vegetationShadow.ts @@ -119,6 +119,7 @@ export interface VegetationFullRouteLayer { export interface VegetationFullRouteReview { sourceId: "RAVNOVES004TREE"; sessionId: "20260828T130511Z_viewer_live"; + linkedRouteReviewResultId: string; sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0"; sourceJobInputSha256: string; sourceStreamSha256: string; @@ -692,6 +693,15 @@ function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null "recorded-camera-eb2783c5480d56bda07c8af0", "vegetation.route_full_review.source_job_id", ); + const linkedRouteReviewResultId = textValue( + row.linked_route_review_result_id, + "vegetation.route_full_review.linked_route_review_result_id", + ); + if (!RESULT_ID.test(linkedRouteReviewResultId)) { + throw new VegetationShadowContractError( + "vegetation.route_full_review: linked route review identity invalid.", + ); + } exact(row.frame_count, 6830, "vegetation.route_full_review.frame_count"); exact(row.width, 800, "vegetation.route_full_review.width"); exact(row.height, 600, "vegetation.route_full_review.height"); @@ -810,6 +820,7 @@ function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null return { sourceId: "RAVNOVES004TREE", sessionId: "20260828T130511Z_viewer_live", + linkedRouteReviewResultId, sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0", sourceJobInputSha256, sourceStreamSha256, diff --git a/apps/control-station/src/styles/laboratory-recorded-clip-player.css b/apps/control-station/src/styles/laboratory-recorded-clip-player.css index 9b9c764..e899b46 100644 --- a/apps/control-station/src/styles/laboratory-recorded-clip-player.css +++ b/apps/control-station/src/styles/laboratory-recorded-clip-player.css @@ -33,6 +33,29 @@ pointer-events: auto; } +.laboratory-recorded-clip-player__pane-controls { + position: absolute; + z-index: 6; + top: 0.6rem; + display: flex; + max-width: calc(100% - 1.2rem); + flex-wrap: wrap; + align-items: center; + gap: 0.38rem; + border-radius: var(--nodedc-radius-control-pill); + background: var(--nodedc-floating-surface); + padding: 0.28rem; + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.laboratory-recorded-clip-player__pane-controls[data-pane="spatial"] { + right: 0.6rem; +} + +.laboratory-recorded-clip-player__pane-controls[data-pane="camera"] { + left: 0.6rem; +} + .laboratory-recorded-clip-player__split > .nodedc-split-pane__separator::before { background: transparent; diff --git a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx index 14d8441..ef14d3e 100644 --- a/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/VegetationShadowResult.tsx @@ -1,5 +1,9 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button, SegmentedControl } from "@nodedc/ui-react"; +import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import { LaboratoryRecordedClipPlayer } from "../../components/laboratory/LaboratoryRecordedClipPlayer"; +import { RerunViewport } from "../../components/RerunViewport"; import { LaboratoryEvidence, LaboratoryResultSummary, @@ -7,7 +11,18 @@ import { LaboratoryWorkTemplate, } from "../../components/laboratory/LaboratoryPresentation"; import { + RecordedEvidenceSemanticMaskOverlay, + type RecordedEvidenceSemanticClass, + type RecordedEvidenceSemanticPaletteEntry, +} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay"; +import { + fetchVegetationShadowResult, + vegetationFullRouteMaskUrl, vegetationVideoMaskUrl, + type VegetationFullRouteLayer, + type VegetationFullRouteReview, + type VegetationMixedRouteCase, + type VegetationMixedRouteReview, type VegetationShadowResult, } from "../../core/laboratory/vegetationShadow"; import { @@ -15,11 +30,480 @@ import { type M49TgsFullShadowResult, } from "../../core/laboratory/m49TgsFullShadow"; import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence"; +import { recordedObservationSources } from "../../core/observation/recordedObservationSources"; +import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive"; +import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; +import { + recordedSessionRerunProfile, + type RerunPlaybackController, +} from "../../core/observation/viewerProfile"; +import type { ObservationSourceDescriptor } from "../../core/runtime/contracts"; +import { defaultSceneSettings, type SceneSettings } from "../../sceneSettings"; +import { + M48EvidenceModeRail, + type M48BlindEvidenceMode, +} from "./annotation/M48EvidenceModeControls"; function decimal(value: number, digits = 1): string { return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); } +const FULL_ROUTE_SEMANTIC_MODES = [ + { value: "city", label: "ГОРОД · EoMT" }, + { value: "vegetation", label: "ПРИРОДА · DDRNet" }, +] as const; + +function semanticPresentation(layer: VegetationFullRouteLayer): { + classes: readonly RecordedEvidenceSemanticClass[]; + palette: readonly RecordedEvidenceSemanticPaletteEntry[]; +} { + return { + classes: layer.taxonomy.map((item) => ({ id: item.classId, label: item.label })), + palette: layer.taxonomy.map((item) => ({ + classId: item.classId, + color: item.classId === 0 + ? { kind: "transparent" as const } + : { kind: "diagnostic" as const, rgb: item.colorRgb }, + })), + }; +} + +function nearestTgsCase( + cases: readonly VegetationMixedRouteCase[], + sequence: number, +): VegetationMixedRouteCase | null { + return cases.reduce((nearest, candidate) => ( + !nearest + || Math.abs(candidate.sourceSequence - sequence) + < Math.abs(nearest.sourceSequence - sequence) + ? candidate + : nearest + ), null); +} + +function FullRouteReviewEvidence({ + resultId, + review, +}: { + resultId: string; + review: VegetationFullRouteReview; +}) { + const [sequence, setSequence] = useState(1); + const [playing, setPlaying] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1); + const [semanticLayer, setSemanticLayer] = useState<"city" | "vegetation">("vegetation"); + const [showCameraSemantic, setShowCameraSemantic] = useState(true); + const [expanded, setExpanded] = useState(false); + const [evidenceMode, setEvidenceMode] = useState("3d"); + const [cameraVisible, setCameraVisible] = useState(true); + const [sceneSettings, setSceneSettings] = useState(() => ({ + ...defaultSceneSettings, + accumulationSeconds: 12, + showPoints: true, + showTrajectory: true, + })); + const [videoSource, setVideoSource] = useState(null); + const [replayLaunch, setReplayLaunch] = useState(null); + const [videoError, setVideoError] = useState(null); + const [linkedReview, setLinkedReview] = useState(null); + const [linkedReviewError, setLinkedReviewError] = useState(null); + const spatialControllerRef = useRef(null); + const frames = useMemo( + () => review.frameSourceTimesNs.map((sourceTimeNs, index) => ({ + sequence: index + 1, + sourceTimeNs, + })), + [review.frameSourceTimesNs], + ); + const layer = review[semanticLayer]; + const semantic = useMemo(() => semanticPresentation(layer), [layer]); + const maskSequence = sequence - 1; + const prefetchSrcs = useMemo(() => showCameraSemantic + ? Array.from({ length: 8 }, (_, offset) => maskSequence + offset + 1) + .filter((candidate) => candidate < review.frameCount) + .map((candidate) => vegetationFullRouteMaskUrl(resultId, semanticLayer, candidate)) + : [], [maskSequence, resultId, review.frameCount, semanticLayer, showCameraSemantic]); + + useEffect(() => { + const controller = new AbortController(); + setVideoSource(null); + setReplayLaunch(null); + setVideoError(null); + void resolveObservationSessionReplay(review.sessionId, { signal: controller.signal }) + .then((launch) => { + const source = recordedObservationSources(launch).find((candidate) => ( + candidate.id === review.recordedMediaSourceId + && candidate.modality === "video" + && candidate.semanticChannelId === "camera.video.recorded" + && candidate.delivery?.kind === "recorded-fmp4-manifest" + && candidate.delivery.manifestGenerationSha256 === review.recordedMediaGenerationSha256 + && candidate.delivery.timelineStartSeconds === review.timelineStartSeconds + && candidate.delivery.timelineEndSeconds >= review.timelineEndSeconds + )); + if (!source) { + throw new Error("RIGHT-видео не совпало с sealed RAVNOVES004TREE timeline."); + } + if (!controller.signal.aborted) { + setVideoSource(source); + setReplayLaunch(launch); + } + }) + .catch((caught: unknown) => { + if (!controller.signal.aborted) { + setVideoError(caught instanceof Error ? caught.message : "Записанное видео недоступно."); + } + }); + return () => controller.abort(); + }, [ + review.recordedMediaGenerationSha256, + review.recordedMediaSourceId, + review.sessionId, + review.timelineEndSeconds, + review.timelineStartSeconds, + ]); + + useEffect(() => { + const controller = new AbortController(); + setLinkedReview(null); + setLinkedReviewError(null); + void fetchVegetationShadowResult(review.linkedRouteReviewResultId, { + signal: controller.signal, + }).then((result) => { + if ( + !result.routeReview + || result.routeReview.sourceId !== review.sourceId + || result.routeReview.sessionId !== review.sessionId + ) { + throw new Error("TGS anchors имеют другую source identity."); + } + if (!controller.signal.aborted) setLinkedReview(result.routeReview); + }).catch((caught: unknown) => { + if (!controller.signal.aborted) { + setLinkedReviewError(caught instanceof Error ? caught.message : "TGS anchors недоступны."); + } + }); + return () => controller.abort(); + }, [review.linkedRouteReviewResultId, review.sessionId, review.sourceId]); + + const spatialProfile = useMemo(() => replayLaunch ? recordedSessionRerunProfile({ + sourceUrl: replayLaunch.viewerSourceUrl, + artifact: { + sourceUrl: replayLaunch.sourceUrl, + viewerSourceUrl: replayLaunch.viewerSourceUrl, + byteLength: replayLaunch.byteLength, + sha256: replayLaunch.sha256, + }, + autoplayWhenReady: false, + presentationGate: "ready", + expectedTimelineStartSeconds: replayLaunch.timelineStartSeconds, + expectedTimelineEndSeconds: replayLaunch.timelineEndSeconds, + initialPlaybackStartSeconds: review.timelineStartSeconds, + view: "spatial", + viewResetGeneration: 0, + followTrajectory: false, + perceptionLayers: { + enabled: false, + detections2d: false, + segmentation: false, + cuboids3d: false, + }, + perceptionRetryGeneration: 0, + lockPerceptionCameraInteraction: false, + }) : null, [replayLaunch, review.timelineStartSeconds]); + const activeFrame = frames.find((candidate) => candidate.sequence === sequence) + ?? frames[0] + ?? null; + const activeSourceTimeNsRef = useRef(activeFrame?.sourceTimeNs ?? null); + activeSourceTimeNsRef.current = activeFrame?.sourceTimeNs ?? null; + const handleSpatialControllerChange = useCallback((controller: RerunPlaybackController | null) => { + spatialControllerRef.current = controller; + const sourceTimeNs = activeSourceTimeNsRef.current; + if (!controller || sourceTimeNs === null) return; + controller.setPlaying(false); + controller.seek(sourceTimeNs); + }, []); + + useEffect(() => { + const controller = spatialControllerRef.current; + if (!controller || !activeFrame) return; + controller.setPlaying(false); + controller.seek(activeFrame.sourceTimeNs); + }, [activeFrame]); + + const selectedTgsCase = linkedReview + ? nearestTgsCase(linkedReview.cases, sequence) + : null; + const selectTgsPlan = useCallback(() => { + if (!linkedReview) return; + const item = nearestTgsCase(linkedReview.cases, sequence); + if (!item) return; + setPlaying(false); + setSequence(item.sourceSequence); + setEvidenceMode("plan"); + }, [linkedReview, sequence]); + const handleEvidenceModeChange = useCallback((nextMode: M48BlindEvidenceMode) => { + if (nextMode === "plan") { + selectTgsPlan(); + return; + } + setEvidenceMode(nextMode); + }, [selectTgsPlan]); + const cameraPresentation = evidenceMode === "camera" + ? "primary" + : cameraVisible ? "companion" : "hidden"; + const spatialScene = evidenceMode === "plan" ? ( + selectedTgsCase ? ( +
+ {`TGS +
+ TGS COSTMAP · ЯКОРЬ {selectedTgsCase.sourceSequence} · {selectedTgsCase.tgs.occupiedCells} OCCUPIED +
+
+ ) : ( +
+ {linkedReviewError ?? "Открываем sealed TGS anchors…"} +
+ ) + ) : spatialProfile ? ( + + ) : ( +
+ Открываем sealed RRD, source points и SLAM trajectory… +
+ ); + + return ( + +
+ {videoSource ? ( + + + { + setSemanticLayer(value); + setShowCameraSemantic(true); + }} + /> + + )} + spatialControls={( + <> + + + + + + )} + cameraOverlay={( + <> +
+ {showCameraSemantic + ? `${semanticLayer === "city" ? "EoMT CITY" : "DDRNet NATURE"} · КАДР ${sequence}/${review.frameCount}` + : `SOURCE · КАДР ${sequence}/${review.frameCount}`} +
+ {showCameraSemantic ? ( +
+ +
+ ) : null} + + )} + /> + ) : ( +
+ {videoError ?? "Открываем автономный RAVNOVES004TREE source…"} +
+ )} + {videoSource ? ( + + ) : null} +
+
+ ); +} + +function FullRouteReviewResult({ + rigLabel, + resultId, + review, +}: { + rigLabel: string; + resultId: string; + review: VegetationFullRouteReview; +}) { + return ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} + function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) { const route = result.routeVideo!; const linkedTgsResultId = route.linkedTgsResultId; @@ -92,6 +576,15 @@ export function VegetationShadowResultView({ rigLabel: string; result: VegetationShadowResult; }) { + if (result.routeFullReview) { + return ( + + ); + } if (!result.routeVideo?.linkedTgsResultId) { throw new Error( "Vegetation LAB result has no canonical M4 source timeline and linked M4.9 TGS evidence.", diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs index 44ed3d5..8e9791f 100644 --- a/apps/control-station/test/vegetationShadow.test.mjs +++ b/apps/control-station/test/vegetationShadow.test.mjs @@ -185,6 +185,7 @@ function fullRouteReview() { return { source_id: "RAVNOVES004TREE", session_id: "20260828T130511Z_viewer_live", + linked_route_review_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`, source_job_id: "recorded-camera-eb2783c5480d56bda07c8af0", source_job_input_sha256: "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715", source_stream_sha256: "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac", @@ -385,12 +386,15 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/); assert.match(m49Source, /spatialSemantic=\{spatialSemantic\}/); assert.match(m49Source, /controlLabel: "SEMANTICS"/); - assert.equal(resultSource.match(/ None: if work_id != "lab-v1-vegetation-shadow": return + full_route = document.get("route_full_review") + if isinstance(full_route, dict): + if ( + full_route.get("source_id") != "RAVNOVES004TREE" + or full_route.get("session_id") != "20260828T130511Z_viewer_live" + or full_route.get("frame_count") != 6830 + or _VEGETATION_RESULT_ID.fullmatch( + str(full_route.get("linked_route_review_result_id", "")) + ) + is None + or document.get("route_video") is not None + or document.get("route_review") is not None + ): + raise ValueError("vegetation LAB has no canonical RAVNOVES004TREE publication shape") + return route = document.get("route_video") fusion = route.get("fusion") if isinstance(route, dict) else None if ( diff --git a/tests/test_advanced_laboratory_api.py b/tests/test_advanced_laboratory_api.py index 87686c3..cd20fe9 100644 --- a/tests/test_advanced_laboratory_api.py +++ b/tests/test_advanced_laboratory_api.py @@ -152,10 +152,10 @@ def test_advanced_index_projects_one_most_mature_lifecycle_phase(tmp_path: Path) ] -def test_advanced_index_skips_noncanonical_vegetation_viewers(tmp_path: Path) -> None: +def test_advanced_index_prefers_canonical_rav004_full_review(tmp_path: Path) -> None: root = tmp_path / "vegetation" - def publish(digest: str, *, canonical: bool) -> Path: + def publish(digest: str, *, publication: str) -> Path: result_id = f"lab-v1-vegetation-shadow-{digest}" candidate = root / result_id candidate.mkdir(parents=True) @@ -167,7 +167,17 @@ def test_advanced_index_skips_noncanonical_vegetation_viewers(tmp_path: Path) -> "mode": "synchronised-multilayer-review", "pixel_raster_fusion": False, }, - } if canonical else None + } if publication == "rav00" else None + route_full_review = { + "source_id": "RAVNOVES004TREE", + "session_id": "20260828T130511Z_viewer_live", + "frame_count": 6830, + "linked_route_review_result_id": ( + f"lab-v1-vegetation-shadow-{'4' * 64}" + if publication == "rav004" + else None + ), + } if publication != "rav00" else None (candidate / "manifest.json").write_text( json.dumps( { @@ -184,17 +194,19 @@ def test_advanced_index_skips_noncanonical_vegetation_viewers(tmp_path: Path) -> "ground_truth": False, "route_video": route_video, "route_review": None, - "route_full_review": None if canonical else {"frame_count": 6830}, + "route_full_review": route_full_review, } ), encoding="utf-8", ) return candidate - canonical = publish("a" * 64, canonical=True) - incomplete = publish("b" * 64, canonical=False) - os.utime(canonical, ns=(10_000_000_000, 10_000_000_000)) + rav00 = publish("a" * 64, publication="rav00") + incomplete = publish("b" * 64, publication="incomplete") + rav004 = publish("c" * 64, publication="rav004") + os.utime(rav00, ns=(10_000_000_000, 10_000_000_000)) os.utime(incomplete, ns=(20_000_000_000, 20_000_000_000)) + os.utime(rav004, ns=(30_000_000_000, 30_000_000_000)) registry = _evidence_registry( root, work_id="lab-v1-vegetation-shadow", @@ -211,7 +223,7 @@ def test_advanced_index_skips_noncanonical_vegetation_viewers(tmp_path: Path) -> assert index["items"] == [ # type: ignore[index] { "work_id": "lab-v1-vegetation-shadow", - "result_id": canonical.name, + "result_id": rav004.name, "created_at_utc": "2026-08-29T10:00:00Z", "access": "read-only", }