feat(observation): add dynamic camera sources

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 20:56:41 +03:00
parent 05bdab24a5
commit a281faf923
15 changed files with 1592 additions and 52 deletions
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ObservationSourceDescriptor } from "../runtime/contracts";
export interface ObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationLayoutController {
visibleSourceIds: ReadonlySet<string>;
focusedSourceId: string | null;
activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
toggleSource: (sourceId: string) => void;
hideSource: (sourceId: string) => void;
setFocusedSourceId: (sourceId: string | null) => void;
activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
}
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
if (!source.capabilities.defaultVisible) return false;
if (source.availability === "unavailable" || source.availability === "error") return false;
return source.modality === "point-cloud" || Boolean(source.previewUrl);
}
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
return sources
.map((source) => [
source.id,
source.binding.deviceSessionId,
source.binding.deviceId,
].filter(Boolean).join(":"))
.sort()
.join("|");
}
export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[],
): ObservationLayoutController {
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
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 identity = catalogIdentity(sources);
useEffect(() => {
setVisibleIds((current) => {
const next = current.filter((sourceId) => sourceIds.has(sourceId));
return next.length === current.length ? current : next;
});
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : 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);
});
}, [sourceIds]);
useEffect(() => {
if (!identity) {
initializedCatalog.current = null;
return;
}
if (initializedCatalog.current === identity) return;
initializedCatalog.current = identity;
const defaults = sources
.filter(canOpenByDefault)
.map((source) => source.id);
setVisibleIds(defaults);
const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay,
);
setActiveFloatingSourceId(firstFloating?.id ?? null);
}, [identity, sources]);
const toggleSource = useCallback((sourceId: string) => {
const selected = visibleIds.includes(sourceId);
setVisibleIds((current) => selected
? current.filter((candidate) => candidate !== sourceId)
: current.includes(sourceId) ? current : [...current, sourceId]);
if (!selected) setActiveFloatingSourceId(sourceId);
}, [visibleIds]);
const hideSource = useCallback((sourceId: string) => {
setVisibleIds((current) => current.filter((candidate) => candidate !== sourceId));
setFocusedSourceIdState((current) => current === sourceId ? null : current);
setActiveFloatingSourceId((current) => current === sourceId ? null : current);
setMaximizedFloatingSourceId((current) => current === sourceId ? null : current);
}, []);
const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId);
if (sourceId) setActiveFloatingSourceId(sourceId);
}, []);
const activateFloatingSource = useCallback((sourceId: string) => {
setActiveFloatingSourceId(sourceId);
}, []);
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
setMaximizedFloatingSourceId(maximized ? sourceId : null);
if (maximized) setActiveFloatingSourceId(sourceId);
}, []);
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
}, []);
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
return {
visibleSourceIds,
focusedSourceId,
activeFloatingSourceId,
maximizedFloatingSourceId,
windowRects,
toggleSource,
hideSource,
setFocusedSourceId,
activateFloatingSource,
setFloatingMaximized,
setWindowRect,
};
}
@@ -72,6 +72,70 @@ export interface SpatialSourceDescriptor {
kind: "rerun-grpc" | "rrd" | "other";
}
export type ObservationSourceModality = "point-cloud" | "video" | "image" | "depth";
export type ObservationSourceAvailability =
| "unverified"
| "declared"
| "available"
| "connecting"
| "streaming"
| "degraded"
| "unavailable"
| "error";
export type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
export interface ObservationSourceProvider {
pluginId: string;
pluginVersion: string;
modelId: string;
compatibilityProfileId?: string | null;
}
export interface ObservationSourceBinding {
deviceId?: string | null;
deviceSessionId?: string | null;
acquisitionId?: string | null;
}
export interface ObservationSourceCapabilities {
overlay: boolean;
fullscreen: boolean;
resizable: boolean;
defaultVisible: boolean;
timelineMode: ObservationTimelineMode;
seekable: boolean;
sessionRecording: boolean;
clockId?: string | null;
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
}
export interface ObservationSourceDescriptor {
id: string;
sourceId: string;
semanticChannelId: string;
label: string;
description: string;
modality: ObservationSourceModality;
role: "primary" | "auxiliary";
availability: ObservationSourceAvailability;
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
endpointLabel?: string | null;
previewUrl?: string | null;
provider: ObservationSourceProvider;
binding: ObservationSourceBinding;
capabilities: ObservationSourceCapabilities;
}
export interface ObservationTimelineSnapshot {
mode: ObservationTimelineMode;
seekable: boolean;
sessionRecording: boolean;
synchronization: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
range: { startSeconds: number; endSeconds: number } | null;
}
export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
@@ -80,6 +144,8 @@ export interface MissionRuntimeState {
acquisition?: RuntimeAcquisitionSnapshot | null;
operations?: readonly RuntimeOperationSnapshot[];
spatialSource?: SpatialSourceDescriptor | null;
observationSources?: readonly ObservationSourceDescriptor[];
observationTimeline?: ObservationTimelineSnapshot;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;
metrics?: StreamMetrics;