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
@@ -0,0 +1,84 @@
import type { ObservationSourceDescriptor } from "../runtime/contracts";
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
export function recordedObservationSources(
launch: ObservationSessionReplayLaunch | null,
): ObservationSourceDescriptor[] {
if (!launch) return [];
const spatial: ObservationSourceDescriptor = {
id: "recorded.spatial.primary",
sourceId: "recorded.spatial.primary",
semanticChannelId: "spatial.point-cloud.recorded",
label: "Сохранённая пространственная сцена",
description: "Облако точек и траектория из записи Rerun",
modality: "point-cloud",
role: "primary",
availability: "available",
transport: "recording",
endpointLabel: "RRD · session_time",
previewUrl: launch.sourceUrl,
delivery: null,
activation: null,
provider: {
pluginId: "missioncore.session-archive",
pluginVersion: "1",
modelId: "recorded-spatial",
compatibilityProfileId: null,
},
binding: {},
capabilities: {
overlay: false,
fullscreen: true,
resizable: false,
defaultVisible: true,
timelineMode: "recorded",
seekable: true,
sessionRecording: true,
clockId: launch.timeline,
spatialRegistration: "native",
},
};
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => ({
id: source.id,
sourceId: source.id,
semanticChannelId: "camera.video.recorded",
label: source.label,
description: "Сохранённый видеоканал на общей временной шкале сессии",
modality: "video",
role: "auxiliary",
availability: "available",
transport: "recording",
endpointLabel: "Сохранённая сессия",
previewUrl: null,
delivery: {
id: `${launch.sessionId}:${source.id}`,
kind: "recorded-fmp4-manifest",
url: source.manifestUrl,
mediaType: source.mediaType,
manifestGenerationSha256: source.manifestGenerationSha256,
byteLength: source.byteLength,
timelineStartSeconds: source.timelineStartSeconds,
timelineEndSeconds: source.timelineEndSeconds,
},
activation: null,
provider: {
pluginId: "missioncore.session-archive",
pluginVersion: "1",
modelId: "recorded-media",
compatibilityProfileId: null,
},
binding: {},
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: index < 2,
timelineMode: "recorded",
seekable: true,
sessionRecording: true,
clockId: "session_time",
spatialRegistration: "unresolved",
},
}));
return [spatial, ...media];
}
@@ -0,0 +1,123 @@
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
// vehicles while the independent byte/concurrency limits keep admission
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
// this in-memory laboratory policy.
export const MAX_RECORDED_CAMERA_SOURCES = 16;
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
export type RecordedAdmissionPhase = "loading" | "ready" | "error";
export type RecordedCameraAdmissionPhase = "pending" | "loading" | "ready" | "error";
export interface RecordedCameraAdmissionState {
phase: RecordedCameraAdmissionPhase;
byteLength: number | null;
message: string | null;
admissionKey?: string | null;
workerGeneration?: number;
}
export type RecordedCameraAdmissionMap = Readonly<Record<string, RecordedCameraAdmissionState>>;
export interface RecordedCameraAdmissionDescriptor {
id: string;
byteLength: number;
}
export function recordedCameraDescriptorPreflight(
sources: readonly RecordedCameraAdmissionDescriptor[],
): RecordedAdmissionPhase {
if (sources.length > MAX_RECORDED_CAMERA_SOURCES) return "error";
if (new Set(sources.map(({ id }) => id)).size !== sources.length) return "error";
let totalBytes = 0;
for (const source of sources) {
if (
!source.id ||
!Number.isSafeInteger(source.byteLength) ||
source.byteLength < 1 ||
source.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
) return "error";
totalBytes += source.byteLength;
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) {
return "error";
}
}
return "ready";
}
export function initialRecordedCameraAdmissions(
sourceIds: readonly string[],
declaredByteLengths: Readonly<Record<string, number>> = {},
): Record<string, RecordedCameraAdmissionState> {
return Object.fromEntries(sourceIds.map((sourceId) => [sourceId, {
phase: "pending" as const,
byteLength: declaredByteLengths[sourceId] ?? null,
message: null,
}]));
}
export function recordedSessionAdmissionPhase(
spatialPhase: RecordedAdmissionPhase,
sourceIds: readonly string[],
cameras: RecordedCameraAdmissionMap,
): RecordedAdmissionPhase {
if (sourceIds.length > MAX_RECORDED_CAMERA_SOURCES || spatialPhase === "error") {
return "error";
}
let totalBytes = 0;
for (const sourceId of sourceIds) {
const camera = cameras[sourceId];
if (!camera || camera.phase === "error") return "error";
if (camera.phase === "ready" && camera.byteLength === null) return "error";
if (camera.byteLength !== null) {
if (
!Number.isSafeInteger(camera.byteLength) ||
camera.byteLength < 1 ||
camera.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
) return "error";
totalBytes += camera.byteLength;
}
}
if (totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) return "error";
if (spatialPhase !== "ready") return "loading";
return sourceIds.every((sourceId) => cameras[sourceId]?.phase === "ready")
? "ready"
: "loading";
}
export function nextRecordedCameraPreparationIds(
sourceIds: readonly string[],
cameras: RecordedCameraAdmissionMap,
preferredSourceIds: ReadonlySet<string> = new Set(),
concurrency = MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS,
): string[] {
if (!Number.isInteger(concurrency) || concurrency < 1) return [];
const pending = sourceIds.filter((sourceId) => {
const phase = cameras[sourceId]?.phase;
return phase === "pending" || phase === "loading" || phase === undefined;
});
pending.sort((left, right) => (
Number(cameras[right]?.phase === "loading") - Number(cameras[left]?.phase === "loading") ||
Number(preferredSourceIds.has(right)) - Number(preferredSourceIds.has(left)) ||
sourceIds.indexOf(left) - sourceIds.indexOf(right)
));
return pending.slice(0, concurrency);
}
export function mergeRecordedCameraAdmission(
current: RecordedCameraAdmissionState,
next: RecordedCameraAdmissionState,
): RecordedCameraAdmissionState {
const normalized = next.byteLength === null && current.byteLength !== null
? { ...next, byteLength: current.byteLength }
: next;
const currentWorker = current.workerGeneration ?? 0;
const nextWorker = normalized.workerGeneration ?? currentWorker;
if (nextWorker < currentWorker) return current;
if (current.phase === "error") return current;
if (nextWorker > currentWorker) return normalized;
if (normalized.phase === "error") return normalized;
if (current.phase === "ready") return current;
return normalized;
}
File diff suppressed because it is too large Load Diff
@@ -6,12 +6,25 @@ import {
openObservationSource,
shouldRestartObservationSource,
} from "./layoutPolicy";
import {
normalizeObservationWindowRect,
projectObservationLayoutSnapshot,
validateObservationLayoutSnapshot,
validateObservationViewportSize,
type ObservationLayoutSnapshot,
type ObservationViewportSize,
type ObservationWindowRect,
} from "./workspaceLayout";
export interface ObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
export type { ObservationWindowRect } from "./workspaceLayout";
export type ObservationLayoutPresentationMode = "preserve" | "reset";
export function observationPresentationSourceAfterLayoutApply(
currentSourceId: string | null,
mode: ObservationLayoutPresentationMode,
): string | null {
return mode === "preserve" ? currentSourceId : null;
}
export interface ObservationLayoutController {
@@ -20,6 +33,7 @@ export interface ObservationLayoutController {
activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
viewportSize: ObservationViewportSize | null;
pendingSourceIds: ReadonlySet<string>;
toggleSource: (sourceId: string) => Promise<boolean>;
hideSource: (sourceId: string) => Promise<boolean>;
@@ -27,6 +41,9 @@ export interface ObservationLayoutController {
activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
setViewportSize: (size: ObservationViewportSize) => void;
snapshot: () => ObservationLayoutSnapshot | null;
restore: (snapshot: ObservationLayoutSnapshot) => void;
}
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
@@ -50,6 +67,17 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
.join("|");
}
function cloneSnapshot(snapshot: ObservationLayoutSnapshot): ObservationLayoutSnapshot {
return {
visibleSourceIds: [...snapshot.visibleSourceIds],
activeFloatingSourceId: snapshot.activeFloatingSourceId,
windowRects: Object.fromEntries(
Object.entries(snapshot.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
),
viewportSize: { ...snapshot.viewportSize },
};
}
export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[],
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
@@ -58,45 +86,151 @@ export function useObservationLayout(
const visibleIdsRef = useRef<string[]>([]);
const [pendingIds, setPendingIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceIdState] = useState<string | null>(null);
const activeFloatingSourceIdRef = useRef<string | null>(null);
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
const [windowRects, setWindowRectsState] = useState<Record<string, ObservationWindowRect>>({});
const windowRectsRef = useRef<Record<string, ObservationWindowRect>>({});
const [viewportSize, setViewportSizeState] = useState<ObservationViewportSize | null>(null);
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
const restoredLayoutAuthorityRef = useRef(false);
const initializedCatalog = useRef<string | null>(null);
const sourceIdList = sources.map((source) => source.id).sort();
const sourceIdsIdentity = sourceIdList.join("\u0000");
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
const sourceIdsRef = useRef<ReadonlySet<string>>(sourceIds);
sourceIdsRef.current = sourceIds;
const sourcesRef = useRef<readonly ObservationSourceDescriptor[]>(sources);
sourcesRef.current = sources;
const identity = catalogIdentity(sources);
const commitVisibleIds = useCallback((next: string[]) => {
visibleIdsRef.current = next;
setVisibleIds(next);
const commitVisibleIds = useCallback((next: readonly string[]) => {
const unique = [...new Set(next)];
visibleIdsRef.current = unique;
setVisibleIds(unique);
}, []);
const clearPresentation = useCallback((removedIds: readonly string[]) => {
const commitActiveFloatingSourceId = useCallback((next: string | null) => {
activeFloatingSourceIdRef.current = next;
setActiveFloatingSourceIdState(next);
}, []);
const commitWindowRects = useCallback((next: Record<string, ObservationWindowRect>) => {
windowRectsRef.current = next;
setWindowRectsState(next);
}, []);
const persistLiveLayout = useCallback(() => {
const currentViewport = viewportSizeRef.current;
if (!currentViewport) return;
const currentKnownIds = sourceIdsRef.current;
const previous = desiredSnapshotRef.current;
const unknownVisibleIds = previous?.visibleSourceIds.filter(
(sourceId) => !currentKnownIds.has(sourceId),
) ?? [];
const visibleSourceIds = [...new Set([...unknownVisibleIds, ...visibleIdsRef.current])];
const unknownRects = Object.entries(previous?.windowRects ?? {}).filter(
([sourceId]) => !currentKnownIds.has(sourceId),
);
const knownRects = Object.entries(windowRectsRef.current).map(([sourceId, rect]) => [
sourceId,
normalizeObservationWindowRect(rect, currentViewport),
] as const);
const previousActive = previous?.activeFloatingSourceId ?? null;
const activeFloatingSourceId = activeFloatingSourceIdRef.current ?? (
previousActive && !currentKnownIds.has(previousActive) ? previousActive : null
);
desiredSnapshotRef.current = validateObservationLayoutSnapshot({
visibleSourceIds,
activeFloatingSourceId: activeFloatingSourceId && visibleSourceIds.includes(activeFloatingSourceId)
? activeFloatingSourceId
: null,
windowRects: Object.fromEntries([...unknownRects, ...knownRects]),
viewportSize: currentViewport,
});
}, []);
const applyDesiredSnapshot = useCallback((
snapshot: ObservationLayoutSnapshot,
presentationMode: ObservationLayoutPresentationMode,
) => {
const targetViewport = viewportSizeRef.current ?? snapshot.viewportSize;
const projected = projectObservationLayoutSnapshot(
snapshot,
sourceIdsRef.current,
targetViewport,
);
let visibleSourceIds = [...projected.visibleSourceIds];
let activeFloatingSourceId = projected.activeFloatingSourceId;
if (visibleSourceIds.length === 0 && sourcesRef.current.length > 0) {
for (const source of sourcesRef.current.filter(canOpenByDefault)) {
visibleSourceIds = openObservationSource(
visibleSourceIds,
source.id,
sourcesRef.current,
).visibleIds;
}
activeFloatingSourceId = sourcesRef.current.find(
(source) => visibleSourceIds.includes(source.id) && source.capabilities.overlay,
)?.id ?? null;
}
commitVisibleIds(visibleSourceIds);
commitActiveFloatingSourceId(activeFloatingSourceId);
commitWindowRects({ ...projected.windowRects });
setFocusedSourceIdState((current) =>
observationPresentationSourceAfterLayoutApply(current, presentationMode));
setMaximizedFloatingSourceId((current) =>
observationPresentationSourceAfterLayoutApply(current, presentationMode));
}, [commitActiveFloatingSourceId, commitVisibleIds, commitWindowRects]);
const clearPresentation = useCallback((removedIds: readonly string[], persist = true) => {
if (!removedIds.length) return;
const removed = new Set(removedIds);
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
setActiveFloatingSourceId((current) => current && removed.has(current) ? null : current);
if (
activeFloatingSourceIdRef.current &&
removed.has(activeFloatingSourceIdRef.current)
) {
commitActiveFloatingSourceId(null);
}
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
}, []);
if (persist) persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
useEffect(() => {
const desired = desiredSnapshotRef.current;
if (desired) {
applyDesiredSnapshot(desired, "reset");
return;
}
const currentVisible = visibleIdsRef.current;
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
if (activeFloatingSourceIdRef.current && !sourceIds.has(activeFloatingSourceIdRef.current)) {
commitActiveFloatingSourceId(null);
}
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
setWindowRects((current) => {
const entries = Object.entries(current);
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
});
}, [commitVisibleIds, sourceIds]);
const entries = Object.entries(windowRectsRef.current);
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
if (nextEntries.length !== entries.length) commitWindowRects(Object.fromEntries(nextEntries));
}, [
applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
commitWindowRects,
sourceIds,
]);
useEffect(() => {
if (!identity) {
initializedCatalog.current = null;
if (!desiredSnapshotRef.current) initializedCatalog.current = null;
return;
}
const desired = desiredSnapshotRef.current;
if (desired) {
initializedCatalog.current = identity;
return;
}
if (initializedCatalog.current === identity) return;
@@ -109,8 +243,16 @@ export function useObservationLayout(
const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay,
);
setActiveFloatingSourceId(firstFloating?.id ?? null);
}, [commitVisibleIds, identity, sources]);
commitActiveFloatingSourceId(firstFloating?.id ?? null);
persistLiveLayout();
}, [
applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
identity,
persistLiveLayout,
sources,
]);
const selectedDeliveryIdentity = sources
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
@@ -124,6 +266,7 @@ export function useObservationLayout(
.join("|");
useEffect(() => {
if (restoredLayoutAuthorityRef.current) return;
const selected = sources.filter(
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
);
@@ -135,8 +278,9 @@ export function useObservationLayout(
change.removedIds.forEach((sourceId) => removed.add(sourceId));
}
commitVisibleIds(change.visibleIds);
clearPresentation([...removed]);
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]);
clearPresentation([...removed], false);
persistLiveLayout();
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
const groupId = source.activation?.groupId;
@@ -160,11 +304,13 @@ export function useObservationLayout(
markPending(source, false);
}
}
restoredLayoutAuthorityRef.current = false;
const change = closeObservationSource(visibleIdsRef.current, sourceId);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
clearPresentation(change.removedIds, false);
persistLiveLayout();
return true;
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]);
}, [clearPresentation, commitVisibleIds, markPending, persistLiveLayout, setSourceActive, sources]);
const toggleSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
@@ -180,30 +326,95 @@ export function useObservationLayout(
markPending(source, false);
}
}
restoredLayoutAuthorityRef.current = false;
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
setActiveFloatingSourceId(sourceId);
clearPresentation(change.removedIds, false);
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
return true;
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]);
}, [
clearPresentation,
commitActiveFloatingSourceId,
commitVisibleIds,
hideSource,
markPending,
persistLiveLayout,
setSourceActive,
sources,
]);
const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId);
if (sourceId) setActiveFloatingSourceId(sourceId);
}, []);
if (sourceId) {
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const activateFloatingSource = useCallback((sourceId: string) => {
setActiveFloatingSourceId(sourceId);
}, []);
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
setMaximizedFloatingSourceId(maximized ? sourceId : null);
if (maximized) setActiveFloatingSourceId(sourceId);
}, []);
if (maximized) {
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
}, []);
if (
!Object.values(rect).every((value) => Number.isFinite(value)) ||
rect.width <= 0 ||
rect.height <= 0
) {
return;
}
const currentViewport = viewportSizeRef.current;
if (currentViewport) normalizeObservationWindowRect(rect, currentViewport);
commitWindowRects({ ...windowRectsRef.current, [sourceId]: { ...rect } });
persistLiveLayout();
}, [commitWindowRects, persistLiveLayout]);
const setViewportSize = useCallback((size: ObservationViewportSize) => {
const next = validateObservationViewportSize(size);
const current = viewportSizeRef.current;
if (current && current.width === next.width && current.height === next.height) return;
viewportSizeRef.current = next;
setViewportSizeState(next);
const desired = desiredSnapshotRef.current;
if (desired) {
desiredSnapshotRef.current = { ...desired, viewportSize: next };
// Entering fullscreen changes the shell geometry, which triggers this
// ResizeObserver path. Reproject persistent window rectangles without
// clearing the transient fullscreen/focus state that caused the resize.
applyDesiredSnapshot(desiredSnapshotRef.current, "preserve");
} else if (initializedCatalog.current) {
persistLiveLayout();
}
}, [applyDesiredSnapshot, persistLiveLayout]);
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
if (!viewportSizeRef.current) return null;
persistLiveLayout();
const desired = desiredSnapshotRef.current;
if (!desired) return null;
return cloneSnapshot(desired);
}, [persistLiveLayout]);
const restore = useCallback((saved: ObservationLayoutSnapshot) => {
const validated = validateObservationLayoutSnapshot(saved);
const currentViewport = viewportSizeRef.current;
desiredSnapshotRef.current = cloneSnapshot({
...validated,
viewportSize: currentViewport ?? validated.viewportSize,
});
restoredLayoutAuthorityRef.current = true;
applyDesiredSnapshot(desiredSnapshotRef.current, "reset");
}, [applyDesiredSnapshot]);
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
@@ -214,6 +425,7 @@ export function useObservationLayout(
activeFloatingSourceId,
maximizedFloatingSourceId,
windowRects,
viewportSize,
pendingSourceIds,
toggleSource,
hideSource,
@@ -221,5 +433,8 @@ export function useObservationLayout(
activateFloatingSource,
setFloatingMaximized,
setWindowRect,
setViewportSize,
snapshot,
restore,
};
}
@@ -0,0 +1,591 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
decodeObservationSessionPreparation,
fetchObservationSessionCatalog,
fetchObservationSessionPreparation,
replayObservationSession,
type ObservationSessionFetch,
type ObservationSessionPreparation,
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "./sessionArchive";
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
export type ObservationPreparationPhase =
| "requesting"
| ObservationSessionPreparation["state"];
export interface ObservationReplayProgress {
readonly sessionId: string;
readonly phase: ObservationPreparationPhase;
readonly progress: number | null;
readonly cancellable: boolean;
}
export interface ObservationSessionsController {
items: readonly ObservationSessionSummary[];
state: ObservationSessionsLoadState;
error: string | null;
replayingSessionId: string | null;
preparation: ObservationSessionPreparation | null;
replayProgress: ObservationReplayProgress | null;
failedSessionId: string | null;
refresh: () => Promise<boolean>;
replay: (sessionId: string) => Promise<boolean>;
retry: () => Promise<boolean>;
}
export interface ObservationReplayAttempt {
readonly signal: AbortSignal;
isCurrent: () => boolean;
finish: () => boolean;
}
export interface ObservationReplayCoordinator {
begin: () => ObservationReplayAttempt;
cancel: () => void;
}
export interface ObservationPreparationPollingOptions {
signal: AbortSignal;
fetcher?: ObservationSessionFetch;
onUpdate?: (preparation: ObservationSessionPreparation) => void;
requestTimeoutMs?: number;
heartbeatStallMs?: number;
maximumWaitMs?: number;
initialPollIntervalMs?: number;
maximumPollIntervalMs?: number;
now?: () => number;
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
}
const PREPARATION_STORAGE_KEY = "missioncore.observation-session-preparation/v1";
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const DEFAULT_HEARTBEAT_STALL_MS = 45_000;
const DEFAULT_MAXIMUM_WAIT_MS = 30 * 60_000;
const DEFAULT_INITIAL_POLL_INTERVAL_MS = 750;
const DEFAULT_MAXIMUM_POLL_INTERVAL_MS = 3_000;
export class ObservationPreparationStalledError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationPreparationStalledError";
}
}
/** Latest selection wins, even if an obsolete server job finishes later. */
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
let sequence = 0;
let active: AbortController | null = null;
return {
begin() {
active?.abort();
const controller = new AbortController();
const attemptSequence = ++sequence;
active = controller;
return {
signal: controller.signal,
isCurrent: () => (
!controller.signal.aborted &&
active === controller &&
sequence === attemptSequence
),
finish: () => {
if (active !== controller || sequence !== attemptSequence) return false;
active = null;
return true;
},
};
},
cancel() {
sequence += 1;
active?.abort();
active = null;
},
};
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Операция с сохранёнными сессиями завершилась ошибкой.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
type PendingPreparationState = Exclude<
ObservationSessionPreparation["state"],
"failed" | "cancelled"
>;
function pendingPreparation(
preparation: ObservationSessionPreparation,
): preparation is ObservationSessionPreparation & { state: PendingPreparationState } {
return preparation.state !== "failed" && preparation.state !== "cancelled";
}
const PREPARATION_PHASE_ORDER: Record<
PendingPreparationState,
number
> = {
queued: 0,
validating: 1,
exporting: 2,
finalizing: 3,
};
function terminalPreparationError(preparation: ObservationSessionPreparation): Error {
if (preparation.error) return new Error(preparation.error);
return new Error(
preparation.state === "cancelled"
? "Подготовка записи отменена."
: "Сервер не смог подготовить сохранённую сессию.",
);
}
function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("cancelled", "AbortError"));
return;
}
const onAbort = () => {
globalThis.clearTimeout(timer);
reject(new DOMException("cancelled", "AbortError"));
};
const timer = globalThis.setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function withRequestTimeout<T>(
signal: AbortSignal,
timeoutMs: number,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
const controller = new AbortController();
let timedOut = false;
const forwardAbort = () => controller.abort();
signal.addEventListener("abort", forwardAbort, { once: true });
const timer = globalThis.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
return await operation(controller.signal);
} catch (error) {
if (timedOut && isAbortError(error)) {
throw new ObservationPreparationStalledError(
"Сервер слишком долго не отвечает. Подготовку можно повторить.",
);
}
throw error;
} finally {
globalThis.clearTimeout(timer);
signal.removeEventListener("abort", forwardAbort);
}
}
export async function waitForObservationReplayPreparation(
initial: ObservationSessionPreparation,
options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
if (!pendingPreparation(initial)) throw terminalPreparationError(initial);
const requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
const heartbeatStallMs = Math.max(250, options.heartbeatStallMs ?? DEFAULT_HEARTBEAT_STALL_MS);
const maximumWaitMs = Math.max(heartbeatStallMs, options.maximumWaitMs ?? DEFAULT_MAXIMUM_WAIT_MS);
const initialInterval = Math.max(
50,
options.initialPollIntervalMs ?? DEFAULT_INITIAL_POLL_INTERVAL_MS,
);
const maximumInterval = Math.max(
initialInterval,
options.maximumPollIntervalMs ?? DEFAULT_MAXIMUM_POLL_INTERVAL_MS,
);
const now = options.now ?? Date.now;
const sleep = options.sleep ?? defaultSleep;
const startedAt = now();
let lastHeartbeatAt = startedAt;
let lastUpdatedAt = Date.parse(initial.updatedAtUtc);
let current: ObservationSessionPreparation = initial;
let interval = initialInterval;
options.onUpdate?.(current);
while (true) {
await sleep(interval, options.signal);
const response = await withRequestTimeout(
options.signal,
requestTimeoutMs,
(signal) => fetchObservationSessionPreparation(current, {
signal,
fetcher: options.fetcher,
}),
);
if (response.kind === "ready") return response.launch;
const next = response.preparation;
const nextUpdatedAt = Date.parse(next.updatedAtUtc);
if (nextUpdatedAt < lastUpdatedAt) {
throw new Error("Сервер вернул устаревшее состояние подготовки записи.");
}
if (nextUpdatedAt > lastUpdatedAt) {
lastUpdatedAt = nextUpdatedAt;
lastHeartbeatAt = now();
} else if (
next.state !== current.state ||
next.progress !== current.progress ||
next.cancellable !== current.cancellable
) {
throw new Error("Сервер изменил подготовку без обновления heartbeat timestamp.");
}
if (pendingPreparation(next) && pendingPreparation(current)) {
if (PREPARATION_PHASE_ORDER[next.state] < PREPARATION_PHASE_ORDER[current.state]) {
throw new Error("Сервер вернул подготовку на уже завершённую фазу.");
}
if (
next.progress !== null &&
current.progress !== null &&
next.progress + Number.EPSILON < current.progress
) {
throw new Error("Прогресс подготовки не может уменьшаться.");
}
}
current = next;
options.onUpdate?.(current);
if (!pendingPreparation(current)) throw terminalPreparationError(current);
if (current.state !== "queued" && now() - lastHeartbeatAt > heartbeatStallMs) {
throw new ObservationPreparationStalledError(
"Подготовка перестала обновляться. Текущая сцена сохранена; повторите запуск.",
);
}
if (now() - startedAt > maximumWaitMs) {
throw new ObservationPreparationStalledError(
"Подготовка превысила допустимое время. Текущая сцена сохранена; повторите запуск.",
);
}
interval = Math.min(maximumInterval, Math.round(interval * 1.35));
}
}
export async function resolveObservationSessionReplay(
sessionId: string,
options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
const response = await withRequestTimeout(
options.signal,
Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS),
(signal) => replayObservationSession(sessionId, { signal, fetcher: options.fetcher }),
);
if (response.kind === "ready") return response.launch;
if (!pendingPreparation(response.preparation)) {
options.onUpdate?.(response.preparation);
throw terminalPreparationError(response.preparation);
}
return waitForObservationReplayPreparation(response.preparation, options);
}
function preparationStoragePayload(preparation: ObservationSessionPreparation): unknown {
return {
schema_version: "missioncore.observation-session-preparation/v1",
preparation: {
preparation_id: preparation.preparationId,
session_id: preparation.sessionId,
state: preparation.state,
progress: preparation.progress,
updated_at_utc: preparation.updatedAtUtc,
status_url: preparation.statusUrl,
cancellable: preparation.cancellable,
...(preparation.state === "failed" || preparation.state === "cancelled"
? { retryable: preparation.retryable, error: preparation.error }
: {}),
},
};
}
export function storeObservationReplayPreparation(
preparation: ObservationSessionPreparation,
storage: Storage = window.localStorage,
): void {
if (!pendingPreparation(preparation)) {
storage.removeItem(PREPARATION_STORAGE_KEY);
return;
}
storage.setItem(PREPARATION_STORAGE_KEY, JSON.stringify(preparationStoragePayload(preparation)));
}
export function loadObservationReplayPreparation(
storage: Storage = window.localStorage,
): ObservationSessionPreparation | null {
const serialized = storage.getItem(PREPARATION_STORAGE_KEY);
if (!serialized) return null;
try {
const parsed = JSON.parse(serialized) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
!("preparation" in parsed) ||
typeof parsed.preparation !== "object" ||
parsed.preparation === null ||
!("session_id" in parsed.preparation) ||
typeof parsed.preparation.session_id !== "string"
) {
throw new Error("invalid persisted preparation");
}
const preparation = decodeObservationSessionPreparation(
parsed,
parsed.preparation.session_id,
);
return pendingPreparation(preparation) ? preparation : null;
} catch {
storage.removeItem(PREPARATION_STORAGE_KEY);
return null;
}
}
export function clearObservationReplayPreparation(
storage: Storage = window.localStorage,
): void {
storage.removeItem(PREPARATION_STORAGE_KEY);
}
export function useObservationSessions({
limit = 3,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
/** Called only after the archive is ready, immediately before replacing the old viewer. */
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
} = {}): ObservationSessionsController {
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
const [error, setError] = useState<string | null>(null);
const [replayingSessionId, setReplayingSessionId] = useState<string | null>(null);
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
const mounted = useRef(true);
const catalogSequence = useRef(0);
const reattachStarted = useRef(false);
const preparationPollSequence = useRef(0);
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
if (replayCoordinator.current === null) {
replayCoordinator.current = createObservationReplayCoordinator();
}
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
const refresh = useCallback(async () => {
const sequence = ++catalogSequence.current;
setState("loading");
setError(null);
try {
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
if (!mounted.current || sequence !== catalogSequence.current) return false;
setItems(catalog.items.slice(0, safeLimit));
setState("ready");
return true;
} catch (loadError) {
if (!mounted.current || sequence !== catalogSequence.current) return false;
setState("error");
setError(errorMessage(loadError));
return false;
}
}, [safeLimit]);
useEffect(() => {
mounted.current = true;
void refresh();
return () => {
mounted.current = false;
catalogSequence.current += 1;
replayCoordinator.current?.cancel();
};
}, [refresh]);
const catalogHasActivePreparation = items.some((item) => (
item.preparation !== null &&
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
));
useEffect(() => {
if (state !== "ready" || !catalogHasActivePreparation) return;
const sequence = ++preparationPollSequence.current;
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
const catalog = await fetchObservationSessionCatalog({
limit: safeLimit,
signal: controller.signal,
});
if (
mounted.current &&
!controller.signal.aborted &&
sequence === preparationPollSequence.current
) {
setItems(catalog.items.slice(0, safeLimit));
}
} catch (pollError) {
if (
mounted.current &&
!controller.signal.aborted &&
sequence === preparationPollSequence.current
) {
setError(errorMessage(pollError));
}
}
}, 1_500);
return () => {
preparationPollSequence.current += 1;
window.clearTimeout(timer);
controller.abort();
};
}, [catalogHasActivePreparation, items, safeLimit, state]);
const executeReplay = useCallback(async (
session: ObservationSessionSummary,
resumedPreparation?: ObservationSessionPreparation,
) => {
const attempt = replayCoordinator.current!.begin();
setReplayingSessionId(session.id);
setFailedSessionId(null);
setError(null);
setReplayProgress({
sessionId: session.id,
phase: resumedPreparation?.state ?? "requesting",
progress: resumedPreparation?.progress ?? null,
cancellable: resumedPreparation?.cancellable ?? false,
});
let outcome: ObservationReplayOutcome = "cancelled";
try {
const onUpdate = (next: ObservationSessionPreparation) => {
if (!mounted.current || !attempt.isCurrent()) return;
setPreparation(next);
setReplayProgress({
sessionId: next.sessionId,
phase: next.state,
progress: next.progress,
cancellable: next.cancellable,
});
try {
storeObservationReplayPreparation(next);
} catch {
// Private browsing/storage quota must not break replay preparation.
}
};
const launch = resumedPreparation
? await waitForObservationReplayPreparation(resumedPreparation, {
signal: attempt.signal,
onUpdate,
})
: await resolveObservationSessionReplay(session.id, {
signal: attempt.signal,
onUpdate,
});
if (!mounted.current || !attempt.isCurrent()) return false;
// The current scene stays mounted throughout preparation. Only now that
// the launch descriptor exists do we release the previous viewer.
await onReplayBegin?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
await onReplayAccepted?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
outcome = "accepted";
try {
clearObservationReplayPreparation();
} catch {
// Storage is optional; an accepted replay must not turn into an error.
}
setPreparation(null);
setReplayProgress(null);
return true;
} catch (replayError) {
const aborted = isAbortError(replayError);
outcome = aborted ? "cancelled" : "error";
if (mounted.current && attempt.isCurrent() && !aborted) {
setError(errorMessage(replayError));
setFailedSessionId(session.id);
setReplayProgress(null);
try {
clearObservationReplayPreparation();
} catch {
// Storage is optional for the current browser lifetime.
}
}
return false;
} finally {
const current = attempt.finish();
if (mounted.current && current) {
setReplayingSessionId(null);
await onReplaySettled?.(session, outcome);
}
}
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
const replay = useCallback(async (sessionId: string) => {
const session = items.find((candidate) => candidate.id === sessionId);
if (!session || !session.replayable) return false;
reattachStarted.current = true;
return executeReplay(session);
}, [executeReplay, items]);
useEffect(() => {
if (state !== "ready" || reattachStarted.current) return;
reattachStarted.current = true;
let stored: ObservationSessionPreparation | null = null;
try {
stored = loadObservationReplayPreparation();
} catch {
stored = null;
}
if (!stored) return;
const session = items.find((candidate) => candidate.id === stored?.sessionId);
if (!session?.replayable) {
try {
clearObservationReplayPreparation();
} catch {
// Storage may be unavailable in hardened browser profiles.
}
return;
}
void executeReplay(session, stored);
}, [executeReplay, items, state]);
const retry = useCallback(async () => {
if (!failedSessionId) return false;
return replay(failedSessionId);
}, [failedSessionId, replay]);
return {
items,
state,
error,
replayingSessionId,
preparation,
replayProgress,
failedSessionId,
refresh,
replay,
retry,
};
}
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
import {
initialRecordedCameraAdmissions,
mergeRecordedCameraAdmission,
nextRecordedCameraPreparationIds,
recordedCameraDescriptorPreflight,
recordedSessionAdmissionPhase,
type RecordedAdmissionPhase,
type RecordedCameraAdmissionMap,
type RecordedCameraAdmissionState,
} from "./recordedSessionAdmission";
interface AdmissionSnapshot {
key: string | null;
spatialPhase: RecordedAdmissionPhase;
cameras: Record<string, RecordedCameraAdmissionState>;
}
export interface RecordedSessionAdmissionController {
key: string;
phase: RecordedAdmissionPhase;
cameraSourceIds: readonly string[];
cameras: RecordedCameraAdmissionMap;
activeCameraSourceIds: ReadonlySet<string>;
reportSpatial: (key: string, phase: RecordedAdmissionPhase) => void;
reportCamera: (
key: string,
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}
function replayAdmissionKey(replay: ObservationSessionReplayLaunch): string {
return [
replay.sessionId,
replay.sha256,
...replay.mediaSources.map((source) => (
[
source.id,
source.manifestGenerationSha256,
source.byteLength,
source.timelineStartSeconds,
source.timelineEndSeconds,
].join(":")
)),
].join("|");
}
export function useRecordedSessionAdmission(
replay: ObservationSessionReplayLaunch | null,
): RecordedSessionAdmissionController | null {
const key = replay ? replayAdmissionKey(replay) : null;
const cameraSourceIds = useMemo(
() => replay?.mediaSources.map(({ id }) => id) ?? [],
[replay],
);
const declaredByteLengths = useMemo(
() => Object.fromEntries(
(replay?.mediaSources ?? []).map(({ id, byteLength }) => [id, byteLength]),
),
[replay],
);
const [snapshot, setSnapshot] = useState<AdmissionSnapshot>(() => ({
key,
spatialPhase: "loading",
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
}));
useEffect(() => {
setSnapshot({
key,
spatialPhase: "loading",
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
});
}, [cameraSourceIds, declaredByteLengths, key]);
const reportSpatial = useCallback((reportedKey: string, phase: RecordedAdmissionPhase) => {
setSnapshot((current) => current.key !== reportedKey
? current
: { ...current, spatialPhase: phase });
}, []);
const reportCamera = useCallback((
reportedKey: string,
sourceId: string,
state: RecordedCameraAdmissionState,
) => {
setSnapshot((current) => {
if (current.key !== reportedKey || !(sourceId in current.cameras)) return current;
const merged = mergeRecordedCameraAdmission(current.cameras[sourceId], state);
if (merged === current.cameras[sourceId]) return current;
return { ...current, cameras: { ...current.cameras, [sourceId]: merged } };
});
}, []);
if (!replay || !key) return null;
const current = snapshot.key === key
? snapshot
: {
key,
spatialPhase: "loading" as const,
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
};
const descriptorPhase = recordedCameraDescriptorPreflight(replay.mediaSources);
const phase = descriptorPhase === "error"
? "error"
: recordedSessionAdmissionPhase(
current.spatialPhase,
cameraSourceIds,
current.cameras,
);
const activeCameraSourceIds = new Set(phase === "error"
? []
: nextRecordedCameraPreparationIds(cameraSourceIds, current.cameras));
return {
key,
phase,
cameraSourceIds,
cameras: current.cameras,
activeCameraSourceIds,
reportSpatial,
reportCamera,
};
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservationWorkspaceLayoutProfile,
saveObservationWorkspaceLayoutProfile,
WorkspaceLayoutApiError,
type ObservationWorkspaceLayoutProfile,
} from "./workspaceLayout";
export type WorkspaceLayoutProfileState =
| "idle"
| "loading"
| "ready"
| "saving"
| "error"
| "conflict";
export interface WorkspaceLayoutProfileController {
profile: ObservationWorkspaceLayoutProfile | null;
state: WorkspaceLayoutProfileState;
error: string | null;
refresh: () => Promise<ObservationWorkspaceLayoutProfile | null>;
save: (
profile: ObservationWorkspaceLayoutProfile,
) => Promise<ObservationWorkspaceLayoutProfile | null>;
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Операция с профилем рабочей поверхности завершилась ошибкой.";
}
export function useWorkspaceLayoutProfile(): WorkspaceLayoutProfileController {
const [profile, setProfile] = useState<ObservationWorkspaceLayoutProfile | null>(null);
const profileRef = useRef<ObservationWorkspaceLayoutProfile | null>(null);
const [state, setState] = useState<WorkspaceLayoutProfileState>("idle");
const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
const requestSequence = useRef(0);
const saveInFlight = useRef(false);
const commitProfile = useCallback((next: ObservationWorkspaceLayoutProfile | null) => {
profileRef.current = next;
setProfile(next);
}, []);
const refresh = useCallback(async () => {
const sequence = ++requestSequence.current;
setState("loading");
setError(null);
try {
const loaded = await fetchObservationWorkspaceLayoutProfile();
if (!mounted.current || sequence !== requestSequence.current) return null;
commitProfile(loaded);
setState("ready");
return loaded;
} catch (loadError) {
if (!mounted.current || sequence !== requestSequence.current) return null;
setState("error");
setError(errorMessage(loadError));
return null;
}
}, [commitProfile]);
useEffect(() => {
mounted.current = true;
void refresh();
return () => {
mounted.current = false;
requestSequence.current += 1;
};
}, [refresh]);
useEffect(() => {
if (state !== "error") return;
// The desktop shell can become ready before its loopback API. Keep the
// last-layout restore self-healing instead of requiring a page reload once
// the local service finishes starting.
const timer = window.setTimeout(() => void refresh(), 5_000);
return () => window.clearTimeout(timer);
}, [refresh, state]);
useEffect(() => {
const refreshAfterNetworkRecovery = () => void refresh();
window.addEventListener("online", refreshAfterNetworkRecovery);
return () => window.removeEventListener("online", refreshAfterNetworkRecovery);
}, [refresh]);
const save = useCallback(async (draft: ObservationWorkspaceLayoutProfile) => {
if (saveInFlight.current) return null;
const current = profileRef.current;
if (current && draft.revision !== current.revision) {
setState("conflict");
setError("Профиль изменился после открытия. Обновите данные перед сохранением.");
return null;
}
saveInFlight.current = true;
setState("saving");
setError(null);
try {
const saved = await saveObservationWorkspaceLayoutProfile(draft);
if (!mounted.current) return null;
commitProfile(saved);
setState("ready");
return saved;
} catch (saveError) {
if (!mounted.current) return null;
const conflict = saveError instanceof WorkspaceLayoutApiError && saveError.conflict;
setState(conflict ? "conflict" : "error");
setError(errorMessage(saveError));
return null;
} finally {
saveInFlight.current = false;
}
}, [commitProfile]);
return { profile, state, error, refresh, save };
}
@@ -0,0 +1,599 @@
import type { SceneSettings } from "../../sceneSettings";
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 1 as const;
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
"/api/v1/workspace-layouts/observation.spatial" as const;
export type ObservationToolWindowId = "sources" | "display" | "layers";
export interface ObservationToolWindows {
sourcesOpen: boolean;
displayOpen: boolean;
layersOpen: boolean;
order: readonly ObservationToolWindowId[];
}
export interface ObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationViewportSize {
width: number;
height: number;
}
export interface NormalizedObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationLayoutSnapshot {
visibleSourceIds: readonly string[];
activeFloatingSourceId: string | null;
windowRects: Readonly<Record<string, NormalizedObservationWindowRect>>;
viewportSize: ObservationViewportSize;
}
export interface ProjectedObservationLayout {
visibleSourceIds: readonly string[];
activeFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
}
export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnapshot {
version: typeof OBSERVATION_WORKSPACE_LAYOUT_VERSION;
revision: number;
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
sceneSettings: SceneSettings;
toolWindows: ObservationToolWindows;
}
export type WorkspaceLayoutFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
type WireProfile = {
version: number;
revision: number;
workspace_id: string;
scene_settings: {
projection: string;
point_size: number;
color_mode: string;
palette: string;
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
show_labels: boolean;
show_camera_frustums: boolean;
};
tool_windows: {
sources_open: boolean;
display_open: boolean;
layers_open: boolean;
order: ObservationToolWindowId[];
};
visible_source_ids: string[];
active_floating_source_id: string | null;
window_rects: Record<string, NormalizedObservationWindowRect>;
viewport_size: ObservationViewportSize;
};
const PROFILE_KEYS = new Set([
"version",
"revision",
"workspace_id",
"scene_settings",
"tool_windows",
"visible_source_ids",
"active_floating_source_id",
"window_rects",
"viewport_size",
]);
const SCENE_KEYS = new Set([
"projection",
"point_size",
"color_mode",
"palette",
"custom_color",
"accumulation_seconds",
"show_points",
"show_trajectory",
"show_grid",
"show_labels",
"show_camera_frustums",
]);
const TOOL_WINDOW_KEYS = new Set(["sources_open", "display_open", "layers_open", "order"]);
const VIEWPORT_KEYS = new Set(["width", "height"]);
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
const COLOR_MODES = new Set<SceneSettings["colorMode"]>([
"intensity",
"height",
"distance",
"rgb",
"class",
]);
const PALETTES = new Set<SceneSettings["palette"]>([
"turbo",
"viridis",
"plasma",
"grayscale",
"custom",
]);
const TOOL_WINDOW_IDS = new Set<ObservationToolWindowId>(["sources", "display", "layers"]);
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const MAX_SOURCES = 256;
const MAX_VIEWPORT_EDGE = 100_000;
const NORMALIZED_EPSILON = 1e-9;
export class WorkspaceLayoutContractError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkspaceLayoutContractError";
}
}
export class WorkspaceLayoutApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "WorkspaceLayoutApiError";
this.status = status;
}
get conflict(): boolean {
return this.status === 409 || this.status === 412;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, field: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть объектом.`);
}
return value;
}
function assertExactKeys(
value: Record<string, unknown>,
expected: ReadonlySet<string>,
field: string,
): void {
const actual = Object.keys(value);
const unknown = actual.filter((key) => !expected.has(key));
const missing = [...expected].filter((key) => !(key in value));
if (unknown.length || missing.length) {
const details = [
unknown.length ? `неизвестные: ${unknown.sort().join(", ")}` : "",
missing.length ? `отсутствуют: ${missing.sort().join(", ")}` : "",
].filter(Boolean).join("; ");
throw new WorkspaceLayoutContractError(`${field}: неверная схема (${details}).`);
}
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") {
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть boolean.`);
}
return value;
}
function requireFiniteInRange(
value: unknown,
field: string,
minimum: number,
maximum: number,
{ integer = false, minimumExclusive = false }: { integer?: boolean; minimumExclusive?: boolean } = {},
): number {
const belowMinimum = minimumExclusive
? typeof value === "number" && value <= minimum
: typeof value === "number" && value < minimum;
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
belowMinimum ||
value > maximum ||
(integer && !Number.isInteger(value))
) {
throw new WorkspaceLayoutContractError(
`Поле ${field} должно быть конечным ${integer ? "целым " : ""}числом в допустимом диапазоне.`,
);
}
return value;
}
function requireStableId(value: unknown, field: string): string {
if (typeof value !== "string" || !SAFE_STABLE_ID.test(value)) {
throw new WorkspaceLayoutContractError(
`Поле ${field} должно содержать безопасный стабильный идентификатор источника.`,
);
}
return value;
}
function decodeViewport(value: unknown, field = "viewport_size"): ObservationViewportSize {
const record = requireRecord(value, field);
assertExactKeys(record, VIEWPORT_KEYS, field);
return {
width: requireFiniteInRange(record.width, `${field}.width`, 1, MAX_VIEWPORT_EDGE),
height: requireFiniteInRange(record.height, `${field}.height`, 1, MAX_VIEWPORT_EDGE),
};
}
export function validateObservationViewportSize(
value: ObservationViewportSize,
): ObservationViewportSize {
return decodeViewport(value, "viewport");
}
function decodeNormalizedRect(
value: unknown,
field: string,
): NormalizedObservationWindowRect {
const record = requireRecord(value, field);
assertExactKeys(record, RECT_KEYS, field);
const rect = {
x: requireFiniteInRange(record.x, `${field}.x`, 0, 1),
y: requireFiniteInRange(record.y, `${field}.y`, 0, 1),
width: requireFiniteInRange(record.width, `${field}.width`, 0, 1, { minimumExclusive: true }),
height: requireFiniteInRange(record.height, `${field}.height`, 0, 1, { minimumExclusive: true }),
};
if (
rect.x + rect.width > 1 + NORMALIZED_EPSILON ||
rect.y + rect.height > 1 + NORMALIZED_EPSILON
) {
throw new WorkspaceLayoutContractError(`${field} выходит за нормализованные границы viewport.`);
}
return rect;
}
function decodeStableIds(value: unknown): string[] {
if (!Array.isArray(value) || value.length > MAX_SOURCES) {
throw new WorkspaceLayoutContractError(
`Поле visible_source_ids должно быть массивом не более ${MAX_SOURCES} элементов.`,
);
}
const result = value.map((entry, index) => requireStableId(entry, `visible_source_ids[${index}]`));
if (new Set(result).size !== result.length) {
throw new WorkspaceLayoutContractError("Поле visible_source_ids содержит повторяющиеся id.");
}
return result;
}
function decodeWindowRects(value: unknown): Record<string, NormalizedObservationWindowRect> {
const record = requireRecord(value, "window_rects");
if (Object.keys(record).length > MAX_SOURCES) {
throw new WorkspaceLayoutContractError(
`Поле window_rects содержит более ${MAX_SOURCES} элементов.`,
);
}
return Object.fromEntries(Object.entries(record).map(([sourceId, rect]) => [
requireStableId(sourceId, "window_rects key"),
decodeNormalizedRect(rect, `window_rects.${sourceId}`),
]));
}
function requireEnum<T extends string>(
value: unknown,
allowed: ReadonlySet<T>,
field: string,
): T {
if (typeof value !== "string" || !allowed.has(value as T)) {
throw new WorkspaceLayoutContractError(`Поле ${field} содержит неизвестное значение.`);
}
return value as T;
}
function decodeSceneSettings(value: unknown): SceneSettings {
const record = requireRecord(value, "scene_settings");
assertExactKeys(record, SCENE_KEYS, "scene_settings");
if (typeof record.custom_color !== "string" || !HEX_COLOR.test(record.custom_color)) {
throw new WorkspaceLayoutContractError("Поле scene_settings.custom_color должно быть цветом #RRGGBB.");
}
return {
projection: requireEnum(record.projection, PROJECTIONS, "scene_settings.projection"),
pointSize: requireFiniteInRange(record.point_size, "scene_settings.point_size", 0.1, 32),
colorMode: requireEnum(record.color_mode, COLOR_MODES, "scene_settings.color_mode"),
palette: requireEnum(record.palette, PALETTES, "scene_settings.palette"),
customColor: record.custom_color,
accumulationSeconds: requireFiniteInRange(
record.accumulation_seconds,
"scene_settings.accumulation_seconds",
0,
3_600,
),
showPoints: requireBoolean(record.show_points, "scene_settings.show_points"),
showTrajectory: requireBoolean(record.show_trajectory, "scene_settings.show_trajectory"),
showGrid: requireBoolean(record.show_grid, "scene_settings.show_grid"),
showLabels: requireBoolean(record.show_labels, "scene_settings.show_labels"),
showCameraFrustums: requireBoolean(
record.show_camera_frustums,
"scene_settings.show_camera_frustums",
),
};
}
function decodeToolWindows(value: unknown): ObservationToolWindows {
const record = requireRecord(value, "tool_windows");
assertExactKeys(record, TOOL_WINDOW_KEYS, "tool_windows");
if (!Array.isArray(record.order) || record.order.length !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно содержать три окна.");
}
const order = record.order.map((entry, index) =>
requireEnum(entry, TOOL_WINDOW_IDS, `tool_windows.order[${index}]`));
if (new Set(order).size !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно быть перестановкой окон.");
}
return {
sourcesOpen: requireBoolean(record.sources_open, "tool_windows.sources_open"),
displayOpen: requireBoolean(record.display_open, "tool_windows.display_open"),
layersOpen: requireBoolean(record.layers_open, "tool_windows.layers_open"),
order,
};
}
export function decodeObservationWorkspaceLayoutProfile(
value: unknown,
): ObservationWorkspaceLayoutProfile {
const record = requireRecord(value, "workspace layout");
assertExactKeys(record, PROFILE_KEYS, "workspace layout");
if (record.version !== OBSERVATION_WORKSPACE_LAYOUT_VERSION) {
throw new WorkspaceLayoutContractError(
`Неподдерживаемая версия workspace layout: ${String(record.version)}.`,
);
}
if (record.workspace_id !== OBSERVATION_WORKSPACE_ID) {
throw new WorkspaceLayoutContractError("Профиль относится к другой рабочей поверхности.");
}
const visibleSourceIds = decodeStableIds(record.visible_source_ids);
const activeFloatingSourceId = record.active_floating_source_id === null
? null
: requireStableId(record.active_floating_source_id, "active_floating_source_id");
if (activeFloatingSourceId && !visibleSourceIds.includes(activeFloatingSourceId)) {
throw new WorkspaceLayoutContractError(
"Активное плавающее окно должно относиться к видимому источнику.",
);
}
return {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: requireFiniteInRange(record.revision, "revision", 0, Number.MAX_SAFE_INTEGER, {
integer: true,
}),
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: decodeSceneSettings(record.scene_settings),
toolWindows: decodeToolWindows(record.tool_windows),
visibleSourceIds,
activeFloatingSourceId,
windowRects: decodeWindowRects(record.window_rects),
viewportSize: decodeViewport(record.viewport_size),
};
}
export function encodeObservationWorkspaceLayoutProfile(
profile: ObservationWorkspaceLayoutProfile,
): WireProfile {
const wire: WireProfile = {
version: profile.version,
revision: profile.revision,
workspace_id: profile.workspaceId,
scene_settings: {
projection: profile.sceneSettings.projection,
point_size: profile.sceneSettings.pointSize,
color_mode: profile.sceneSettings.colorMode,
palette: profile.sceneSettings.palette,
custom_color: profile.sceneSettings.customColor,
accumulation_seconds: profile.sceneSettings.accumulationSeconds,
show_points: profile.sceneSettings.showPoints,
show_trajectory: profile.sceneSettings.showTrajectory,
show_grid: profile.sceneSettings.showGrid,
show_labels: profile.sceneSettings.showLabels,
show_camera_frustums: profile.sceneSettings.showCameraFrustums,
},
tool_windows: {
sources_open: profile.toolWindows.sourcesOpen,
display_open: profile.toolWindows.displayOpen,
layers_open: profile.toolWindows.layersOpen,
order: [...profile.toolWindows.order],
},
visible_source_ids: [...profile.visibleSourceIds],
active_floating_source_id: profile.activeFloatingSourceId,
window_rects: Object.fromEntries(
Object.entries(profile.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
),
viewport_size: { ...profile.viewportSize },
};
// The decoder is the single runtime schema authority for inbound and outbound documents.
decodeObservationWorkspaceLayoutProfile(wire);
return wire;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function normalizeObservationWindowRect(
rect: ObservationWindowRect,
viewport: ObservationViewportSize,
): NormalizedObservationWindowRect {
const safeViewport = decodeViewport(viewport, "viewport");
for (const [field, value] of Object.entries(rect)) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new WorkspaceLayoutContractError(`Поле rect.${field} должно быть конечным числом.`);
}
}
const x = clamp(rect.x, 0, Math.max(0, safeViewport.width - 1));
const y = clamp(rect.y, 0, Math.max(0, safeViewport.height - 1));
const width = clamp(rect.width, 1, Math.max(1, safeViewport.width - x));
const height = clamp(rect.height, 1, Math.max(1, safeViewport.height - y));
return {
x: x / safeViewport.width,
y: y / safeViewport.height,
width: width / safeViewport.width,
height: height / safeViewport.height,
};
}
export function denormalizeObservationWindowRect(
rect: NormalizedObservationWindowRect,
viewport: ObservationViewportSize,
): ObservationWindowRect {
const normalized = decodeNormalizedRect(rect, "rect");
const safeViewport = decodeViewport(viewport, "viewport");
return {
x: normalized.x * safeViewport.width,
y: normalized.y * safeViewport.height,
width: normalized.width * safeViewport.width,
height: normalized.height * safeViewport.height,
};
}
export function validateObservationLayoutSnapshot(
snapshot: ObservationLayoutSnapshot,
): ObservationLayoutSnapshot {
const wire = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: 0,
workspace_id: OBSERVATION_WORKSPACE_ID,
scene_settings: {
projection: "3d",
point_size: 1,
color_mode: "intensity",
palette: "turbo",
custom_color: "#ffffff",
accumulation_seconds: 0,
show_points: true,
show_trajectory: true,
show_grid: true,
show_labels: false,
show_camera_frustums: true,
},
tool_windows: {
sources_open: false,
display_open: false,
layers_open: false,
order: ["sources", "display", "layers"],
},
visible_source_ids: [...snapshot.visibleSourceIds],
active_floating_source_id: snapshot.activeFloatingSourceId,
window_rects: snapshot.windowRects,
viewport_size: snapshot.viewportSize,
};
const decoded = decodeObservationWorkspaceLayoutProfile(wire);
return {
visibleSourceIds: decoded.visibleSourceIds,
activeFloatingSourceId: decoded.activeFloatingSourceId,
windowRects: decoded.windowRects,
viewportSize: decoded.viewportSize,
};
}
export function projectObservationLayoutSnapshot(
snapshot: ObservationLayoutSnapshot,
knownSourceIds: ReadonlySet<string>,
viewport: ObservationViewportSize,
): ProjectedObservationLayout {
const validated = validateObservationLayoutSnapshot(snapshot);
const targetViewport = decodeViewport(viewport, "viewport");
const visibleSourceIds = validated.visibleSourceIds.filter((sourceId) => knownSourceIds.has(sourceId));
const visibleSet = new Set(visibleSourceIds);
return {
visibleSourceIds,
activeFloatingSourceId: validated.activeFloatingSourceId && visibleSet.has(validated.activeFloatingSourceId)
? validated.activeFloatingSourceId
: null,
windowRects: Object.fromEntries(
Object.entries(validated.windowRects)
.filter(([sourceId]) => knownSourceIds.has(sourceId))
.map(([sourceId, rect]) => [
sourceId,
denormalizeObservationWindowRect(rect, targetViewport),
]),
),
};
}
async function responseMessage(response: Response, fallback: string): Promise<string> {
try {
const body: unknown = await response.json();
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
return body.detail.trim();
}
} catch {
// A non-JSON error body is represented by the stable fallback.
}
return fallback;
}
export async function fetchObservationWorkspaceLayoutProfile({
fetcher = fetch,
}: { fetcher?: WorkspaceLayoutFetch } = {}): Promise<ObservationWorkspaceLayoutProfile | null> {
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (response.status === 404) return null;
if (!response.ok) {
throw new WorkspaceLayoutApiError(
await responseMessage(response, "Не удалось загрузить профиль рабочей поверхности."),
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new WorkspaceLayoutContractError("Сервер вернул профиль не в формате JSON.");
}
return decodeObservationWorkspaceLayoutProfile(body);
}
export async function saveObservationWorkspaceLayoutProfile(
profile: ObservationWorkspaceLayoutProfile,
{ fetcher = fetch }: { fetcher?: WorkspaceLayoutFetch } = {},
): Promise<ObservationWorkspaceLayoutProfile> {
const wire = encodeObservationWorkspaceLayoutProfile(profile);
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"If-Match": `"${profile.revision}"`,
},
body: JSON.stringify(wire),
});
if (!response.ok) {
throw new WorkspaceLayoutApiError(
await responseMessage(response, response.status === 409 || response.status === 412
? "Профиль уже изменён в другом окне. Обновите данные и повторите сохранение."
: "Не удалось сохранить профиль рабочей поверхности."),
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new WorkspaceLayoutContractError("Сервер не вернул сохранённый профиль в формате JSON.");
}
const saved = decodeObservationWorkspaceLayoutProfile(body);
if (saved.revision <= profile.revision) {
throw new WorkspaceLayoutContractError("Сервер не увеличил revision сохранённого профиля.");
}
return saved;
}
@@ -123,6 +123,16 @@ export type ObservationSourceDelivery =
kind: "video-url" | "image-url";
url: string;
mediaType?: string | null;
}
| {
id: string;
kind: "recorded-fmp4-manifest";
url: string;
mediaType: "video/mp4";
manifestGenerationSha256: string;
byteLength: number;
timelineStartSeconds: number;
timelineEndSeconds: number;
};
export interface ObservationSourceActivation {
@@ -0,0 +1,89 @@
export interface LatestAsyncCommitter<T> {
enqueue: (value: T) => void;
hasPending: () => boolean;
isBusy: () => boolean;
waitForIdle: () => Promise<void>;
dispose: () => void;
}
export interface LatestAsyncCommitResult<T> {
value: T;
applied: boolean;
superseded: boolean;
}
/**
* Serializes an expensive settings commit while retaining only the newest
* value submitted during the active request. This keeps slider and color
* interactions from creating an unbounded backend queue.
*/
export function createLatestAsyncCommitter<T>({
commit,
onSettled,
}: {
commit: (value: T) => Promise<boolean>;
onSettled?: (result: LatestAsyncCommitResult<T>) => void;
}): LatestAsyncCommitter<T> {
let pending: T | undefined;
let running = false;
let disposed = false;
const idleWaiters = new Set<() => void>();
const settleIdleWaiters = () => {
if (running || pending !== undefined) return;
for (const resolve of idleWaiters) resolve();
idleWaiters.clear();
};
const drain = async () => {
if (running || disposed) return;
running = true;
try {
while (!disposed && pending !== undefined) {
const value = pending;
pending = undefined;
let applied = false;
try {
applied = await commit(value);
} catch {
applied = false;
}
if (disposed) return;
onSettled?.({
value,
applied,
superseded: pending !== undefined,
});
}
} finally {
running = false;
if (!disposed && pending !== undefined) {
void drain();
} else {
settleIdleWaiters();
}
}
};
return {
enqueue(value) {
if (disposed) return;
pending = value;
void drain();
},
hasPending: () => pending !== undefined,
isBusy: () => running || pending !== undefined,
waitForIdle() {
if (!running && pending === undefined) return Promise.resolve();
return new Promise<void>((resolve) => idleWaiters.add(resolve));
},
dispose() {
disposed = true;
pending = undefined;
settleIdleWaiters();
},
};
}