feat(k1): add live cameras and reliable spatial following

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 00:26:03 +03:00
parent a281faf923
commit 2bda1986bd
33 changed files with 2927 additions and 330 deletions
@@ -0,0 +1,68 @@
import type { ObservationSourceDescriptor } from "../runtime/contracts";
export interface ObservationVisibilityChange {
visibleIds: string[];
removedIds: string[];
}
export function shouldRestartObservationSource(
source: ObservationSourceDescriptor,
): boolean {
return Boolean(
source.activation?.selected &&
source.activation.controllable &&
(source.availability === "error" || !source.delivery),
);
}
export function openObservationSource(
currentIds: readonly string[],
sourceId: string,
sources: readonly ObservationSourceDescriptor[],
): ObservationVisibilityChange {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return { visibleIds: [...currentIds], removedIds: [] };
const uniqueCurrent = [...new Set(currentIds)];
const activation = source.activation;
if (!activation) {
return {
visibleIds: uniqueCurrent.includes(sourceId) ? uniqueCurrent : [...uniqueCurrent, sourceId],
removedIds: [],
};
}
const groupSourceIds = new Set(
sources
.filter((candidate) => candidate.activation?.groupId === activation.groupId)
.map((candidate) => candidate.id),
);
const capacity = Math.max(1, Math.floor(activation.maxActive));
const currentPeers = uniqueCurrent.filter(
(candidate) => candidate !== sourceId && groupSourceIds.has(candidate),
);
const retainedPeerCount = Math.max(0, capacity - 1);
const retainedPeers = new Set(
retainedPeerCount > 0 ? currentPeers.slice(-retainedPeerCount) : [],
);
const visibleIds = uniqueCurrent.filter(
(candidate) => !groupSourceIds.has(candidate) || retainedPeers.has(candidate),
);
if (!visibleIds.includes(sourceId)) visibleIds.push(sourceId);
const visibleSet = new Set(visibleIds);
return {
visibleIds,
removedIds: uniqueCurrent.filter((candidate) => !visibleSet.has(candidate)),
};
}
export function closeObservationSource(
currentIds: readonly string[],
sourceId: string,
): ObservationVisibilityChange {
const visibleIds = currentIds.filter((candidate) => candidate !== sourceId);
return {
visibleIds,
removedIds: visibleIds.length === currentIds.length ? [] : [sourceId],
};
}
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ObservationSourceDescriptor } from "../runtime/contracts";
import {
closeObservationSource,
openObservationSource,
shouldRestartObservationSource,
} from "./layoutPolicy";
export interface ObservationWindowRect {
x: number;
@@ -15,8 +20,9 @@ export interface ObservationLayoutController {
activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
toggleSource: (sourceId: string) => void;
hideSource: (sourceId: string) => void;
pendingSourceIds: ReadonlySet<string>;
toggleSource: (sourceId: string) => Promise<boolean>;
hideSource: (sourceId: string) => Promise<boolean>;
setFocusedSourceId: (sourceId: string | null) => void;
activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
@@ -26,7 +32,11 @@ export interface ObservationLayoutController {
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);
return (
source.modality === "point-cloud" ||
Boolean(source.previewUrl) ||
Boolean(source.delivery && (!source.activation || source.activation.selected))
);
}
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
@@ -42,8 +52,11 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[],
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
): ObservationLayoutController {
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const visibleIdsRef = useRef<string[]>([]);
const [pendingIds, setPendingIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
@@ -54,11 +67,23 @@ export function useObservationLayout(
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
const identity = catalogIdentity(sources);
const commitVisibleIds = useCallback((next: string[]) => {
visibleIdsRef.current = next;
setVisibleIds(next);
}, []);
const clearPresentation = useCallback((removedIds: readonly string[]) => {
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);
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
}, []);
useEffect(() => {
setVisibleIds((current) => {
const next = current.filter((sourceId) => sourceIds.has(sourceId));
return next.length === current.length ? current : next;
});
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);
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
@@ -67,7 +92,7 @@ export function useObservationLayout(
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
});
}, [sourceIds]);
}, [commitVisibleIds, sourceIds]);
useEffect(() => {
if (!identity) {
@@ -76,30 +101,91 @@ export function useObservationLayout(
}
if (initializedCatalog.current === identity) return;
initializedCatalog.current = identity;
const defaults = sources
.filter(canOpenByDefault)
.map((source) => source.id);
setVisibleIds(defaults);
let defaults: string[] = [];
for (const source of sources.filter(canOpenByDefault)) {
defaults = openObservationSource(defaults, source.id, sources).visibleIds;
}
commitVisibleIds(defaults);
const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay,
);
setActiveFloatingSourceId(firstFloating?.id ?? null);
}, [identity, sources]);
}, [commitVisibleIds, 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 selectedDeliveryIdentity = sources
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
.map((source) => [
source.id,
source.delivery?.id,
source.activation?.groupId,
source.activation?.maxActive,
].join(":"))
.sort()
.join("|");
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);
}, []);
useEffect(() => {
const selected = sources.filter(
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
);
if (!selected.length) return;
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
const removed = new Set<string>();
for (const source of selected) {
change = openObservationSource(change.visibleIds, source.id, sources);
change.removedIds.forEach((sourceId) => removed.add(sourceId));
}
commitVisibleIds(change.visibleIds);
clearPresentation([...removed]);
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]);
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
const groupId = source.activation?.groupId;
const affected = groupId
? sources.filter((candidate) => candidate.activation?.groupId === groupId).map(({ id }) => id)
: [source.id];
setPendingIds((current) => pending
? [...new Set([...current, ...affected])]
: current.filter((candidate) => !affected.includes(candidate)));
}, [sources]);
const hideSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return false;
if (source.activation?.selected && source.activation.controllable) {
if (!setSourceActive) return false;
markPending(source, true);
try {
if (!await setSourceActive(source.sourceId, false)) return false;
} finally {
markPending(source, false);
}
}
const change = closeObservationSource(visibleIdsRef.current, sourceId);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
return true;
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]);
const toggleSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return false;
const restart = shouldRestartObservationSource(source);
if (visibleIdsRef.current.includes(sourceId) && !restart) return hideSource(sourceId);
if (source.activation && (!source.activation.selected || restart)) {
if (!source.activation.controllable || !setSourceActive) return false;
markPending(source, true);
try {
if (!await setSourceActive(source.sourceId, true)) return false;
} finally {
markPending(source, false);
}
}
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
setActiveFloatingSourceId(sourceId);
return true;
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]);
const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId);
@@ -120,6 +206,7 @@ export function useObservationLayout(
}, []);
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
return {
visibleSourceIds,
@@ -127,6 +214,7 @@ export function useObservationLayout(
activeFloatingSourceId,
maximizedFloatingSourceId,
windowRects,
pendingSourceIds,
toggleSource,
hideSource,
setFocusedSourceId,
@@ -12,6 +12,7 @@ const idleRuntime: MissionRuntimeController = {
pendingAction: null,
refresh: () => undefined,
updateViewerSettings: async () => false,
setObservationSourceActive: async () => false,
};
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
@@ -111,6 +111,27 @@ export interface ObservationSourceCapabilities {
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
}
export type ObservationSourceDelivery =
| {
id: string;
kind: "mse-fmp4-websocket";
url: string;
mediaType: string;
}
| {
id: string;
kind: "video-url" | "image-url";
url: string;
mediaType?: string | null;
};
export interface ObservationSourceActivation {
groupId: string;
maxActive: number;
selected: boolean;
controllable: boolean;
}
export interface ObservationSourceDescriptor {
id: string;
sourceId: string;
@@ -123,6 +144,8 @@ export interface ObservationSourceDescriptor {
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
endpointLabel?: string | null;
previewUrl?: string | null;
delivery?: ObservationSourceDelivery | null;
activation?: ObservationSourceActivation | null;
provider: ObservationSourceProvider;
binding: ObservationSourceBinding;
capabilities: ObservationSourceCapabilities;
@@ -157,4 +180,5 @@ export interface MissionRuntimeController {
pendingAction: string | null;
refresh: () => void | Promise<void>;
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
}