fix(lab): make replay pause authoritative
This commit is contained in:
@@ -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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user