diff --git a/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts b/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts index 95e660d..1ad45a0 100644 --- a/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts +++ b/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts @@ -40,6 +40,23 @@ export function advanceRecordedEvidencePlayback( return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) }; } +export function synchronizeRecordedEvidencePlayback( + current: RecordedObservationPlayback, + observed: RecordedObservationPlayback, + range: RecordedEvidencePlaybackRange, +): RecordedObservationPlayback { + const currentSeconds = clampRecordedEvidenceSeconds(observed.currentSeconds, range); + return { + ...current, + currentSeconds, + // The transport button owns play/pause intent. A native frame callback may + // still report the previous media state immediately after an operator + // command, so it must not restart playback. The sealed end is the only + // media-clock transition allowed to stop the shared LAB clock. + playing: currentSeconds >= range.endSeconds ? false : current.playing, + }; +} + export function useRecordedEvidencePlayback( range: RecordedEvidencePlaybackRange | null, { clock = "animation" }: { clock?: "animation" | "external" } = {}, @@ -106,11 +123,7 @@ export function useRecordedEvidencePlayback( const synchronize = useCallback((next: RecordedObservationPlayback) => { if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return; - setPlayback((current) => ({ - ...current, - currentSeconds: clampRecordedEvidenceSeconds(next.currentSeconds, range), - playing: next.playing, - })); + setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range)); }, [range]); return useMemo(() => ({ diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 1a0af6b..7e575bc 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -219,10 +219,13 @@ .m4-replay-threat-visual__overlay { box-sizing: border-box; + min-width: 0; align-items: stretch; - width: min(22rem, calc(33.333333% - 0.8rem)); - grid-template-columns: minmax(0, 1fr); + width: max-content; + max-width: calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem); + grid-template-columns: minmax(0, max-content); gap: 0.3rem; + overflow: hidden; background: transparent; padding: 0; backdrop-filter: none; @@ -235,9 +238,17 @@ backdrop-filter: blur(var(--nodedc-blur-control)); } +.m4-replay-threat-visual__overlay span, +.m4-replay-threat-visual__overlay strong, +.m4-replay-threat-visual__overlay small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + @media (max-width: 900px) { .m4-replay-threat-visual__overlay { - width: min(22rem, calc(100% - 1.2rem)); + max-width: calc(100% - 1.2rem); } } diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 0181887..964740c 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Button, Icon, @@ -442,8 +442,17 @@ export function M4ReplayThreatVisual({ const trailingActions = !splitView && spatialMode ? resetSpatialView : null; + const overlayPanePercent = splitView && splitOrientation === "vertical" + ? splitPrimarySize + : 100; + const overlay = metadata.timeline && frame ? ( -
+
RAVNOVES00 ยท recorded realtime frame {frame.sequence + 1}/{metadata.timeline.frameCount} diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 373a8cf..7128795 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -12,6 +12,7 @@ let fetchM4ThreatTimelineChunk; let selectM4ThreatTimelineFrame; let selectM4ThreatTimelineSequence; let advanceRecordedEvidencePlayback; +let synchronizeRecordedEvidencePlayback; let m4ThreatChunkWindowStarts; let buildM4LocalSurface; @@ -31,7 +32,10 @@ before(async () => { selectM4ThreatTimelineFrame, selectM4ThreatTimelineSequence, } = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts")); - ({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule( + ({ + advanceRecordedEvidencePlayback, + synchronizeRecordedEvidencePlayback, + } = await server.ssrLoadModule( "/src/components/laboratory/useRecordedEvidencePlayback.ts", )); ({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule( @@ -391,6 +395,26 @@ test("recorded evidence clock advances by selected rate and stops at the sealed ); }); +test("recorded VIDEO clock cannot reverse an explicit operator pause", () => { + const range = { startSeconds: 10, endSeconds: 20 }; + assert.deepEqual( + synchronizeRecordedEvidencePlayback( + { currentSeconds: 15, playing: false, rate: 2 }, + { currentSeconds: 15.1, playing: true, rate: 1 }, + range, + ), + { currentSeconds: 15.1, playing: false, rate: 2 }, + ); + assert.deepEqual( + synchronizeRecordedEvidencePlayback( + { currentSeconds: 19.9, playing: true, rate: 1 }, + { currentSeconds: 20, playing: false, rate: 1 }, + range, + ), + { currentSeconds: 20, playing: false, rate: 1 }, + ); +}); + test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => { const [visual, visualCss, imageScene, videoScene, metricScene] = await Promise.all([ readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"), @@ -434,8 +458,10 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(visualCss, /width: 33\.333333%/); assert.match(visualCss, /flex-flow: column nowrap/); assert.match(visualCss, /m4-replay-threat-visual__overlay > div/); - assert.match(visualCss, /grid-template-columns: minmax\(0, 1fr\)/); - assert.match(visualCss, /width: min\(22rem, calc\(33\.333333% - 0\.8rem\)\)/); + assert.match(visualCss, /grid-template-columns: minmax\(0, max-content\)/); + assert.match(visualCss, /width: max-content/); + assert.match(visualCss, /--m4-replay-threat-overlay-pane-width/); + assert.match(visualCss, /white-space: nowrap/); assert.match(visualCss, /laboratory-metric-evidence-scene__legend/); assert.match(visualCss, /bottom: auto/); assert.match(videoScene, /