feat(observation): add dynamic camera sources
This commit is contained in:
parent
05bdab24a5
commit
a281faf923
|
|
@ -126,7 +126,9 @@ immutable donor revision remains a packaging and CI prerequisite.
|
|||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||
inside the Mission Core shell. It can open a compatible RRD file over HTTP(S) or a
|
||||
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
||||
use an external hosted viewer UI.
|
||||
use an external hosted viewer UI. Dynamic point-cloud and camera source
|
||||
composition, host-owned window layout and the current live-only timeline contract
|
||||
are fixed in [`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
|
||||
|
||||
The first K1 live session or replay in a `k1link serve` process creates one local
|
||||
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { LandingStage } from "./components/LandingStage";
|
|||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||
import {
|
||||
rootById,
|
||||
roots,
|
||||
|
|
@ -132,6 +133,7 @@ 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 ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
|
|
@ -238,7 +240,7 @@ export default function App() {
|
|||
|
||||
if (activeDefinition?.kind === "spatial") {
|
||||
actions.push(
|
||||
{ label: "Настроить источник", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||
);
|
||||
|
|
@ -370,6 +372,7 @@ export default function App() {
|
|||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
sceneSettings={sceneSettings}
|
||||
observationLayout={observationLayout}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
|
|
@ -384,8 +387,8 @@ export default function App() {
|
|||
|
||||
<Window
|
||||
open={sourceWindowOpen}
|
||||
title="Источники"
|
||||
subtitle="Потоки и записи пространственной сцены"
|
||||
title="Визуальный движок"
|
||||
subtitle="Rerun gRPC и записи пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import type { RefObject } from "react";
|
||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
||||
|
||||
export function initialObservationWindowRect(index: number): ObservationWindowRect {
|
||||
return {
|
||||
x: 18 + (index % 3) * 354,
|
||||
y: 78 + Math.floor(index / 3) * 226,
|
||||
width: 336,
|
||||
height: 210,
|
||||
};
|
||||
}
|
||||
|
||||
export function FloatingObservationWindow({
|
||||
source,
|
||||
index,
|
||||
boundsRef,
|
||||
rect,
|
||||
maximized,
|
||||
active,
|
||||
onRectChange,
|
||||
onMaximizedChange,
|
||||
onActivate,
|
||||
onClose,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
index: number;
|
||||
boundsRef: RefObject<HTMLElement | null>;
|
||||
rect?: ObservationWindowRect;
|
||||
maximized: boolean;
|
||||
active: boolean;
|
||||
onRectChange: (rect: ObservationWindowRect) => void;
|
||||
onMaximizedChange: (maximized: boolean) => void;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
boundsRef={boundsRef}
|
||||
rect={rect ?? initialObservationWindowRect(index)}
|
||||
onRectChange={onRectChange}
|
||||
maximized={maximized}
|
||||
onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
title={source.label}
|
||||
subtitle={source.description}
|
||||
status={(
|
||||
<span className="floating-observation-window__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
)}
|
||||
footer={(
|
||||
<span className="floating-observation-window__footer">
|
||||
<span>{source.endpointLabel || source.transport}</span>
|
||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||
</span>
|
||||
)}
|
||||
minWidth={280}
|
||||
minHeight={190}
|
||||
resizable={source.capabilities.resizable}
|
||||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className="floating-observation-window"
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
<ObservationMedia source={source} />
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
video: "video",
|
||||
image: "image",
|
||||
depth: "image",
|
||||
};
|
||||
|
||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||
unverified: "Не подтверждён",
|
||||
declared: "Канал объявлен",
|
||||
available: "Доступен",
|
||||
connecting: "Подключение",
|
||||
streaming: "Эфир",
|
||||
degraded: "Нестабильно",
|
||||
unavailable: "Недоступен",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
||||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
|
||||
if (source.previewUrl && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
className="observation-media__asset"
|
||||
src={source.previewUrl}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (source.previewUrl && (source.modality === "image" || source.modality === "depth")) {
|
||||
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="observation-media__empty">
|
||||
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||
<strong>{observationSourceStatusLabel(source)}</strong>
|
||||
<span>
|
||||
{source.modality === "video"
|
||||
? "Канал известен, browser-preview ещё не подключён"
|
||||
: source.description}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationSourcePicker({
|
||||
sources,
|
||||
visibleSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
sources: readonly ObservationSourceDescriptor[];
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void;
|
||||
}) {
|
||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="scene-source-picker"
|
||||
placement="bottom-start"
|
||||
width={340}
|
||||
offset={10}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-source-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="scene-source-picker__trigger"
|
||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||
aria-label="Источники данных сцены"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={17} />
|
||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<header className="observation-source-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||
<strong>Источники данных</strong>
|
||||
</div>
|
||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||
</header>
|
||||
{sources.length ? (
|
||||
<div className="observation-source-menu__list">
|
||||
{sources.map((source) => {
|
||||
const selected = visibleSourceIds.has(source.id);
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-source-option"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onToggle(source.id)}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<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}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__check">
|
||||
{selected ? <Icon name="check" size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-source-menu__empty">
|
||||
<Icon name="database" size={18} />
|
||||
<strong>Каталог пока пуст</strong>
|
||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного device-плагина; сцена не знает модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
export function ObservationTimeline({
|
||||
active,
|
||||
sourceCount,
|
||||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
sourceCount: number;
|
||||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
className?: string;
|
||||
}) {
|
||||
const buffered = seekable && (mode === "buffered" || mode === "recorded");
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
>
|
||||
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" disabled>
|
||||
{buffered ? "Воспроизвести" : "Только эфир"}
|
||||
</Button>
|
||||
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}>
|
||||
<span style={{ width: active ? "100%" : "0%" }} />
|
||||
</div>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>{active ? "LIVE" : "—:—:—.———"}</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}>
|
||||
ЭФИР
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
|
||||
export interface ObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
focusedSourceId: string | null;
|
||||
activeFloatingSourceId: string | null;
|
||||
maximizedFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
toggleSource: (sourceId: string) => void;
|
||||
hideSource: (sourceId: string) => void;
|
||||
setFocusedSourceId: (sourceId: string | null) => void;
|
||||
activateFloatingSource: (sourceId: string) => void;
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
}
|
||||
|
||||
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
if (!source.capabilities.defaultVisible) return false;
|
||||
if (source.availability === "unavailable" || source.availability === "error") return false;
|
||||
return source.modality === "point-cloud" || Boolean(source.previewUrl);
|
||||
}
|
||||
|
||||
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||
return sources
|
||||
.map((source) => [
|
||||
source.id,
|
||||
source.binding.deviceSessionId,
|
||||
source.binding.deviceId,
|
||||
].filter(Boolean).join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
export function useObservationLayout(
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
): ObservationLayoutController {
|
||||
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
||||
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
||||
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
|
||||
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
||||
const identity = catalogIdentity(sources);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleIds((current) => {
|
||||
const next = current.filter((sourceId) => sourceIds.has(sourceId));
|
||||
return next.length === current.length ? current : next;
|
||||
});
|
||||
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
||||
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
setWindowRects((current) => {
|
||||
const entries = Object.entries(current);
|
||||
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
||||
});
|
||||
}, [sourceIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity) {
|
||||
initializedCatalog.current = null;
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
initializedCatalog.current = identity;
|
||||
const defaults = sources
|
||||
.filter(canOpenByDefault)
|
||||
.map((source) => source.id);
|
||||
setVisibleIds(defaults);
|
||||
const firstFloating = sources.find(
|
||||
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
||||
);
|
||||
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||
}, [identity, sources]);
|
||||
|
||||
const toggleSource = useCallback((sourceId: string) => {
|
||||
const selected = visibleIds.includes(sourceId);
|
||||
setVisibleIds((current) => selected
|
||||
? current.filter((candidate) => candidate !== sourceId)
|
||||
: current.includes(sourceId) ? current : [...current, sourceId]);
|
||||
if (!selected) setActiveFloatingSourceId(sourceId);
|
||||
}, [visibleIds]);
|
||||
|
||||
const hideSource = useCallback((sourceId: string) => {
|
||||
setVisibleIds((current) => current.filter((candidate) => candidate !== sourceId));
|
||||
setFocusedSourceIdState((current) => current === sourceId ? null : current);
|
||||
setActiveFloatingSourceId((current) => current === sourceId ? null : current);
|
||||
setMaximizedFloatingSourceId((current) => current === sourceId ? null : current);
|
||||
}, []);
|
||||
|
||||
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
||||
setFocusedSourceIdState(sourceId);
|
||||
if (sourceId) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
|
||||
const activateFloatingSource = useCallback((sourceId: string) => {
|
||||
setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
|
||||
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
|
||||
setMaximizedFloatingSourceId(maximized ? sourceId : null);
|
||||
if (maximized) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
|
||||
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
|
||||
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
|
||||
}, []);
|
||||
|
||||
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||
|
||||
return {
|
||||
visibleSourceIds,
|
||||
focusedSourceId,
|
||||
activeFloatingSourceId,
|
||||
maximizedFloatingSourceId,
|
||||
windowRects,
|
||||
toggleSource,
|
||||
hideSource,
|
||||
setFocusedSourceId,
|
||||
activateFloatingSource,
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
};
|
||||
}
|
||||
|
|
@ -72,6 +72,70 @@ export interface SpatialSourceDescriptor {
|
|||
kind: "rerun-grpc" | "rrd" | "other";
|
||||
}
|
||||
|
||||
export type ObservationSourceModality = "point-cloud" | "video" | "image" | "depth";
|
||||
|
||||
export type ObservationSourceAvailability =
|
||||
| "unverified"
|
||||
| "declared"
|
||||
| "available"
|
||||
| "connecting"
|
||||
| "streaming"
|
||||
| "degraded"
|
||||
| "unavailable"
|
||||
| "error";
|
||||
|
||||
export type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||
|
||||
export interface ObservationSourceProvider {
|
||||
pluginId: string;
|
||||
pluginVersion: string;
|
||||
modelId: string;
|
||||
compatibilityProfileId?: string | null;
|
||||
}
|
||||
|
||||
export interface ObservationSourceBinding {
|
||||
deviceId?: string | null;
|
||||
deviceSessionId?: string | null;
|
||||
acquisitionId?: string | null;
|
||||
}
|
||||
|
||||
export interface ObservationSourceCapabilities {
|
||||
overlay: boolean;
|
||||
fullscreen: boolean;
|
||||
resizable: boolean;
|
||||
defaultVisible: boolean;
|
||||
timelineMode: ObservationTimelineMode;
|
||||
seekable: boolean;
|
||||
sessionRecording: boolean;
|
||||
clockId?: string | null;
|
||||
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
|
||||
}
|
||||
|
||||
export interface ObservationSourceDescriptor {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
semanticChannelId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
modality: ObservationSourceModality;
|
||||
role: "primary" | "auxiliary";
|
||||
availability: ObservationSourceAvailability;
|
||||
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
|
||||
endpointLabel?: string | null;
|
||||
previewUrl?: string | null;
|
||||
provider: ObservationSourceProvider;
|
||||
binding: ObservationSourceBinding;
|
||||
capabilities: ObservationSourceCapabilities;
|
||||
}
|
||||
|
||||
export interface ObservationTimelineSnapshot {
|
||||
mode: ObservationTimelineMode;
|
||||
seekable: boolean;
|
||||
sessionRecording: boolean;
|
||||
synchronization: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
range: { startSeconds: number; endSeconds: number } | null;
|
||||
}
|
||||
|
||||
export interface MissionRuntimeState {
|
||||
phase: RuntimePhase;
|
||||
message?: string | null;
|
||||
|
|
@ -80,6 +144,8 @@ export interface MissionRuntimeState {
|
|||
acquisition?: RuntimeAcquisitionSnapshot | null;
|
||||
operations?: readonly RuntimeOperationSnapshot[];
|
||||
spatialSource?: SpatialSourceDescriptor | null;
|
||||
observationSources?: readonly ObservationSourceDescriptor[];
|
||||
observationTimeline?: ObservationTimelineSnapshot;
|
||||
viewerSettings?: ViewerSettings | null;
|
||||
sourceMode: SourceMode;
|
||||
metrics?: StreamMetrics;
|
||||
|
|
|
|||
|
|
@ -122,6 +122,22 @@ export interface XgridsK1Metrics {
|
|||
[key: string]: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface XgridsSensorCatalogStream {
|
||||
stream_id: string;
|
||||
sensor_kind?: string | null;
|
||||
modality?: string | null;
|
||||
availability?: string | null;
|
||||
decode_status?: string | null;
|
||||
frame_id?: string | null;
|
||||
coordinate_convention?: string | null;
|
||||
}
|
||||
|
||||
export interface XgridsSensorCatalog {
|
||||
schema_version?: string | null;
|
||||
revision?: string | null;
|
||||
streams?: XgridsSensorCatalogStream[];
|
||||
}
|
||||
|
||||
export interface XgridsK1State {
|
||||
contract_version?: string | null;
|
||||
phase?: string | null;
|
||||
|
|
@ -141,6 +157,7 @@ export interface XgridsK1State {
|
|||
acquisition?: XgridsAcquisition | null;
|
||||
operations?: XgridsOperation[];
|
||||
last_operation?: XgridsOperation | null;
|
||||
sensor_catalog?: XgridsSensorCatalog | null;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
||||
import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceProvider,
|
||||
} from "../../core/runtime/contracts";
|
||||
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
import type { XgridsK1State } from "./api";
|
||||
|
||||
function providerFor(
|
||||
state: XgridsK1State,
|
||||
activeModel: DeviceModelDefinition,
|
||||
): ObservationSourceProvider {
|
||||
return {
|
||||
pluginId: xgridsK1Manifest.metadata.id,
|
||||
pluginVersion: xgridsK1Manifest.metadata.version,
|
||||
modelId: state.device_ref?.model_id || activeModel.id,
|
||||
compatibilityProfileId:
|
||||
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function bindingFor(state: XgridsK1State) {
|
||||
const acquisition = effectiveAcquisition(state);
|
||||
return {
|
||||
deviceId: state.device_ref?.device_id ?? null,
|
||||
deviceSessionId: state.device_session?.device_session_id ?? null,
|
||||
acquisitionId: acquisition?.acquisition_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogDeclares(state: XgridsK1State, streamId: string): boolean {
|
||||
return Boolean(state.sensor_catalog?.streams?.some((stream) => stream.stream_id === streamId));
|
||||
}
|
||||
|
||||
function spatialAvailability(state: XgridsK1State): ObservationSourceAvailability {
|
||||
const mode = confirmedRuntimeSourceMode(state);
|
||||
if (mode !== "idle" && state.rerun_grpc_url?.trim()) return "streaming";
|
||||
if (state.rerun_grpc_url?.trim()) return "available";
|
||||
if (state.device_session?.connectivity === "degraded") return "degraded";
|
||||
if (state.device_session?.connectivity === "connected") return "available";
|
||||
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",
|
||||
);
|
||||
const compatibilityProfileId =
|
||||
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null;
|
||||
if (!stream) return "unavailable";
|
||||
if (stream.availability !== "observed" || !compatibilityProfileId) 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";
|
||||
}
|
||||
|
||||
export function xgridsK1ObservationSources(
|
||||
state: XgridsK1State,
|
||||
activeModel: DeviceModelDefinition,
|
||||
): ObservationSourceDescriptor[] {
|
||||
const provider = providerFor(state, activeModel);
|
||||
const binding = bindingFor(state);
|
||||
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
|
||||
const descriptorId = (sourceId: string) =>
|
||||
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: descriptorId("sensor.lidar.primary"),
|
||||
sourceId: "sensor.lidar.primary",
|
||||
semanticChannelId: "spatial.point-cloud.live",
|
||||
label: "K1 · облако точек",
|
||||
description: "Облако точек, поза и траектория в общей 3D-сцене",
|
||||
modality: "point-cloud",
|
||||
role: "primary",
|
||||
availability: spatialAvailability(state),
|
||||
transport: "rerun-grpc",
|
||||
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
|
||||
previewUrl: state.rerun_grpc_url?.trim() || null,
|
||||
provider,
|
||||
binding,
|
||||
capabilities: {
|
||||
overlay: false,
|
||||
fullscreen: true,
|
||||
resizable: false,
|
||||
defaultVisible: true,
|
||||
timelineMode: "live-only",
|
||||
// Rerun can hold replay data, but the Mission Core host timeline is not
|
||||
// wired to its time controller yet.
|
||||
seekable: false,
|
||||
sessionRecording: false,
|
||||
clockId,
|
||||
spatialRegistration: "native",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: descriptorId("sensor.camera.left"),
|
||||
sourceId: "sensor.camera.left",
|
||||
semanticChannelId: "camera.preview.live",
|
||||
label: "K1 · камера слева",
|
||||
description: "Левый H.264 preview-канал профиля устройства",
|
||||
modality: "video",
|
||||
role: "auxiliary",
|
||||
availability: cameraAvailability(state),
|
||||
transport: "rtsp",
|
||||
endpointLabel: "RTSP · left",
|
||||
previewUrl: null,
|
||||
provider,
|
||||
binding,
|
||||
capabilities: {
|
||||
overlay: true,
|
||||
fullscreen: true,
|
||||
resizable: true,
|
||||
defaultVisible: false,
|
||||
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",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
import { finiteMetric, pipelineLatency } from "./presentation";
|
||||
import { xgridsK1ObservationSources } from "./observationSources";
|
||||
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
|
||||
|
||||
export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
|
||||
|
|
@ -90,6 +91,16 @@ function normalizeState(
|
|||
kind: "rerun-grpc",
|
||||
}
|
||||
: null,
|
||||
observationSources: xgridsK1ObservationSources(state, activeModel),
|
||||
observationTimeline: {
|
||||
// This is the shared host timeline. A replayable Rerun source alone does
|
||||
// not make camera and point-cloud time jointly seekable.
|
||||
mode: "live-only",
|
||||
seekable: false,
|
||||
sessionRecording: false,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
range: null,
|
||||
},
|
||||
viewerSettings: state.viewer_settings,
|
||||
sourceMode: confirmedRuntimeSourceMode(state),
|
||||
metrics: {
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@
|
|||
@import "./styles/spatial.css";
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
@import "./styles/observation.css";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,451 @@
|
|||
.scene-source-controls {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger,
|
||||
.scene-source-control,
|
||||
.scene-focus-exit {
|
||||
display: inline-grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 50%;
|
||||
background: rgb(12 13 16 / 0.82);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger:hover,
|
||||
.scene-source-control:hover,
|
||||
.scene-focus-exit:hover,
|
||||
.scene-source-picker__trigger[data-active="true"] {
|
||||
border-color: rgb(255 255 255 / 0.18);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger > span {
|
||||
position: absolute;
|
||||
top: -0.2rem;
|
||||
right: -0.2rem;
|
||||
display: grid;
|
||||
min-width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-text-primary);
|
||||
color: #090a0c;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scene-focus-exit {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
left: 7.1rem;
|
||||
}
|
||||
|
||||
.scene-status[aria-hidden="true"],
|
||||
.scene-metrics[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observation-source-menu {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.observation-source-menu__head,
|
||||
.observation-source-menu__foot {
|
||||
padding: 0.85rem 0.9rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong,
|
||||
.observation-source-menu__head span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong {
|
||||
margin-top: 0.34rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head small,
|
||||
.observation-source-menu__foot {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observation-source-menu__list {
|
||||
display: grid;
|
||||
max-height: min(28rem, 60vh);
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option .nodedc-dropdown-option__description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option i,
|
||||
.floating-observation-window i,
|
||||
.camera-slot__status i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
flex: 0 0 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="available"],
|
||||
i[data-availability="streaming"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="connecting"],
|
||||
i[data-availability="declared"] {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="unverified"] {
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="degraded"],
|
||||
i[data-availability="error"] {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observation-source-menu__empty,
|
||||
.camera-grid__empty,
|
||||
.observation-media__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty {
|
||||
min-height: 10rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty strong,
|
||||
.observation-media__empty strong,
|
||||
.camera-grid__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty span,
|
||||
.observation-media__empty span,
|
||||
.camera-grid__empty span {
|
||||
max-width: 24rem;
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observation-source-menu__foot {
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
}
|
||||
|
||||
.observation-media__asset,
|
||||
.observation-media__empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.observation-media__asset {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #050608;
|
||||
}
|
||||
|
||||
.observation-media__empty {
|
||||
min-height: 9rem;
|
||||
background: #07080a;
|
||||
}
|
||||
|
||||
.floating-observation-window__status,
|
||||
.floating-observation-window__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button {
|
||||
display: grid;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.floating-observation-window__status {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.floating-observation-window__footer {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.floating-observation-window .nodedc-workspace-window__body {
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.055);
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(9 10 13 / 0.78);
|
||||
padding: 0.4rem;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
height: 0.3rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.observation-timeline__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track[data-disabled="true"] {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.observation-timeline__meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.observation-timeline__meta code,
|
||||
.observation-timeline__meta small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.observation-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"] {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] {
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
|
||||
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.camera-workspace__catalog {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.8rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||
grid-template-rows: none;
|
||||
grid-auto-rows: minmax(16rem, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.camera-grid[data-count="1"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.camera-grid__empty {
|
||||
min-height: 18rem;
|
||||
border-radius: 1rem;
|
||||
background: #07080a;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
}
|
||||
|
||||
.camera-slot {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
.camera-slot footer {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions,
|
||||
.camera-slot__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__status {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.camera-slot__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-slot footer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.camera-slot footer span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-slot footer small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.camera-timeline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] {
|
||||
height: 100%;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] > .workspace-lead,
|
||||
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] .camera-grid {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.scene-status--top-left {
|
||||
top: 4.2rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-timeline > .nodedc-button:first-child,
|
||||
.observation-timeline__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: minmax(14rem, 1fr);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
|
|
@ -7,7 +7,19 @@ import {
|
|||
} from "@nodedc/ui-react";
|
||||
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import type { BackendStatus, MissionRuntimeState } from "../core/runtime/contracts";
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
observationSourceStatusLabel,
|
||||
} from "../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type {
|
||||
BackendStatus,
|
||||
MissionRuntimeState,
|
||||
ObservationSourceDescriptor,
|
||||
} from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
type RerunSelection,
|
||||
|
|
@ -84,6 +96,7 @@ export interface WorkspaceRendererProps {
|
|||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
sceneSettings: SceneSettings;
|
||||
observationLayout: ObservationLayoutController;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +249,7 @@ function SpatialWorkspace({
|
|||
state,
|
||||
sourceUrl,
|
||||
sceneSettings,
|
||||
observationLayout,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
|
|
@ -246,6 +260,23 @@ function SpatialWorkspace({
|
|||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
const observationSources = state?.observationSources ?? [];
|
||||
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
|
||||
const pointCloudVisible = pointCloudSource
|
||||
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
|
||||
: Boolean(sourceUrl.trim());
|
||||
const mediaSources = observationSources.filter(
|
||||
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
||||
);
|
||||
const visibleMediaSources = mediaSources.filter((source) =>
|
||||
observationLayout.visibleSourceIds.has(source.id),
|
||||
);
|
||||
const pointCloudFocused = Boolean(
|
||||
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
|
||||
);
|
||||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
|
|
@ -253,6 +284,13 @@ function SpatialWorkspace({
|
|||
}, []);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
|
|
@ -263,14 +301,14 @@ function SpatialWorkspace({
|
|||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||
|
||||
return (
|
||||
<div className="spatial-workspace">
|
||||
<div className="spatial-workspace" data-focused={pointCloudFocused ? "true" : undefined}>
|
||||
<div className="spatial-toolbar">
|
||||
<div className="spatial-toolbar__mode">
|
||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||
</div>
|
||||
<div className="spatial-toolbar__actions">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
Источник
|
||||
Движок
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||
Слои
|
||||
|
|
@ -281,8 +319,13 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spatial-viewport-shell">
|
||||
{sourceUrl.trim() ? (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="spatial-viewport-shell"
|
||||
data-primary-focused={pointCloudFocused ? "true" : undefined}
|
||||
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
{sourceUrl.trim() && pointCloudVisible ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
onStatusChange={onStatusChange}
|
||||
|
|
@ -292,13 +335,49 @@ function SpatialWorkspace({
|
|||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
<div className="scene-status scene-status--top-left">
|
||||
{pointCloudFocused ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-focus-exit"
|
||||
aria-label="Выйти из полноэкранного режима облака точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(null)}
|
||||
>
|
||||
<Icon name="minimize" size={16} />
|
||||
</button>
|
||||
) : !floatingSourceMaximized ? (
|
||||
<div className="scene-source-controls">
|
||||
<ObservationSourcePicker
|
||||
sources={observationSources}
|
||||
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||
onToggle={observationLayout.toggleSource}
|
||||
/>
|
||||
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-source-control"
|
||||
aria-label="Развернуть облако точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
|
||||
>
|
||||
<Icon name="expand" size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="scene-status scene-status--top-left"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
|
||||
<div
|
||||
className="scene-metrics"
|
||||
aria-label="Метрики пространственной сцены"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
|
|
@ -313,7 +392,7 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
|
|
@ -323,7 +402,7 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{selection ? (
|
||||
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
|
|
@ -331,21 +410,33 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="scene-timeline">
|
||||
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
|
||||
Воспроизвести
|
||||
</Button>
|
||||
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
|
||||
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
|
||||
</div>
|
||||
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
|
||||
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
|
||||
ЭФИР
|
||||
</span>
|
||||
</div>
|
||||
{!pointCloudFocused && !floatingSourceMaximized ? (
|
||||
<ObservationTimeline
|
||||
active={viewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
|
||||
mode={timeline?.mode}
|
||||
seekable={timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused ? visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
boundsRef={viewportRef}
|
||||
rect={observationLayout.windowRects[source.id]}
|
||||
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||
active={observationLayout.activeFloatingSourceId === source.id}
|
||||
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
|
||||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
onClose={() => observationLayout.hideSource(source.id)}
|
||||
/>
|
||||
)) : null}
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
|
|
@ -360,31 +451,85 @@ function SpatialWorkspace({
|
|||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
|
||||
function CameraSourceCard({
|
||||
source,
|
||||
focused,
|
||||
onFocus,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
focused: boolean;
|
||||
onFocus: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace">
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
<div className="camera-grid">
|
||||
{slots.map((slot, index) => (
|
||||
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
|
||||
<div className="camera-slot__grid" aria-hidden="true" />
|
||||
<header>
|
||||
<span>{slot}</span>
|
||||
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
|
||||
</header>
|
||||
<div className="camera-slot__empty">
|
||||
<Icon name={index === 3 ? "image" : "video"} />
|
||||
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
|
||||
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
|
||||
</footer>
|
||||
</div>
|
||||
))}
|
||||
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
||||
<header>
|
||||
<span>{source.label}</span>
|
||||
<div className="camera-slot__head-actions">
|
||||
<span className="camera-slot__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
{source.capabilities.fullscreen ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
|
||||
onClick={onFocus}
|
||||
>
|
||||
<Icon name={focused ? "minimize" : "expand"} size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="camera-slot__body">
|
||||
<ObservationMedia source={source} />
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
<footer>
|
||||
<span>{source.description}</span>
|
||||
<small>{source.endpointLabel || source.transport}</small>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
|
||||
const sources = (state?.observationSources ?? []).filter(
|
||||
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
|
||||
);
|
||||
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
|
||||
const timeline = state?.observationTimeline;
|
||||
const displayedSources = focusedSource ? [focusedSource] : sources;
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
<div className="camera-workspace__catalog">
|
||||
<span>{sources.length ? `${sources.length} визуальных каналов` : "Каталог источников пуст"}</span>
|
||||
</div>
|
||||
<div className="camera-grid" data-count={displayedSources.length}>
|
||||
{displayedSources.length ? displayedSources.map((source) => (
|
||||
<CameraSourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
focused={focusedSource?.id === source.id}
|
||||
onFocus={() => observationLayout.setFocusedSourceId(
|
||||
focusedSource?.id === source.id ? null : source.id,
|
||||
)}
|
||||
/>
|
||||
)) : (
|
||||
<div className="camera-grid__empty">
|
||||
<Icon name="video" size={22} />
|
||||
<strong>Камеры не объявлены</strong>
|
||||
<span>Выберите модель устройства — плагин опубликует доступные видеоканалы.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ObservationTimeline
|
||||
active={sources.some((source) => source.availability === "streaming")}
|
||||
sourceCount={sources.length}
|
||||
mode={timeline?.mode}
|
||||
seekable={timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
className="camera-timeline"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let xgridsK1Manifest;
|
||||
let xgridsK1ObservationSources;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ xgridsK1Manifest } = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/manifest.ts",
|
||||
));
|
||||
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/observationSources.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function model() {
|
||||
const activeModel = xgridsK1Manifest.spec.models[0];
|
||||
assert.ok(activeModel, "XGRIDS plugin must declare at least one model");
|
||||
return activeModel;
|
||||
}
|
||||
|
||||
function declaredState() {
|
||||
const activeModel = model();
|
||||
return {
|
||||
phase: "connected",
|
||||
source_mode: "idle",
|
||||
k1_ip: "192.168.7.10",
|
||||
foxglove_ws_url: "ws://192.168.7.10:8765",
|
||||
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
|
||||
compatibility: {
|
||||
profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||
camera_preview: "rtsp://192.168.7.10:8554/vendor-preview",
|
||||
},
|
||||
device_ref: {
|
||||
device_id: "device-k1-001",
|
||||
model_id: activeModel.id,
|
||||
identity_stability: "stable",
|
||||
identity_basis: "hardware-identifier",
|
||||
},
|
||||
device_session: {
|
||||
device_session_id: "device-session-001",
|
||||
device_id: "device-k1-001",
|
||||
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||
connectivity: "connected",
|
||||
},
|
||||
sensor_catalog: {
|
||||
schema_version: "missioncore.sensor-catalog/v1alpha2",
|
||||
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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function streamingState() {
|
||||
return {
|
||||
...declaredState(),
|
||||
phase: "streaming",
|
||||
source_mode: "live",
|
||||
rerun_grpc_url: "rerun+http://127.0.0.1:9877/proxy",
|
||||
acquisition: {
|
||||
acquisition_id: "acquisition-001",
|
||||
device_id: "device-k1-001",
|
||||
device_session_id: "device-session-001",
|
||||
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||
control_mode: "operator-manual",
|
||||
requested_streams: ["spatial.point-cloud.live"],
|
||||
target_host: "127.0.0.1",
|
||||
duration_seconds: 0,
|
||||
evidence_policy: "required",
|
||||
state: "acquiring",
|
||||
state_revision: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectUrlLikeStrings(value, found = []) {
|
||||
if (typeof value === "string") {
|
||||
if (value.includes("://")) found.push(value);
|
||||
return found;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectUrlLikeStrings(item, found);
|
||||
return found;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const item of Object.values(value)) collectUrlLikeStrings(item, 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",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
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"));
|
||||
});
|
||||
|
||||
test("observation source ids remain stable while runtime availability changes", () => {
|
||||
const declared = xgridsK1ObservationSources(declaredState(), model());
|
||||
const streaming = xgridsK1ObservationSources(streamingState(), 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());
|
||||
const cameras = sources.filter(({ modality }) => modality === "video");
|
||||
|
||||
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.deepEqual(collectUrlLikeStrings(sources), [
|
||||
"rerun+http://127.0.0.1:9877/proxy",
|
||||
]);
|
||||
const serialized = JSON.stringify(sources);
|
||||
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();
|
||||
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);
|
||||
|
||||
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));
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# ADR 0006: Vendor-neutral observation sources and live-only timeline
|
||||
|
||||
Status: accepted for the experimental Mission Core runtime, 2026-07-16.
|
||||
|
||||
## Context
|
||||
|
||||
The spatial scene and camera workspace must accept one device, a custom rig or a
|
||||
future multi-sensor vehicle without adding vendor-specific branches to the host
|
||||
UI. The currently verified K1 profile exposes one spatial visualization path and
|
||||
two independent H.264 camera preview producers. The browser does not yet have a
|
||||
camera delivery adapter, a shared point-cloud/video clock or confirmed camera
|
||||
intrinsics/extrinsics.
|
||||
|
||||
Hard-coded camera slots, direct RTSP URLs in React components and an apparently
|
||||
seekable timeline would therefore create three false contracts: a fixed sensor
|
||||
count, browser ownership of device transport and replay that does not exist.
|
||||
|
||||
## Decision
|
||||
|
||||
1. A device plugin normalizes its capabilities into
|
||||
`ObservationSourceDescriptor[]`. Mission Core consumes only these generic
|
||||
descriptors.
|
||||
2. `id` identifies a runtime source instance; `sourceId` identifies the producer
|
||||
inside the plugin model; `semanticChannelId` describes the canonical channel.
|
||||
Transport addresses are not identities.
|
||||
3. Provider metadata and device/session/acquisition binding are separate. A new
|
||||
acquisition may change layout context without changing the logical producer.
|
||||
4. The host owns visibility, z-order, position, resize and fullscreen. A plugin
|
||||
cannot inject global layout behavior.
|
||||
5. Direct device RTSP URLs do not cross into the generic UI. A future read-only
|
||||
camera adapter must publish a browser-decodable local delivery URL.
|
||||
6. Until a bounded buffer and clock mapping exist, the shared timeline is
|
||||
`live-only`, `seekable=false`, `sessionRecording=false` and explicitly marked
|
||||
`host-arrival-best-effort`.
|
||||
7. Directional 3D camera frustums are not rendered until calibration and
|
||||
extrinsics are available and verified. An unresolved camera may be listed as
|
||||
a source, but its orientation must not be invented.
|
||||
|
||||
## Current K1 mapping
|
||||
|
||||
| Producer | Canonical channel | Host representation |
|
||||
| --- | --- | --- |
|
||||
| `sensor.lidar.primary` | `spatial.point-cloud.live` | primary Rerun viewport |
|
||||
| `sensor.camera.left` | `camera.preview.live` | auxiliary media window / camera card |
|
||||
| `sensor.camera.right` | `camera.preview.live` | auxiliary media window / camera card |
|
||||
|
||||
Both camera producers share the proven canonical channel and remain distinct by
|
||||
`sourceId`. Their current state is `declared`: the compatibility profile proves
|
||||
the channels, but no browser preview URL is advertised yet.
|
||||
|
||||
## UI consequences
|
||||
|
||||
- The spatial scene source picker renders any number of plugin-published sources.
|
||||
- Auxiliary media uses the design-system `WorkspaceWindow`, constrained to the
|
||||
scene bounds, with controlled drag, resize, close and maximize state.
|
||||
- The Cameras workspace filters media modalities and renders 0, 1, 2 or N cards;
|
||||
only its grid scrolls when the source count exceeds available space.
|
||||
- Point-cloud focus and camera focus stay inside the application panel rather
|
||||
than taking over the browser viewport.
|
||||
- Empty and declared channels remain honest placeholders; no demo frames or
|
||||
synthetic sensor values are substituted.
|
||||
|
||||
## Follow-up gate
|
||||
|
||||
The next implementation step is a plugin-owned, read-only H.264 adapter that
|
||||
terminates RTSP/RTP locally and exposes a browser-supported stream plus measured
|
||||
per-source metrics. Synchronized rewind becomes eligible only after the adapter
|
||||
provides bounded buffering and an evidenced mapping between the RTP 90 kHz clock
|
||||
and the spatial stream clock.
|
||||
|
||||
## Code anchors
|
||||
|
||||
- `apps/control-station/src/core/runtime/contracts.ts`
|
||||
- `apps/control-station/src/core/observation/useObservationLayout.ts`
|
||||
- `apps/control-station/src/components/ObservationSources.tsx`
|
||||
- `apps/control-station/src/components/ObservationTimeline.tsx`
|
||||
- `apps/control-station/src/workspaces/Workspaces.tsx`
|
||||
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
|
||||
Loading…
Reference in New Issue