fix(lab): make replay pause authoritative

This commit is contained in:
DCCONSTRUCTIONS
2026-08-24 00:12:49 +03:00
parent a7caed70c1
commit 8877ee947f
4 changed files with 72 additions and 13 deletions
@@ -40,6 +40,23 @@ export function advanceRecordedEvidencePlayback(
return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) }; 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( export function useRecordedEvidencePlayback(
range: RecordedEvidencePlaybackRange | null, range: RecordedEvidencePlaybackRange | null,
{ clock = "animation" }: { clock?: "animation" | "external" } = {}, { clock = "animation" }: { clock?: "animation" | "external" } = {},
@@ -106,11 +123,7 @@ export function useRecordedEvidencePlayback(
const synchronize = useCallback((next: RecordedObservationPlayback) => { const synchronize = useCallback((next: RecordedObservationPlayback) => {
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return; if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
setPlayback((current) => ({ setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
...current,
currentSeconds: clampRecordedEvidenceSeconds(next.currentSeconds, range),
playing: next.playing,
}));
}, [range]); }, [range]);
return useMemo(() => ({ return useMemo(() => ({
@@ -219,10 +219,13 @@
.m4-replay-threat-visual__overlay { .m4-replay-threat-visual__overlay {
box-sizing: border-box; box-sizing: border-box;
min-width: 0;
align-items: stretch; align-items: stretch;
width: min(22rem, calc(33.333333% - 0.8rem)); width: max-content;
grid-template-columns: minmax(0, 1fr); max-width: calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem);
grid-template-columns: minmax(0, max-content);
gap: 0.3rem; gap: 0.3rem;
overflow: hidden;
background: transparent; background: transparent;
padding: 0; padding: 0;
backdrop-filter: none; backdrop-filter: none;
@@ -235,9 +238,17 @@
backdrop-filter: blur(var(--nodedc-blur-control)); 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) { @media (max-width: 900px) {
.m4-replay-threat-visual__overlay { .m4-replay-threat-visual__overlay {
width: min(22rem, calc(100% - 1.2rem)); max-width: calc(100% - 1.2rem);
} }
} }
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { import {
Button, Button,
Icon, Icon,
@@ -442,8 +442,17 @@ export function M4ReplayThreatVisual({
const trailingActions = !splitView && spatialMode ? resetSpatialView : null; const trailingActions = !splitView && spatialMode ? resetSpatialView : null;
const overlayPanePercent = splitView && splitOrientation === "vertical"
? splitPrimarySize
: 100;
const overlay = metadata.timeline && frame ? ( const overlay = metadata.timeline && frame ? (
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay"> <div
className="l3-visual-audit__overlay m4-replay-threat-visual__overlay"
style={{
"--m4-replay-threat-overlay-pane-width": `${overlayPanePercent}%`,
} as CSSProperties}
>
<div> <div>
<span>RAVNOVES00 · recorded realtime</span> <span>RAVNOVES00 · recorded realtime</span>
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong> <strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
@@ -12,6 +12,7 @@ let fetchM4ThreatTimelineChunk;
let selectM4ThreatTimelineFrame; let selectM4ThreatTimelineFrame;
let selectM4ThreatTimelineSequence; let selectM4ThreatTimelineSequence;
let advanceRecordedEvidencePlayback; let advanceRecordedEvidencePlayback;
let synchronizeRecordedEvidencePlayback;
let m4ThreatChunkWindowStarts; let m4ThreatChunkWindowStarts;
let buildM4LocalSurface; let buildM4LocalSurface;
@@ -31,7 +32,10 @@ before(async () => {
selectM4ThreatTimelineFrame, selectM4ThreatTimelineFrame,
selectM4ThreatTimelineSequence, selectM4ThreatTimelineSequence,
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts")); } = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule( ({
advanceRecordedEvidencePlayback,
synchronizeRecordedEvidencePlayback,
} = await server.ssrLoadModule(
"/src/components/laboratory/useRecordedEvidencePlayback.ts", "/src/components/laboratory/useRecordedEvidencePlayback.ts",
)); ));
({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule( ({ 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 () => { test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
const [visual, visualCss, imageScene, videoScene, metricScene] = await Promise.all([ const [visual, visualCss, imageScene, videoScene, metricScene] = await Promise.all([
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"), 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, /width: 33\.333333%/);
assert.match(visualCss, /flex-flow: column nowrap/); assert.match(visualCss, /flex-flow: column nowrap/);
assert.match(visualCss, /m4-replay-threat-visual__overlay > div/); assert.match(visualCss, /m4-replay-threat-visual__overlay > div/);
assert.match(visualCss, /grid-template-columns: minmax\(0, 1fr\)/); assert.match(visualCss, /grid-template-columns: minmax\(0, max-content\)/);
assert.match(visualCss, /width: min\(22rem, calc\(33\.333333% - 0\.8rem\)\)/); 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, /laboratory-metric-evidence-scene__legend/);
assert.match(visualCss, /bottom: auto/); assert.match(visualCss, /bottom: auto/);
assert.match(videoScene, /<RecordedFmp4Player/); assert.match(videoScene, /<RecordedFmp4Player/);