Share the K1 live scene template and preserve idle media channels

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 16:38:39 +03:00
parent 309bdf759e
commit fa8ac760a1
49 changed files with 2813 additions and 2126 deletions
+5 -206
View File
@@ -1,3 +1,5 @@
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AdminNavigationPanel,
@@ -5,15 +7,11 @@ import {
ApplicationPanel,
ApplicationShell,
Button,
Checker,
ColorField,
ControlRow,
HeaderNavigation,
HeaderProfile,
Icon,
Inspector,
RangeControl,
Select,
StatusBadge,
TextField,
UserProfileMenu,
@@ -66,8 +64,6 @@ import {
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
@@ -77,22 +73,6 @@ import "./styles/scene-windows.css";
type SceneToolWindowId = "sources" | "display" | "layers";
const viewerSettingsQuietPeriodMs = 750;
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
];
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
];
interface LivePerceptionLayers {
detections2d: boolean;
segmentation: boolean;
@@ -1029,132 +1009,8 @@ export default function App() {
onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("display")}
>
<Inspector
defaultOpen={["points"]}
singleOpen
sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
min={0.5}
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
/>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Атрибут цвета">
<Select
variant="split"
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
/>
</ControlRow>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Палитра">
<Select
variant="split"
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => stageDisplayPatch({ palette })}
/>
</ControlRow>
</div>
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => stageDisplayPatch({ customColor })}
/>
</ControlRow>
</div>
) : null}
{replayPresented ? (
<p className="scene-window-note">
Первый новый режим читает индекс архивных точек. Следующие палитры
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
</p>
) : null}
</div>
),
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
min={0}
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
/>
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
<Checker
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
/>
</div>
</div>
),
},
{
id: "scene",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: (
<div className="inspector-control-stack">
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
</Window>
<Window
@@ -1180,64 +1036,7 @@ export default function App() {
</span>
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
</div>
<Inspector
defaultOpen={["geometry"]}
singleOpen
sections={[
{
id: "geometry",
label: "Геометрия",
description: "Облако точек и путь",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div>
</div>
),
},
{
id: "frames",
label: "Координаты и камеры",
description: "Преобразования и области обзора",
content: (
<div className="inspector-control-stack">
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
{
id: "perception",
label: "Объекты и маски",
description: "Ожидают внешние обработчики",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
</Window>
</>
);
@@ -1,5 +1,6 @@
import { useLayoutEffect, useState, type RefObject } from "react";
import { WorkspaceWindow } from "@nodedc/ui-react";
import type {RefObject} from "react";
import {FloatingMediaWindow} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
export {initialObservationWindowRect,shouldCaptureWorkspacePointer} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
@@ -10,60 +11,6 @@ import type {
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const WINDOW_WIDTH = 336;
const WINDOW_HEIGHT = 210;
const WINDOW_GAP = 16;
const WINDOW_INSET = 18;
const TIMELINE_CLEARANCE = 64;
type ClosestTarget = { closest: (selectors: string) => unknown };
export function shouldCaptureWorkspacePointer(
button: number,
target: ClosestTarget | null,
): boolean {
if (button !== 0 || !target) return false;
if (target.closest(".nodedc-workspace-window__resize")) return true;
if (!target.closest(".nodedc-workspace-window__head")) return false;
return !target.closest("button, input, select, textarea, a");
}
export function initialObservationWindowRect(
index: number,
count = 1,
bounds: { width: number; height: number } = { width: 1280, height: 720 },
): ObservationWindowRect {
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
const maxColumns = Math.max(
1,
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
);
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
const rowHeight = Math.max(
1,
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
);
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
const row = Math.floor(index / columns);
const column = index % columns;
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
return {
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
y: Math.max(
0,
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
),
width,
height,
};
}
export function FloatingObservationWindow({
source,
index,
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
state: RecordedCameraAdmissionState,
) => void;
}) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
useLayoutEffect(() => {
const element = boundsRef.current;
if (!element) return;
const measure = () => {
const next = { width: element.clientWidth, height: element.clientHeight };
setBounds((current) => (
current?.width === next.width && current.height === next.height ? current : next
));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [boundsRef]);
// Camera media admission must not wait for the viewport observer. A source
// can arrive in the same commit as a workspace transition; mounting it with
// the canonical fallback geometry keeps the browser transport alive until
// the real bounds are measured immediately afterward.
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
const windowRect = rect ?? initialRect;
return (
<WorkspaceWindow
boundsRef={boundsRef}
rect={windowRect}
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={Math.min(280, windowRect.width)}
minHeight={Math.min(190, windowRect.height)}
resizable={source.capabilities.resizable}
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
onPointerDownCapture={(event) => {
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture can fail if the browser ended the pointer between
// dispatch and capture. The donor's window listeners remain fallback.
}
}}
closeLabel={`Закрыть ${source.label}`}
maximizeLabel={`Развернуть ${source.label}`}
restoreLabel={`Восстановить ${source.label}`}
moveLabel={`Переместить ${source.label}`}
resizeLabel={`Изменить размер ${source.label}`}
>
return <FloatingMediaWindow title={source.label} subtitle={source.description}
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
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>}>
<ObservationMedia
source={source}
playback={playback}
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</WorkspaceWindow>
);
</FloatingMediaWindow>;
}
@@ -1,9 +1,9 @@
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
import { Icon } from "@nodedc/ui-react";
import type {
ObservationSourceAvailability,
ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import {
@@ -16,28 +16,6 @@ import type {
} from "../core/observation/recordedSessionAdmission";
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
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,
playback,
@@ -159,105 +137,3 @@ export function ObservationMedia({
</div>
);
}
export function ObservationSourcePicker({
sources,
visibleSourceIds,
pendingSourceIds,
onToggle,
}: {
sources: readonly ObservationSourceDescriptor[];
visibleSourceIds: ReadonlySet<string>;
pendingSourceIds?: ReadonlySet<string>;
onToggle: (sourceId: string) => void | Promise<boolean>;
}) {
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);
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}
type="button"
className="nodedc-dropdown-option observation-source-option"
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
disabled={pending || !canOpen}
onClick={() => void 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" />
{pending ? "Переключение" : 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">
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
модель оборудования.
</footer>
</Dropdown>
);
}
@@ -1,197 +1 @@
import { Button, Icon, Select } 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",
rangeNs = null,
currentNs = null,
playing = false,
onSeek,
onPlayingChange,
onJumpToEnd,
showJumpToEnd = true,
playbackRate,
onPlaybackRateChange,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
className = "",
}: {
active: boolean;
sourceCount: number;
mode?: ObservationTimelineMode;
seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
rangeNs?: { min: number; max: number } | null;
currentNs?: number | null;
playing?: boolean;
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
showJumpToEnd?: boolean;
playbackRate?: number;
onPlaybackRateChange?: (rate: number) => void;
accumulationSeconds?: number;
onAccumulationChange?: (value: number) => void;
onAccumulationCommit?: () => void;
className?: string;
}) {
const playbackRange = normalizeTimelineRange(rangeNs);
const buffered = Boolean(
seekable &&
(mode === "buffered" || mode === "recorded") &&
playbackRange,
);
const elapsedSeconds = playbackRange
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
: 0;
const durationSeconds = playbackRange
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
: 0;
const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация",
}[synchronization];
const accumulationValue = accumulationSeconds === undefined
? null
: normalizeAccumulationSeconds(accumulationSeconds);
return (
<div
className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined}
data-mode={mode}
data-accumulation={accumulationValue !== null ? "true" : undefined}
>
{accumulationValue !== null ? (
<div className="observation-timeline__accumulation">
<span>Накопление</span>
<input
className="observation-timeline__track"
type="range"
min={0}
max={120}
step={1}
value={accumulationValue}
aria-label="Окно накопления облака точек"
aria-valuetext={formatAccumulationDuration(accumulationValue)}
onChange={(event) =>
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
onPointerUp={onAccumulationCommit}
onTouchEnd={onAccumulationCommit}
onKeyUp={onAccumulationCommit}
onBlur={onAccumulationCommit}
/>
<code>{formatAccumulationDuration(accumulationValue)}</code>
</div>
) : null}
<div className="observation-timeline__playback">
<Button
size="compact"
variant="ghost"
disabled={!buffered || !onSeek}
aria-label="Перейти к началу"
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
>
<Icon name="chevron-left" />
</Button>
<Button
className="observation-timeline__transport"
size="compact"
variant="ghost"
disabled={!buffered || !onPlayingChange}
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
icon={<Icon name={playing ? "stop" : "play"} />}
onClick={() => onPlayingChange?.(!playing)}
/>
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
<Select
label="Скорость воспроизведения"
value={String(playbackRate)}
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
variant="inline"
menuWidth="anchor"
onChange={(value) => onPlaybackRateChange(Number(value))}
/>
) : null}
<input
className="observation-timeline__track"
type="range"
min={0}
max={Math.max(durationSeconds, 0.001)}
step={0.01}
value={Math.min(elapsedSeconds, durationSeconds)}
disabled={!buffered || !onSeek}
aria-label="Позиция воспроизведения"
onChange={(event) => {
if (!playbackRange) return;
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
}}
/>
<div className="observation-timeline__meta">
<code>
{buffered
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
: active ? "LIVE" : "—:—:—.———"}
</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
{showJumpToEnd ? (
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
) : null}
</div>
</div>
);
}
export function normalizeAccumulationSeconds(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(120, Math.max(0, Math.round(value)));
}
export function formatAccumulationDuration(value: number): string {
const seconds = normalizeAccumulationSeconds(value);
return seconds === 0 ? "Кадр" : `${seconds} с`;
}
export function normalizeTimelineRange(
range: { min: number; max: number } | null | undefined,
): { min: number; max: number } | null {
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
if (range.max <= range.min) return null;
return range;
}
export function timelineOffsetSeconds(
range: { min: number; max: number },
currentNs: number,
): number {
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
return Math.max(0, (clamped - range.min) / 1_000_000_000);
}
export function formatTimelineDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
const wholeMinutes = Math.floor(seconds / 60);
const remaining = seconds - wholeMinutes * 60;
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
}
export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
+1 -31
View File
@@ -1,31 +1 @@
export type SceneProjection = "3d" | "2d" | "map";
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
export interface SceneSettings {
projection: SceneProjection;
pointSize: number;
colorMode: PointColorMode;
palette: PointPalette;
customColor: string;
accumulationSeconds: number;
showPoints: boolean;
showTrajectory: boolean;
showGrid: boolean;
showLabels: boolean;
showCameraFrustums: boolean;
}
export const defaultSceneSettings: SceneSettings = {
projection: "3d",
pointSize: 2.5,
colorMode: "intensity",
palette: "turbo",
customColor: "#35d7c1",
accumulationSeconds: 12,
showPoints: true,
showTrajectory: true,
showGrid: true,
showLabels: false,
showCameraFrustums: true,
};
export * from "../../../packages/spatial-ui/src/sceneSettings";
+1 -659
View File
@@ -1,659 +1 @@
.scene-source-controls {
position: absolute;
z-index: 12;
top: 0.85rem;
left: 0.85rem;
display: flex;
align-items: center;
gap: 0.45rem;
}
.scene-device-controls {
position: absolute;
z-index: 30;
top: 0.85rem;
left: 50%;
max-width: calc(100% - 9rem);
transform: translateX(-50%);
}
.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-navigation-hint {
position: absolute;
z-index: 11;
right: 0.85rem;
bottom: 4.9rem;
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(9 10 13 / 0.7);
color: var(--nodedc-text-muted);
padding: 0.42rem 0.65rem;
font-size: 0.52rem;
font-weight: 680;
pointer-events: none;
backdrop-filter: blur(14px);
}
.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;
font-family: var(--nodedc-font-family);
}
.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;
}
.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: var(--nodedc-font-size-md);
}
.observation-source-menu__head small,
.observation-source-menu__foot {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
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: 0;
}
.observation-media__asset,
.observation-media__empty {
width: 100%;
height: 100%;
}
.observation-media__asset {
display: block;
object-fit: contain;
background: #050608;
}
.recorded-media-player {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #070809;
}
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
visibility: hidden;
}
.recorded-media-player__notice {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
color: rgba(247, 248, 244, 0.72);
background: #070809;
font: inherit;
font-size: 11px;
text-align: center;
pointer-events: none;
}
.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;
}
.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);
}
.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;
}
.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;
}
.floating-observation-window.nodedc-material-rim::after {
display: none;
}
.floating-observation-window[data-active="true"] {
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
}
.floating-observation-window--hidden {
visibility: hidden;
pointer-events: none;
}
.observation-timeline {
display: block;
min-width: 0;
border: 0;
border-radius: 0.9rem;
background: rgb(9 10 13 / 0.78);
padding: 0.4rem;
backdrop-filter: blur(16px);
}
.observation-timeline[data-accumulation="true"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
gap: 0.8rem;
}
.observation-timeline__playback {
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
}
.observation-timeline__playback > .nodedc-select-anchor {
display: inline-flex;
width: auto;
flex: none;
}
.observation-timeline__transport {
width: var(--nodedc-control-height-compact);
padding: 0;
border-radius: var(--nodedc-radius-circle);
}
.observation-timeline__accumulation {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
padding: 0 0.45rem;
}
.observation-timeline__accumulation span,
.observation-timeline__accumulation code {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
white-space: nowrap;
}
.observation-timeline__accumulation code {
min-width: 2.65rem;
color: var(--nodedc-text-primary);
text-align: right;
}
.observation-timeline__track {
width: 100%;
height: 0.3rem;
appearance: none;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
cursor: pointer;
}
.observation-timeline__track::-webkit-slider-thumb {
width: 0.72rem;
height: 0.72rem;
appearance: none;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track::-moz-range-thumb {
width: 0.72rem;
height: 0.72rem;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track:disabled {
opacity: 0.46;
cursor: default;
}
.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);
border: 0;
background: transparent;
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.observation-timeline__follow:not(:disabled) {
cursor: pointer;
}
.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: none;
}
.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[data-accumulation="true"] {
grid-template-columns: minmax(0, 1fr);
}
.observation-timeline__playback {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-timeline__playback > .nodedc-button:first-child,
.observation-timeline__meta {
display: none;
}
.camera-grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(14rem, 1fr);
}
}
@import "../../../../packages/spatial-ui/src/observation.css";
@@ -1,83 +1 @@
.scene-tool-window {
--scene-tool-window-top: 5.75rem;
--scene-tool-window-right: 27rem;
position: fixed;
top: var(--scene-tool-window-top);
right: var(--scene-tool-window-right);
max-height: calc(100vh - var(--scene-tool-window-top) - 1.5rem);
}
.scene-tool-window--display {
--scene-tool-window-top: 7.75rem;
--scene-tool-window-right: 28.5rem;
}
.scene-tool-window--layers {
--scene-tool-window-top: 9.75rem;
--scene-tool-window-right: 30rem;
}
.scene-tool-window--layout {
--scene-tool-window-top: 11.75rem;
--scene-tool-window-right: 31.5rem;
}
.nodedc-overlay:has(> .scene-tool-window[data-scene-active="true"]) {
z-index: calc(var(--nodedc-layer-overlay) + 1);
}
.nodedc-overlay[data-placement="center"] {
z-index: calc(var(--nodedc-layer-overlay) + 2);
}
.scene-window-state {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.5rem;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.scene-window-code {
display: block;
overflow: hidden;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-field-bg);
color: var(--nodedc-text-secondary);
padding: 0.72rem 0.8rem;
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-window-note {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.5;
}
@media (max-width: 44rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 4.75rem;
--scene-tool-window-right: 0.875rem;
max-height: calc(100vh - 5.625rem);
}
}
@media (min-width: 44.01rem) and (max-width: 70rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 13.5rem;
--scene-tool-window-right: 1.5rem;
max-height: calc(100vh - 15rem);
}
}
@import "../../../../packages/spatial-ui/src/scene-windows.css";
+1 -484
View File
@@ -1,484 +1 @@
.spatial-workspace {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 34rem;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0.65rem;
overflow: hidden;
}
.spatial-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-start;
gap: 1rem;
}
.spatial-toolbar__actions {
display: flex;
min-width: 0;
align-items: center;
gap: 0.45rem;
}
.spatial-toolbar__view-switch {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(255 255 255 / 0.025);
padding: 0.2rem;
}
.spatial-toolbar__view-switch .nodedc-button {
border-radius: 999px;
}
.spatial-viewport-shell {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 1rem;
background: #06070a;
box-shadow: none;
}
.rerun-viewport,
.rerun-viewport__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.rerun-viewport__canvas[data-presented="false"] {
visibility: hidden;
}
.recorded-session-preloaders {
position: fixed;
left: -10000px;
top: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.rerun-viewport {
--rerun-native-chrome-height: 72px;
overflow: hidden;
}
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
panels are overridden: the native top row, recording tab and view tab.
They are drawn inside WASM and cannot be styled independently, so crop the
fixed native chrome while keeping the actual 3D viewport full-height. */
.rerun-viewport__canvas {
top: calc(-1 * var(--rerun-native-chrome-height));
bottom: auto;
height: calc(100% + var(--rerun-native-chrome-height));
}
.rerun-viewport__runtime {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.rerun-viewport__canvas canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.rerun-viewport__camera-lock {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
left: 0;
width: calc(46% - 2px);
cursor: default;
touch-action: none;
}
.rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas {
visibility: hidden;
pointer-events: none;
}
.rerun-viewport__notice {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
gap: 0.8rem;
border: 0;
border-radius: 1rem;
background: rgb(10 11 14 / 0.82);
padding: 0.9rem 1rem;
transform: translate(-50%, -50%);
backdrop-filter: blur(18px);
}
.rerun-viewport__notice strong,
.rerun-viewport__notice span {
display: block;
}
.rerun-viewport__notice strong {
font-size: 0.72rem;
}
.rerun-viewport__notice span {
margin-top: 0.18rem;
color: var(--nodedc-text-muted);
font-size: 0.61rem;
}
.rerun-viewport__notice--error {
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;
flex: 0 0 1rem;
border: 2px solid rgb(255 255 255 / 0.12);
border-top-color: var(--nodedc-text-primary);
border-radius: 50%;
animation: viewer-spin 900ms linear infinite;
}
@keyframes viewer-spin {
to { transform: rotate(360deg); }
}
.empty-spatial-stage {
position: absolute;
inset: 0;
overflow: hidden;
background: #06070a;
}
.empty-spatial-stage__grid {
display: none;
}
.spatial-axis {
position: absolute;
right: 2rem;
bottom: 5.2rem;
width: 4.4rem;
height: 4.4rem;
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.spatial-axis::before,
.spatial-axis::after {
position: absolute;
bottom: 0.65rem;
left: 0.75rem;
width: 2.7rem;
height: 1px;
background: rgb(255 255 255 / 0.2);
content: "";
transform-origin: left center;
}
.spatial-axis::before { transform: rotate(-24deg); }
.spatial-axis::after { transform: rotate(-90deg); }
.spatial-axis span { position: absolute; }
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
.empty-spatial-stage__message {
position: absolute;
top: 50%;
left: 50%;
display: grid;
max-width: 25rem;
justify-items: center;
gap: 0.62rem;
transform: translate(-50%, -58%);
text-align: center;
}
.empty-spatial-stage__icon {
display: grid;
width: 3.4rem;
height: 3.4rem;
place-items: center;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.035);
color: var(--nodedc-text-secondary);
}
.empty-spatial-stage__message strong {
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.empty-spatial-stage__message p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.67rem;
line-height: 1.5;
}
.scene-status,
.scene-metrics,
.scene-adapter-note,
.scene-selection,
.scene-timeline {
position: absolute;
z-index: 3;
border: 0;
background: rgb(9 10 13 / 0.74);
backdrop-filter: blur(16px);
}
.scene-status--top-left {
top: 0.85rem;
left: 0.85rem;
display: grid;
justify-items: start;
gap: 0.42rem;
border-radius: 0.9rem;
padding: 0.7rem;
}
.scene-status small {
max-width: 18rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
line-height: 1.35;
}
.scene-metrics {
top: 0.85rem;
right: 0.85rem;
display: grid;
min-width: 10rem;
border-radius: 0.9rem;
padding: 0.35rem 0.75rem;
}
.scene-metrics > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
border-bottom: 0;
padding: 0.38rem 0;
}
.scene-metrics > div:last-child { border-bottom: 0; }
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
.scene-adapter-note {
top: 50%;
left: 50%;
display: flex;
max-width: 34rem;
align-items: center;
gap: 0.65rem;
border-radius: 0.9rem;
color: var(--nodedc-text-secondary);
padding: 0.75rem 0.9rem;
font-size: 0.63rem;
line-height: 1.4;
transform: translate(-50%, 5.2rem);
}
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
.scene-selection {
right: 0.85rem;
bottom: 4.6rem;
display: grid;
max-width: 19rem;
gap: 0.2rem;
border-radius: 0.85rem;
padding: 0.65rem 0.75rem;
}
.scene-selection span,
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
.scene-operation-status-stack {
position: absolute;
z-index: 11;
left: 0.85rem;
bottom: 7.15rem;
display: grid;
justify-items: start;
gap: 0.3rem;
}
.scene-operation-status {
display: inline-flex;
max-width: min(24rem, calc(100% - 1.7rem));
align-items: center;
gap: 0.42rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(9 10 13 / 0.7);
color: var(--nodedc-text-muted);
padding: 0.42rem 0.65rem;
font: inherit;
font-size: 0.52rem;
font-weight: 680;
line-height: 1;
white-space: nowrap;
backdrop-filter: blur(14px);
}
.scene-operation-status .busy-indicator {
width: 0.66rem;
height: 0.66rem;
flex-basis: 0.66rem;
border-width: 1px;
}
.scene-operation-status--action {
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.scene-operation-status--action:hover,
.scene-operation-status--action:focus-visible {
border-color: rgb(255 255 255 / 0.14);
background: rgb(20 21 25 / 0.82);
color: var(--nodedc-text-primary);
outline: none;
}
.scene-operation-status--error {
color: rgb(var(--nodedc-danger-rgb));
}
.scene-timeline {
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
border-radius: 0.9rem;
padding: 0.4rem;
}
.scene-timeline__track {
height: 0.3rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
}
.scene-timeline__track span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--nodedc-text-primary);
}
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
.scene-timeline__follow {
display: inline-flex;
align-items: center;
gap: 0.38rem;
border-radius: var(--nodedc-radius-circle);
background: transparent;
color: var(--nodedc-text-muted);
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.scene-timeline__follow::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.scene-timeline__follow[data-active="true"] {
background: transparent;
color: var(--nodedc-text-primary);
}
.scene-timeline__follow[data-active="true"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.spatial-contract-strip {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 1rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
}
.spatial-contract-strip span {
display: inline-flex;
align-items: center;
gap: 0.38rem;
}
.spatial-contract-strip i {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
@media (prefers-reduced-motion: reduce) {
.busy-indicator { animation: none; }
}
@import "../../../../packages/spatial-ui/src/spatial.css";
@@ -1,3 +1,4 @@
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
@@ -498,15 +499,7 @@ function SpatialWorkspace({
Сброс вида
</Button>
) : null}
<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}>
Слои
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
Отображение
</Button>
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/>
</div>
</div>
@@ -35,9 +35,15 @@ test('active acquisition exposes STOP and Rerun without another START',()=>{
const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
const markup=render(active);
assert.match(markup,/Остановить устройство/);
assert.match(markup,/k1-preview-spatial/);
assert.match(markup,/rerun-viewport__canvas/);
assert.match(markup,/>Статус</);
assert.match(markup,/>Rerun</);
assert.match(markup,/>Пространственная модель</);
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
assert.ok(markup.includes('data-presented="false"'));
assert.ok(!markup.includes('Loading Rerun'));
assert.ok(!markup.includes('The data layer'));
assert.ok(!markup.includes('data-align="center"'));
assert.doesNotMatch(markup,/Восстановить просмотр|nodedc-activity-indicator/);
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
assert.equal(presentation.k1ManualState(active,false).canStart,false);
@@ -2,9 +2,11 @@ import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {execFileSync} from 'node:child_process';
import {createServer} from 'vite';
let server,previewFrames,assertRrd;
let server,previewFrames,assertRrd,previewFreshness,previewCamera;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({previewCamera}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewCamera.ts'));
({previewFreshness}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFreshness.ts'));
({previewFrames,assertRrd}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts'));
});
after(async()=>{await server?.close();});
@@ -41,3 +43,38 @@ test('partial, oversized, unframed and invalid records never enter the decoder',
receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(12))));
assert.equal(calls,0);
});
test('source age expires even when metadata repeats, old frames never look live',()=>{
let now=10000;const gate=previewFreshness(()=>now);
assert.equal(gate.fresh,false);
gate.receive({type:'lidar-state',sequence:1,age_ms:250,points:3});
assert.equal(gate.fresh,true);assert.equal(gate.points,3);
now+=1800;gate.receive({type:'lidar-state',sequence:1,age_ms:0,points:3});
assert.equal(gate.fresh,false);
gate.receive({type:'lidar-state',sequence:2,age_ms:9000,points:3});
assert.equal(gate.fresh,false);
gate.receive({type:'lidar-state',sequence:3,age_ms:0,points:4});
assert.equal(gate.fresh,true);assert.equal(gate.points,4);
assert.throws(()=>gate.receive({type:'lidar-state',sequence:4,age_ms:-1,points:4}));
});
test('onboard camera admits immediate sourceopen and ignores callbacks after close',()=>{
const saved={media:globalThis.MediaSource,create:URL.createObjectURL,revoke:URL.revokeObjectURL};
let source,buffer,appended=0,ready=0,failed=0;
class Buffer extends EventTarget {updating=false;buffered={length:0};appendBuffer(){appended++;}}
class Media extends EventTarget {
readyState='open';static isTypeSupported(){return true;}
constructor(){super();source=this;}
addSourceBuffer(){buffer=new Buffer();return buffer;}
}
class Video extends EventTarget {currentTime=0;set src(_){source.dispatchEvent(new Event('sourceopen'));}pause(){}load(){}removeAttribute(){}play(){return Promise.resolve();}}
globalThis.MediaSource=Media;URL.createObjectURL=()=> 'blob:synthetic';URL.revokeObjectURL=()=>{};
try{
const video=new Video(),decoder=previewCamera(video,()=>ready++,()=>failed++);
decoder.open('video/mp4');decoder.push(new Uint8Array([1,2,3]));
assert.equal(appended,1);
video.dispatchEvent(new Event('loadeddata'));assert.equal(ready,1);
decoder.close();video.dispatchEvent(new Event('loadeddata'));buffer.dispatchEvent(new Event('error'));
assert.equal(ready,1);assert.equal(failed,0);
}finally{globalThis.MediaSource=saved.media;URL.createObjectURL=saved.create;URL.revokeObjectURL=saved.revoke;}
});
@@ -414,7 +414,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
test("recording preparation statuses share the viewer's left alignment", async () => {
const spatialStyles = await readFile(
new URL("../src/styles/spatial.css", import.meta.url),
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
"utf8",
);
const statusStack = spatialStyles.slice(
@@ -280,7 +280,7 @@ test("recorded player skips only a proven buffered corrupt timestamp interval",
test("loading and error overlays fully conceal recorded camera pixels", async () => {
const css = await readFile(
new URL("../src/styles/observation.css", import.meta.url),
new URL("../../../packages/spatial-ui/src/observation.css", import.meta.url),
"utf8",
);
assert.match(
@@ -382,7 +382,7 @@ test("the spatial header reports raw replay as an active source", async () => {
});
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
const css = await readFile(new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url), "utf8");
assert.match(
css,
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
+1
View File
@@ -33,6 +33,7 @@ def provenance():
"design_guideline_commit": DG_COMMIT,
"design_guideline_files": guideline_sources(),
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
"shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()},
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.8"
VERSION = "0.8.9"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
@@ -0,0 +1,81 @@
# K1 onboard live presentation R12
## Owner request and composition decision
The operator starts the enrolled K1 from Fleet and observes the live point cloud
and camera from its paired onboard computer. The owner explicitly selected the
established direct K1 live-acquisition template for this detail view: shared
engine/layer/display tools, accumulation, floating resizable camera, source
visibility, metrics and expansion. An independent simplified onboard viewer was
rejected because it had already drifted in layout, controls and status meaning.
This is reuse within the admitted Fleet sensor-detail surface, not a new product
root, navigation change or Design Guideline primitive. The existing manual
START/STOP authority remains in the device control card above the shared scene.
Primitives remain Design Guideline Button/IconButton, SettingsCard, StatusBadge,
Inspector, Window and WorkspaceWindow. Shared domain composition is extracted to
`packages/spatial-ui`: both the direct host and onboard plugin consume the same
controls, timeline, media frame, source picker and styles. Direct live, recorded
and LAB source/transport/profile owners remain separate. No recorded blueprint,
archive format, physical command or reconnect supervisor policy changes here.
## Evidence and fault
Owner's sequence after installing R11: Bluetooth/Wi-Fi enrollment, manual START
and reopening Mission Core succeeded. The viewer repeatedly showed upstream
welcome/loading chrome, eventually displayed points, had no camera and could
present old points after a source pause. This confirms those user-observed
behaviors only; it does not establish full live-preview acceptance.
The native Rerun binary sink returns `None` when there are no new bytes.
`RrdSubscriber.run` called `len(payload)` unconditionally, swallowed TypeError
and closed the subscription. NodeMediaPeers then closed the whole media peer,
including camera delivery. Browser retry constructed another native runtime.
A local native SDK reproduction and an actual loopback WebRTC regression now
cover idle reads, idle media channels and subsequent new point/camera data.
A second presentation defect treated existence of the blueprint's stream_time
range as proof of lidar data. Preview protocol v2 adds bounded sequence/age/point
metadata after native RRD bytes. Only fresh source frames admit the scene.
Repeated metadata cannot renew freshness. Old decoded frames are discarded
before encoding; a newly opened subscriber does not replay expired cached data.
No operator/board wall-clock synchronization is required for the age gate.
## Implementation boundaries
- Native runtime lifetime is the mounted scene, separate from its disposable
WebRTC media connection. Recovery opens a new recording channel inside the
existing runtime. No automatic START, STOP, Wi-Fi or BLE action is added.
- Native Following remains owned by the live blueprint. Polling no longer
repeatedly pauses and positions its cursor.
- Both upstream loading/welcome content and native header/view tabs are masked
using the same presentation gate and crop as the established direct viewer.
- The heading is “Пространственная модель”; “Статус” is left aligned and its
message is centered. Physical action loaders remain inside their button.
- Display edits are serialized live-acquisition settings operations. Closing a
modeless tool flushes changes and closes unconditionally; failures use the
host's existing error channel. LAB and recorded settings are unchanged.
- Camera visibility is local presentation, with its decoder kept mounted when
hidden. Acquisition and the durable camera producer remain onboard-owned.
## Validation and acceptance
Completed at draft time: both TypeScript projects; architecture/focused frontend
checks except the deliberately obsolete old viewer-class assertion (updated to
assert shared template and masked initial presentation); 12 Python checks across
native subscriber and media, including one bounded loopback WebRTC peer with two
channels and idle/resume. No real BLE/MQTT commands were issued for these checks.
Full Control Station regression: 802/802 passed. Control Station production
build passed. Node package build and physical acceptance remain pending.
R12 packages target Node 0.8.9 and optional K1 0.1.9. Immutable source/artifact
identity, installation and acceptance are recorded after packaging.
Required physical acceptance remains: owner clears Chrome cache before each
UI run; enrolled K1 START; both actual points and camera; settings/accumulation;
normal/expanded windows and Escape; STOP; pause/recovery; browser reopen without
stale live data. The agent's CUA session exposes the in-app browser, not Chrome
cache reset. Do not describe that as a completed clean-cache hardware test.
No Ops publication or Git push is attempted here: prior specific automatic
approval rejections remain unresolved. Private raw artifacts remain outside Git.
+1 -1
View File
@@ -35,7 +35,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
return <div className="sensor-workspace">{device?Detail?<Detail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
+1
View File
@@ -2,3 +2,4 @@ export * from './contracts';
export type * from './enrollment';
export type * from './rerunHost';
export type * from './extensions';
export * from '../../spatial-ui/src';
+2 -1
View File
@@ -1,9 +1,10 @@
/** Native renderer capability supplied by each host, independent of device APIs. */
export interface LiveRerunViewer {
start:(source:string|string[]|null,parent:HTMLElement,options:{width:string;height:string;hide_welcome_screen:boolean;enable_history:boolean})=>Promise<void>;
start:(source:string|string[]|null,parent:HTMLElement,options:{width:string;height:string;hide_welcome_screen:boolean;enable_history:boolean;theme?:'dark'|'light';allow_fullscreen?:boolean;panel_state_overrides?:Record<string,string>})=>Promise<void>;
open_channel:(name:string)=>{ready:boolean;send_rrd:(bytes:Uint8Array)=>void;close:()=>void};
override_panel_state:(panel:'top'|'blueprint'|'selection'|'time',state:'hidden'|'collapsed'|'expanded')=>void;
get_active_recording_id:()=>string|null;
get_active_timeline:(id:string)=>string|null;
get_time_range:(id:string,timeline:string)=>{min:number;max:number}|null;
set_active_timeline:(id:string,timeline:string)=>void;
set_current_time:(id:string,timeline:string,time:number)=>void;
@@ -0,0 +1,130 @@
import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
import { WorkspaceWindow } from "@nodedc/ui-react";
export interface ObservationWindowRect {x:number;y:number;width:number;height:number}
const WINDOW_WIDTH = 336;
const WINDOW_HEIGHT = 210;
const WINDOW_GAP = 16;
const WINDOW_INSET = 18;
const TIMELINE_CLEARANCE = 64;
type ClosestTarget = { closest: (selectors: string) => unknown };
export function shouldCaptureWorkspacePointer(
button: number,
target: ClosestTarget | null,
): boolean {
if (button !== 0 || !target) return false;
if (target.closest(".nodedc-workspace-window__resize")) return true;
if (!target.closest(".nodedc-workspace-window__head")) return false;
return !target.closest("button, input, select, textarea, a");
}
export function initialObservationWindowRect(
index: number,
count = 1,
bounds: { width: number; height: number } = { width: 1280, height: 720 },
): ObservationWindowRect {
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
const maxColumns = Math.max(
1,
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
);
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
const rowHeight = Math.max(
1,
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
);
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
const row = Math.floor(index / columns);
const column = index % columns;
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
return {
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
y: Math.max(
0,
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
),
width,
height,
};
}
export function FloatingMediaWindow({title,subtitle,status,footer,children,index=0,count=1,boundsRef,rect,maximized,active,hidden=false,resizable=true,onRectChange,onMaximizedChange,onActivate,onClose}:{
title:string;subtitle?:string;status?:ReactNode;footer?:ReactNode;children:ReactNode;
index?:number;count?:number;boundsRef:RefObject<HTMLElement|null>;rect?:ObservationWindowRect;
maximized:boolean;active:boolean;hidden?:boolean;resizable?:boolean;
onRectChange:(rect:ObservationWindowRect)=>void;onMaximizedChange:(value:boolean)=>void;
onActivate:()=>void;onClose:()=>void;
}) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
useLayoutEffect(() => {
const element = boundsRef.current;
if (!element) return;
const measure = () => {
const next = { width: element.clientWidth, height: element.clientHeight };
setBounds((current) => (
current?.width === next.width && current.height === next.height ? current : next
));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [boundsRef]);
// Camera media admission must not wait for the viewport observer. A source
// can arrive in the same commit as a workspace transition; mounting it with
// the canonical fallback geometry keeps the browser transport alive until
// the real bounds are measured immediately afterward.
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
const windowRect = rect ?? initialRect;
return (
<WorkspaceWindow
boundsRef={boundsRef}
rect={windowRect}
onRectChange={onRectChange}
maximized={maximized}
onMaximizedChange={onMaximizedChange}
onActivate={onActivate}
onClose={onClose}
title={title}
subtitle={subtitle}
status={status}
footer={footer}
minWidth={Math.min(280, windowRect.width)}
minHeight={Math.min(190, windowRect.height)}
resizable={resizable}
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
onPointerDownCapture={(event) => {
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture can fail if the browser ended the pointer between
// dispatch and capture. The donor's window listeners remain fallback.
}
}}
closeLabel={`Закрыть ${title}`}
maximizeLabel={`Развернуть ${title}`}
restoreLabel={`Восстановить ${title}`}
moveLabel={`Переместить ${title}`}
resizeLabel={`Изменить размер ${title}`}
>
{children}
</WorkspaceWindow>
);
}
@@ -0,0 +1,127 @@
import {Dropdown,Icon,type IconName} from '@nodedc/ui-react';
export type ObservationSourceModality='point-cloud'|'video'|'image'|'depth';
export type ObservationSourceAvailability='unverified'|'declared'|'available'|'connecting'|'streaming'|'degraded'|'unavailable'|'error';
export interface SpatialSourceDescriptor {id:string;label:string;modality:ObservationSourceModality;availability:ObservationSourceAvailability;endpointLabel?:string|null;transport:string;previewUrl?:string|null;delivery?:unknown;activation?:{controllable:boolean}|null}
export 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: SpatialSourceDescriptor): string {
return availabilityCopy[source.availability];
}
export function ObservationSourcePicker({
sources,
visibleSourceIds,
pendingSourceIds,
onToggle,
}: {
sources: readonly SpatialSourceDescriptor[];
visibleSourceIds: ReadonlySet<string>;
pendingSourceIds?: ReadonlySet<string>;
onToggle: (sourceId: string) => void | Promise<boolean>;
}) {
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);
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}
type="button"
className="nodedc-dropdown-option observation-source-option"
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
disabled={pending || !canOpen}
onClick={() => void 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" />
{pending ? "Переключение" : 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">
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
модель оборудования.
</footer>
</Dropdown>
);
}
@@ -0,0 +1,197 @@
import { Button, Icon, Select } from "@nodedc/ui-react";
type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
export function ObservationTimeline({
active,
sourceCount,
mode = "live-only",
seekable = false,
synchronization = "host-arrival-best-effort",
rangeNs = null,
currentNs = null,
playing = false,
onSeek,
onPlayingChange,
onJumpToEnd,
showJumpToEnd = true,
playbackRate,
onPlaybackRateChange,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
className = "",
}: {
active: boolean;
sourceCount: number;
mode?: ObservationTimelineMode;
seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
rangeNs?: { min: number; max: number } | null;
currentNs?: number | null;
playing?: boolean;
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
showJumpToEnd?: boolean;
playbackRate?: number;
onPlaybackRateChange?: (rate: number) => void;
accumulationSeconds?: number;
onAccumulationChange?: (value: number) => void;
onAccumulationCommit?: () => void;
className?: string;
}) {
const playbackRange = normalizeTimelineRange(rangeNs);
const buffered = Boolean(
seekable &&
(mode === "buffered" || mode === "recorded") &&
playbackRange,
);
const elapsedSeconds = playbackRange
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
: 0;
const durationSeconds = playbackRange
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
: 0;
const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация",
}[synchronization];
const accumulationValue = accumulationSeconds === undefined
? null
: normalizeAccumulationSeconds(accumulationSeconds);
return (
<div
className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined}
data-mode={mode}
data-accumulation={accumulationValue !== null ? "true" : undefined}
>
{accumulationValue !== null ? (
<div className="observation-timeline__accumulation">
<span>Накопление</span>
<input
className="observation-timeline__track"
type="range"
min={0}
max={120}
step={1}
value={accumulationValue}
aria-label="Окно накопления облака точек"
aria-valuetext={formatAccumulationDuration(accumulationValue)}
onChange={(event) =>
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
onPointerUp={onAccumulationCommit}
onTouchEnd={onAccumulationCommit}
onKeyUp={onAccumulationCommit}
onBlur={onAccumulationCommit}
/>
<code>{formatAccumulationDuration(accumulationValue)}</code>
</div>
) : null}
<div className="observation-timeline__playback">
<Button
size="compact"
variant="ghost"
disabled={!buffered || !onSeek}
aria-label="Перейти к началу"
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
>
<Icon name="chevron-left" />
</Button>
<Button
className="observation-timeline__transport"
size="compact"
variant="ghost"
disabled={!buffered || !onPlayingChange}
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
icon={<Icon name={playing ? "stop" : "play"} />}
onClick={() => onPlayingChange?.(!playing)}
/>
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
<Select
label="Скорость воспроизведения"
value={String(playbackRate)}
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
variant="inline"
menuWidth="anchor"
onChange={(value) => onPlaybackRateChange(Number(value))}
/>
) : null}
<input
className="observation-timeline__track"
type="range"
min={0}
max={Math.max(durationSeconds, 0.001)}
step={0.01}
value={Math.min(elapsedSeconds, durationSeconds)}
disabled={!buffered || !onSeek}
aria-label="Позиция воспроизведения"
onChange={(event) => {
if (!playbackRange) return;
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
}}
/>
<div className="observation-timeline__meta">
<code>
{buffered
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
: active ? "LIVE" : "—:—:—.———"}
</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
{showJumpToEnd ? (
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
) : null}
</div>
</div>
);
}
export function normalizeAccumulationSeconds(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(120, Math.max(0, Math.round(value)));
}
export function formatAccumulationDuration(value: number): string {
const seconds = normalizeAccumulationSeconds(value);
return seconds === 0 ? "Кадр" : `${seconds} с`;
}
export function normalizeTimelineRange(
range: { min: number; max: number } | null | undefined,
): { min: number; max: number } | null {
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
if (range.max <= range.min) return null;
return range;
}
export function timelineOffsetSeconds(
range: { min: number; max: number },
currentNs: number,
): number {
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
return Math.max(0, (clamped - range.min) / 1_000_000_000);
}
export function formatTimelineDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
const wholeMinutes = Math.floor(seconds / 60);
const remaining = seconds - wholeMinutes * 60;
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
}
@@ -0,0 +1,150 @@
import {Checker,ColorField,ControlRow,Inspector,RangeControl,Select} from '@nodedc/ui-react';
import type {SceneSettings,PointColorMode,PointPalette} from './sceneSettings';
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
];
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
];
export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDisplayPatch,flushDisplaySettings,replayPresented=false}:{
displayDraft:SceneSettings;stageDisplayPatch:(patch:Partial<SceneSettings>)=>void;
commitDisplayPatch:(patch:Partial<SceneSettings>)=>void;flushDisplaySettings:()=>void;replayPresented?:boolean;
}) { return (
<Inspector
defaultOpen={["points"]}
singleOpen
sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
min={0.5}
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
/>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Атрибут цвета">
<Select
variant="split"
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
/>
</ControlRow>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Палитра">
<Select
variant="split"
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => stageDisplayPatch({ palette })}
/>
</ControlRow>
</div>
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => stageDisplayPatch({ customColor })}
/>
</ControlRow>
</div>
) : null}
{replayPresented ? (
<p className="scene-window-note">
Первый новый режим читает индекс архивных точек. Следующие палитры
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
</p>
) : null}
</div>
),
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
min={0}
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
/>
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
<Checker
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
/>
</div>
</div>
),
},
{
id: "scene",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: (
<div className="inspector-control-stack">
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
); }
@@ -0,0 +1,62 @@
import {Checker,Inspector} from '@nodedc/ui-react';
import type {SceneSettings} from './sceneSettings';
export function SceneLayerControls({sceneSettings,pending=false,applyScenePatch}:{sceneSettings:SceneSettings;pending?:boolean;applyScenePatch:(patch:Partial<SceneSettings>)=>void}) { return (
<Inspector
defaultOpen={["geometry"]}
singleOpen
sections={[
{
id: "geometry",
label: "Геометрия",
description: "Облако точек и путь",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
<Checker disabled={pending} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
<Checker disabled={pending} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div>
</div>
),
},
{
id: "frames",
label: "Координаты и камеры",
description: "Преобразования и области обзора",
content: (
<div className="inspector-control-stack">
<Checker disabled={pending} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
{
id: "perception",
label: "Объекты и маски",
description: "Ожидают внешние обработчики",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
); }
@@ -0,0 +1,8 @@
import {Button,Icon} from '@nodedc/ui-react';
export function SpatialToolbarActions({openSource,openLayers,openDisplay}:{openSource:()=>void;openLayers:()=>void;openDisplay:()=>void}) {
return <>
<Button size="compact" variant="secondary" icon={<Icon name="network"/>} onClick={openSource}>Движок</Button>
<Button size="compact" variant="secondary" icon={<Icon name="list"/>} onClick={openLayers}>Слои</Button>
<Button size="compact" variant="secondary" icon={<Icon name="sliders"/>} onClick={openDisplay}>Отображение</Button>
</>;
}
+10
View File
@@ -0,0 +1,10 @@
import './spatial.css';
import './scene-windows.css';
import './observation.css';
export * from './SceneDisplayControls';
export * from './SceneLayerControls';
export * from './SpatialToolbarActions';
export * from './FloatingMediaWindow';
export * from './ObservationTimeline';
export * from './sceneSettings';
export * from './ObservationSourcePicker';
+659
View File
@@ -0,0 +1,659 @@
.scene-source-controls {
position: absolute;
z-index: 12;
top: 0.85rem;
left: 0.85rem;
display: flex;
align-items: center;
gap: 0.45rem;
}
.scene-device-controls {
position: absolute;
z-index: 30;
top: 0.85rem;
left: 50%;
max-width: calc(100% - 9rem);
transform: translateX(-50%);
}
.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-navigation-hint {
position: absolute;
z-index: 11;
right: 0.85rem;
bottom: 4.9rem;
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(9 10 13 / 0.7);
color: var(--nodedc-text-muted);
padding: 0.42rem 0.65rem;
font-size: 0.52rem;
font-weight: 680;
pointer-events: none;
backdrop-filter: blur(14px);
}
.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;
font-family: var(--nodedc-font-family);
}
.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;
}
.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: var(--nodedc-font-size-md);
}
.observation-source-menu__head small,
.observation-source-menu__foot {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
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: 0;
}
.observation-media__asset,
.observation-media__empty {
width: 100%;
height: 100%;
}
.observation-media__asset {
display: block;
object-fit: contain;
background: #050608;
}
.recorded-media-player {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #070809;
}
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
visibility: hidden;
}
.recorded-media-player__notice {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
color: rgba(247, 248, 244, 0.72);
background: #070809;
font: inherit;
font-size: 11px;
text-align: center;
pointer-events: none;
}
.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;
}
.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);
}
.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;
}
.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;
}
.floating-observation-window.nodedc-material-rim::after {
display: none;
}
.floating-observation-window[data-active="true"] {
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
}
.floating-observation-window--hidden {
visibility: hidden;
pointer-events: none;
}
.observation-timeline {
display: block;
min-width: 0;
border: 0;
border-radius: 0.9rem;
background: rgb(9 10 13 / 0.78);
padding: 0.4rem;
backdrop-filter: blur(16px);
}
.observation-timeline[data-accumulation="true"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
gap: 0.8rem;
}
.observation-timeline__playback {
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
}
.observation-timeline__playback > .nodedc-select-anchor {
display: inline-flex;
width: auto;
flex: none;
}
.observation-timeline__transport {
width: var(--nodedc-control-height-compact);
padding: 0;
border-radius: var(--nodedc-radius-circle);
}
.observation-timeline__accumulation {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
padding: 0 0.45rem;
}
.observation-timeline__accumulation span,
.observation-timeline__accumulation code {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
white-space: nowrap;
}
.observation-timeline__accumulation code {
min-width: 2.65rem;
color: var(--nodedc-text-primary);
text-align: right;
}
.observation-timeline__track {
width: 100%;
height: 0.3rem;
appearance: none;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
cursor: pointer;
}
.observation-timeline__track::-webkit-slider-thumb {
width: 0.72rem;
height: 0.72rem;
appearance: none;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track::-moz-range-thumb {
width: 0.72rem;
height: 0.72rem;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track:disabled {
opacity: 0.46;
cursor: default;
}
.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);
border: 0;
background: transparent;
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.observation-timeline__follow:not(:disabled) {
cursor: pointer;
}
.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: none;
}
.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[data-accumulation="true"] {
grid-template-columns: minmax(0, 1fr);
}
.observation-timeline__playback {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-timeline__playback > .nodedc-button:first-child,
.observation-timeline__meta {
display: none;
}
.camera-grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(14rem, 1fr);
}
}
+85
View File
@@ -0,0 +1,85 @@
.scene-tool-window {
--scene-tool-window-top: 5.75rem;
--scene-tool-window-right: 27rem;
position: fixed;
top: var(--scene-tool-window-top);
right: var(--scene-tool-window-right);
max-height: calc(100vh - var(--scene-tool-window-top) - 1.5rem);
}
.scene-tool-window--display {
--scene-tool-window-top: 7.75rem;
--scene-tool-window-right: 28.5rem;
}
.scene-tool-window--layers {
--scene-tool-window-top: 9.75rem;
--scene-tool-window-right: 30rem;
}
.scene-tool-window--layout {
--scene-tool-window-top: 11.75rem;
--scene-tool-window-right: 31.5rem;
}
.nodedc-overlay:has(> .scene-tool-window[data-scene-active="true"]) {
z-index: calc(var(--nodedc-layer-overlay) + 1);
}
.nodedc-overlay[data-placement="center"] {
z-index: calc(var(--nodedc-layer-overlay) + 2);
}
.scene-window-state {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.5rem;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.scene-window-code {
display: block;
overflow: hidden;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-field-bg);
color: var(--nodedc-text-secondary);
padding: 0.72rem 0.8rem;
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-window-note {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.5;
}
@media (max-width: 44rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 4.75rem;
--scene-tool-window-right: 0.875rem;
max-height: calc(100vh - 5.625rem);
}
}
@media (min-width: 44.01rem) and (max-width: 70rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 13.5rem;
--scene-tool-window-right: 1.5rem;
max-height: calc(100vh - 15rem);
}
}
.inspector-control-stack { display:grid; gap:1rem; }
+31
View File
@@ -0,0 +1,31 @@
export type SceneProjection = "3d" | "2d" | "map";
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
export interface SceneSettings {
projection: SceneProjection;
pointSize: number;
colorMode: PointColorMode;
palette: PointPalette;
customColor: string;
accumulationSeconds: number;
showPoints: boolean;
showTrajectory: boolean;
showGrid: boolean;
showLabels: boolean;
showCameraFrustums: boolean;
}
export const defaultSceneSettings: SceneSettings = {
projection: "3d",
pointSize: 2.5,
colorMode: "intensity",
palette: "turbo",
customColor: "#35d7c1",
accumulationSeconds: 12,
showPoints: true,
showTrajectory: true,
showGrid: true,
showLabels: false,
showCameraFrustums: true,
};
+484
View File
@@ -0,0 +1,484 @@
.spatial-workspace {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 34rem;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0.65rem;
overflow: hidden;
}
.spatial-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-start;
gap: 1rem;
}
.spatial-toolbar__actions {
display: flex;
min-width: 0;
align-items: center;
gap: 0.45rem;
}
.spatial-toolbar__view-switch {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(255 255 255 / 0.025);
padding: 0.2rem;
}
.spatial-toolbar__view-switch .nodedc-button {
border-radius: 999px;
}
.spatial-viewport-shell {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 1rem;
background: #06070a;
box-shadow: none;
}
.rerun-viewport,
.rerun-viewport__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.rerun-viewport__canvas[data-presented="false"] {
visibility: hidden;
}
.recorded-session-preloaders {
position: fixed;
left: -10000px;
top: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.rerun-viewport {
--rerun-native-chrome-height: 72px;
overflow: hidden;
}
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
panels are overridden: the native top row, recording tab and view tab.
They are drawn inside WASM and cannot be styled independently, so crop the
fixed native chrome while keeping the actual 3D viewport full-height. */
.rerun-viewport__canvas {
top: calc(-1 * var(--rerun-native-chrome-height));
bottom: auto;
height: calc(100% + var(--rerun-native-chrome-height));
}
.rerun-viewport__runtime {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.rerun-viewport__canvas canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.rerun-viewport__camera-lock {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
left: 0;
width: calc(46% - 2px);
cursor: default;
touch-action: none;
}
.rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas {
visibility: hidden;
pointer-events: none;
}
.rerun-viewport__notice {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
gap: 0.8rem;
border: 0;
border-radius: 1rem;
background: rgb(10 11 14 / 0.82);
padding: 0.9rem 1rem;
transform: translate(-50%, -50%);
backdrop-filter: blur(18px);
}
.rerun-viewport__notice strong,
.rerun-viewport__notice span {
display: block;
}
.rerun-viewport__notice strong {
font-size: 0.72rem;
}
.rerun-viewport__notice span {
margin-top: 0.18rem;
color: var(--nodedc-text-muted);
font-size: 0.61rem;
}
.rerun-viewport__notice--error {
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;
flex: 0 0 1rem;
border: 2px solid rgb(255 255 255 / 0.12);
border-top-color: var(--nodedc-text-primary);
border-radius: 50%;
animation: viewer-spin 900ms linear infinite;
}
@keyframes viewer-spin {
to { transform: rotate(360deg); }
}
.empty-spatial-stage {
position: absolute;
inset: 0;
overflow: hidden;
background: #06070a;
}
.empty-spatial-stage__grid {
display: none;
}
.spatial-axis {
position: absolute;
right: 2rem;
bottom: 5.2rem;
width: 4.4rem;
height: 4.4rem;
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.spatial-axis::before,
.spatial-axis::after {
position: absolute;
bottom: 0.65rem;
left: 0.75rem;
width: 2.7rem;
height: 1px;
background: rgb(255 255 255 / 0.2);
content: "";
transform-origin: left center;
}
.spatial-axis::before { transform: rotate(-24deg); }
.spatial-axis::after { transform: rotate(-90deg); }
.spatial-axis span { position: absolute; }
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
.empty-spatial-stage__message {
position: absolute;
top: 50%;
left: 50%;
display: grid;
max-width: 25rem;
justify-items: center;
gap: 0.62rem;
transform: translate(-50%, -58%);
text-align: center;
}
.empty-spatial-stage__icon {
display: grid;
width: 3.4rem;
height: 3.4rem;
place-items: center;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.035);
color: var(--nodedc-text-secondary);
}
.empty-spatial-stage__message strong {
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.empty-spatial-stage__message p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.67rem;
line-height: 1.5;
}
.scene-status,
.scene-metrics,
.scene-adapter-note,
.scene-selection,
.scene-timeline {
position: absolute;
z-index: 3;
border: 0;
background: rgb(9 10 13 / 0.74);
backdrop-filter: blur(16px);
}
.scene-status--top-left {
top: 0.85rem;
left: 0.85rem;
display: grid;
justify-items: start;
gap: 0.42rem;
border-radius: 0.9rem;
padding: 0.7rem;
}
.scene-status small {
max-width: 18rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
line-height: 1.35;
}
.scene-metrics {
top: 0.85rem;
right: 0.85rem;
display: grid;
min-width: 10rem;
border-radius: 0.9rem;
padding: 0.35rem 0.75rem;
}
.scene-metrics > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
border-bottom: 0;
padding: 0.38rem 0;
}
.scene-metrics > div:last-child { border-bottom: 0; }
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
.scene-adapter-note {
top: 50%;
left: 50%;
display: flex;
max-width: 34rem;
align-items: center;
gap: 0.65rem;
border-radius: 0.9rem;
color: var(--nodedc-text-secondary);
padding: 0.75rem 0.9rem;
font-size: 0.63rem;
line-height: 1.4;
transform: translate(-50%, 5.2rem);
}
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
.scene-selection {
right: 0.85rem;
bottom: 4.6rem;
display: grid;
max-width: 19rem;
gap: 0.2rem;
border-radius: 0.85rem;
padding: 0.65rem 0.75rem;
}
.scene-selection span,
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
.scene-operation-status-stack {
position: absolute;
z-index: 11;
left: 0.85rem;
bottom: 7.15rem;
display: grid;
justify-items: start;
gap: 0.3rem;
}
.scene-operation-status {
display: inline-flex;
max-width: min(24rem, calc(100% - 1.7rem));
align-items: center;
gap: 0.42rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(9 10 13 / 0.7);
color: var(--nodedc-text-muted);
padding: 0.42rem 0.65rem;
font: inherit;
font-size: 0.52rem;
font-weight: 680;
line-height: 1;
white-space: nowrap;
backdrop-filter: blur(14px);
}
.scene-operation-status .busy-indicator {
width: 0.66rem;
height: 0.66rem;
flex-basis: 0.66rem;
border-width: 1px;
}
.scene-operation-status--action {
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.scene-operation-status--action:hover,
.scene-operation-status--action:focus-visible {
border-color: rgb(255 255 255 / 0.14);
background: rgb(20 21 25 / 0.82);
color: var(--nodedc-text-primary);
outline: none;
}
.scene-operation-status--error {
color: rgb(var(--nodedc-danger-rgb));
}
.scene-timeline {
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
border-radius: 0.9rem;
padding: 0.4rem;
}
.scene-timeline__track {
height: 0.3rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
}
.scene-timeline__track span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--nodedc-text-primary);
}
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
.scene-timeline__follow {
display: inline-flex;
align-items: center;
gap: 0.38rem;
border-radius: var(--nodedc-radius-circle);
background: transparent;
color: var(--nodedc-text-muted);
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.scene-timeline__follow::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.scene-timeline__follow[data-active="true"] {
background: transparent;
color: var(--nodedc-text-primary);
}
.scene-timeline__follow[data-active="true"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.spatial-contract-strip {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 1rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
}
.spatial-contract-strip span {
display: inline-flex;
align-items: center;
gap: 0.38rem;
}
.spatial-contract-strip i {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
@media (prefers-reduced-motion: reduce) {
.busy-indicator { animation: none; }
}
@@ -3,13 +3,15 @@ import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from
import {perform,type Sensor,type SensorTransport} from './runtime';
import {K1LiveView} from './K1LiveView';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
import {K1LiveSettings} from './K1LiveSettings';
import {K1SceneWindows,type SceneTool} from './K1SceneWindows';
import {useK1SceneSettings} from './useK1SceneSettings';
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation';
import {pendingPreview} from './useK1Preview';
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
const [busy,setBusy]=useState(''),[settings,setSettings]=useState(false),[operationError,setOperationError]=useState('');
const [busy,setBusy]=useState(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
const [preview,setPreview]=useState(pendingPreview);
const scene=useK1SceneSettings(device,transport,failure);
const running=useRef(false);
const failedAction=useRef('');
const streaming=device.snapshot.acquisition==='streaming';
@@ -27,15 +29,15 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
return <div className="sensor-content">
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setTool('display')}><Icon name="settings"/></IconButton>}>
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart} aria-busy={pending==='start'}
icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
{manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
{!manual.connected&&device.control?.can_verify&&<Button disabled={!enabled||!!pending} aria-busy={pending==='verify'} icon={pending==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
</SettingsCard>
<SettingsCard title="Статус" align="center" description={streaming?`${summary} ${preview.lidar}. ${preview.camera}.`:summary} role="status" aria-live="polite"/>
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!pending} refresh={refresh} failure={failure}/>}
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview}/>}
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></SettingsCard>
{manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled} scene={scene} openTool={setTool}/>}
</div>;
}
@@ -1,28 +0,0 @@
import {useState} from 'react';
import {Button,Select,SettingsCard,Switch,TextField} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './runtime';
export interface LiveSettings {
point_size:number; accumulation_seconds:number;
color_mode:'intensity'|'height'|'distance'|'rgb'|'class';
palette:'turbo'|'viridis'|'plasma'|'grayscale'|'custom'; custom_color:string;
show_points:boolean;show_trajectory:boolean;show_grid:boolean;
show_detections_2d:boolean;show_segmentation:boolean;show_cuboids_3d:boolean;
}
const defaults:LiveSettings={point_size:2.5,accumulation_seconds:12,color_mode:'intensity',palette:'turbo',custom_color:'#f7f8f4',show_points:true,show_trajectory:true,show_grid:true,show_detections_2d:false,show_segmentation:false,show_cuboids_3d:false};
export function K1LiveSettings({device,transport,enabled,refresh,failure}:{device:Sensor;transport:SensorTransport;enabled:boolean;refresh:()=>Promise<void>;failure:(error:unknown)=>void}){
const [settings,setSettings]=useState<LiveSettings>({...defaults,...device.live_settings});
const [busy,setBusy]=useState(false);
const valid=Number.isFinite(settings.point_size)&&settings.point_size>=0.5&&settings.point_size<=12&&Number.isFinite(settings.accumulation_seconds)&&settings.accumulation_seconds>=0&&settings.accumulation_seconds<=120;
async function apply(){setBusy(true);failure(null);try{await perform(transport,device,'option',{profile:'live-acquisition',settings});await refresh();}catch(error){failure(error);}finally{setBusy(false);}}
return <SettingsCard title="Живой просмотр" description="Настройки текущего потока с БК. Воспроизведение записей и результаты LAB настраиваются отдельно.">
<div className="sensor-fields">
<TextField label="Размер точек" type="number" min={0.5} max={12} step={0.5} value={String(settings.point_size)} disabled={busy} onChange={event=>setSettings(v=>({...v,point_size:Number(event.target.value)}))}/>
<TextField label="Накопление, с" type="number" min={0} max={120} step={1} value={String(settings.accumulation_seconds)} disabled={busy} onChange={event=>setSettings(v=>({...v,accumulation_seconds:Number(event.target.value)}))}/>
<Select label="Цвет точек" value={settings.color_mode} disabled={busy} options={[{value:'intensity',label:'Интенсивность'},{value:'height',label:'Высота'},{value:'distance',label:'Расстояние'},{value:'rgb',label:'Цвет камеры'},{value:'class',label:'Класс'}]} onChange={value=>setSettings(v=>({...v,color_mode:value as LiveSettings['color_mode']}))}/>
<Select label="Палитра" value={settings.palette} disabled={busy} options={[{value:'turbo',label:'Turbo'},{value:'viridis',label:'Viridis'},{value:'plasma',label:'Plasma'},{value:'grayscale',label:'Оттенки серого'}]} onChange={value=>setSettings(v=>({...v,palette:value as LiveSettings['palette']}))}/>
</div>
<div className="sensor-actions"><Switch label="Точки" checked={settings.show_points} disabled={busy} onChange={value=>setSettings(v=>({...v,show_points:value}))}/><Switch label="Траектория" checked={settings.show_trajectory} disabled={busy} onChange={value=>setSettings(v=>({...v,show_trajectory:value}))}/><Switch label="Сетка" checked={settings.show_grid} disabled={busy} onChange={value=>setSettings(v=>({...v,show_grid:value}))}/><Button disabled={!enabled||busy||!valid} onClick={()=>void apply()}>Применить</Button></div>
</SettingsCard>;
}
@@ -1,26 +1,69 @@
import {useCallback,useEffect,useRef,useState} from 'react';
import {Icon,IconButton,SettingsCard} from '@nodedc/ui-react';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
import type {Sensor,SensorTransport} from './runtime';
import {useK1Preview,type PreviewStatus} from './useK1Preview';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Icon, IconButton, SettingsCard, StatusBadge } from '@nodedc/ui-react';
import { FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
import type { Sensor, SensorTransport } from './runtime';
import type { K1SceneState, SceneTool } from './K1SceneWindows';
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
import './livePreview.css';
export function K1LiveView({device,transport,createRerunHost,onStatus}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onStatus:(value:PreviewStatus)=>void}){
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
const [expanded,setExpanded]=useState(false);
const [generation,setGeneration]=useState(0),[status,setStatus]=useState<PreviewStatus|null>(null);
const attempts=useRef(0);
const report=useCallback((value:PreviewStatus)=>{setStatus(value);onStatus(value);},[onStatus]);
useK1Preview(device,transport,createRerunHost,spatial,video,report,generation);
useEffect(()=>{
if(!status?.retry)return;
const timer=setTimeout(()=>setGeneration(value=>value+1),Math.min(30000,2000*2**Math.min(attempts.current++,4)));
return()=>clearTimeout(timer);
},[status?.retry]);
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
return <SettingsCard title="Rerun" className={expanded?'k1-preview sensor-viewer-expanded':'k1-preview'}
actions={<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}>
<div className="k1-preview-spatial" ref={spatial}/>
<video className="k1-preview-camera" hidden={status?.camera!=='Камера: изображение поступает'} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool }: {
device: Sensor;
transport: SensorTransport;
createRerunHost: RerunHostFactory;
onStatus: (value: PreviewStatus) => void;
enabled: boolean;
scene: K1SceneState;
openTool: (tool: SceneTool) => void;
}) {
const spatial = useRef<HTMLDivElement>(null), viewport = useRef<HTMLDivElement>(null), video = useRef<HTMLVideoElement>(null);
const [expanded, setExpanded] = useState(false), [cameraMaximized, setCameraMaximized] = useState(false), [cameraRect, setCameraRect] = useState<ObservationWindowRect>();
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
const attempts = useRef(0);
const report = useCallback((value: PreviewStatus) => { setStatus(value); onStatus(value); }, [onStatus]);
useK1Preview(device, transport, createRerunHost, spatial, video, report, generation, enabled);
useEffect(() => {
if (!status.retry || !enabled)
return;
const timer = setTimeout(() => setGeneration(value => value + 1), Math.min(30000, 2000 * 2 ** Math.min(attempts.current++, 4)));
return () => clearTimeout(timer);
}, [status.retry, enabled]);
useEffect(() => { if (!status.presented)
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
setExpanded(false);
setCameraMaximized(false);
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
const sources: SpatialSourceDescriptor[] = [
{ id: 'lidar', label: 'Облако точек', modality: 'point-cloud', availability: status.presented ? 'streaming' : 'connecting', transport: 'Бортовой компьютер' },
{ id: 'camera', label: 'Камера K1', modality: 'video', availability: status.cameraPresented ? 'streaming' : 'connecting', transport: 'Бортовой компьютер', delivery: true },
];
const toggle = (id: string) => setVisible(current => { const next = new Set(current); if (next.has(id))
next.delete(id);
else
next.add(id); return next; });
return <SettingsCard title="Пространственная модель" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'} actions={<IconButton label={expanded ? 'Свернуть просмотр' : 'Развернуть просмотр'} onClick={() => setExpanded(value => !value)}><Icon name={expanded ? 'minimize' : 'expand'}/></IconButton>}>
<div className="spatial-workspace" data-focused={cameraMaximized ? 'true' : undefined}>
<div className="spatial-toolbar" data-viewer-controls="v1"><div className="spatial-toolbar__actions"><SpatialToolbarActions openSource={() => openTool('source')} openLayers={() => openTool('layers')} openDisplay={() => openTool('display')}/></div></div>
<div ref={viewport} className="spatial-viewport-shell" data-media-maximized={cameraMaximized ? 'true' : undefined}>
<div className="rerun-viewport" data-status={status.presented ? 'ready' : 'loading'}>
<div className="rerun-viewport__canvas" data-presented={status.presented && visible.has('lidar') ? 'true' : 'false'} ref={spatial}/>
</div>
{!cameraMaximized && <div className="scene-source-controls"><ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/></div>}
<div className="scene-status scene-status--top-left" aria-hidden={cameraMaximized}>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span><StatusBadge tone={status.presented ? 'success' : 'neutral'}>{status.presented ? 'Визуализатор готов' : status.retry ? 'Восстановление связи' : 'Ожидаем свежие данные'}</StatusBadge>
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={cameraMaximized}>
<div><span>КАДР/С</span><strong>{status.presented ? (device.frames?.pcl_fps ?? 0).toFixed(1) : '—'}</strong></div>
<div><span>До публикации</span><strong>{status.presented && device.frames?.mqtt_to_publish_ms != null ? device.frames.mqtt_to_publish_ms.toFixed(1) : '—'}<small> мс</small></strong></div>
<div><span>Точек в кадре</span><strong>{status.presented ? status.points.toLocaleString('ru-RU') : '—'}</strong></div>
</div>
{status.presented && !cameraMaximized && <div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
{!cameraMaximized && <ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds => scene.stage({ accumulationSeconds })} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
<FloatingMediaWindow title="Камера K1" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={() => { }} onClose={() => { setCameraMaximized(false); setVisible(current => { const next = new Set(current); next.delete('camera'); return next; }); }} status={<span className="floating-observation-window__status">{status.cameraPresented ? 'Эфир' : 'Ожидание'}</span>} footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
<video className="observation-media__asset" style={{ visibility: status.cameraPresented ? 'visible' : 'hidden' }} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
{!status.cameraPresented && <div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
</FloatingMediaWindow>
</div>
</div>
</SettingsCard>;
}
@@ -0,0 +1,23 @@
import { ControlRow, Inspector, Window } from '@nodedc/ui-react';
import { SceneDisplayControls, SceneLayerControls } from '@mission-core/sensor-sdk';
import type { useK1SceneSettings } from './useK1SceneSettings';
export type SceneTool = 'source' | 'layers' | 'display';
export type K1SceneState = ReturnType<typeof useK1SceneSettings>;
export function K1SceneWindows({ tool, close, scene, connected }: {
tool: SceneTool | null;
close: () => void;
scene: K1SceneState;
connected: boolean;
}) {
const title = tool === 'source' ? 'Визуальный движок' : tool === 'layers' ? 'Слои сцены' : 'Отображение';
return <Window open={tool !== null} title={title} placement="end" draggable closeOnBackdrop={false} closeOnEscape={false} lockBodyScroll={false} trapFocus={false} className={`scene-tool-window scene-tool-window--${tool === 'source' ? 'source' : tool === 'layers' ? 'layers' : 'display'}`} onClose={() => { scene.flush(); close(); }}>
{tool === 'display' && <SceneDisplayControls displayDraft={scene.draft} stageDisplayPatch={scene.stage} commitDisplayPatch={scene.commit} flushDisplaySettings={scene.flush}/>}
{tool === 'layers' && <SceneLayerControls sceneSettings={scene.draft} pending={scene.pending} applyScenePatch={scene.commit}/>}
{tool === 'source' && <Inspector defaultOpen={['connection']} singleOpen sections={[{ id: 'connection', label: 'Подключение', description: 'Живые данные с бортового компьютера', content: <div className="inspector-control-stack">
<ControlRow label="Состояние">{connected ? 'Бортовой компьютер доступен' : 'Нет связи с бортовым компьютером'}</ControlRow>
<ControlRow label="Источник">Камера и лидар K1</ControlRow>
<ControlRow label="Режим">Живой просмотр</ControlRow>
<p className="scene-window-note">Потоки открываются автоматически после запуска устройства. Запись сохраняется на бортовом компьютере.</p>
</div> }]}/>}
</Window>;
}
@@ -1,6 +1,6 @@
/* Host allocation only; spatial controls and media geometry come from spatial-ui. */
.k1-preview {min-width:0;width:100%}
.k1-preview-spatial {position:relative;min-width:0;width:100%;height:clamp(420px,60vh,800px);overflow:hidden}
.k1-preview-spatial iframe {display:block;position:absolute;inset:0;width:100%;height:100%;border:0}
.k1-preview-camera {display:block;width:100%;height:clamp(220px,32vh,360px);object-fit:contain}
.k1-preview-camera[hidden] {display:none}
.k1-preview.sensor-viewer-expanded .k1-preview-spatial {height:calc(100vh - 150px)}
.k1-preview .spatial-workspace {height:clamp(540px,72vh,900px)}
.k1-preview.sensor-viewer-expanded .spatial-workspace {height:calc(100vh - 150px)}
.k1-camera-notice {position:absolute;inset:0}
.k1-preview-status {margin:0;text-align:center;color:var(--nodedc-text-muted);font-size:var(--nodedc-font-size-xs)}
@@ -1,38 +1,71 @@
/** One MSE decoder per preview; the onboard producer owns camera activation. */
export function previewCamera(video:HTMLVideoElement,ready:()=>void,failed:()=>void){
let active=true,media:MediaSource|undefined,buffer:SourceBuffer|undefined,url:string|undefined;
let pending:Uint8Array<ArrayBuffer>[]=[],bytes=0;
const append=()=>{
if(!active||!buffer||buffer.updating||media?.readyState!=='open')return;
try{
if(buffer.buffered.length&&video.currentTime-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),video.currentTime-5);return;}
const next=pending.shift();if(next){bytes-=next.byteLength;buffer.appendBuffer(next);}
}catch{failed();}
};
const decoded=()=>{if(active)ready();};
video.addEventListener('loadeddata',decoded);
return {
open(mime:string){
if(media||typeof MediaSource==='undefined'||!MediaSource.isTypeSupported(mime))throw new Error('Camera format unavailable');
media=new MediaSource();url=URL.createObjectURL(media);video.src=url;
media.addEventListener('sourceopen',()=>{
if(!active)return;
try{
buffer=media!.addSourceBuffer(mime);
buffer.addEventListener('error',failed);
buffer.addEventListener('updateend',()=>{
if(!active)return;
if(buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-video.currentTime>1)video.currentTime=Math.max(0,end-0.2);void video.play().catch(()=>{});}
append();
});append();
}catch{failed();}
},{once:true});
},
push(payload:Uint8Array<ArrayBuffer>){
if(!media)throw new Error('Camera metadata missing');
bytes+=payload.byteLength;if(bytes>8*1024*1024)throw new Error('Camera preview backlog');
pending.push(payload);append();
},
close(){active=false;pending=[];bytes=0;video.removeEventListener('loadeddata',decoded);video.pause();video.removeAttribute('src');video.load();if(url)URL.revokeObjectURL(url);},
};
export function previewCamera(video: HTMLVideoElement, ready: () => void, failed: () => void) {
let active = true, media: MediaSource | undefined, buffer: SourceBuffer | undefined, url: string | undefined;
const fail = () => { if (active) failed(); };
let pending: Uint8Array<ArrayBuffer>[] = [], bytes = 0;
const append = () => {
if (!active || !buffer || buffer.updating || media?.readyState !== 'open')
return;
try {
if (buffer.buffered.length && video.currentTime - buffer.buffered.start(0) > 8) {
buffer.remove(buffer.buffered.start(0), video.currentTime - 5);
return;
}
const next = pending.shift();
if (next) {
bytes -= next.byteLength;
buffer.appendBuffer(next);
}
}
catch {
fail();
}
};
const decoded = () => { if (active)
ready(); };
video.addEventListener('loadeddata', decoded);
return {
open(mime: string) {
if (media || typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported(mime))
throw new Error('Camera format unavailable');
media = new MediaSource();
url = URL.createObjectURL(media);
media.addEventListener('sourceopen', () => {
if (!active)
return;
try {
buffer = media!.addSourceBuffer(mime);
buffer.addEventListener('error', fail);
buffer.addEventListener('updateend', () => {
if (!active)
return;
if (buffer!.buffered.length) {
const end = buffer!.buffered.end(buffer!.buffered.length - 1);
if (end - video.currentTime > 1)
video.currentTime = Math.max(0, end - 0.2);
void video.play().catch(() => { });
}
append();
});
append();
}
catch {
fail();
}
}, { once: true });
// Installing the listener first also handles immediate sourceopen on reuse.
video.src = url;
},
push(payload: Uint8Array<ArrayBuffer>) {
if (!media)
throw new Error('Camera metadata missing');
bytes += payload.byteLength;
if (bytes > 8 * 1024 * 1024)
throw new Error('Camera preview backlog');
pending.push(payload);
append();
},
close() { active = false; pending = []; bytes = 0; video.removeEventListener('loadeddata', decoded); video.pause(); video.removeAttribute('src'); video.load(); if (url)
URL.revokeObjectURL(url); },
};
}
@@ -1,5 +1,5 @@
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
export const MEDIA_PROTOCOL='missioncore.node-preview/v1';
export const MEDIA_PROTOCOL='missioncore.node-preview/v2';
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
@@ -0,0 +1,25 @@
/** Source-age gate, independent of wall-clock skew between operator and board. */
export function previewFreshness(now: () => number = () => performance.now()) {
let sequence = 0, expires = 0, points = 0;
return {
receive(value: unknown) {
const frame = value as {
type?: string;
sequence?: number;
age_ms?: number | null;
points?: number;
};
if (frame?.type !== 'lidar-state' || !Number.isSafeInteger(frame.sequence) || frame.sequence! < 0 ||
!Number.isSafeInteger(frame.points) || frame.points! < 0 ||
(frame.age_ms !== null && (!Number.isFinite(frame.age_ms) || frame.age_ms! < 0)))
throw new Error('Invalid live frame state');
if (frame.sequence! <= sequence)
return;
sequence = frame.sequence!;
points = frame.points!;
expires = frame.age_ms === null ? 0 : now() + Math.max(0, 2000 - frame.age_ms!);
},
get fresh() { return sequence > 0 && now() < expires; },
get points() { return points; },
};
}
@@ -1,83 +1,198 @@
import {useEffect,type RefObject} from 'react';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
import {perform,type Sensor,type SensorTransport} from './runtime';
import {assertRrd,MEDIA_PROTOCOL,previewFrames} from './previewFrames';
import {previewCamera} from './previewCamera';
export type PreviewStatus={lidar:string;camera:string;retry:boolean};
export const pendingPreview:PreviewStatus={lidar:'Лидар: ожидаем данные',camera:'Камера: ожидаем изображение',retry:false};
function privateCandidate(sdp:string):string{
return sdp.split('\r\n').filter(line=>{
if(!line.startsWith('a=candidate:'))return true;
const fields=line.split(' '),ip=fields[4]??'',parts=ip.split('.').map(Number);
return fields[7]==='host'&&(ip.endsWith('.local')||(parts.length===4&&parts.every(v=>Number.isInteger(v)&&v>=0&&v<=255)&&
(parts[0]===10||parts[0]===127||(parts[0]===192&&parts[1]===168)||(parts[0]===172&&parts[1]>=16&&parts[1]<=31)||(parts[0]===100&&parts[1]>=64&&parts[1]<=127))));
}).join('\r\n');
import { useEffect, useState, type RefObject } from 'react';
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
import { perform, type Sensor, type SensorTransport } from './runtime';
import { assertRrd, MEDIA_PROTOCOL, previewFrames } from './previewFrames';
import { previewCamera } from './previewCamera';
import { previewFreshness } from './previewFreshness';
export type PreviewStatus = {
lidar: string;
camera: string;
retry: boolean;
presented: boolean;
cameraPresented: boolean;
points: number;
};
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, cameraPresented: false, points: 0 };
function privateCandidate(sdp: string): string {
return sdp.split('\r\n').filter(line => {
if (!line.startsWith('a=candidate:'))
return true;
const fields = line.split(' '), ip = fields[4] ?? '', parts = ip.split('.').map(Number);
return fields[7] === 'host' && (ip.endsWith('.local') || (parts.length === 4 && parts.every(v => Number.isInteger(v) && v >= 0 && v <= 255) &&
(parts[0] === 10 || parts[0] === 127 || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127))));
}).join('\r\n');
}
export function useK1Preview(device:Sensor,transport:SensorTransport,createRerunHost:RerunHostFactory,
spatial:RefObject<HTMLDivElement|null>,video:RefObject<HTMLVideoElement|null>,onStatus:(value:PreviewStatus)=>void,generation=0){
useEffect(()=>{
let active=true,failed=false,peerID:string|undefined,viewClose:(()=>void)|undefined;
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|undefined,iceTimeout:ReturnType<typeof setTimeout>|undefined;
let status={...pendingPreview};onStatus(status);
const update=(patch:Partial<PreviewStatus>)=>{if(active){status={...status,...patch};onStatus(status);}};
const host=createRerunHost(spatial.current!);
const pc=new RTCPeerConnection({iceServers:[]});
const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true});
rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer';
const fail=()=>{if(!active||failed)return;failed=true;update({lidar:'Лидар: восстанавливаем просмотр',camera:'Камера: ожидаем соединение',retry:true});clearInterval(follow);clearInterval(keepalive);pc.close();};
const cameraFail=()=>{update({camera:'Камера: изображение недоступно'});camera.close();};
const decoder=previewCamera(video.current!,()=>update({camera:'Камера: изображение поступает'}),cameraFail);
const cameraFrames=previewFrames(payload=>decoder.push(payload));
let rrdFrames:ReturnType<typeof previewFrames>|undefined;
rrd.onclose=()=>{if(active&&!failed)fail();};
camera.onclose=()=>{if(active&&!failed)update({camera:'Камера: изображение недоступно'});};
camera.onmessage=event=>{
if(!active)return;
try{
if(typeof event.data==='string'){
const metadata=JSON.parse(event.data);
if(metadata.type!=='camera-ready'||typeof metadata.mime!=='string')throw new Error('Invalid camera metadata');
decoder.open(metadata.mime);
}else if(event.data instanceof ArrayBuffer)cameraFrames.push(event.data);
}catch{cameraFail();}
};
const run=async()=>{
const {viewer,mount}=await host.ready;if(!active)return;
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false});if(!active)return;
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
const channel=viewer.open_channel('live-acquisition:'+device.snapshot.context.session_id);viewClose=()=>channel.close();
rrdFrames=previewFrames(payload=>{assertRrd(payload);if(!channel.ready)throw new Error('Viewer unavailable');channel.send_rrd(payload);});
rrd.onmessage=event=>{if(active&&event.data instanceof ArrayBuffer)try{rrdFrames!.push(event.data);}catch{fail();}};
follow=setInterval(()=>{
if(!active||failed)return;
try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){
if(status.lidar!=='Лидар: данные поступают')update({lidar:'Лидар: данные поступают'});
viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);
}}catch{/* Native recording has not opened yet. */}
},250);
await pc.setLocalDescription(await pc.createOffer());
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{
iceTimeout=setTimeout(()=>reject(new Error('ICE timeout')),8000);
pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(iceTimeout);resolve();}};
});
if(!active)return;
const answer=await perform<{peer_id:string;sdp:string;type:'answer';media_protocol:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)});
peerID=answer.peer_id;
if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});return;}
if(answer.media_protocol!==MEDIA_PROTOCOL){failed=true;clearInterval(follow);pc.close();update({lidar:'Для просмотра требуется обновление приложения на БК',camera:'Камера: ожидаем обновление',retry:false});return;}
pc.onconnectionstatechange=()=>{if(active&&['failed','closed'].includes(pc.connectionState))fail();};
await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp});
keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000);
};
void run().catch(fail);
return()=>{
active=false;clearInterval(keepalive);clearInterval(follow);clearTimeout(iceTimeout);
rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.onicegatheringstatechange=null;pc.close();
rrdFrames?.close();cameraFrames.close();decoder.close();try{viewClose?.();}finally{host.dispose();}
if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});
};
},[device.snapshot.context.session_id,transport,createRerunHost,onStatus,spatial,video,generation]);
export function useK1Preview(device: Sensor, transport: SensorTransport, createRerunHost: RerunHostFactory, spatial: RefObject<HTMLDivElement | null>, video: RefObject<HTMLVideoElement | null>, onStatus: (value: PreviewStatus) => void, generation = 0, enabled = true) {
const [native, setNative] = useState<LiveRerunViewer | null>(null);
// The native runtime belongs to the view. Media recovery opens a new recording
// channel inside it; it never downloads/recreates the WASM iframe on each retry.
useEffect(() => {
let active = true;
const host = createRerunHost(spatial.current!);
void host.ready.then(async ({ viewer, mount }) => {
if (!active)
return;
await viewer.start(null, mount, { width: '100%', height: '100%', theme: 'dark', hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
panel_state_overrides: { top: 'Hidden', blueprint: 'Hidden', selection: 'Hidden', time: 'Hidden' } });
if (!active)
return;
for (const panel of ['top', 'blueprint', 'selection', 'time'] as const)
viewer.override_panel_state(panel, 'hidden');
setNative(viewer);
}).catch(() => { if (active)
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
return () => { active = false; setNative(null); host.dispose(); };
}, [createRerunHost, spatial, onStatus]);
useEffect(() => {
onStatus(enabled ? { ...pendingPreview } : { ...pendingPreview, lidar: 'Лидар: нет связи с БК', camera: 'Камера: нет связи с БК' });
if (!native || !enabled)
return;
let active = true, failed = false, peerID: string | undefined;
let keepalive: ReturnType<typeof setInterval> | undefined, follow: ReturnType<typeof setInterval> | undefined, iceTimeout: ReturnType<typeof setTimeout> | undefined;
let status = { ...pendingPreview };
const update = (patch: Partial<PreviewStatus>) => { if (active) {
const next = { ...status, ...patch };
if (JSON.stringify(next) !== JSON.stringify(status)) {
status = next;
onStatus(status);
}
} };
const viewer = native, freshness = previewFreshness();
const pc = new RTCPeerConnection({ iceServers: [] });
const rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
rrd.binaryType = 'arraybuffer';
camera.binaryType = 'arraybuffer';
const previousRecording = viewer.get_active_recording_id();
const channel = viewer.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + generation);
const fail = () => { if (!active || failed)
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); pc.close(); };
let cameraDecoded = false, cameraFailed = false, lastVideoTime = -1, lastVideoProgress = 0, seenLidar = false;
const cameraFail = () => { cameraFailed = true; update({ camera: 'Камера: изображение недоступно', cameraPresented: false }); camera.close(); };
const decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
const cameraFrames = previewFrames(payload => decoder.push(payload));
const rrdFrames = previewFrames(payload => { assertRrd(payload); if (!channel.ready)
throw new Error('Viewer unavailable'); channel.send_rrd(payload); });
rrd.onclose = () => { if (active && !failed)
fail(); };
camera.onclose = () => { if (active && !failed)
cameraFail(); };
rrd.onmessage = event => {
if (!active)
return;
try {
if (typeof event.data === 'string')
freshness.receive(JSON.parse(event.data));
else if (event.data instanceof ArrayBuffer)
rrdFrames.push(event.data);
}
catch {
fail();
}
};
camera.onmessage = event => {
if (!active || failed)
return;
try {
if (typeof event.data === 'string') {
const metadata = JSON.parse(event.data);
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
throw new Error('Invalid camera metadata');
decoder.open(metadata.mime);
}
else if (event.data instanceof ArrayBuffer)
cameraFrames.push(event.data);
}
catch {
cameraFail();
}
};
const run = async () => {
let synchronizedRecording: string | null = null;
follow = setInterval(() => {
if (!active || failed)
return;
let presented = false;
try {
const id = viewer.get_active_recording_id();
const range = id ? viewer.get_time_range(id, 'stream_time') : null;
if (id && range) {
if (synchronizedRecording !== id) {
viewer.set_active_timeline(id, 'stream_time');
if (viewer.get_active_timeline(id) === 'stream_time')
synchronizedRecording = id;
}
// Following is owned by the live blueprint. SetTime/SetPlaying would
// pin the cursor and override that profile on every heartbeat.
presented = id !== previousRecording && freshness.fresh && range.max > range.min;
}
}
catch { /* Native recording/timeline creation is asynchronous. */ }
seenLidar ||= presented;
const v = video.current!;
if (cameraDecoded && v.currentTime !== lastVideoTime) {
lastVideoTime = v.currentTime;
lastVideoProgress = performance.now();
}
const cameraPresented = cameraDecoded && !cameraFailed && performance.now() - lastVideoProgress < 2000;
update({ presented, cameraPresented, points: presented ? freshness.points : 0,
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
}, 250);
await pc.setLocalDescription(await pc.createOffer());
if (pc.iceGatheringState !== 'complete')
await new Promise<void>((resolve, reject) => {
iceTimeout = setTimeout(() => reject(new Error('ICE timeout')), 8000);
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
resolve();
} };
});
if (!active)
return;
const answer = await perform<{
peer_id: string;
sdp: string;
type: 'answer';
media_protocol: string;
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id });
peerID = answer.peer_id;
if (!active) {
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
return;
}
if (answer.media_protocol !== MEDIA_PROTOCOL) {
failed = true;
clearInterval(follow);
pc.close();
update({ lidar: 'Для просмотра требуется обновление приложения на БК', camera: 'Камера: ожидаем обновление', retry: false });
return;
}
pc.onconnectionstatechange = () => { if (active && ['failed', 'closed'].includes(pc.connectionState))
fail(); };
await pc.setRemoteDescription({ type: answer.type, sdp: answer.sdp });
keepalive = setInterval(() => { for (const c of [rrd, camera])
if (c.readyState === 'open')
c.send('keepalive'); }, 5000);
};
void run().catch(fail);
return () => {
active = false;
clearInterval(keepalive);
clearInterval(follow);
clearTimeout(iceTimeout);
rrd.onmessage = null;
camera.onmessage = null;
camera.onclose = null;
rrd.onclose = null;
pc.onconnectionstatechange = null;
pc.onicegatheringstatechange = null;
pc.close();
rrdFrames.close();
cameraFrames.close();
decoder.close();
channel.close();
if (peerID)
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
};
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
}
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react';
import { defaultSceneSettings, type SceneSettings } from '@mission-core/sensor-sdk';
import { perform, type Sensor, type SensorTransport } from './runtime';
const fields = { pointSize: 'point_size', accumulationSeconds: 'accumulation_seconds', colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color', showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid' } as const;
export function k1SceneSettings(value: Record<string, unknown> = {}): SceneSettings {
const settings = { ...defaultSceneSettings, customColor: '#f7f8f4' };
for (const [key, field] of Object.entries(fields))
if (value[field] !== undefined)
Object.assign(settings, { [key]: value[field] });
return settings;
}
export function k1ScenePatch(settings: SceneSettings) {
return Object.fromEntries(Object.entries(fields).map(([key, field]) => [field, settings[key as keyof typeof fields]]));
}
/** One serialized autosave owner. Scene changes never issue physical commands. */
export function useK1SceneSettings(device: Sensor, transport: SensorTransport, failure: (error: unknown) => void) {
const [draft, setDraft] = useState(() => k1SceneSettings(device.live_settings));
const current = useRef(draft), dirty = useRef(false), running = useRef(false), queued = useRef(false), active = useRef(true);
const [pending, setPending] = useState(false);
useEffect(() => { active.current = true; return () => { active.current = false; queued.current = false; }; }, []);
const stage = (patch: Partial<SceneSettings>) => { current.current = { ...current.current, ...patch }; dirty.current = true; setDraft(current.current); };
const flush = async () => {
if (!dirty.current)
return;
queued.current = true;
if (running.current)
return;
running.current = true;
setPending(true);
try {
while (queued.current && active.current) {
queued.current = false;
dirty.current = false;
const settings = k1ScenePatch(current.current);
await perform(transport, device, 'option', { profile: 'live-acquisition', settings });
}
}
catch (error) {
dirty.current = true;
queued.current = false;
if (active.current)
failure(error);
}
finally {
running.current = false;
if (active.current)
setPending(false);
}
};
return { draft, pending, stage, flush: () => void flush(), commit: (patch: Partial<SceneSettings>) => { stage(patch); void flush(); } };
}
+2 -2
View File
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
from debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.8"
VERSION = "0.1.9"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
@@ -135,7 +135,7 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin
Priority: optional
Depends: mission-core-node (>= 0.8.0), mission-core-node (<< 0.9.0),
Depends: mission-core-node (>= 0.8.9), mission-core-node (<< 0.9.0),
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
Breaks: mission-core-node (<< 0.8.0)
Replaces: mission-core-node (<< 0.8.0)
@@ -151,9 +151,21 @@ class NodeK1Sensor:
await self.peers.close(params.get("peer_id"))
return {"ok": True}
if action == "offer":
if item["snapshot"]["acquisition"] != "streaming":
raise ValueError("Acquisition is not active")
return await self.peers.offer(params)
acquisition_id = item["control"]["acquisition_id"]
if (item["snapshot"]["acquisition"] != "streaming" or not acquisition_id
or params.get("acquisition_id") != acquisition_id):
raise ValueError("Acquisition is not active or changed")
answer = await self.peers.offer(params)
# Signalling may outlive STOP or a replacement acquisition. Retire
# that disposable preview instead of attaching it to another run.
current = project_sensor(await self.raw_state(), node_id)
if (current is None or current["snapshot"]["acquisition"] != "streaming"
or current["snapshot"]["context"]["session_id"]
!= command["session"]["session_id"]
or current["control"]["acquisition_id"] != acquisition_id):
await self.peers.close(answer["peer_id"])
raise ValueError("Acquisition changed during preview signalling")
return answer
async with self.bridge.lock:
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
raise ValueError("Command expired before dispatch")
+5 -1
View File
@@ -13,7 +13,7 @@ from uuid import uuid4
import aioice.ice
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
MEDIA_PROTOCOL = "missioncore.node-preview/v1"
MEDIA_PROTOCOL = "missioncore.node-preview/v2"
MAX_PAYLOAD = 8 * 1024 * 1024
FRAGMENT_BYTES = 16384
logger = logging.getLogger(__name__)
@@ -175,6 +175,10 @@ class NodeMediaPeers:
break
if payload:
await self.send(channel, payload)
if subscriber:
# Ordered after native bytes. Age is source arrival age,
# not time spent replaying an encoded preview backlog.
channel.send(json.dumps(subscriber.snapshot()))
except asyncio.CancelledError:
pass
except Exception as error:
+32 -6
View File
@@ -5,14 +5,19 @@ Latest-value queues discard decoded preview frames before encoding; encoded
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
"""
import logging
import queue
import threading
import time
from contextlib import suppress
from uuid import uuid4
from k1link.data_plane import DecodedPointCloudView
from k1link.viewer.rerun_bridge import RerunBridge
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
logger = logging.getLogger(__name__)
class RrdSubscriber:
@@ -21,6 +26,7 @@ class RrdSubscriber:
self.inputs = queue.Queue(maxsize=2)
self.output = queue.Queue(maxsize=2)
self.settings_provider = settings_provider
self.last_frame = None
self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True)
self.thread.start()
@@ -41,6 +47,12 @@ class RrdSubscriber:
except queue.Empty:
return b""
def snapshot(self):
frame = self.last_frame
return {"type": "lidar-state", "sequence": frame[0] if frame else 0,
"age_ms": max(0, (time.monotonic_ns() - frame[1]) / 1_000_000) if frame else None,
"points": frame[2] if frame else 0}
def close(self):
self.closed.set()
@@ -58,7 +70,9 @@ class RrdSubscriber:
bridge.begin_session()
while not self.closed.is_set():
payload = binary.read()
if len(payload) > MAX_ENCODED_CHUNK:
# The SDK returns None while its live sink has no new bytes.
# An idle read is not EOF and must not retire the media peer.
if payload and len(payload) > MAX_ENCODED_CHUNK:
break
if payload:
# Bound both bytes and waiting time. The archive/producer
@@ -68,9 +82,20 @@ class RrdSubscriber:
envelope = self.inputs.get(timeout=0.1)
except queue.Empty:
continue
received = envelope.context.received_monotonic_ns
if (received is not None
and (time.monotonic_ns() - received) / 1_000_000_000
> LIVE_FRAME_MAX_AGE_SECONDS):
continue
bridge.process(envelope)
except Exception:
pass
if isinstance(envelope, DecodedPointCloudView):
self.last_frame = (
envelope.context.sequence,
envelope.context.received_monotonic_ns or time.monotonic_ns(),
envelope.point_count,
)
except Exception as error:
logger.warning("Node RRD subscriber failed exception=%s", type(error).__name__)
finally:
self.closed.set()
if bridge is not None:
@@ -98,7 +123,7 @@ class NodeRerunBridge(RerunBridge):
# It is continuously drained even when no viewer is attached.
self.binary.read()
with self.lock:
self.latest[type(envelope)] = envelope
self.latest[type(envelope)] = (time.monotonic(), envelope)
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
for subscriber in self.subscribers:
subscriber.offer(envelope)
@@ -113,8 +138,9 @@ class NodeRerunBridge(RerunBridge):
if self._closed or len(self.subscribers) >= 2:
raise RuntimeError("Live viewer unavailable")
subscriber = RrdSubscriber(self._settings_provider)
for envelope in self.latest.values():
subscriber.offer(envelope)
for observed, envelope in self.latest.values():
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
subscriber.offer(envelope)
self.subscribers.append(subscriber)
return subscriber
+1 -1
View File
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
position += 60 + length + length % 2
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
control = archive.extractfile("control").read().decode()
assert "Depends: mission-core-node (>= 0.8.0)" in control
assert "Depends: mission-core-node (>= 0.8.9)" in control
assert "Replaces: mission-core-node (<< 0.8.0)" in control
+38
View File
@@ -556,3 +556,41 @@ def test_native_node_rrd_opens_no_grpc_listener(monkeypatch):
subscriber.close()
subscriber.thread.join(6)
publisher.close()
@pytest.mark.parametrize("replacement", [False, True])
def test_preview_offer_cannot_cross_acquisition_boundary(replacement):
async def run():
device = bridge()
device.facade.current["acquisition"] = {"acquisition_id": "acq-one", "state": "running"}
device.facade.current["source_mode"] = "live"
class Peers:
opened = 0
closed = []
async def offer(self, _):
self.opened += 1
if replacement:
device.facade.current["acquisition"]["acquisition_id"] = "acq-two"
return {"peer_id": "synthetic-peer"}
async def close(self, identifier):
self.closed.append(identifier)
peers = Peers()
sensor = NodeK1Sensor(device, peers)
item = project_sensor(device.facade.current, "node-test")
command = {
"operation_id": "op_" + "a" * 32,
"action_id": "offer",
"session": {"device_id": item["id"], "session_id": "session-test"},
"parameters": {"acquisition_id": "acq-one" if replacement else "acq-old"},
}
with pytest.raises(ValueError):
await sensor.execute(command, "node-test")
assert peers.opened == int(replacement)
assert peers.closed == (["synthetic-peer"] if replacement else [])
assert all(action == "state.read" for action, _ in device.facade.actions)
asyncio.run(run())
+92
View File
@@ -59,6 +59,9 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
except queue.Empty:
return b""
def snapshot(self):
return {"type": "lidar-state", "sequence": 1, "age_ms": 0, "points": 5000}
def close(self):
pass
@@ -90,6 +93,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
@channel.on("message")
def message(data):
if isinstance(data, str):
return
payloads.append(data)
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
received.set()
@@ -153,3 +158,90 @@ def test_installed_camera_uses_declared_os_ffmpeg():
unit = (root / "plugins/xgrids-k1/packaging/mission-core-k1.service").read_text()
assert "Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg" in unit
assert "iproute2, ffmpeg" in (root / "plugins/xgrids-k1/packaging/build_deb.py").read_text()
def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
"""Regression: the real binary sink, two media channels, idle then resume."""
import json
import time
from types import SimpleNamespace
from k1link.data_plane import ConsumerFrameContext, DecodedPointCloudView
from k1link.viewer.node_rerun import NodeRerunBridge
class Segments:
def __init__(self):
self.queue = queue.Queue()
def get(self, timeout):
return self.queue.get(timeout=timeout)
class Camera:
def __init__(self):
self.lease = SimpleNamespace(segments=Segments())
self.released = []
def snapshot(self):
return {"generation": 1, "delivery": {"media_type": "video/mp4"}}
def open_delivery(self, generation):
assert generation == 1
return self.lease
def release_delivery(self, lease, **_):
self.released.append(lease)
def mark_streaming(self, _):
pass
async def run():
import aioice.ice
monkeypatch.setattr(aioice.ice, "get_host_addresses", lambda **_: ["127.0.0.1"])
hub, camera = NodeRerunBridge(), Camera()
peers = NodeMediaPeers(hub, camera)
client = RTCPeerConnection(RTCConfiguration(iceServers=[]))
rrd = client.createDataChannel("rrd", ordered=True)
video = client.createDataChannel("camera", ordered=True)
ready, resumed, camera_frame = asyncio.Event(), asyncio.Event(), asyncio.Event()
@rrd.on("message")
def rrd_message(data):
if isinstance(data, str):
value = json.loads(data)
if value["sequence"] == 1:
resumed.set()
else:
ready.set()
@video.on("message")
def camera_message(data):
if data == b"synthetic-camera-fragment":
camera_frame.set()
try:
await client.setLocalDescription(await client.createOffer())
answer = await peers.offer({"sdp": client.localDescription.sdp})
await client.setRemoteDescription(
RTCSessionDescription(sdp=answer["sdp"], type="answer")
)
await asyncio.wait_for(ready.wait(), 8)
await asyncio.sleep(1.5)
assert answer["peer_id"] in peers.items
assert rrd.readyState == video.readyState == "open"
assert not camera.released
now = time.monotonic_ns()
hub.process(DecodedPointCloudView(
ConsumerFrameContext(1, time.time_ns(), now, now, 1, True),
"world", ((1.0, 2.0, 3.0),), b"\x01",
))
camera.lease.segments.queue.put(("media", b"synthetic-camera-fragment"))
await asyncio.wait_for(resumed.wait(), 5)
await asyncio.wait_for(camera_frame.wait(), 5)
assert rrd.readyState == video.readyState == "open"
finally:
await client.close()
await peers.close_all()
hub.close()
assert not peers.items
asyncio.run(run())
+80
View File
@@ -0,0 +1,80 @@
"""Native RRD empty reads are pauses, not peer EOF. No device/network involved."""
import time
from k1link.data_plane import ConsumerFrameContext, DecodedPointCloudView
from k1link.viewer.node_rerun import NodeRerunBridge, RrdSubscriber
from k1link.viewer.rerun_bridge import RerunSceneSettings
def points(sequence=1, age=0):
now = time.monotonic_ns()
return DecodedPointCloudView(
context=ConsumerFrameContext(sequence, time.time_ns(), now - int(age * 1e9), now, 1, True),
frame_id="world", positions_xyz=((1.0, 2.0, 3.0),), intensities=b"\x01",
)
def drain_until(subscriber, predicate, seconds=3):
deadline = time.monotonic() + seconds
payloads = []
while time.monotonic() < deadline:
payload = subscriber.read()
assert payload is not None, "idle native binary read retired the live subscriber"
if payload:
assert payload.startswith(b"RRF2")
payloads.append(payload)
if predicate():
return payloads
raise AssertionError("native subscriber did not make progress")
def test_native_idle_read_keeps_subscriber_alive_and_resumes():
subscriber = RrdSubscriber(RerunSceneSettings)
try:
assert drain_until(subscriber, lambda: subscriber.output.empty())
# This forces several SDK reads with no data; the previous len(None)
# retired both RRD and camera before a first point could arrive.
assert subscriber.read() == b""
assert not subscriber.closed.is_set()
subscriber.offer(points())
assert drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 1)
assert subscriber.read() == b""
subscriber.offer(points(2))
assert drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 2)
assert subscriber.snapshot()["points"] == 1
assert not subscriber.closed.is_set()
finally:
subscriber.close()
subscriber.thread.join(timeout=3)
assert not subscriber.thread.is_alive()
def test_subscriber_does_not_publish_queued_stale_points():
subscriber = RrdSubscriber(RerunSceneSettings)
try:
drain_until(subscriber, lambda: subscriber.output.empty())
subscriber.offer(points(age=5))
assert subscriber.read() == b""
assert subscriber.snapshot()["sequence"] == 0
subscriber.offer(points(2))
drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 2)
finally:
subscriber.close()
subscriber.thread.join(timeout=3)
def test_reopened_viewer_does_not_replay_cached_points_after_source_pause(monkeypatch):
bridge = NodeRerunBridge()
try:
bridge.process(points())
cached_at, frame = next(iter(bridge.latest.values()))
bridge.latest[type(frame)] = (cached_at - 5, frame)
sub = bridge.subscribe()
drain_until(sub, lambda: sub.output.empty())
assert sub.read() == b""
assert sub.snapshot()["sequence"] == 0
bridge.process(points(2))
drain_until(sub, lambda: sub.snapshot()["sequence"] == 2)
finally:
bridge.close()
sub.thread.join(timeout=3)