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) };
}
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(() => ({
@@ -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);
}
}
@@ -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 ? (
<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>
<span>RAVNOVES00 · recorded realtime</span>
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
@@ -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, /<RecordedFmp4Player/);