feat(control-station): add atomic recorded-session playback
This commit is contained in:
@@ -14,6 +14,12 @@ import {
|
||||
} from "../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type {
|
||||
BackendStatus,
|
||||
@@ -22,6 +28,10 @@ import type {
|
||||
} from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
rerunPresentationStatus,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
} from "../components/RerunViewport";
|
||||
@@ -95,7 +105,12 @@ export interface WorkspaceRendererProps {
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
recordedReplay: ObservationSessionReplayLaunch | null;
|
||||
recordedSessionAdmission: RecordedSessionAdmissionController | null;
|
||||
sceneSettings: SceneSettings;
|
||||
accumulationSeconds: number;
|
||||
onAccumulationChange: (value: number) => void;
|
||||
onAccumulationCommit: () => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
@@ -248,13 +263,27 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
sceneSettings,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
observationLayout,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const recordedPlaybackReady = !recordedSource ||
|
||||
(recordedSessionGate === "ready" &&
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
@@ -277,28 +306,83 @@ function SpatialWorkspace({
|
||||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const presentedViewerStatus = rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
}, []);
|
||||
if (recordedSessionAdmission) {
|
||||
recordedSessionAdmission.reportSpatial(
|
||||
recordedSessionAdmission.key,
|
||||
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
|
||||
);
|
||||
}
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
(next: RerunPlaybackState | null) => setPlaybackState(next),
|
||||
[],
|
||||
);
|
||||
const onPlaybackControllerChange = useCallback(
|
||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const publishViewportSize = () => {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (bounds.width < 1 || bounds.height < 1) return;
|
||||
observationLayout.setViewportSize({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
};
|
||||
publishViewportSize();
|
||||
const observer = new ResizeObserver(publishViewportSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[viewerStatus];
|
||||
}[presentedViewerStatus];
|
||||
|
||||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||
const viewerStatusTone = presentedViewerStatus === "ready"
|
||||
? "success"
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -331,9 +415,21 @@ function SpatialWorkspace({
|
||||
{sourceUrl.trim() && pointCloudVisible ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
followLive={state?.sourceMode === "live"}
|
||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
||||
followLive={!recordedSource && state?.sourceMode === "live"}
|
||||
autoplayWhenReady={recordedSource}
|
||||
presentationGate={recordedSessionGate}
|
||||
expectedTimelineStartSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.startSeconds
|
||||
: undefined}
|
||||
expectedTimelineEndSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.endSeconds
|
||||
: undefined}
|
||||
sceneSettings={sceneSettings}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackControllerChange={onPlaybackControllerChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
@@ -415,13 +511,26 @@ function SpatialWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized ? (
|
||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={viewerStatus === "ready"}
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
|
||||
mode={timeline?.mode}
|
||||
seekable={timeline?.seekable}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
seekable={recordedSource && playbackState?.rangeNs
|
||||
? true
|
||||
: timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
rangeNs={playbackState?.rangeNs}
|
||||
currentNs={playbackState?.currentNs}
|
||||
playing={playbackState?.playing}
|
||||
onSeek={playbackController?.seek}
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
@@ -440,6 +549,14 @@ function SpatialWorkspace({
|
||||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
playback={recordedSource && playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: playbackState.playing,
|
||||
} : null}
|
||||
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
onClose={() => {
|
||||
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||
observationLayout.setFloatingMaximized(source.id, false);
|
||||
@@ -447,6 +564,28 @@ function SpatialWorkspace({
|
||||
}}
|
||||
/>
|
||||
)) : null}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
(pointCloudFocused || !observationLayout.visibleSourceIds.has(source.id)) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
playback={playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: false,
|
||||
} : null}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
@@ -470,6 +609,10 @@ function CameraSourceCard({
|
||||
onToggle,
|
||||
onFocus,
|
||||
onClose,
|
||||
prepareRecorded,
|
||||
recordedSessionGate,
|
||||
recordedAdmissionKey,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
focused: boolean;
|
||||
@@ -479,6 +622,13 @@ function CameraSourceCard({
|
||||
onToggle: () => void;
|
||||
onFocus: () => void;
|
||||
onClose: () => void;
|
||||
prepareRecorded: boolean;
|
||||
recordedSessionGate: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey: string | null;
|
||||
onRecordedAdmissionChange: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const deliveryActive = Boolean(
|
||||
(source.delivery || source.previewUrl) &&
|
||||
@@ -542,7 +692,13 @@ function CameraSourceCard({
|
||||
</div>
|
||||
</header>
|
||||
<div className="camera-slot__body">
|
||||
<ObservationMedia source={source} />
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
prepareRecorded={prepareRecorded}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{source.description}</span>
|
||||
@@ -552,13 +708,36 @@ function CameraSourceCard({
|
||||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
|
||||
function CamerasWorkspace({
|
||||
definition,
|
||||
state,
|
||||
observationLayout,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
}: WorkspaceRendererProps) {
|
||||
const sources = (state?.observationSources ?? []).filter(
|
||||
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
|
||||
);
|
||||
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
|
||||
const timeline = state?.observationTimeline;
|
||||
const displayedSources = focusedSource ? [focusedSource] : sources;
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedReplay
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedReplay) return true;
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
}, [recordedReplay, recordedSessionAdmission]);
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
@@ -587,6 +766,10 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
||||
observationLayout.setFocusedSourceId(null);
|
||||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
prepareRecorded={shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
)) : (
|
||||
<div className="camera-grid__empty">
|
||||
@@ -596,7 +779,25 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ObservationTimeline
|
||||
{recordedReplay ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{sources.filter((source) => (
|
||||
!displayedSources.some(({ id }) => id === source.id) &&
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!recordedReplay || recordedSessionGate === "ready" ? <ObservationTimeline
|
||||
active={sources.some((source) =>
|
||||
source.availability === "streaming" &&
|
||||
(!source.activation || source.activation.selected))}
|
||||
@@ -606,7 +807,7 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
||||
seekable={timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
className="camera-timeline"
|
||||
/>
|
||||
/> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user