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
+4 -1
View File
@@ -133,7 +133,10 @@ export default function App() {
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
const observationLayout = useObservationLayout(runtime.state?.observationSources ?? []);
const observationLayout = useObservationLayout(
runtime.state?.observationSources ?? [],
runtime.setObservationSourceActive,
);
useEffect(() => {
const remote = runtime.state?.viewerSettings;
@@ -0,0 +1,279 @@
import { useEffect, useRef, useState } from "react";
import { Icon } from "@nodedc/ui-react";
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
export interface CameraLeaseRetryBudget {
deliveryId: string;
count: number;
}
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
return { deliveryId, count: 0 };
}
export function consumeCameraLeaseRetry(
current: CameraLeaseRetryBudget,
deliveryId: string,
): { budget: CameraLeaseRetryBudget; delay: number | null } {
const count = current.deliveryId === deliveryId ? current.count : 0;
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
return {
budget: { deliveryId, count: delay === null ? count : count + 1 },
delay,
};
}
function websocketUrl(path: string): string {
const url = new URL(path, window.location.href);
if (url.protocol === "http:") url.protocol = "ws:";
if (url.protocol === "https:") url.protocol = "wss:";
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new Error("Адаптер вернул неподдерживаемый адрес видеопотока.");
}
return url.toString();
}
export function MseFmp4WebSocketPlayer({
delivery,
label,
}: {
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
label: string;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
const [attempt, setAttempt] = useState(0);
const [status, setStatus] = useState<PlayerStatus>("connecting");
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
useEffect(() => {
const video = videoRef.current;
if (!video) return;
let disposed = false;
let socket: WebSocket | null = null;
let sourceBuffer: SourceBuffer | null = null;
let objectUrl = "";
let retryTimer: number | undefined;
let receivedMedia = false;
let failed = false;
const queue: ArrayBuffer[] = [];
let queuedBytes = 0;
const fail = (copy: string) => {
if (disposed || failed) return;
failed = true;
setStatus("error");
setMessage(copy);
try {
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(1011, "Live video buffer reset");
}
} catch {
// The manual reconnect button will create a fresh transport and MSE buffer.
}
};
const retryLease = () => {
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
leaseRetryRef.current = retry.budget;
if (retry.delay === null) {
fail("Camera adapter ещё занят предыдущим окном. Подключитесь повторно.");
return;
}
setStatus("connecting");
setMessage("Освобождение предыдущего окна камеры");
retryTimer = window.setTimeout(() => {
if (!disposed) setAttempt((value) => value + 1);
}, retry.delay);
};
const onPlaying = () => {
if (disposed) return;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
setStatus("playing");
setMessage("");
};
video.addEventListener("playing", onPlaying);
const appendNext = () => {
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
const chunk = queue.shift();
if (!chunk) return;
queuedBytes -= chunk.byteLength;
try {
sourceBuffer.appendBuffer(chunk);
} catch (error) {
fail(error instanceof DOMException && error.name === "QuotaExceededError"
? "Live-буфер переполнен и сброшен, чтобы не накапливать задержку."
: "Не удалось добавить видеосегмент. Повторите подключение.");
}
};
const enqueue = (chunk: ArrayBuffer) => {
if (disposed || chunk.byteLength === 0) return;
// Never drop arbitrary fMP4 fragments: the following samples may depend
// on them. A bounded reset is safer and keeps live latency deterministic.
if (queuedBytes + chunk.byteLength > 2 * 1024 * 1024) {
fail("Видеодекодер не успевает за эфиром. Live-буфер сброшен.");
return;
}
queue.push(chunk);
queuedBytes += chunk.byteLength;
appendNext();
};
const mediaType = delivery.mediaType?.trim();
if (typeof MediaSource === "undefined") {
fail("Этот браузер не поддерживает Media Source Extensions.");
return;
}
if (!mediaType || !MediaSource.isTypeSupported(mediaType)) {
fail(mediaType
? `Браузер не поддерживает ${mediaType}`
: "Адаптер не сообщил MIME/codec fMP4-потока.");
return;
}
setStatus("connecting");
setMessage("Подключение к локальному видеопотоку");
const mediaSource = new MediaSource();
objectUrl = URL.createObjectURL(mediaSource);
video.src = objectUrl;
const onSourceOpen = () => {
if (disposed) return;
try {
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
} catch {
fail("Не удалось создать MSE-буфер для указанного кодека.");
return;
}
sourceBuffer.addEventListener("updateend", () => {
if (disposed || !sourceBuffer) return;
const buffered = sourceBuffer.buffered;
if (buffered.length > 0) {
const end = buffered.end(buffered.length - 1);
const start = buffered.start(0);
if (end - video.currentTime > 1) video.currentTime = Math.max(0, end - 0.1);
if (!receivedMedia) {
receivedMedia = true;
setStatus("buffering");
setMessage("Запуск первого декодированного кадра");
void video.play().catch(() => undefined);
}
const removeBefore = end - 3;
if (removeBefore > start && !sourceBuffer.updating) {
try {
sourceBuffer.remove(0, removeBefore);
return;
} catch {
// Continue appending; quota handling performs a clean reset.
}
}
}
appendNext();
});
sourceBuffer.addEventListener("error", () => {
fail("MSE сообщил об ошибке декодирования видеосегмента.");
});
try {
socket = new WebSocket(websocketUrl(delivery.url));
} catch (error) {
fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока.");
return;
}
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (disposed) return;
setStatus("buffering");
setMessage("Ожидание первого видеокадра");
});
socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
enqueue(event.data);
} else if (event.data instanceof Blob) {
void event.data.arrayBuffer().then(enqueue).catch(() => {
fail("Получен повреждённый видеосегмент.");
});
}
});
socket.addEventListener("error", () => {
fail("Соединение с локальным video adapter потеряно.");
});
socket.addEventListener("close", (event) => {
if (!disposed && !failed && event.code === 1008) {
retryLease();
} else if (!disposed) {
fail(event.code === 1000
? "Видеопоток завершён. Можно подключиться повторно."
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
}
});
};
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
return () => {
disposed = true;
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0;
socket?.close(1000, "Источник скрыт оператором");
video.removeEventListener("playing", onPlaying);
try {
if (sourceBuffer?.updating) sourceBuffer.abort();
} catch {
// The MediaSource can already be closing.
}
try {
if (mediaSource.readyState === "open") mediaSource.endOfStream();
} catch {
// Cleanup must continue even if the browser has already detached MSE.
}
video.pause();
video.removeAttribute("src");
video.load();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
return (
<div className="mse-fmp4-player" data-status={status}>
<video
ref={videoRef}
className="observation-media__asset"
aria-label={label}
autoPlay
muted
playsInline
/>
{status !== "playing" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
<span>{message}</span>
{status === "error" ? (
<button
type="button"
onClick={() => {
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
setAttempt((value) => value + 1);
}}
>
<Icon name="refresh" size={14} />
Подключиться повторно
</button>
) : null}
</div>
) : null}
</div>
);
}
@@ -5,6 +5,7 @@ import type {
ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
@@ -29,7 +30,38 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
}
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
if (source.previewUrl && source.modality === "video") {
const sourceSelected = source.activation ? source.activation.selected : true;
const deliveryActive = Boolean(source.delivery && sourceSelected);
if (
deliveryActive &&
source.delivery?.kind === "mse-fmp4-websocket" &&
source.modality === "video"
) {
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
}
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
return (
<video
className="observation-media__asset"
src={source.delivery.url}
autoPlay
muted
playsInline
/>
);
}
if (
deliveryActive &&
source.delivery?.kind === "image-url" &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.delivery.url} alt={source.label} />;
}
if (sourceSelected && source.previewUrl && source.modality === "video") {
return (
<video
className="observation-media__asset"
@@ -41,7 +73,11 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
);
}
if (source.previewUrl && (source.modality === "image" || source.modality === "depth")) {
if (
sourceSelected &&
source.previewUrl &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
}
@@ -51,7 +87,11 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
<strong>{observationSourceStatusLabel(source)}</strong>
<span>
{source.modality === "video"
? "Канал известен, browser-preview ещё не подключён"
? source.activation?.controllable
? source.activation.selected
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
: "Откройте канал — локальный адаптер подключит выбранную камеру"
: "Канал известен, browser-preview сейчас недоступен"
: source.description}
</span>
</div>
@@ -61,11 +101,13 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
export function ObservationSourcePicker({
sources,
visibleSourceIds,
pendingSourceIds,
onToggle,
}: {
sources: readonly ObservationSourceDescriptor[];
visibleSourceIds: ReadonlySet<string>;
onToggle: (sourceId: string) => void;
pendingSourceIds?: ReadonlySet<string>;
onToggle: (sourceId: string) => void | Promise<boolean>;
}) {
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
@@ -108,6 +150,14 @@ export function ObservationSourcePicker({
<div className="observation-source-menu__list">
{sources.map((source) => {
const selected = visibleSourceIds.has(source.id);
const pending = pendingSourceIds?.has(source.id) ?? false;
const canOpen = Boolean(
selected ||
source.modality === "point-cloud" ||
source.previewUrl ||
source.delivery ||
source.activation?.controllable,
);
return (
<button
key={source.id}
@@ -115,7 +165,8 @@ export function ObservationSourcePicker({
className="nodedc-dropdown-option observation-source-option"
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
onClick={() => onToggle(source.id)}
disabled={pending || !canOpen}
onClick={() => void onToggle(source.id)}
>
<span className="nodedc-dropdown-option__icon">
<Icon name={sourceIcon[source.modality]} size={16} />
@@ -124,7 +175,7 @@ export function ObservationSourcePicker({
<span className="nodedc-dropdown-option__label">{source.label}</span>
<span className="nodedc-dropdown-option__description">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
</span>
</span>
<span className="nodedc-dropdown-option__check">
@@ -10,21 +10,25 @@ export interface RerunSelection {
export interface RerunViewportProps {
sourceUrl: string;
followLive?: boolean;
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
onSelectionChange?: (selection: RerunSelection | null) => void;
}
export function RerunViewport({
sourceUrl,
followLive = false,
onStatusChange,
onSelectionChange,
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [retryNonce, setRetryNonce] = useState(0);
useEffect(() => {
const normalizedSource = sourceUrl.trim();
if (!normalizedSource || !hostRef.current) {
const host = hostRef.current;
if (!normalizedSource || !host) {
setStatus("idle");
onStatusChange?.("idle");
onSelectionChange?.(null);
@@ -32,51 +36,86 @@ export function RerunViewport({
}
let disposed = false;
let stopViewer: (() => void) | undefined;
let disposeViewer: (() => void) | undefined;
let recordingOpenTimer: number | undefined;
let recordingOpened = false;
const unsubscribers: Array<() => void> = [];
const unsubscribeAll = () => {
while (unsubscribers.length > 0) {
const unsubscribe = unsubscribers.pop();
try {
unsubscribe?.();
} catch {
// A stopped vendor viewer can already have released its event map.
}
}
};
const clearRecordingOpenTimer = () => {
if (recordingOpenTimer === undefined) return;
window.clearTimeout(recordingOpenTimer);
recordingOpenTimer = undefined;
};
const reportError = (message: string) => {
if (disposed) return;
setStatus("error");
onStatusChange?.("error", message);
};
host.replaceChildren();
setStatus("loading");
onStatusChange?.("loading");
void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => {
// React StrictMode intentionally disposes the first effect while the
// dynamic import is still pending. Never start that stale viewer (the
// vendor API treats a missing host as document.body).
if (disposed) return;
const viewer = new WebViewer();
stopViewer = () => {
// Remove the gRPC receiver explicitly before tearing down WASM. This
// closes the browser-side stream promptly so the local SDK server can
// release its port before the next device session starts.
let viewerDisposed = false;
disposeViewer = () => {
if (viewerDisposed) return;
viewerDisposed = true;
clearRecordingOpenTimer();
unsubscribeAll();
try {
viewer.close(normalizedSource);
if (viewer.ready) viewer.close(normalizedSource);
} catch {
// The viewer can already be stopped after a startup failure.
// The receiver may already be gone after a transport failure.
}
viewer.stop();
try {
viewer.stop();
} catch {
// A partially initialized WASM handle can already be gone after a
// startup failure. The host still has to be cleared below.
}
host.replaceChildren();
};
await viewer.start(
normalizedSource,
hostRef.current,
{
width: "100%",
height: "100%",
theme: "dark",
hide_welcome_screen: true,
enable_history: false,
allow_fullscreen: false,
},
null,
unsubscribers.push(
viewer.on("recording_open", (event) => {
if (disposed) return;
recordingOpened = true;
clearRecordingOpenTimer();
if (followLive) {
try {
// The backend blueprint owns native `Following`; the host only
// selects its live timeline once. Repeated SetTime calls would
// force Rerun out of Following and create needless repaints.
viewer.set_active_timeline(event.recording_id, "stream_time");
} catch {
// A receiver can disappear between the event and the WASM call.
}
}
setStatus("ready");
onStatusChange?.("ready");
}),
);
if (disposed) {
stopViewer();
return;
}
viewer.override_panel_state("top", "hidden");
viewer.override_panel_state("blueprint", "hidden");
viewer.override_panel_state("selection", "hidden");
viewer.override_panel_state("time", "hidden");
unsubscribers.push(
viewer.on("selection_change", (event) => {
if (disposed) return;
const entity = event.items.find((item) => item.type === "entity");
if (!entity || entity.type !== "entity") {
onSelectionChange?.(null);
@@ -90,23 +129,54 @@ export function RerunViewport({
}),
);
setStatus("ready");
onStatusChange?.("ready");
try {
await viewer.start(
normalizedSource,
host,
{
width: "100%",
height: "100%",
theme: "dark",
hide_welcome_screen: true,
enable_history: false,
allow_fullscreen: false,
},
null,
);
if (disposed) {
disposeViewer();
return;
}
viewer.override_panel_state("top", "hidden");
viewer.override_panel_state("blueprint", "hidden");
viewer.override_panel_state("selection", "hidden");
viewer.override_panel_state("time", "hidden");
if (!recordingOpened) {
recordingOpenTimer = window.setTimeout(() => {
reportError("Визуализатор запущен, но поток записи не открылся.");
}, 12_000);
}
} catch {
disposeViewer();
reportError("Не удалось запустить встроенный визуализатор.");
}
})
.catch(() => {
if (disposed) return;
const message = "Не удалось запустить встроенный визуализатор.";
setStatus("error");
onStatusChange?.("error", message);
disposeViewer?.();
reportError("Не удалось загрузить модуль визуализатора.");
});
return () => {
disposed = true;
unsubscribers.forEach((unsubscribe) => unsubscribe());
stopViewer?.();
clearRecordingOpenTimer();
unsubscribeAll();
disposeViewer?.();
onSelectionChange?.(null);
};
}, [onSelectionChange, onStatusChange, sourceUrl]);
}, [followLive, onSelectionChange, onStatusChange, retryNonce, sourceUrl]);
return (
<div className="rerun-viewport" data-status={status}>
@@ -124,7 +194,14 @@ export function RerunViewport({
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
<div>
<strong>Источник не открыт</strong>
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
<span>Проверьте локальный поток Rerun или повторите подключение.</span>
<button
type="button"
className="rerun-viewport__retry"
onClick={() => setRetryNonce((nonce) => nonce + 1)}
>
Повторить подключение
</button>
</div>
</div>
) : null}
@@ -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>;
}
@@ -122,11 +122,39 @@ export interface XgridsK1Metrics {
[key: string]: number | null | undefined;
}
export interface XgridsCameraPreviewDelivery {
id: string;
kind: "mse-fmp4-websocket";
url: string;
media_type: string;
}
export interface XgridsCameraPreviewActivation {
group_id: string;
max_active: number;
selected: boolean;
controllable: boolean;
}
export interface XgridsCameraPreviewState {
phase?: string | null;
revision?: number | null;
generation?: number | null;
active_source_id?: string | null;
delivery?: XgridsCameraPreviewDelivery | null;
}
export interface XgridsSensorCatalogStream {
stream_id: string;
source_id?: string | null;
semantic_channel_id?: string | null;
label?: string | null;
sensor_kind?: string | null;
modality?: string | null;
availability?: string | null;
endpoint_label?: string | null;
activation?: XgridsCameraPreviewActivation | null;
delivery?: XgridsCameraPreviewDelivery | null;
decode_status?: string | null;
frame_id?: string | null;
coordinate_convention?: string | null;
@@ -158,6 +186,7 @@ export interface XgridsK1State {
operations?: XgridsOperation[];
last_operation?: XgridsOperation | null;
sensor_catalog?: XgridsSensorCatalog | null;
camera_preview?: XgridsCameraPreviewState | null;
}
export interface HealthResponse {
@@ -239,6 +268,16 @@ export interface ReplayRequest {
loop?: boolean;
}
export interface SelectCameraPreviewRequest {
source_id: string;
device_session_id: string;
}
export interface StopCameraPreviewRequest {
device_session_id: string;
generation: number;
}
export class ApiError extends Error {
readonly status: number;
@@ -375,6 +414,14 @@ export const xgridsK1Api = {
return invokeState(xgridsK1Actions.compatibilityStreamStop);
},
selectCameraPreview(body: SelectCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewSelect, body);
},
stopCameraPreview(body: StopCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewStop, body);
},
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
},
@@ -27,5 +27,7 @@ export const xgridsK1Actions = Object.freeze({
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
cameraPreviewSelect: requirePluginAction(xgridsK1Manifest, "camera.preview.select"),
cameraPreviewStop: requirePluginAction(xgridsK1Manifest, "camera.preview.stop"),
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
});
@@ -1,12 +1,17 @@
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import type {
ObservationSourceAvailability,
ObservationSourceDelivery,
ObservationSourceDescriptor,
ObservationSourceProvider,
} from "../../core/runtime/contracts";
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
import { xgridsK1Manifest } from "./manifest";
import type { XgridsK1State } from "./api";
import type {
XgridsCameraPreviewDelivery,
XgridsK1State,
XgridsSensorCatalogStream,
} from "./api";
function providerFor(
state: XgridsK1State,
@@ -43,19 +48,144 @@ function spatialAvailability(state: XgridsK1State): ObservationSourceAvailabilit
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
}
function cameraAvailability(state: XgridsK1State): ObservationSourceAvailability {
const stream = state.sensor_catalog?.streams?.find(
(candidate) => candidate.stream_id === "camera.preview.live",
function catalogAvailability(value: string | null | undefined): ObservationSourceAvailability {
switch (value?.trim().toLowerCase()) {
case "observed":
case "available":
return "available";
case "connecting":
return "connecting";
case "streaming":
return "streaming";
case "degraded":
return "degraded";
case "unavailable":
return "unavailable";
case "error":
return "error";
case "declared":
return "declared";
default:
return "unverified";
}
}
function cameraCatalogEntry(stream: XgridsSensorCatalogStream): boolean {
const sourceId = stream.source_id?.trim();
const semanticChannelId = stream.semantic_channel_id?.trim();
if (!sourceId || !semanticChannelId) return false;
const modality = stream.modality?.trim().toLowerCase();
const sensorKind = stream.sensor_kind?.trim().toLowerCase();
return (
modality === "encoded-video" ||
modality === "video" ||
sensorKind === "camera" ||
semanticChannelId.startsWith("camera.")
);
const compatibilityProfileId =
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null;
if (!stream) return "unavailable";
if (stream.availability !== "observed" || !compatibilityProfileId) return "unverified";
}
function safeEndpointLabel(value: string | null | undefined): string | null {
const label = value?.trim();
if (!label || label.length > 128) return null;
if (label.includes("://") || /(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(label)) {
return null;
}
return label;
}
const SENSITIVE_QUERY_KEYS = new Set([
"accesskey", "accesstoken", "apikey", "auth", "authorization", "clientsecret",
"credential", "credentials", "password", "passwd", "passphrase", "privatekey",
"psk", "pwd", "refreshtoken", "secret", "sessionkey", "token", "user",
"username", "wifipassword",
]);
function decodedForInspection(value: string): string | null {
let decoded = value;
try {
for (let depth = 0; depth < 4; depth += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
}
} catch {
return null;
}
return decoded;
}
function sensitiveQueryKey(value: string): boolean {
const canonical = value.toLowerCase().replace(/[^a-z0-9]/g, "");
return SENSITIVE_QUERY_KEYS.has(canonical) ||
/(?:token|secret|password|passwd|passphrase|credential|credentials)$/.test(canonical);
}
function safeBrowserDeliveryUrl(value: string): boolean {
if (value.length > 4_096 || !value.startsWith("/") || value.startsWith("//")) return false;
const inspected = decodedForInspection(value);
if (!inspected) return false;
if (
/[\\\r\n\u0000-\u001f\u007f]/.test(inspected) ||
/\brtsps?\s*:/i.test(inspected) ||
/(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(inspected) ||
/\[[0-9a-f:]+\]/i.test(inspected) ||
/(?:^|[/?#&=])[^/?#&=\s:@]+:[^/?#&\s@]+@/.test(inspected) ||
/\b(?:basic|bearer)\s+[a-z0-9._~+/=-]+/i.test(inspected)
) return false;
const base = new URL("https://mission-core.invalid/");
let parsed: URL;
let inspectedParsed: URL;
try {
parsed = new URL(value, base);
inspectedParsed = new URL(inspected, base);
} catch {
return false;
}
if (
parsed.origin !== base.origin || inspectedParsed.origin !== base.origin ||
parsed.username || parsed.password || parsed.hash ||
inspectedParsed.username || inspectedParsed.password || inspectedParsed.hash
) return false;
for (const [key] of inspectedParsed.searchParams) {
if (sensitiveQueryKey(key)) return false;
}
return true;
}
function browserDelivery(
value: XgridsCameraPreviewDelivery | null | undefined,
): ObservationSourceDelivery | null {
const id = value?.id?.trim();
const url = value?.url?.trim();
const mediaType = value?.media_type?.trim();
if (!id || value?.kind !== "mse-fmp4-websocket" || !url || !mediaType) return null;
// Browser delivery is host-owned and same-origin. A device RTSP/IP endpoint
// must never cross the plugin boundary into the host descriptor catalog.
if (!safeBrowserDeliveryUrl(url)) return null;
if (!/^video\/mp4(?:\s*;|$)/i.test(mediaType)) return null;
return { id, kind: value.kind, url, mediaType };
}
function cameraAvailability(
state: XgridsK1State,
stream: XgridsSensorCatalogStream,
selected: boolean,
delivery: ObservationSourceDelivery | null,
attested: boolean,
): ObservationSourceAvailability {
if (!attested) return "unverified";
if (state.device_session?.connectivity === "degraded") return "degraded";
// The firmware profile proves that both RTSP channels exist, but Mission Core
// does not yet expose a browser-decodable preview URL. Do not report a live
// camera merely because the point-cloud acquisition is running.
return "declared";
const base = catalogAvailability(stream.availability);
if (!selected) return base === "streaming" || base === "connecting" ? "available" : base;
const phase = state.camera_preview?.phase?.trim().toLowerCase();
if (phase === "error" || base === "error") return "error";
if (delivery && (phase === "streaming" || phase === "active" || phase === "ready")) {
return "streaming";
}
if (phase === "degraded") return "degraded";
return "connecting";
}
export function xgridsK1ObservationSources(
@@ -67,9 +197,7 @@ export function xgridsK1ObservationSources(
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
const descriptorId = (sourceId: string) =>
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
return [
{
const pointCloud: ObservationSourceDescriptor = {
id: descriptorId("sensor.lidar.primary"),
sourceId: "sensor.lidar.primary",
semanticChannelId: "spatial.point-cloud.live",
@@ -81,6 +209,8 @@ export function xgridsK1ObservationSources(
transport: "rerun-grpc",
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
previewUrl: state.rerun_grpc_url?.trim() || null,
delivery: null,
activation: null,
provider,
binding,
capabilities: {
@@ -96,58 +226,73 @@ export function xgridsK1ObservationSources(
clockId,
spatialRegistration: "native",
},
},
{
id: descriptorId("sensor.camera.left"),
sourceId: "sensor.camera.left",
semanticChannelId: "camera.preview.live",
label: "K1 · камера слева",
description: "Левый H.264 preview-канал профиля устройства",
};
const cameraRows = (state.sensor_catalog?.streams ?? []).filter(cameraCatalogEntry);
const sourceIdCounts = new Map<string, number>();
for (const stream of cameraRows) {
const sourceId = stream.source_id?.trim();
if (sourceId) sourceIdCounts.set(sourceId, (sourceIdCounts.get(sourceId) ?? 0) + 1);
}
const attested = Boolean(provider.compatibilityProfileId && binding.deviceSessionId);
const sessionScope = binding.deviceSessionId ?? binding.deviceId ?? "unbound";
const activeSourceId = state.camera_preview?.active_source_id?.trim() ?? null;
const cameras = cameraRows.flatMap<ObservationSourceDescriptor>((stream) => {
const sourceId = stream.source_id?.trim();
const semanticChannelId = stream.semantic_channel_id?.trim();
if (!sourceId || !semanticChannelId || sourceIdCounts.get(sourceId) !== 1) return [];
const rawActivation = stream.activation;
const groupId = rawActivation?.group_id?.trim();
const maxActive = rawActivation?.max_active;
const activationValid = Boolean(
groupId && Number.isInteger(maxActive) && (maxActive ?? 0) > 0,
);
const selected = Boolean(
attested && activationValid && rawActivation?.selected === true && activeSourceId === sourceId,
);
const activation = activationValid
? {
groupId: `${provider.pluginId}:${sessionScope}:${groupId}`,
maxActive: maxActive as number,
selected,
controllable: Boolean(attested && rawActivation?.controllable),
}
: null;
const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
const delivery = selected ? browserDelivery(candidateDelivery) : null;
const label = stream.label?.trim() || sourceId;
return [{
id: descriptorId(sourceId),
sourceId,
semanticChannelId,
label,
description: "Видеоканал, опубликованный активным device-плагином",
modality: "video",
role: "auxiliary",
availability: cameraAvailability(state),
transport: "rtsp",
endpointLabel: "RTSP · left",
availability: cameraAvailability(state, stream, selected, delivery, attested),
transport: delivery ? "websocket" : "other",
endpointLabel: safeEndpointLabel(stream.endpoint_label) ?? "Локальный video adapter",
previewUrl: null,
delivery,
activation,
provider,
binding,
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: false,
defaultVisible: selected && Boolean(delivery),
timelineMode: "live-only",
seekable: false,
sessionRecording: false,
clockId,
spatialRegistration: "unresolved",
},
},
{
id: descriptorId("sensor.camera.right"),
sourceId: "sensor.camera.right",
semanticChannelId: "camera.preview.live",
label: "K1 · камера справа",
description: "Правый H.264 preview-канал профиля устройства",
modality: "video",
role: "auxiliary",
availability: cameraAvailability(state),
transport: "rtsp",
endpointLabel: "RTSP · right",
previewUrl: null,
provider,
binding,
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: false,
timelineMode: "live-only",
seekable: false,
sessionRecording: false,
clockId,
spatialRegistration: "unresolved",
},
},
];
}];
});
return [pointCloud, ...cameras];
}
@@ -130,6 +130,7 @@ export function XgridsK1RuntimeProvider({
pendingAction: controller.pendingAction,
refresh: controller.refresh,
updateViewerSettings: controller.updateViewerSettings,
setObservationSourceActive: controller.setObservationSourceActive,
};
useEffect(() => {
@@ -0,0 +1,55 @@
import type { XgridsCameraPreviewState, XgridsK1State } from "./api";
function monotonicInteger(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function deviceSessionScope(state: XgridsK1State): string | null {
const sessionId = state.device_session?.device_session_id;
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
}
function cameraSnapshotIsAtLeastAsNew(
current: XgridsCameraPreviewState,
incoming: XgridsCameraPreviewState,
): boolean {
const currentRevision = monotonicInteger(current.revision);
const incomingRevision = monotonicInteger(incoming.revision);
if (currentRevision !== null || incomingRevision !== null) {
if (incomingRevision === null) return false;
if (currentRevision === null) return true;
if (incomingRevision !== currentRevision) return incomingRevision > currentRevision;
}
const currentGeneration = monotonicInteger(current.generation);
const incomingGeneration = monotonicInteger(incoming.generation);
if (currentGeneration === null) return true;
if (incomingGeneration === null) return false;
return incomingGeneration >= currentGeneration;
}
/**
* Select an authoritative runtime snapshot without allowing asynchronous REST,
* mutation, or event responses to roll camera preview state backwards.
*
* Revision is the primary ordering key. In particular, a stop snapshot with a
* newer revision and a null generation is valid and must replace the active
* generation. Generation is only a tie-breaker when revisions are equal or
* absent.
*/
export function selectMonotonicXgridsState(
current: XgridsK1State | null,
incoming: XgridsK1State,
): XgridsK1State {
if (current && deviceSessionScope(current) !== deviceSessionScope(incoming)) {
return incoming;
}
if (!current?.camera_preview) return incoming;
if (!incoming.camera_preview) return current;
// Backend snapshots are atomic. Reject the whole stale response instead of
// combining camera/catalog/metrics fields captured at different moments.
return cameraSnapshotIsAtLeastAsNew(current.camera_preview, incoming.camera_preview)
? incoming
: current;
}
@@ -19,6 +19,7 @@ import {
operationNeedsReconciliation,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
@@ -27,6 +28,7 @@ export type PendingAction =
| "replay"
| "stop"
| "abort"
| "camera"
| "viewer";
function messageFor(error: unknown): string {
@@ -65,7 +67,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
const mounted = useRef(true);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState(nextState);
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
setBackendStatus("online");
}, []);
@@ -219,6 +221,51 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
}, [run, state?.acquisition]);
const setObservationSourceActive = useCallback(
(sourceId: string, active: boolean) =>
run("camera", async () => {
if (!state) {
throw new ApiError("Состояние устройства ещё не загружено.");
}
const deviceSessionId = state.device_session?.device_session_id?.trim();
if (!deviceSessionId) {
throw new ApiError("Для камеры нет активной сессии устройства.");
}
const catalogSource = state.sensor_catalog?.streams?.find(
(candidate) => candidate.source_id === sourceId,
);
if (!catalogSource || catalogSource.activation?.controllable !== true) {
throw new ApiError("Плагин не разрешает управление выбранным видеоканалом.");
}
if (active) {
if (
catalogSource.activation.selected &&
state.camera_preview?.active_source_id === sourceId &&
state.camera_preview?.phase !== "error" &&
state.camera_preview?.delivery
) {
return state;
}
return xgridsK1Api.selectCameraPreview({
source_id: sourceId,
device_session_id: deviceSessionId,
});
}
if (state.camera_preview?.active_source_id !== sourceId) return state;
const generation = state.camera_preview.generation;
if (!Number.isInteger(generation) || (generation ?? 0) < 1) {
throw new ApiError("Плагин не вернул поколение активной camera-preview сессии.");
}
return xgridsK1Api.stopCameraPreview({
device_session_id: deviceSessionId,
generation: generation as number,
});
}),
[run, state],
);
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
[run],
@@ -300,6 +347,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
startReplay,
stop,
abort,
setObservationSourceActive,
updateViewerSettings,
};
}
@@ -191,6 +191,59 @@ i[data-availability="error"] {
background: #050608;
}
.mse-fmp4-player {
position: relative;
width: 100%;
height: 100%;
min-height: 9rem;
overflow: hidden;
background: #050608;
}
.mse-fmp4-player__status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
align-content: center;
gap: 0.5rem;
padding: 1rem;
background: rgb(5 6 8 / 0.88);
color: var(--nodedc-text-muted);
text-align: center;
}
.mse-fmp4-player__status strong {
color: var(--nodedc-text-secondary);
font-size: 0.7rem;
}
.mse-fmp4-player__status span {
max-width: 24rem;
font-size: 0.59rem;
line-height: 1.45;
}
.mse-fmp4-player__status button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.25rem;
border: 1px solid rgb(255 255 255 / 0.12);
border-radius: 999px;
background: rgb(255 255 255 / 0.06);
color: var(--nodedc-text-primary);
padding: 0.48rem 0.72rem;
font: inherit;
font-size: 0.58rem;
font-weight: 760;
cursor: pointer;
}
.mse-fmp4-player__status button:hover {
background: rgb(255 255 255 / 0.11);
}
.observation-media__empty {
min-height: 9rem;
background: #07080a;
@@ -220,6 +273,24 @@ i[data-availability="error"] {
color: var(--nodedc-text-primary);
}
.camera-slot__head-actions .camera-slot__activation {
width: auto;
min-width: 5.6rem;
height: 1.9rem;
border: 1px solid rgb(255 255 255 / 0.09);
border-radius: 999px;
padding: 0 0.65rem;
font: inherit;
font-size: 0.53rem;
font-weight: 760;
white-space: nowrap;
}
.camera-slot__head-actions .camera-slot__activation:disabled {
cursor: wait;
opacity: 0.48;
}
.floating-observation-window__status {
color: var(--nodedc-text-secondary);
font-size: 0.54rem;
@@ -90,6 +90,24 @@
background: rgb(10 11 14 / 0.9);
}
.rerun-viewport__retry {
margin-top: 0.65rem;
border: 1px solid var(--station-hairline-strong);
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
padding: 0.42rem 0.7rem;
color: var(--nodedc-text-primary);
font: inherit;
font-size: 0.61rem;
cursor: pointer;
}
.rerun-viewport__retry:hover,
.rerun-viewport__retry:focus-visible {
background: rgb(255 255 255 / 0.14);
outline: none;
}
.busy-indicator {
width: 1rem;
height: 1rem;
@@ -328,6 +328,7 @@ function SpatialWorkspace({
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
followLive={state?.sourceMode === "live"}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
@@ -349,6 +350,7 @@ function SpatialWorkspace({
<ObservationSourcePicker
sources={observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
pendingSourceIds={observationLayout.pendingSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
@@ -434,7 +436,7 @@ function SpatialWorkspace({
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
onClose={() => observationLayout.hideSource(source.id)}
onClose={() => void observationLayout.hideSource(source.id)}
/>
)) : null}
</div>
@@ -454,12 +456,41 @@ function SpatialWorkspace({
function CameraSourceCard({
source,
focused,
visible,
pending,
selectedPeer,
onToggle,
onFocus,
}: {
source: ObservationSourceDescriptor;
focused: boolean;
visible: boolean;
pending: boolean;
selectedPeer: boolean;
onToggle: () => void;
onFocus: () => void;
}) {
const deliveryActive = Boolean(
(source.delivery || source.previewUrl) &&
(!source.activation || source.activation.selected),
);
const restartAvailable = Boolean(
source.activation?.selected &&
source.activation.controllable &&
(source.availability === "error" || !source.delivery),
);
const activationLabel = pending
? "Переключение…"
: restartAvailable
? "Повторить подключение"
: source.activation?.selected && visible
? "Закрыть канал"
: source.activation?.selected
? "Показать канал"
: selectedPeer
? "Переключить"
: "Открыть канал";
return (
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
<header>
@@ -469,7 +500,17 @@ function CameraSourceCard({
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
{source.capabilities.fullscreen ? (
{source.activation ? (
<button
type="button"
className="camera-slot__activation"
disabled={pending || (!source.activation.selected && !source.activation.controllable)}
onClick={onToggle}
>
{activationLabel}
</button>
) : null}
{source.capabilities.fullscreen && deliveryActive ? (
<button
type="button"
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
@@ -510,6 +551,15 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
key={source.id}
source={source}
focused={focusedSource?.id === source.id}
visible={observationLayout.visibleSourceIds.has(source.id)}
pending={observationLayout.pendingSourceIds.has(source.id)}
selectedPeer={Boolean(
source.activation && sources.some((candidate) =>
candidate.id !== source.id &&
candidate.activation?.groupId === source.activation?.groupId &&
candidate.activation?.selected === true),
)}
onToggle={() => void observationLayout.toggleSource(source.id)}
onFocus={() => observationLayout.setFocusedSourceId(
focusedSource?.id === source.id ? null : source.id,
)}
@@ -523,8 +573,11 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
)}
</div>
<ObservationTimeline
active={sources.some((source) => source.availability === "streaming")}
sourceCount={sources.length}
active={sources.some((source) =>
source.availability === "streaming" &&
(!source.activation || source.activation.selected))}
sourceCount={sources.filter((source) =>
!source.activation || source.activation.selected).length}
mode={timeline?.mode}
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}
@@ -6,6 +6,10 @@ import { createServer } from "vite";
let server;
let xgridsK1Manifest;
let xgridsK1ObservationSources;
let openObservationSource;
let shouldRestartObservationSource;
let consumeCameraLeaseRetry;
let resetCameraLeaseRetryBudget;
before(async () => {
server = await createServer({
@@ -19,6 +23,12 @@ before(async () => {
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/observationSources.ts",
));
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
"/src/core/observation/layoutPolicy.ts",
));
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
"/src/components/MseFmp4WebSocketPlayer.tsx",
));
});
after(async () => {
@@ -31,7 +41,30 @@ function model() {
return activeModel;
}
function declaredState() {
function cameraRow(sourceId, label) {
return {
stream_id: `camera.preview.${sourceId.split(".").at(-1)}`,
source_id: sourceId,
semantic_channel_id: "camera.preview.live",
label,
sensor_kind: "camera",
modality: "encoded-video",
availability: "available",
endpoint_label: "MSE · fMP4",
activation: {
group_id: "camera.preview.decoder",
max_active: 1,
selected: false,
controllable: true,
},
delivery: null,
};
}
function declaredState(cameraRows = [
cameraRow("sensor.camera.left", "K1 · камера слева"),
cameraRow("sensor.camera.right", "K1 · камера справа"),
]) {
const activeModel = model();
return {
phase: "connected",
@@ -60,13 +93,20 @@ function declaredState() {
revision: "test-profile",
streams: [
{ stream_id: "spatial.point-cloud.live", modality: "point-cloud", availability: "observed" },
{ stream_id: "camera.preview.live", modality: "encoded-video", availability: "observed" },
...cameraRows,
],
},
camera_preview: {
phase: "idle",
revision: 1,
generation: 0,
active_source_id: null,
delivery: null,
},
};
}
function streamingState() {
function pointCloudStreamingState() {
return {
...declaredState(),
phase: "streaming",
@@ -88,6 +128,36 @@ function streamingState() {
};
}
function cameraStreamingState(sourceId) {
const state = pointCloudStreamingState();
const delivery = {
id: `preview-generation-7:${sourceId}`,
kind: "mse-fmp4-websocket",
url: "/api/v1/device-plugins/xgrids-k1/camera-preview/ws?generation=7",
media_type: 'video/mp4; codecs="avc1.640028"',
};
state.sensor_catalog = {
...state.sensor_catalog,
streams: state.sensor_catalog.streams.map((stream) =>
stream.source_id === sourceId
? {
...stream,
availability: "streaming",
activation: { ...stream.activation, selected: true },
delivery,
}
: stream),
};
state.camera_preview = {
phase: "streaming",
revision: 4,
generation: 7,
active_source_id: sourceId,
delivery,
};
return state;
}
function collectUrlLikeStrings(value, found = []) {
if (typeof value === "string") {
if (value.includes("://")) found.push(value);
@@ -103,95 +173,199 @@ function collectUrlLikeStrings(value, found = []) {
return found;
}
test("K1 maps to one primary point cloud and two auxiliary camera channels", () => {
const sources = xgridsK1ObservationSources(declaredState(), model());
assert.equal(sources.length, 3);
assert.deepEqual(
sources.map(({ sourceId, semanticChannelId, modality, role }) => ({
sourceId,
semanticChannelId,
modality,
role,
})),
[
{
sourceId: "sensor.lidar.primary",
semanticChannelId: "spatial.point-cloud.live",
modality: "point-cloud",
role: "primary",
},
{
sourceId: "sensor.camera.left",
semanticChannelId: "camera.preview.live",
modality: "video",
role: "auxiliary",
},
{
sourceId: "sensor.camera.right",
semanticChannelId: "camera.preview.live",
modality: "video",
role: "auxiliary",
},
],
test("K1 maps zero, one or N catalog cameras without model-specific source ids", () => {
const zero = xgridsK1ObservationSources(declaredState([]), model());
const one = xgridsK1ObservationSources(
declaredState([cameraRow("rig.front", "Передняя камера")]),
model(),
);
const many = xgridsK1ObservationSources(declaredState(), model());
assert.equal(new Set(sources.map(({ id }) => id)).size, sources.length);
assert.ok(sources.every(({ provider }) => provider.pluginId && provider.modelId));
assert.ok(sources.every(({ provider }) => provider.pluginVersion));
assert.ok(sources.every(({ binding }) => binding.deviceId === "device-k1-001"));
assert.ok(sources.every(({ capabilities }) => capabilities.timelineMode === "live-only"));
assert.deepEqual(zero.map(({ modality }) => modality), ["point-cloud"]);
assert.deepEqual(one.map(({ sourceId }) => sourceId), ["sensor.lidar.primary", "rig.front"]);
assert.deepEqual(many.map(({ sourceId }) => sourceId), [
"sensor.lidar.primary",
"sensor.camera.left",
"sensor.camera.right",
]);
assert.equal(new Set(many.map(({ id }) => id)).size, many.length);
assert.ok(many.every(({ provider }) => provider.pluginId && provider.modelId));
assert.ok(many.every(({ binding }) => binding.deviceId === "device-k1-001"));
});
test("observation source ids remain stable while runtime availability changes", () => {
test("descriptor ids remain stable while point cloud and selected camera start streaming", () => {
const declared = xgridsK1ObservationSources(declaredState(), model());
const streaming = xgridsK1ObservationSources(streamingState(), model());
assert.deepEqual(
streaming.map(({ id }) => id),
declared.map(({ id }) => id),
const streaming = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
assert.deepEqual(streaming.map(({ id }) => id), declared.map(({ id }) => id));
assert.equal(declared[0].availability, "available");
assert.equal(streaming[0].availability, "streaming");
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
});
test("camera descriptors stay declared and URL-free until a browser delivery adapter exists", () => {
const sources = xgridsK1ObservationSources(streamingState(), model());
test("only the authoritative selected camera receives browser delivery", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const cameras = sources.filter(({ modality }) => modality === "video");
const [left, right] = cameras;
assert.equal(cameras.length, 2);
for (const camera of cameras) {
assert.equal(camera.availability, "declared");
assert.equal(camera.transport, "rtsp");
assert.equal(camera.previewUrl, null);
assert.equal(camera.capabilities.spatialRegistration, "unresolved");
assert.equal(camera.capabilities.resizable, true);
assert.doesNotMatch(camera.endpointLabel ?? "", /:\/\//);
}
assert.equal(left.activation.selected, true);
assert.equal(left.activation.maxActive, 1);
assert.equal(left.availability, "streaming");
assert.equal(left.delivery.kind, "mse-fmp4-websocket");
assert.equal(left.transport, "websocket");
assert.equal(right.activation.selected, false);
assert.equal(right.availability, "available");
assert.equal(right.delivery, null);
assert.equal(left.activation.groupId, right.activation.groupId);
assert.match(left.activation.groupId, /device-session-001/);
});
test("switching left to right keeps ids stable and never exposes both deliveries", () => {
const left = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
).filter(({ modality }) => modality === "video");
const right = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.right"),
model(),
).filter(({ modality }) => modality === "video");
assert.deepEqual(right.map(({ id }) => id), left.map(({ id }) => id));
assert.deepEqual(left.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
"sensor.camera.left",
]);
assert.deepEqual(right.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
"sensor.camera.right",
]);
});
test("camera descriptors never leak vendor RTSP endpoints or device IP addresses", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const cameras = sources.filter(({ modality }) => modality === "video");
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
assert.deepEqual(collectUrlLikeStrings(sources), [
"rerun+http://127.0.0.1:9877/proxy",
]);
const serialized = JSON.stringify(sources);
const serialized = JSON.stringify(cameras);
assert.doesNotMatch(serialized, /rtsp:\/\//i);
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
});
test("unattested camera catalog entries remain unverified", () => {
const state = declaredState();
test("unattested, duplicate and unsafe camera entries fail closed", () => {
const duplicate = cameraRow("sensor.camera.left", "Duplicate");
const state = cameraStreamingState("sensor.camera.left");
state.compatibility.profile_id = null;
state.device_session.compatibility_profile_id = null;
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.stream_id === "camera.preview.live"
? { ...stream, availability: "unverified", decode_status: "profile-not-attested" }
: stream);
state.sensor_catalog.streams.push(duplicate);
state.camera_preview.delivery = {
...state.camera_preview.delivery,
url: "ws://192.168.7.10:9000/leak",
};
const cameras = xgridsK1ObservationSources(state, model()).filter(
({ modality }) => modality === "video",
);
assert.equal(cameras.length, 2);
assert.ok(cameras.every(({ availability }) => availability === "unverified"));
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
assert.deepEqual(cameras.map(({ sourceId }) => sourceId), ["sensor.camera.right"]);
assert.equal(cameras[0].availability, "unverified");
assert.equal(cameras[0].activation.controllable, false);
assert.equal(cameras[0].delivery, null);
});
test("camera delivery rejects literal and encoded endpoint or credential leaks", () => {
const unsafeUrls = [
"/api/preview?upstream=rtsp://camera.local/live",
"/api/preview?upstream=rtsp%3A%2F%2Fcamera.local%2Flive",
"/api/preview?upstream=rtsp%253A%252F%252Fcamera.local%252Flive",
"/api/preview?endpoint=192.168.68.52:8554",
"/api/preview?endpoint=192%2E168%2E68%2E52",
"/api/preview?password=not-for-the-browser",
"/api/preview?%70%61%73%73%77%6f%72%64=not-for-the-browser",
"/api/preview/camera:secret@device",
"/%2f%2fevil.example/preview",
];
for (const url of unsafeUrls) {
const state = cameraStreamingState("sensor.camera.left");
state.camera_preview.delivery = { ...state.camera_preview.delivery, url };
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.source_id === "sensor.camera.left"
? { ...stream, delivery: { ...stream.delivery, url } }
: stream,
);
const left = xgridsK1ObservationSources(state, model()).find(
({ sourceId }) => sourceId === "sensor.camera.left",
);
assert.ok(left, `left camera descriptor missing for ${url}`);
assert.equal(left.delivery, null, `unsafe delivery escaped for ${url}`);
}
});
test("manual camera reconnect restores an exhausted lease retry budget", () => {
let budget = resetCameraLeaseRetryBudget("delivery-7");
for (const expectedDelay of [400, 1_000, 2_000]) {
const retry = consumeCameraLeaseRetry(budget, "delivery-7");
assert.equal(retry.delay, expectedDelay);
budget = retry.budget;
}
assert.equal(consumeCameraLeaseRetry(budget, "delivery-7").delay, null);
budget = resetCameraLeaseRetryBudget("delivery-7");
const retryAfterManualReset = consumeCameraLeaseRetry(budget, "delivery-7");
assert.equal(retryAfterManualReset.delay, 400);
assert.equal(retryAfterManualReset.budget.count, 1);
});
test("layout policy evicts only exclusive camera peers", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
assert.ok(left && right && pointCloud);
const change = openObservationSource(
[pointCloud.id, left.id],
right.id,
sources,
);
assert.deepEqual(change.visibleIds, [pointCloud.id, right.id]);
assert.deepEqual(change.removedIds, [left.id]);
});
test("selected camera without delivery is explicitly restartable", () => {
const state = cameraStreamingState("sensor.camera.left");
state.camera_preview = {
...state.camera_preview,
phase: "error",
delivery: null,
};
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.source_id === "sensor.camera.left"
? { ...stream, availability: "error", delivery: null }
: stream,
);
const sources = xgridsK1ObservationSources(state, model());
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
assert.ok(left && right);
assert.equal(left.activation.selected, true);
assert.equal(left.delivery, null);
assert.equal(left.availability, "error");
assert.equal(shouldRestartObservationSource(left), true);
assert.equal(shouldRestartObservationSource(right), false);
});
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let selectMonotonicXgridsState;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/stateOrdering.ts",
));
});
after(async () => {
await server?.close();
});
function snapshot(revision, generation, phase = "streaming", sessionId = "device-session-a") {
return {
phase: "connected",
message: `${phase}:${revision}:${generation}`,
device_session: sessionId === null
? null
: {
device_session_id: sessionId,
device_id: `device-for-${sessionId}`,
connectivity: "connected",
},
camera_preview: {
phase,
revision,
generation,
active_source_id: generation === null ? null : "sensor.camera.left",
delivery: null,
},
};
}
test("accepts the first camera preview snapshot", () => {
const incoming = snapshot(1, 1);
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
});
test("rejects a lower revision even when its generation is higher", () => {
const current = snapshot(8, 3);
const stale = snapshot(7, 99);
assert.equal(selectMonotonicXgridsState(current, stale), current);
});
test("uses generation only as a tie-breaker for equal revisions", () => {
const current = snapshot(8, 3);
const stale = snapshot(8, 2);
const equal = snapshot(8, 3, "selected");
assert.equal(selectMonotonicXgridsState(current, stale), current);
assert.equal(selectMonotonicXgridsState(current, equal), equal);
});
test("accepts a newer stop revision with a null generation", () => {
const current = snapshot(8, 3);
const stopped = snapshot(9, null, "idle");
assert.equal(selectMonotonicXgridsState(current, stopped), stopped);
});
test("rejects an unversioned camera snapshot after a versioned one", () => {
const current = snapshot(8, 3);
const stale = {
phase: "connected",
device_session: current.device_session,
};
assert.equal(selectMonotonicXgridsState(current, stale), current);
});
test("accepts revision reset when the authoritative device session changes", () => {
const current = snapshot(18, 7, "streaming", "device-session-a");
const nextDevice = snapshot(0, null, "idle", "device-session-b");
assert.equal(selectMonotonicXgridsState(current, nextDevice), nextDevice);
});
test("accepts backend reset that clears and later recreates the device session", () => {
const current = snapshot(18, 7, "streaming", "device-session-a");
const reset = snapshot(0, null, "idle", null);
const reconnected = snapshot(0, null, "idle", "device-session-c");
assert.equal(selectMonotonicXgridsState(current, reset), reset);
assert.equal(selectMonotonicXgridsState(reset, reconnected), reconnected);
});
test("rejects the entire stale atomic snapshot instead of merging unrelated fields", () => {
const current = {
...snapshot(8, 3),
metrics: { point_count: 2_500 },
};
const stale = {
...snapshot(7, 2),
metrics: { point_count: 9_999 },
};
const accepted = selectMonotonicXgridsState(current, stale);
assert.equal(accepted, current);
assert.equal(accepted.metrics.point_count, 2_500);
});
+6
View File
@@ -8,6 +8,12 @@ export default defineConfig(({ mode }) => {
return {
plugins: [react(), wasm()],
optimizeDeps: {
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
// pointing at the SPA fallback, which browsers reject as text/html.
exclude: ["@rerun-io/web-viewer"],
},
build: {
target: "esnext",
},