feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from './ObservationHeader';
|
||||
import type {SensorObservationProps} from './observation';
|
||||
import {useSensorPreview} from './useSensorPreview';
|
||||
import {PointViewport} from './PointViewport';
|
||||
import {useObservationStart} from './useObservationStart';
|
||||
import {startCameraObservation} from './observationStart';
|
||||
|
||||
export function CameraObservation({device,transport,enabled,views}:SensorObservationProps){
|
||||
const video=useRef<HTMLVideoElement>(null);const [generation,setGeneration]=useState(0);
|
||||
const activation=useObservationStart(device,enabled&&device.online&&device.prepared,!['idle','failed'].includes(device.snapshot.acquisition)||!!device.playback_id,()=>startCameraObservation(device,transport));
|
||||
const view=views[0];const {state,telemetry,error,live,hasFrame}=useSensorPreview(device,view.layer,transport,video,enabled,generation);
|
||||
return createPortal(<div className="observation-source">
|
||||
<ObservationHeader targets={view} live={live} label={activation.pending?'Запуск камеры':state}>{activation.error?<IconButton label="Повторить запуск камеры" disabled={activation.pending} onClick={()=>void activation.retry()}><Icon name="refresh"/></IconButton>:error&&<IconButton label="Повторить подключение камеры" onClick={()=>setGeneration(v=>v+1)}><Icon name="refresh"/></IconButton>}</ObservationHeader>
|
||||
<div className="observation-source__media" data-live={hasFrame}>
|
||||
{view.layer==='points'?<PointViewport points={telemetry.points??[]}/>:view.layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,value])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {value.x.toFixed(3)} · Y {value.y.toFixed(3)} · Z {value.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} autoPlay muted playsInline aria-label={device.name}/>}
|
||||
</div>
|
||||
{!hasFrame&&<p className="observation-source__notice">{activation.error||error|| (!enabled||!device.online?'Нет связи с камерой.':!device.prepared?'Подготовьте камеру в конфигураторе.':activation.pending?'Запуск предпросмотра.':'Ожидание свежих данных источника.')}</p>}
|
||||
</div>,view.target);
|
||||
}
|
||||
@@ -5,18 +5,18 @@ 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}){
|
||||
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
||||
const [state,setState]=useState('Подключаем просмотр');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
useEffect(()=>{
|
||||
if(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);};
|
||||
setTelemetry({});setState('Подключаем просмотр');
|
||||
setTelemetry({});setState('Подключение просмотра');
|
||||
if(typeof RTCPeerConnection==='undefined'){setState('Просмотр не поддерживается этим браузером');return;}
|
||||
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.ontrack=event=>{if(video.current){video.current.srcObject=new MediaStream([event.track]);if(typeof video.current.requestVideoFrameCallback==='function')frameHandle=video.current.requestVideoFrameCallback(frameSeen);}};
|
||||
pc.onconnectionstatechange=()=>{if(cancelled||terminal)return;if(pc.connectionState==='failed'){terminal=true;setState('Нет связи с камерой');}else if(pc.connectionState==='connected')setState(current=>current==='Прямой эфир'||current==='Исходная запись'?current:'Ожидаем изображение');};
|
||||
pc.onconnectionstatechange=()=>{if(cancelled||terminal)return;if(pc.connectionState==='failed'){terminal=true;setState('Нет связи с камерой');}else if(pc.connectionState==='connected')setState(current=>current==='Прямой эфир'||current==='Исходная запись'?current:'Ожидание изображения');};
|
||||
channel.onmessage=event=>{try{const v=JSON.parse(event.data);if(layer==='points'||layer==='motion')live();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
|
||||
timer=setInterval(()=>{if(terminal)return;if(channel.readyState==='open')channel.send('keepalive');if(video.current&&typeof video.current.requestVideoFrameCallback!=='function')void pc.getStats().then(stats=>{stats.forEach(value=>{if(value.type==='inbound-rtp'&&value.kind==='video'&&value.framesDecoded>decoded){decoded=value.framesDecoded;live();}});}).catch(()=>undefined);if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
|
||||
async function connect(){try{
|
||||
@@ -33,7 +33,7 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive
|
||||
const active=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}>
|
||||
<LoadingRegion loading={state==='Подключение просмотра'||state==='Ожидание изображения'} label={state}>
|
||||
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
|
||||
</LoadingRegion>
|
||||
<p className="sensor-note">{note??(layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.')}</p>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {StatusBadge} from '@nodedc/ui-react';
|
||||
import type {ObservationHeaderTargets} from './observation';
|
||||
|
||||
/** The source owns liveness and retry actions; the host owns header placement. */
|
||||
export function ObservationHeader({targets,live,label,children}:{targets:ObservationHeaderTargets;live:boolean;label:string;children?:ReactNode}){
|
||||
return <>{createPortal(<StatusBadge variant="indicator" tone={live?'success':'neutral'} role="img" aria-label={label} title={label}/>,targets.statusTarget)}{createPortal(children,targets.actionsTarget)}</>;
|
||||
}
|
||||
@@ -18,13 +18,13 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
|
||||
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
|
||||
<div className="sensor-actions"><Button loading={pendingAction==='preview.start'} disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button loading={pendingAction==='record.start'} disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={!enabled||pending||(!active&&device.snapshot.acquisition!=='failed')} loading={pendingAction==='stop'} onClick={()=>void act('stop')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button></div>
|
||||
{device.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
|
||||
{device.snapshot.acquisition==='stopping'&&<p>Сохраняем исходную запись и проверяем её целостность.</p>}
|
||||
{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}/>
|
||||
<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>{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
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} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<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?<LoadingRegion loading label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
||||
const pending=localBusy.get(item.id)??operation?.action_id;
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SensorEnrollmentProps {
|
||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||
}
|
||||
export interface SensorUiContribution {
|
||||
observation?:import('./observation').SensorObservationContribution;
|
||||
kind:string;
|
||||
Detail:ComponentType<SensorDetailProps>;
|
||||
icon:IconName;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {ComponentType} from 'react';
|
||||
import type {Sensor,SensorTransport} from './contracts';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
|
||||
export interface SensorObservationView {id:string;label:string;layers?:{value:string;label:string}[]}
|
||||
/** Stable host slots let a session update its header without moving its media. */
|
||||
export interface ObservationHeaderTargets {statusTarget:HTMLElement;actionsTarget:HTMLElement}
|
||||
export interface SensorObservationProps {
|
||||
device:Sensor;transport:SensorTransport;enabled:boolean;createRerunHost:RerunHostFactory;
|
||||
views:({id:string;layer:string;target:HTMLElement}&ObservationHeaderTargets)[];
|
||||
}
|
||||
export interface SensorObservationContribution {
|
||||
views:(device:Sensor)=>SensorObservationView[];
|
||||
Session:ComponentType<SensorObservationProps>;
|
||||
}
|
||||
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]}));
|
||||
layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color'));
|
||||
return layers.length?[{id:'camera',label:device.name,layers}]:[];
|
||||
}
|
||||
export function observationKey(deviceID:string,viewID:string):string {return JSON.stringify([deviceID,viewID]);}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
|
||||
const pending=new Map<string,Promise<unknown>>();
|
||||
export const observationSession=(device:Sensor)=>JSON.stringify([device.snapshot.context.execution.node_id,device.id,device.snapshot.context.session_id]);
|
||||
/** Share an in-flight start/recovery across lens windows and React remounts. */
|
||||
export function observationOperation<T>(device:Sensor,run:()=>Promise<T>):Promise<T>{
|
||||
const key=observationSession(device),current=pending.get(key);if(current)return current as Promise<T>;
|
||||
const task=Promise.resolve().then(run);pending.set(key,task);
|
||||
const clear=()=>{if(pending.get(key)===task)pending.delete(key);};void task.then(clear,clear);return task;
|
||||
}
|
||||
export async function currentObservationDevice(device:Sensor,transport:SensorTransport):Promise<Sensor>{
|
||||
const inventory=await transport.inventory();
|
||||
const current=inventory.items.find(item=>item.id===device.id&&observationSession(item)===observationSession(device));
|
||||
if(inventory.fresh===false||!current?.online||!current.prepared)throw new Error('Нет свежей связи с камерой.');
|
||||
return current;
|
||||
}
|
||||
export function startCameraObservation(device:Sensor,transport:SensorTransport){
|
||||
return observationOperation(device,async()=>{
|
||||
const current=await currentObservationDevice(device,transport);
|
||||
if(current.playback_id||['streaming','starting','stopping'].includes(current.snapshot.acquisition))return;
|
||||
// The sensor owns its published default profiles. Observation never records.
|
||||
await perform(transport,current,'start',{record:false});
|
||||
});
|
||||
}
|
||||
@@ -2,4 +2,6 @@ export * from './contracts';
|
||||
export type * from './enrollment';
|
||||
export type * from './rerunHost';
|
||||
export type * from './extensions';
|
||||
export type * from './observation';
|
||||
export {ObservationHeader} from './ObservationHeader';
|
||||
export * from '../../spatial-ui/src';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Media progress and capture freshness are independent of DOM painting and inventory SSE. */
|
||||
export class PreviewLiveness {
|
||||
private lastFrame:number|null=null;
|
||||
private sourceSeen=Number.NEGATIVE_INFINITY;
|
||||
private staleSince:number|null=null;
|
||||
constructor(private connectedAt:number){}
|
||||
connected(now:number){this.connectedAt=now;}
|
||||
frame(now:number){this.lastFrame=now;}
|
||||
source(fresh:boolean,now:number){
|
||||
this.sourceSeen=now;
|
||||
this.staleSince=fresh?null:this.staleSince??now;
|
||||
}
|
||||
state(now:number):'live'|'waiting'|'delayed'|'source-stalled'|'peer-stalled'{
|
||||
if(this.staleSince!==null&&now-this.sourceSeen<3000){
|
||||
return now-this.staleSince>=12000?'source-stalled':'delayed';
|
||||
}
|
||||
const elapsed=now-(this.lastFrame??this.connectedAt);
|
||||
if(elapsed>8000)return 'peer-stalled';
|
||||
if(this.lastFrame===null)return 'waiting';
|
||||
return elapsed>2000?'delayed':'live';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
type PreviewVideo = Pick<HTMLVideoElement, 'paused' | 'srcObject' | 'muted' | 'play'>;
|
||||
|
||||
/** Restore browser presentation without changing the peer or camera capture. */
|
||||
export function previewPlayback(video: PreviewVideo | null) {
|
||||
let disposed = false, pending = false;
|
||||
return {
|
||||
resume() {
|
||||
if (disposed || pending || !video?.srcObject || !video.paused) return;
|
||||
pending = true;
|
||||
video.muted = true;
|
||||
// WebKit can pause an offscreen panel and reject playback while it remains hidden.
|
||||
// A later visibility/presentation check retries playback on the same stream.
|
||||
void video.play().catch(() => undefined).finally(() => { pending = false; });
|
||||
},
|
||||
dispose() { disposed = true; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import type {Sensor} from './contracts';
|
||||
import {observationSession} from './observationStart';
|
||||
|
||||
/** One automatic activation per mounted view/session; failures require explicit retry. */
|
||||
export function useObservationStart(device:Sensor,enabled:boolean,active:boolean,start:()=>Promise<unknown>){
|
||||
const scope=observationSession(device),latest=useRef({scope,enabled,start});latest.current={scope,enabled,start};
|
||||
const mounted=useRef(false),attempted=useRef(''),running=useRef(false);
|
||||
const [pending,setPending]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
useEffect(()=>{setError('');},[scope]);
|
||||
const retry=useCallback(async()=>{
|
||||
const value=latest.current;if(!value.enabled||running.current)return;
|
||||
attempted.current=value.scope;running.current=true;setPending(true);setError('');
|
||||
try{await value.start();}catch(reason){if(mounted.current&&latest.current.scope===value.scope)setError(reason instanceof Error?reason.message:'Не удалось начать просмотр.');}
|
||||
finally{running.current=false;if(mounted.current)setPending(false);}
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
if(!enabled){attempted.current='';return;}
|
||||
if(active){attempted.current=scope;setError('');return;}
|
||||
if(attempted.current!==scope)void retry();
|
||||
},[scope,enabled,active,retry,pending]);
|
||||
return {pending,error,retry};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {useEffect,useRef,useState,type RefObject} from 'react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {PreviewLiveness} from './previewLiveness';
|
||||
import {previewPlayback} from './previewPlayback';
|
||||
|
||||
export interface SensorPreviewTelemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;frame_age_ms?:number;fresh?:boolean}
|
||||
/** One viewing peer; capture ownership belongs to the device session. */
|
||||
export function useSensorPreview(device:Sensor,layer:string,transport:SensorTransport,video:RefObject<HTMLVideoElement|null>,enabled=true,generation=0){
|
||||
const [state,setState]=useState('Подключение просмотра');
|
||||
const [telemetry,setTelemetry]=useState<SensorPreviewTelemetry>({});
|
||||
const [error,setError]=useState(''),[live,setLive]=useState(false),[hasFrame,setHasFrame]=useState(false);
|
||||
const [sourceStalled,setSourceStalled]=useState(false),[retryGeneration,setRetryGeneration]=useState(0);const retries=useRef(0);
|
||||
useEffect(()=>{setHasFrame(false);},[device.id,device.snapshot.context.session_id,layer]);
|
||||
useEffect(()=>{retries.current=0;},[device.id,device.snapshot.context.session_id,layer,enabled,generation]);
|
||||
useEffect(()=>{
|
||||
setTelemetry({});setError('');setLive(false);setSourceStalled(false);
|
||||
if(!enabled||!device.online){setState('Нет связи с источником');return;}
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Просмотр остановлен');return;}
|
||||
if(typeof RTCPeerConnection==='undefined'){setState('Просмотр не поддерживается браузером');return;}
|
||||
let cancelled=false,terminal=false,peerID='',closedPeerID='',firstLive=0,statsPending=false;
|
||||
let frameHandle:number|undefined,iceTimer:ReturnType<typeof setTimeout>|undefined,retryTimer:ReturnType<typeof setTimeout>|undefined;
|
||||
const element=video.current,health=new PreviewLiveness(performance.now());
|
||||
const playback=previewPlayback(element);
|
||||
const resumePlayback=()=>{if(!cancelled&&!terminal)playback.resume();};
|
||||
document.addEventListener('visibilitychange',resumePlayback);
|
||||
const pc=new RTCPeerConnection({iceServers:[]});
|
||||
const data=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||
const closePeer=()=>{if(peerID&&closedPeerID!==peerID){closedPeerID=peerID;void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);}};
|
||||
const fail=(reason:string)=>{
|
||||
if(cancelled||terminal)return;
|
||||
terminal=true;setLive(false);pc.close();closePeer();
|
||||
if(retries.current<4){const delay=2000*2**retries.current++;setState('Восстановление просмотра');retryTimer=setTimeout(()=>{if(!cancelled)setRetryGeneration(v=>v+1);},delay);}
|
||||
else{setState('Просмотр недоступен');setError(reason);}
|
||||
};
|
||||
const publish=(checkPeer=false)=>{
|
||||
if(cancelled||terminal)return;
|
||||
const now=performance.now(),value=health.state(now),paused=element?.paused&&document.visibilityState!=='hidden';
|
||||
setLive(value==='live'&&!paused);setSourceStalled(value==='source-stalled'&&document.visibilityState!=='hidden');
|
||||
if(value==='live'&&!paused){firstLive||=now;if(now-firstLive>15000)retries.current=0;setState(device.playback_id?'Исходная запись':'Прямой эфир');}
|
||||
else if(paused&&value==='live'){firstLive=0;setState('Восстановление просмотра');}
|
||||
else{firstLive=0;if(value==='delayed'||value==='source-stalled')setState('Задержка изображения');}
|
||||
if(checkPeer&&value==='peer-stalled'&&document.visibilityState!=='hidden')fail('Не удалось восстановить изображение. Повторите подключение.');
|
||||
};
|
||||
const markFrame=()=>{if(cancelled||terminal)return;health.frame(performance.now());setHasFrame(true);publish();};
|
||||
const frameSeen=()=>{markFrame();if(!cancelled&&!terminal)frameHandle=element?.requestVideoFrameCallback(frameSeen);};
|
||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.ontrack=event=>{if(element){element.muted=true;element.srcObject=new MediaStream([event.track]);resumePlayback();if(typeof element.requestVideoFrameCallback==='function')frameHandle=element.requestVideoFrameCallback(frameSeen);}};
|
||||
pc.onconnectionstatechange=()=>{if(!cancelled&&!terminal&&['failed','closed'].includes(pc.connectionState))fail('Не удалось получить изображение. Повторите подключение.');};
|
||||
data.onmessage=event=>{
|
||||
if(cancelled||terminal)return;
|
||||
try{const value=JSON.parse(event.data);setTelemetry(value);if(typeof value.fresh==='boolean')health.source(value.fresh,performance.now());if(value.fresh!==false&&((layer==='points'&&Array.isArray(value.points))||(layer==='motion'&&value.motion)))markFrame();else publish();}
|
||||
catch{/* Malformed telemetry cannot become a control result. */}
|
||||
};
|
||||
let decoded=0;
|
||||
const timer=setInterval(()=>{
|
||||
if(cancelled||terminal)return;
|
||||
if(data.readyState==='open')data.send('keepalive');
|
||||
resumePlayback();
|
||||
if(!pc.remoteDescription||statsPending)return;
|
||||
// requestVideoFrameCallback can stop for a clipped/hidden decoder or an offscreen panel.
|
||||
// RTP decode progress remains the authority for a functioning video channel.
|
||||
if(element){statsPending=true;void pc.getStats().then(stats=>{if(cancelled||terminal)return;stats.forEach(value=>{if(value.type==='inbound-rtp'&&value.kind==='video'&&value.framesDecoded>decoded){decoded=value.framesDecoded;markFrame();}});publish(true);}).catch(()=>undefined).finally(()=>{statsPending=false;});}
|
||||
else publish(true);
|
||||
},2000);
|
||||
setState('Подключение просмотра');
|
||||
async function connect(){try{
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{iceTimer=setTimeout(()=>reject(new Error('Не удалось подготовить канал просмотра.')),10000);pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(iceTimer);resolve();}};});
|
||||
if(cancelled)return;
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer'}>(transport,device,'offer',{sdp:pc.localDescription!.sdp,layer});peerID=answer.peer_id;
|
||||
if(cancelled||terminal){closePeer();return;}
|
||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});health.connected(performance.now());
|
||||
}catch(reason){fail(reason instanceof Error?reason.message:'Не удалось открыть просмотр.');}}
|
||||
void connect();
|
||||
return()=>{cancelled=true;playback.dispose();document.removeEventListener('visibilitychange',resumePlayback);clearTimeout(iceTimer);clearTimeout(retryTimer);clearInterval(timer);if(frameHandle!==undefined)element?.cancelVideoFrameCallback(frameHandle);pc.ontrack=null;pc.onconnectionstatechange=null;data.onmessage=null;pc.close();if(element)element.srcObject=null;closePeer();};
|
||||
},[device.id,device.online,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport,video,enabled,generation,retryGeneration]);
|
||||
return {state,telemetry,error,live,hasFrame,sourceStalled};
|
||||
}
|
||||
Reference in New Issue
Block a user