feat(observation): expose offline sources with transparent viewport headers

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 19:14:21 +03:00
parent db3b1d7b67
commit 8283f9ba85
13 changed files with 124 additions and 30 deletions
+4 -4
View File
@@ -3,11 +3,11 @@ import {LoadingRegion,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui
import {perform,type Sensor,type SensorTransport} from './contracts';
import {PointViewport} from './PointViewport';
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage,enabled=true}:{enabled?:boolean;device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState<Telemetry>({});
useEffect(()=>{
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
if(!enabled||device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();let frameHandle:number|undefined;let decoded=0;
const live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');};
const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);};
@@ -28,9 +28,9 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
}catch(e){if(!cancelled){terminal=true;setState('Просмотр недоступен');failure(e);}}}
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);if(frameHandle!==undefined)video.current?.cancelVideoFrameCallback(frameHandle);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
},[enabled,device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
const active=device.snapshot.acquisition==='streaming';
const active=enabled&&device.snapshot.acquisition==='streaming';
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={title??(layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer))} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
{!active?<p>{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}</p>:<>
<LoadingRegion loading={state==='Подключение просмотра'||state==='Ожидание изображения'} label={state}>
+3 -3
View File
@@ -5,7 +5,7 @@ import {LiveViewport} from './LiveViewport';
export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pendingAction,setPendingAction]=useState<string|null>(null);const pending=pendingAction!==null;const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
useEffect(()=>{let live=true;if(enabled&&device.online&&device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[enabled,device.online,device.id,device.prepared,device.snapshot.context.session_id]);
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPendingAction(action==='start'?(parameters.record?'record.start':'preview.start'):action==='replay'?'replay:'+String(parameters.recording_id):action);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPendingAction(null);}}
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
@@ -21,10 +21,10 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
{device.snapshot.acquisition==='stopping'&&<p>Сохранение исходной записи и проверка целостности.</p>}
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
</SettingsCard>
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport enabled={enabled&&device.online} device={device} layer={layer} transport={transport} failure={failure}/>
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={!enabled||pending||active}/>)}</div></SettingsCard>
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} loading={pendingAction==='option'} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} loading={pendingAction==='replay:'+item.id} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
{!detail&&<p>{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
{!detail&&<p>{!enabled||!device.online?'Возможности камеры будут прочитаны после подключения.':device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
</div>;
}
+3 -1
View File
@@ -12,10 +12,12 @@ export interface SensorObservationProps {
export interface SensorObservationContribution {
views:(device:Sensor)=>SensorObservationView[];
Session:ComponentType<SensorObservationProps>;
catalog?:{label:string;views:SensorObservationView[]};
}
export const sensorLayerLabels:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Облако точек',motion:'Движение'};
export function cameraObservationViews(device:Sensor):SensorObservationView[] {
const layers=device.layers.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
const published=device.layers.length?device.layers:/^RealSense D455$/i.test(device.model??'')?['color','depth','infrared1','infrared2','points','motion']:[];
const layers=published.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color'));
return layers.length?[{id:'camera',label:device.name,layers}]:[];
}
@@ -0,0 +1,11 @@
import type {Sensor} from './contracts';
import type {SensorUiContribution} from './extensions';
/** Supported view slots are not physical devices and carry no command authority. */
export function unboundObservationViews(devices:Sensor[],contributions:readonly SensorUiContribution[]) {
return contributions.flatMap(contribution=>{
const catalog=contribution.observation?.catalog;
if(!catalog||devices.some(device=>device.kind===contribution.kind)||contributions.filter(other=>other.kind===contribution.kind).length!==1)return [];
return catalog.views.map(view=>({key:JSON.stringify(['catalog',contribution.kind,view.id]),catalog:catalog.label,view}));
});
}