feat(control-station): add atomic recorded-session playback

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 17:51:24 +03:00
parent 007ff9fff2
commit e94c64eebd
35 changed files with 8907 additions and 302 deletions
@@ -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>
);
}