diff --git a/apps/control-station/src/components/map/mapPresentation.ts b/apps/control-station/src/components/map/mapPresentation.ts new file mode 100644 index 0000000..46698a3 --- /dev/null +++ b/apps/control-station/src/components/map/mapPresentation.ts @@ -0,0 +1,59 @@ +import type {MapVisualSettings} from '../../core/map/mapView'; +import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react'; + +export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] { + return { +atmosphere_enabled: settings.atmosphereEnabled, + atmosphere_hue: settings.atmosphereHue, + atmosphere_saturation: + settings.atmosphereSaturation, + atmosphere_brightness: + settings.atmosphereBrightness, + fog_enabled: settings.fogEnabled, + fog_density: settings.fogDensity, + sun_enabled: settings.sunEnabled, + sun_hour: settings.sunHour, + sun_intensity: settings.sunIntensity, + shadows_enabled: settings.shadowsEnabled, + monochrome_enabled: settings.monochromeEnabled, + monochrome_color: settings.monochromeColor, + imagery_brightness: settings.imageryBrightness, + imagery_contrast: settings.imageryContrast, + imagery_saturation: settings.imagerySaturation, + imagery_gamma: settings.imageryGamma, + imagery_hue: settings.imageryHue, + imagery_alpha: settings.imageryAlpha, + globe_color: settings.globeColor, + background_color: settings.backgroundColor, + terrain_exaggeration: + settings.terrainExaggeration, + buildings_color: settings.buildingsColor, + buildings_opacity: settings.buildingsOpacity, + buildings_maximum_screen_space_error: + settings.buildingsMaximumScreenSpaceError, + grid_lod_enabled: settings.gridLodEnabled, + grid_height_meters: settings.gridHeightMeters, + grid_lod_1_max_height_km: + settings.gridLod1MaxHeightKm, + grid_lod_1_step_km: + settings.gridLod1StepKm, + grid_lod_2_max_height_km: + settings.gridLod2MaxHeightKm, + grid_lod_2_step_km: + settings.gridLod2StepKm, + grid_lod_3_step_km: + settings.gridLod3StepKm, + grid_radius_km: settings.gridRadiusKm, + grid_line_width: settings.gridLineWidth, + grid_color: settings.gridColor, + grid_opacity: settings.gridOpacity, + grid_dots_enabled: + settings.gridDotsEnabled, + grid_dots_size: settings.gridDotsSize, + grid_dots_color: settings.gridDotsColor, + grid_dots_opacity: + settings.gridDotsOpacity, + camera_animation_enabled: + settings.cameraAnimationEnabled, + }; +} diff --git a/apps/control-station/src/core/fleet/observationLayout.ts b/apps/control-station/src/core/fleet/observationLayout.ts new file mode 100644 index 0000000..1497b9a --- /dev/null +++ b/apps/control-station/src/core/fleet/observationLayout.ts @@ -0,0 +1,23 @@ +export interface ObservationLayout { + version:1; order:string[]; hidden:string[]; layers:Record; + splits:Record; arrangement:'auto'|'columns'|'rows'; +} +export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'}); +const record=(value:unknown):Record=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; +export function decodeObservationLayout(value:unknown):ObservationLayout { + const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result; + const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[]; + result.order=ids(source.order);result.hidden=ids(source.hidden); + result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record; + result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))])); + if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement; + return result; +} +export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{ + const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))]; +} +export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout { + const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to); + if(source<0||target<0||source===target)return layout; + order.splice(source,1);order.splice(target,0,from);return {...layout,order}; +} diff --git a/apps/control-station/src/core/fleet/sensorTransport.ts b/apps/control-station/src/core/fleet/sensorTransport.ts new file mode 100644 index 0000000..9ee8749 --- /dev/null +++ b/apps/control-station/src/core/fleet/sensorTransport.ts @@ -0,0 +1,18 @@ +import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts'; +import {fleetRequest} from './useFleet'; + +export function createFleetSensorTransport(vehicleID:string):SensorTransport { + const path=`/${encodeURIComponent(vehicleID)}/devices`; + const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{ + const value=fleet.items.find(v=>v.id===vehicleID); + if(!value)throw new Error('Аппарат не найден.'); + return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'}; + }; + return { + enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)}, + inventory:async()=>inventory(await fleetRequest()), + subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();}, + submit:value=>fleetRequest(`${path}/operations`,'POST',value), + operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`), + }; +} diff --git a/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx b/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx index 2e1589d..1b93dc7 100644 --- a/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx +++ b/apps/control-station/src/workspaces/fleet/BoardMonitorView.tsx @@ -20,7 +20,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string {latest&&

Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}

} {value?.database_bytes!=null&&

Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}

} - {!value?:!value.available?:<> + {!value?:!value.available?:<>
undefined} disabled={pending} /> setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>} + + {source.key!=='map'&&{setFocused(source.key);setLayersOpen(true);}}>} + setFull(value=>value===source.key?null:source.key)}> + setVisible(source.key,false)}> +
+ + + + } + ,mounts.current.get(source.key)!,source.key))} + + {groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)} + {visible.includes('map')&&createPortal(,media.current.get('map')!,'map-session')} + setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md">
+ {!active&&({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/>
;})} + {active?.view.layers&&({value,label:videoNames[value]??value}))} onChange={setLayer}/>
{groups.map(group=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/>}
{detail.recordings?.length?detail.recordings.map(item=>
{new Date(item.started_at).toLocaleString('ru-RU')}{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}void act('replay',{recording_id:item.id})}>
):

Записей пока нет.

}
} - {!detail&&

{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}

} + {!detail&&

{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}

} ; } diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index 46b2907..0c82e6b 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -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
{device?Detail?setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:
:<>
{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}
{transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(true)}>}{void refresh();}}>
- {!inventory?:connected.length===0?:{connected.map(item=>{ + {!inventory?:connected.length===0?:{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'; diff --git a/packages/sensor-ui/src/extensions.ts b/packages/sensor-ui/src/extensions.ts index 4bd0917..492950b 100644 --- a/packages/sensor-ui/src/extensions.ts +++ b/packages/sensor-ui/src/extensions.ts @@ -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; icon:IconName; diff --git a/packages/sensor-ui/src/observation.ts b/packages/sensor-ui/src/observation.ts new file mode 100644 index 0000000..ef0efab --- /dev/null +++ b/packages/sensor-ui/src/observation.ts @@ -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; +} +export const sensorLayerLabels:Record={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]);} diff --git a/packages/sensor-ui/src/observationStart.ts b/packages/sensor-ui/src/observationStart.ts new file mode 100644 index 0000000..505251a --- /dev/null +++ b/packages/sensor-ui/src/observationStart.ts @@ -0,0 +1,24 @@ +import {perform,type Sensor,type SensorTransport} from './contracts'; + +const pending=new Map>(); +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(device:Sensor,run:()=>Promise):Promise{ + const key=observationSession(device),current=pending.get(key);if(current)return current as Promise; + 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{ + 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}); + }); +} diff --git a/packages/sensor-ui/src/pluginSdk.ts b/packages/sensor-ui/src/pluginSdk.ts index c970748..ad7ac32 100644 --- a/packages/sensor-ui/src/pluginSdk.ts +++ b/packages/sensor-ui/src/pluginSdk.ts @@ -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'; diff --git a/packages/sensor-ui/src/previewLiveness.ts b/packages/sensor-ui/src/previewLiveness.ts new file mode 100644 index 0000000..0eac0f4 --- /dev/null +++ b/packages/sensor-ui/src/previewLiveness.ts @@ -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'; + } +} diff --git a/packages/sensor-ui/src/previewPlayback.ts b/packages/sensor-ui/src/previewPlayback.ts new file mode 100644 index 0000000..67bdd78 --- /dev/null +++ b/packages/sensor-ui/src/previewPlayback.ts @@ -0,0 +1,17 @@ +type PreviewVideo = Pick; + +/** 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; }, + }; +} diff --git a/packages/sensor-ui/src/useObservationStart.ts b/packages/sensor-ui/src/useObservationStart.ts new file mode 100644 index 0000000..400cde6 --- /dev/null +++ b/packages/sensor-ui/src/useObservationStart.ts @@ -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){ + 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}; +} diff --git a/packages/sensor-ui/src/useSensorPreview.ts b/packages/sensor-ui/src/useSensorPreview.ts new file mode 100644 index 0000000..675a10a --- /dev/null +++ b/packages/sensor-ui/src/useSensorPreview.ts @@ -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;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,enabled=true,generation=0){ + const [state,setState]=useState('Подключение просмотра'); + const [telemetry,setTelemetry]=useState({}); + 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|undefined,retryTimer:ReturnType|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((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}; +} diff --git a/plugins/insta360-x4/frontend/src/RecoverySettings.tsx b/plugins/insta360-x4/frontend/src/RecoverySettings.tsx index ff0f023..f6930c5 100644 --- a/plugins/insta360-x4/frontend/src/RecoverySettings.tsx +++ b/plugins/insta360-x4/frontend/src/RecoverySettings.tsx @@ -6,7 +6,7 @@ export function RecoverySettings({value,online,enabled,pending,change}:{ }) { const failed=['exhausted','unavailable','preview_failed'].includes(value.phase); const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры': - ['waiting','waking','restoring_preview'].includes(value.phase)?'Восстанавливаем подключение':'Включено'; + ['waiting','waking','restoring_preview'].includes(value.phase)?'Восстановление подключения':'Включено'; return {label}}>

На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.

diff --git a/plugins/insta360-x4/frontend/src/X4Detail.tsx b/plugins/insta360-x4/frontend/src/X4Detail.tsx index ee1a9d4..7c4a3a4 100644 --- a/plugins/insta360-x4/frontend/src/X4Detail.tsx +++ b/plugins/insta360-x4/frontend/src/X4Detail.tsx @@ -52,7 +52,7 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD
{available&&status.recording===1&&

Запись продолжится после закрытия просмотра.

}
- + {settings?void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?

Настройки недоступны. Обновите состояние камеры.

:null}
void action('files.list',{offset:0},'files.refresh')}>Обновить список}> diff --git a/plugins/insta360-x4/frontend/src/X4Observation.tsx b/plugins/insta360-x4/frontend/src/X4Observation.tsx new file mode 100644 index 0000000..e602b0a --- /dev/null +++ b/plugins/insta360-x4/frontend/src/X4Observation.tsx @@ -0,0 +1,47 @@ +import {useEffect,useRef,useState,type RefObject} from 'react'; +import {createPortal} from 'react-dom'; +import {Icon,IconButton} from '@nodedc/ui-react'; +import {ObservationHeader} from '../../../../packages/sensor-ui/src/ObservationHeader'; +import type {SensorObservationProps} from '../../../../packages/sensor-ui/src/observation'; +import {useSensorPreview} from '../../../../packages/sensor-ui/src/useSensorPreview'; +import {useObservationStart} from '../../../../packages/sensor-ui/src/useObservationStart'; +import {startX4Observation} from './observationStart'; +import {cameraStatus} from './model'; + +function Lens({video,side,label,live}:{video:RefObject;side:number;label:string;live:boolean}){ + const canvas=useRef(null);const [supported,setSupported]=useState(true); + useEffect(()=>{ + const source=video.current,target=canvas.current;if(!source||!target)return; + let handle:number|undefined,frame:number|undefined,active=true; + const draw=()=>{ + if(!active)return; + if(source.readyState>=2&&source.videoWidth){ + const valid=Math.abs(source.videoWidth/source.videoHeight-2)<.02;setSupported(valid); + if(valid){const size=Math.max(1,Math.round(Math.min(target.clientWidth,target.clientHeight)*Math.min(devicePixelRatio,2)));if(target.width!==size||target.height!==size){target.width=size;target.height=size;}target.getContext('2d')?.drawImage(source,side*source.videoWidth/2,0,source.videoWidth/2,source.videoHeight,0,0,size,size);} + } + }; + const tick=()=>{draw();if(active)handle=source.requestVideoFrameCallback(tick);}; + const fallback=()=>{draw();if(active)frame=requestAnimationFrame(fallback);}; + const resize=new ResizeObserver(draw);resize.observe(target); + if(typeof source.requestVideoFrameCallback==='function')handle=source.requestVideoFrameCallback(tick);else frame=requestAnimationFrame(fallback); + return()=>{active=false;resize.disconnect();if(handle!==undefined)source.cancelVideoFrameCallback(handle);if(frame!==undefined)cancelAnimationFrame(frame);}; + },[video,side]); + return <>{!supported&&

Текущий формат изображения не содержит пару объективов.

}; +} +export function X4Observation({device,transport,enabled,views}:SensorObservationProps){ + const video=useRef(null);const [generation,setGeneration]=useState(0); + const status=cameraStatus(device);const available=enabled&&device.online&&status.connected; + const activation=useObservationStart(device,available&&device.prepared,status.preview!==0,()=>startX4Observation(device,transport)); + const {state,error,live,hasFrame,sourceStalled}=useSensorPreview(device,'preview',transport,video,enabled,generation); + const [attempts,setAttempts]=useState(0); + const recovery=useObservationStart(device,available&&sourceStalled&&status.preview===1&&status.recording===0&&attempts<2,false,async()=>{setAttempts(v=>v+1);await startX4Observation(device,transport,true);setGeneration(v=>v+1);}); + useEffect(()=>{if(!live)return;const timer=setTimeout(()=>setAttempts(0),30000);return()=>clearTimeout(timer);},[live]); + useEffect(()=>{setAttempts(0);},[device.id,device.snapshot.context.session_id]); + const pending=activation.pending||recovery.pending; + const failure=!live&&(activation.error||recovery.error||(sourceStalled&&attempts>=2&&!pending?'Не удалось восстановить изображение. Повторите просмотр.':''))||''; + return <>