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
@@ -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)