feat(lab): add recorded realtime spatial playback
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
@@ -14,6 +14,8 @@ export function ObservationTimeline({
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
@@ -30,6 +32,8 @@ export function ObservationTimeline({
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
@@ -102,6 +106,20 @@ export function ObservationTimeline({
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
value={String(playbackRate)}
|
||||
options={[
|
||||
{ value: "0.5", label: "0,5×" },
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
export interface RecordedObservationPlayback {
|
||||
currentSeconds: number;
|
||||
playing: boolean;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
export interface RecordedMediaArchive {
|
||||
@@ -290,6 +291,9 @@ export function RecordedFmp4Player({
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||
? Math.min(4, Math.max(0.25, playback.rate))
|
||||
: 1;
|
||||
const epoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
@@ -460,12 +464,21 @@ export function RecordedFmp4Player({
|
||||
return;
|
||||
}
|
||||
}
|
||||
video.playbackRate = playbackRate;
|
||||
if (playback?.playing) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
}, [
|
||||
archive?.byteLength,
|
||||
bufferRevision,
|
||||
currentSeconds,
|
||||
epoch,
|
||||
playback?.playing,
|
||||
playbackRate,
|
||||
visualState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -475,6 +488,7 @@ export function RecordedFmp4Player({
|
||||
onPlaybackChangeRef.current?.({
|
||||
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
|
||||
playing: !video.paused && !video.ended,
|
||||
rate: video.playbackRate,
|
||||
});
|
||||
};
|
||||
const scheduleVideoFrame = () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export function LaboratoryEvidenceViewer<
|
||||
actions,
|
||||
secondaryMode,
|
||||
overlay,
|
||||
transport,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -46,6 +47,7 @@ export function LaboratoryEvidenceViewer<
|
||||
onChange: (mode: U) => void;
|
||||
};
|
||||
overlay?: ReactNode;
|
||||
transport?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -75,6 +77,11 @@ export function LaboratoryEvidenceViewer<
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{secondaryMode ? (
|
||||
|
||||
@@ -53,6 +53,15 @@ function disposeRenderable(object: THREE.Object3D): void {
|
||||
materials.forEach((material) => material.dispose());
|
||||
}
|
||||
|
||||
function clearGroup(group: THREE.Group): void {
|
||||
while (group.children.length) {
|
||||
const child = group.children[0];
|
||||
if (!child) break;
|
||||
group.remove(child);
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
}
|
||||
|
||||
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
|
||||
return [point[0], point[2], -point[1]];
|
||||
}
|
||||
@@ -101,7 +110,8 @@ export function LaboratoryMetricEvidenceScene({
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const contentRef = useRef<THREE.Group | null>(null);
|
||||
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
@@ -137,12 +147,14 @@ export function LaboratoryMetricEvidenceScene({
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.4;
|
||||
controls.maxDistance = 80;
|
||||
const content = new THREE.Group();
|
||||
scene.add(content);
|
||||
const staticContent = new THREE.Group();
|
||||
const dynamicContent = new THREE.Group();
|
||||
scene.add(staticContent, dynamicContent);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
contentRef.current = content;
|
||||
staticContentRef.current = staticContent;
|
||||
dynamicContentRef.current = dynamicContent;
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
@@ -172,20 +184,16 @@ export function LaboratoryMetricEvidenceScene({
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
contentRef.current = null;
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
};
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = contentRef.current;
|
||||
const content = dynamicContentRef.current;
|
||||
if (!host || !content) return;
|
||||
while (content.children.length) {
|
||||
const child = content.children[0];
|
||||
if (!child) break;
|
||||
content.remove(child);
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
clearGroup(content);
|
||||
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
@@ -244,6 +252,19 @@ export function LaboratoryMetricEvidenceScene({
|
||||
content.add(centroid);
|
||||
}
|
||||
|
||||
}, [
|
||||
obstacles,
|
||||
pointCloudBodyXyzM,
|
||||
showCurrentIncrement,
|
||||
showRollingMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = staticContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
|
||||
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
|
||||
const corridorMesh = new THREE.Mesh(
|
||||
@@ -302,14 +323,7 @@ export function LaboratoryMetricEvidenceScene({
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [
|
||||
corridor,
|
||||
obstacles,
|
||||
pointCloudBodyXyzM,
|
||||
rig,
|
||||
showCurrentIncrement,
|
||||
showRollingMap,
|
||||
]);
|
||||
}, [corridor, rig]);
|
||||
|
||||
const resetView = () => {
|
||||
const camera = cameraRef.current;
|
||||
@@ -327,7 +341,7 @@ export function LaboratoryMetricEvidenceScene({
|
||||
controls.update();
|
||||
};
|
||||
|
||||
useEffect(resetView, [corridor.forwardLengthM, mode, obstacles]);
|
||||
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
||||
|
||||
return (
|
||||
<div className="laboratory-metric-evidence-scene">
|
||||
|
||||
@@ -18,6 +18,7 @@ export function RecordedEvidenceVideoScene({
|
||||
imageHeight,
|
||||
boxes,
|
||||
ariaLabel,
|
||||
interactive = true,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
@@ -26,14 +27,15 @@ export function RecordedEvidenceVideoScene({
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
ariaLabel: string;
|
||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
||||
interactive?: boolean;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="recorded-evidence-video-scene">
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive
|
||||
interactive={interactive}
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { RecordedObservationPlayback } from "../RecordedFmp4Player";
|
||||
|
||||
export interface RecordedEvidencePlaybackRange {
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
}
|
||||
|
||||
function validRange(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
): range is RecordedEvidencePlaybackRange {
|
||||
return Boolean(
|
||||
range
|
||||
&& Number.isFinite(range.startSeconds)
|
||||
&& Number.isFinite(range.endSeconds)
|
||||
&& range.endSeconds > range.startSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
export function clampRecordedEvidenceSeconds(
|
||||
seconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): number {
|
||||
return Math.min(range.endSeconds, Math.max(range.startSeconds, seconds));
|
||||
}
|
||||
|
||||
export function advanceRecordedEvidencePlayback(
|
||||
playback: RecordedObservationPlayback,
|
||||
elapsedSeconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): RecordedObservationPlayback {
|
||||
if (!playback.playing || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
|
||||
return playback;
|
||||
}
|
||||
const next = playback.currentSeconds + elapsedSeconds * (playback.rate ?? 1);
|
||||
if (next >= range.endSeconds) {
|
||||
return { ...playback, currentSeconds: range.endSeconds, playing: false };
|
||||
}
|
||||
return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) };
|
||||
}
|
||||
|
||||
export function useRecordedEvidencePlayback(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
) {
|
||||
const [playback, setPlayback] = useState<RecordedObservationPlayback>({
|
||||
currentSeconds: 0,
|
||||
playing: false,
|
||||
rate: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: current.currentSeconds === 0
|
||||
? range.startSeconds
|
||||
: clampRecordedEvidenceSeconds(current.currentSeconds, range),
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range) || !playback.playing) return;
|
||||
let animationFrame = 0;
|
||||
let previous = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = Math.max(0, now - previous);
|
||||
if (elapsed >= 32) {
|
||||
previous = now;
|
||||
setPlayback((current) => {
|
||||
if (!current.playing) return current;
|
||||
return advanceRecordedEvidencePlayback(current, elapsed / 1_000, range);
|
||||
});
|
||||
}
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
}, [playback.playing, range]);
|
||||
|
||||
const seek = useCallback((seconds: number, pause = true) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: clampRecordedEvidenceSeconds(seconds, range),
|
||||
playing: pause ? false : current.playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setPlaying = useCallback((playing: boolean) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: playing && current.currentSeconds >= range.endSeconds
|
||||
? range.startSeconds
|
||||
: current.currentSeconds,
|
||||
playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setRate = useCallback((rate: number) => {
|
||||
if (![0.5, 1, 2].includes(rate)) return;
|
||||
setPlayback((current) => ({ ...current, rate }));
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
playback,
|
||||
seek,
|
||||
setPlaying,
|
||||
setRate,
|
||||
}), [playback, seek, setPlaying, setRate]);
|
||||
}
|
||||
Reference in New Issue
Block a user