feat(control-station): add atomic recorded-session playback
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user