feat(k1): add live cameras and reliable spatial following
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user