Preserve onboard preview recordings across backpressure and share the complete spatial scene
This commit is contained in:
@@ -124,19 +124,8 @@ export interface XgridsHostFailureDiagnostic {
|
||||
redacted: true;
|
||||
}
|
||||
|
||||
export type AcquisitionState =
|
||||
| "preparing"
|
||||
| "prepared"
|
||||
| "awaiting_external_start"
|
||||
| "starting"
|
||||
| "acquiring"
|
||||
| "awaiting_external_stop"
|
||||
| "stopping"
|
||||
| "finalizing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted"
|
||||
| "interrupted";
|
||||
export type {AcquisitionState} from './spatialSessionModel';
|
||||
import type {AcquisitionState} from './spatialSessionModel';
|
||||
|
||||
export type OperationStatus =
|
||||
| "accepted"
|
||||
@@ -832,32 +821,8 @@ export interface XgridsConnectionAttempt extends XgridsConnectionAttemptSummary
|
||||
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
|
||||
}
|
||||
|
||||
export interface XgridsK1Metrics {
|
||||
pcl_frames?: number | null;
|
||||
pose_frames?: number | null;
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
pipeline_ms?: number | null;
|
||||
end_to_end_ms?: number | null;
|
||||
frame_rate?: number | null;
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
ai_end_to_end_ms?: number | null;
|
||||
ai_end_to_end_p95_ms?: number | null;
|
||||
ai_frame_rate_hz?: number | null;
|
||||
ai_dropped_frames?: number | null;
|
||||
ai_stale_ms?: number | null;
|
||||
device_elapsed_seconds?: number | null;
|
||||
device_route_distance_meters?: number | null;
|
||||
device_speed_meters_per_second?: number | null;
|
||||
device_speed_mps?: number | null;
|
||||
elapsed_seconds?: number | null;
|
||||
route_distance_meters?: number | null;
|
||||
speed_meters_per_second?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
}
|
||||
export type {XgridsK1Metrics} from './spatialSessionModel';
|
||||
import type {XgridsK1Metrics} from './spatialSessionModel';
|
||||
|
||||
export interface XgridsCameraPreviewDelivery {
|
||||
id: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ActivityIndicator, Button } from "@nodedc/ui-react";
|
||||
import {K1SpatialSession, k1SpatialPhasePresentation} from './K1SpatialSession';
|
||||
export {k1SpatialPhasePresentation} from './K1SpatialSession';
|
||||
import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import type {
|
||||
AcquisitionState,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
} from "../api";
|
||||
import {
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "../lifecycle";
|
||||
import {
|
||||
deviceTelemetry,
|
||||
formatNumber,
|
||||
spatialActionFailure,
|
||||
} from "../presentation";
|
||||
import {
|
||||
@@ -34,12 +33,6 @@ import {
|
||||
} from "../runtimeContext";
|
||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||
|
||||
interface PhasePresentation {
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface K1SpatialAuthorityState {
|
||||
controlAuthoritative: boolean;
|
||||
dataAuthoritative: boolean;
|
||||
@@ -64,85 +57,6 @@ export function k1SpatialAuthorityState(
|
||||
};
|
||||
}
|
||||
|
||||
export function k1SpatialPhasePresentation(
|
||||
acquisition: XgridsAcquisition,
|
||||
softwareCommanded: boolean,
|
||||
): PhasePresentation {
|
||||
const presentations: Record<AcquisitionState, PhasePresentation> = {
|
||||
preparing: {
|
||||
label: "Подготовка локального приёма",
|
||||
detail: "Проверка контура и создание сессии записи.",
|
||||
busy: true,
|
||||
},
|
||||
prepared: {
|
||||
label: "Приём подготовлен",
|
||||
detail: softwareCommanded
|
||||
? "Можно инициировать работу устройства из Mission Core."
|
||||
: "Программная команда K1 недоступна в текущем профиле.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_start: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Ожидание запуска на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Запустите сканирование физической кнопкой K1.",
|
||||
busy: true,
|
||||
},
|
||||
starting: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Подготовка локального приёмника",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Mission Core запускает запись до физического старта K1.",
|
||||
busy: true,
|
||||
},
|
||||
acquiring: {
|
||||
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
|
||||
detail: softwareCommanded
|
||||
? "Состояние получено из профилированного контура управления K1."
|
||||
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_stop: {
|
||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
|
||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
busy: true,
|
||||
},
|
||||
stopping: {
|
||||
label: softwareCommanded ? "Остановка K1 и записи" : "Остановка локального приёма",
|
||||
detail: softwareCommanded
|
||||
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
|
||||
: "Физическое состояние K1 остаётся неизвестным.",
|
||||
busy: true,
|
||||
},
|
||||
finalizing: {
|
||||
label: "Сохранение локальной записи",
|
||||
detail: "Не закрывайте Mission Core до завершения финализации.",
|
||||
busy: true,
|
||||
},
|
||||
completed: { label: "Приём завершён", detail: "", busy: false },
|
||||
failed: { label: "Ошибка приёма", detail: "", busy: false },
|
||||
aborted: { label: "Приём прерван", detail: "", busy: false },
|
||||
interrupted: { label: "Приём прерван", detail: "", busy: false },
|
||||
};
|
||||
return presentations[acquisition.state];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const wholeSeconds = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(wholeSeconds / 3_600);
|
||||
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
|
||||
const remainder = wholeSeconds % 60;
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
|
||||
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function runSpatialActiveStreamForceFinish(
|
||||
controller: Pick<
|
||||
XgridsK1Controller,
|
||||
@@ -276,30 +190,7 @@ export function K1SpatialControlsView({
|
||||
);
|
||||
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls"
|
||||
aria-label="Управление сессией XGRIDS K1"
|
||||
aria-busy={phase.busy}
|
||||
data-busy={phase.busy ? "true" : undefined}
|
||||
>
|
||||
<div className="xgrids-k1-spatial-controls__phase">
|
||||
{phase.busy ? <ActivityIndicator size="compact" /> : null}
|
||||
<span>
|
||||
<strong>{phase.label}</strong>
|
||||
<small>{phase.detail}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
|
||||
{telemetry.elapsedSeconds !== null ? (
|
||||
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
|
||||
) : null}
|
||||
{telemetry.routeDistanceMeters !== null ? (
|
||||
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
|
||||
) : null}
|
||||
{telemetry.speedMetersPerSecond !== null ? (
|
||||
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
|
||||
) : null}
|
||||
</div>
|
||||
<K1SpatialSession phase={phase} telemetry={telemetry}>
|
||||
{actionFailure ? (
|
||||
<div className="xgrids-k1-spatial-controls__error" role="alert">
|
||||
<strong>{actionFailure.title}</strong>
|
||||
@@ -345,7 +236,7 @@ export function K1SpatialControlsView({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
</K1SpatialSession>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.xgrids-k1-spatial-controls {
|
||||
display: flex;
|
||||
min-width: min(42rem, 100%);
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 1rem;
|
||||
background: rgb(9 10 13 / 0.88);
|
||||
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase {
|
||||
display: flex;
|
||||
min-width: 11rem;
|
||||
flex: 1 1 15rem;
|
||||
align-items: center;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong,
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry > span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error {
|
||||
display: grid;
|
||||
max-width: 17rem;
|
||||
gap: 0.12rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.51rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label {
|
||||
display: block;
|
||||
width: 9.75rem;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.08;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label--local {
|
||||
width: 8.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.xgrids-k1-spatial-controls {min-width:0}
|
||||
.xgrids-k1-spatial-controls__phase small,
|
||||
.xgrids-k1-spatial-controls__telemetry,
|
||||
.xgrids-k1-spatial-controls__error small {display:none}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {ActivityIndicator} from '@nodedc/ui-react';
|
||||
import type {AcquisitionState} from '../spatialSessionModel';
|
||||
import {formatNumber, type K1DeviceTelemetry} from '../spatialSessionModel';
|
||||
import './K1SpatialSession.css';
|
||||
interface PhasePresentation {
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export function k1SpatialPhasePresentation(
|
||||
acquisition: {state:AcquisitionState},
|
||||
softwareCommanded: boolean,
|
||||
): PhasePresentation {
|
||||
const presentations: Record<AcquisitionState, PhasePresentation> = {
|
||||
preparing: {
|
||||
label: "Подготовка локального приёма",
|
||||
detail: "Проверка контура и создание сессии записи.",
|
||||
busy: true,
|
||||
},
|
||||
prepared: {
|
||||
label: "Приём подготовлен",
|
||||
detail: softwareCommanded
|
||||
? "Можно инициировать работу устройства из Mission Core."
|
||||
: "Программная команда K1 недоступна в текущем профиле.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_start: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Ожидание запуска на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Запустите сканирование физической кнопкой K1.",
|
||||
busy: true,
|
||||
},
|
||||
starting: {
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Подготовка локального приёмника",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Mission Core запускает запись до физического старта K1.",
|
||||
busy: true,
|
||||
},
|
||||
acquiring: {
|
||||
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
|
||||
detail: softwareCommanded
|
||||
? "Состояние получено из профилированного контура управления K1."
|
||||
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_stop: {
|
||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
|
||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
busy: true,
|
||||
},
|
||||
stopping: {
|
||||
label: softwareCommanded ? "Остановка K1 и записи" : "Остановка локального приёма",
|
||||
detail: softwareCommanded
|
||||
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
|
||||
: "Физическое состояние K1 остаётся неизвестным.",
|
||||
busy: true,
|
||||
},
|
||||
finalizing: {
|
||||
label: "Сохранение локальной записи",
|
||||
detail: "Не закрывайте Mission Core до завершения финализации.",
|
||||
busy: true,
|
||||
},
|
||||
completed: { label: "Приём завершён", detail: "", busy: false },
|
||||
failed: { label: "Ошибка приёма", detail: "", busy: false },
|
||||
aborted: { label: "Приём прерван", detail: "", busy: false },
|
||||
interrupted: { label: "Приём прерван", detail: "", busy: false },
|
||||
};
|
||||
return presentations[acquisition.state];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const wholeSeconds = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(wholeSeconds / 3_600);
|
||||
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
|
||||
const remainder = wholeSeconds % 60;
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
|
||||
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function K1SpatialSession({phase,telemetry,children}:{phase:PhasePresentation;telemetry:K1DeviceTelemetry;children?:ReactNode}) {
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls"
|
||||
aria-label="Управление сессией XGRIDS K1"
|
||||
aria-busy={phase.busy}
|
||||
data-busy={phase.busy ? "true" : undefined}
|
||||
>
|
||||
<div className="xgrids-k1-spatial-controls__phase">
|
||||
{phase.busy ? <ActivityIndicator size="compact" /> : null}
|
||||
<span>
|
||||
<strong>{phase.label}</strong>
|
||||
<small>{phase.detail}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
|
||||
{telemetry.elapsedSeconds !== null ? (
|
||||
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
|
||||
) : null}
|
||||
{telemetry.routeDistanceMeters !== null ? (
|
||||
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
|
||||
) : null}
|
||||
{telemetry.speedMetersPerSecond !== null ? (
|
||||
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import {finiteMetric} from './spatialSessionModel';
|
||||
export {finiteMetric, deviceTelemetry, formatNumber} from './spatialSessionModel';
|
||||
export type {K1DeviceTelemetry} from './spatialSessionModel';
|
||||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus } from "@mission-core/plugin-sdk";
|
||||
@@ -295,9 +298,6 @@ export function eventStatusLabel(status: string): string {
|
||||
}[status] ?? "неизвестно";
|
||||
}
|
||||
|
||||
export function finiteMetric(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number | null {
|
||||
if (!metrics) return null;
|
||||
@@ -308,17 +308,6 @@ export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number |
|
||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
function nonNegativeMetric(value: number | null | undefined): number | null {
|
||||
const finite = finiteMetric(value);
|
||||
return finite !== null && finite >= 0 ? finite : null;
|
||||
}
|
||||
|
||||
export interface K1DeviceTelemetry {
|
||||
elapsedSeconds: number | null;
|
||||
routeDistanceMeters: number | null;
|
||||
speedMetersPerSecond: number | null;
|
||||
}
|
||||
|
||||
export interface SpatialActionFailure {
|
||||
title: string;
|
||||
detail: string;
|
||||
@@ -336,31 +325,6 @@ export function spatialActionFailure(
|
||||
: null;
|
||||
}
|
||||
|
||||
export function deviceTelemetry(
|
||||
metrics: XgridsK1Metrics | null | undefined,
|
||||
): K1DeviceTelemetry {
|
||||
return {
|
||||
elapsedSeconds: nonNegativeMetric(
|
||||
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
|
||||
),
|
||||
routeDistanceMeters: nonNegativeMetric(
|
||||
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
|
||||
),
|
||||
speedMetersPerSecond: nonNegativeMetric(
|
||||
metrics?.device_speed_meters_per_second ??
|
||||
metrics?.device_speed_mps ??
|
||||
metrics?.speed_meters_per_second,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function sourceModeLabel(mode: string | null | undefined): string {
|
||||
if (mode === "live") return "Реальное время";
|
||||
|
||||
@@ -116,7 +116,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
|
||||
}
|
||||
|
||||
return renderWindow({busy:pending,
|
||||
actions:confirmed?<Button variant="primary" disabled={pending}
|
||||
actions:confirmed?<Button variant="primary" style={{width:'100%'}} disabled={pending}
|
||||
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
|
||||
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
|
||||
content:<div className="sensor-content" ref={content}>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {K1SpatialSession, k1SpatialPhasePresentation} from '../components/K1SpatialSession';
|
||||
import {deviceTelemetry} from '../spatialSessionModel';
|
||||
import type {AcquisitionState} from '../spatialSessionModel';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {K1LiveView} from './K1LiveView';
|
||||
@@ -11,6 +14,9 @@ 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(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
|
||||
const [preview,setPreview]=useState(pendingPreview);
|
||||
const [sceneOpen,setSceneOpen]=useState(['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition));
|
||||
const acquisitionId=device.control?.acquisition_id;
|
||||
useEffect(()=>{if(acquisitionId&&['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition))setSceneOpen(true);},[acquisitionId]);
|
||||
const scene=useK1SceneSettings(device,transport,failure);
|
||||
const running=useRef(false);
|
||||
const failedAction=useRef('');
|
||||
@@ -19,6 +25,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
||||
const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':'');
|
||||
async function act(action:'start'|'stop'|'verify'){
|
||||
if(running.current||!enabled)return;
|
||||
if(action==='start')setSceneOpen(true);
|
||||
running.current=true;setBusy(action);failedAction.current='';setOperationError('');failure(null);
|
||||
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
|
||||
catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1.');await refresh().catch(()=>{});}
|
||||
@@ -26,6 +33,11 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
||||
}
|
||||
const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected);
|
||||
const summary=(!confirmed&&operationError)||(pending==='start'?'Запускаем устройство. Ожидаем завершения калибровки.':pending==='stop'?'Останавливаем устройство. Ожидаем подтверждения.':!manual.connected?k1ConnectionNotice(device,enabled):status.label);
|
||||
const phaseName = (device.control?.acquisition_phase || (pending==='start'?'starting':pending==='stop'?'stopping':streaming?'acquiring':'completed')) as AcquisitionState;
|
||||
const phase = k1SpatialPhasePresentation({state:phaseName},true) ?? {label:summary,detail:'',busy:!!pending};
|
||||
const sessionControls = <K1SpatialSession phase={!enabled?{label:'Нет свежих сведений с БК',detail:'Ожидаем восстановления связи.',busy:false}:phase} telemetry={deviceTelemetry(device.frames)}>
|
||||
{manual.showStop&&<Button size="compact" disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'} icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}><span className="xgrids-k1-spatial-controls__action-label">{pending==='stop'?'Остановка устройства…':'Остановить устройство и запись'}</span></Button>}
|
||||
</K1SpatialSession>;
|
||||
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="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||
@@ -38,6 +50,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
||||
</SettingsCard>
|
||||
<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}/>}
|
||||
{!sceneOpen&&manual.showStop&&<Button onClick={()=>setSceneOpen(true)}>Открыть пространственную сцену</Button>}
|
||||
{sceneOpen&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled&&streaming} scene={scene} openTool={setTool} close={()=>setSceneOpen(false)} sessionControls={sessionControls}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type {ReactNode} from 'react';
|
||||
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 { ApplicationPanel, Icon, IconButton, StatusBadge } from '@nodedc/ui-react';
|
||||
import { SpatialScene, EmptySpatialStage, 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, enabled, scene, openTool }: {
|
||||
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool, close, sessionControls }: {
|
||||
device: Sensor;
|
||||
transport: SensorTransport;
|
||||
createRerunHost: RerunHostFactory;
|
||||
@@ -13,8 +14,10 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
||||
enabled: boolean;
|
||||
scene: K1SceneState;
|
||||
openTool: (tool: SceneTool) => void;
|
||||
close: () => void; sessionControls: ReactNode;
|
||||
}) {
|
||||
const spatial = useRef<HTMLDivElement>(null), viewport = useRef<HTMLDivElement>(null), video = useRef<HTMLVideoElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
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);
|
||||
@@ -31,6 +34,7 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
||||
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
|
||||
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
setFocused(false);
|
||||
setCameraMaximized(false);
|
||||
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
|
||||
const sources: SpatialSourceDescriptor[] = [
|
||||
@@ -41,29 +45,38 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
|
||||
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>;
|
||||
const hasScene = status.hasScene;
|
||||
const focusedAny = focused || cameraMaximized;
|
||||
return <ApplicationPanel title="Пространственная сцена" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'}
|
||||
expanded={expanded} onExpandedChange={setExpanded} onClose={close}
|
||||
headerTools={<StatusBadge tone={status.presented?'success':'neutral'}>{status.presented?'Эфир':'Ожидание эфира'}</StatusBadge>}>
|
||||
<SpatialScene viewportRef={viewport} focused={focusedAny} primaryFocused={focused} mediaMaximized={cameraMaximized}
|
||||
toolbar={<SpatialToolbarActions openSource={()=>openTool('source')} openLayers={()=>openTool('layers')} openDisplay={()=>openTool('display')}/>}
|
||||
renderer={<>
|
||||
<div className="rerun-viewport" data-status={hasScene?'ready':'loading'}>
|
||||
<div className="rerun-viewport__canvas" data-presented={hasScene&&visible.has('lidar')?'true':'false'} ref={spatial}/>
|
||||
</div>
|
||||
{!hasScene&&<EmptySpatialStage settings={scene.draft} title="Ожидаем данные K1" detail="Пространственная сцена появится после получения облака точек."/>}
|
||||
</>}
|
||||
deviceControls={sessionControls}
|
||||
sourceControls={focused?<IconButton className="scene-focus-exit" label="Выйти из полноэкранного режима облака точек" onClick={()=>setFocused(false)}><Icon name="minimize"/></IconButton>:!cameraMaximized&&<div className="scene-source-controls">
|
||||
<ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/>
|
||||
{hasScene&&visible.has('lidar')&&<IconButton className="scene-source-control" label="Развернуть облако точек" onClick={()=>setFocused(true)}><Icon name="expand"/></IconButton>}
|
||||
</div>}
|
||||
status={{tone:status.presented?'success':'neutral',label:status.presented?'Визуализатор готов':status.retry?'Восстановление связи':hasScene?'Нет свежих данных':'Ожидание источника'}}
|
||||
metrics={<>
|
||||
<div><span>КАДР/С</span><strong>{status.presented?(device.frames?.pcl_fps??device.frames?.frame_rate_hz??0).toFixed(1):'—'}</strong></div>
|
||||
<div><span>Точек</span><strong>{status.presented?status.points.toLocaleString('ru-RU'):'—'}</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>
|
||||
</>}
|
||||
navigationReady={hasScene}
|
||||
timeline={!focusedAny&&<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"/>}
|
||||
media={<FloatingMediaWindow title="K1 · камера справа" subtitle="Видеоканал, опубликованный активным устройством" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={focused||!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>}
|
||||
/>
|
||||
</ApplicationPanel>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
|
||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v2';
|
||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v3';
|
||||
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;
|
||||
@@ -23,3 +23,17 @@ export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||
export function assertRrd(bytes:Uint8Array){
|
||||
if(bytes.length<12||bytes[0]!==82||bytes[1]!==82||bytes[2]!==70||bytes[3]!==50)throw new Error('Invalid RRD recording');
|
||||
}
|
||||
|
||||
/** Kept by the acquisition view while disposable media peers reconnect. */
|
||||
export function previewRecording(send:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||
let after=0;
|
||||
return {
|
||||
get after(){return after;},
|
||||
accept(sequence:number,payload:Uint8Array<ArrayBuffer>) {
|
||||
assertRrd(payload);
|
||||
if(!Number.isSafeInteger(sequence)||sequence<1||sequence>after+1)throw new Error('Preview cursor gap');
|
||||
if(sequence<=after)return false;
|
||||
send(payload);after=sequence;return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { MEDIA_PROTOCOL, previewFrames, previewRecording } from './previewFrames';
|
||||
import { previewCamera } from './previewCamera';
|
||||
import { previewFreshness } from './previewFreshness';
|
||||
export type PreviewStatus = {
|
||||
@@ -9,10 +9,11 @@ export type PreviewStatus = {
|
||||
camera: string;
|
||||
retry: boolean;
|
||||
presented: boolean;
|
||||
hasScene: boolean;
|
||||
cameraPresented: boolean;
|
||||
points: number;
|
||||
};
|
||||
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, cameraPresented: false, points: 0 };
|
||||
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, hasScene: false, cameraPresented: false, points: 0 };
|
||||
function privateCandidate(sdp: string): string {
|
||||
return sdp.split('\r\n').filter(line => {
|
||||
if (!line.startsWith('a=candidate:'))
|
||||
@@ -24,8 +25,7 @@ function privateCandidate(sdp: string): string {
|
||||
}
|
||||
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.
|
||||
// The runtime and native channel belong to this acquisition view, not its peers.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const host = createRerunHost(spatial.current!);
|
||||
@@ -43,13 +43,31 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
|
||||
return () => { active = false; setNative(null); host.dispose(); };
|
||||
}, [createRerunHost, spatial, onStatus]);
|
||||
const [view, setView] = useState<{
|
||||
id: string; session: string; acquisition: string | null | undefined; channel: ReturnType<LiveRerunViewer['open_channel']>;
|
||||
recording: ReturnType<typeof previewRecording>; previousRecording: string | null; hasScene: boolean;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
onStatus(enabled ? { ...pendingPreview } : { ...pendingPreview, lidar: 'Лидар: нет связи с БК', camera: 'Камера: нет связи с БК' });
|
||||
if (!native || !enabled)
|
||||
if (!native) return;
|
||||
const id = crypto.randomUUID();
|
||||
const channel = native.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + id);
|
||||
setView({id, session:device.snapshot.context.session_id, acquisition:device.control?.acquisition_id, channel, recording: previewRecording(bytes => {if (!channel.ready) throw new Error('Viewer unavailable'); channel.send_rrd(bytes);}), previousRecording: native.get_active_recording_id(), hasScene: false});
|
||||
return () => { setView(null); channel.close();
|
||||
void perform(transport, device, 'close-peer', {view_id:id, retire_view:true}).catch(()=>{});
|
||||
};
|
||||
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport]);
|
||||
useEffect(() => {
|
||||
const currentView = view?.session === device.snapshot.context.session_id && view?.acquisition === device.control?.acquisition_id;
|
||||
const paused = device.snapshot.acquisition === 'streaming'
|
||||
? {lidar:'Лидар: нет связи с БК', camera:'Камера: нет связи с БК'}
|
||||
: ['starting','preparing'].includes(device.snapshot.acquisition)
|
||||
? {} : {lidar:'Лидар: приём завершён', camera:'Камера: изображение остановлено'};
|
||||
onStatus({...pendingPreview, hasScene:currentView ? view?.hasScene ?? false : false, ...(!enabled ? paused : {})});
|
||||
if (!native || !view || !currentView || !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 };
|
||||
let status = { ...pendingPreview, hasScene: view.hasScene };
|
||||
const update = (patch: Partial<PreviewStatus>) => { if (active) {
|
||||
const next = { ...status, ...patch };
|
||||
if (JSON.stringify(next) !== JSON.stringify(status)) {
|
||||
@@ -62,16 +80,23 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
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 {channel, previousRecording} = view;
|
||||
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);
|
||||
let 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); });
|
||||
let batch: {sequence: number; lidar: Record<string, unknown>; received: number} | null = null;
|
||||
const rrdFrames = previewFrames(payload => {
|
||||
if (!batch || !channel.ready) throw new Error('Viewer unavailable');
|
||||
if (view.recording.accept(batch.sequence, payload)) {
|
||||
const age = batch.lidar.age_ms;
|
||||
freshness.receive({...batch.lidar, age_ms: typeof age === 'number' ? age + performance.now() - batch.received : age});
|
||||
}
|
||||
rrd.send(JSON.stringify({ack: batch.sequence}));
|
||||
batch = null;
|
||||
});
|
||||
rrd.onclose = () => { if (active && !failed)
|
||||
fail(); };
|
||||
camera.onclose = () => { if (active && !failed)
|
||||
@@ -80,8 +105,17 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
if (!active)
|
||||
return;
|
||||
try {
|
||||
if (typeof event.data === 'string')
|
||||
freshness.receive(JSON.parse(event.data));
|
||||
if (typeof event.data === 'string') {
|
||||
const value = JSON.parse(event.data);
|
||||
if (value.type === 'preview-unavailable' && value.code === 'resume-expired') {
|
||||
failed = true; clearInterval(follow); clearInterval(keepalive);
|
||||
update({retry:false, presented:false, cameraPresented:false, lidar:'Сессия просмотра истекла. Закройте и откройте пространственную сцену.'});
|
||||
pc.close(); return;
|
||||
}
|
||||
if (batch || value.type !== 'rrd-batch' || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !value.lidar)
|
||||
throw new Error('Invalid RRD batch');
|
||||
batch = {...value, received: performance.now()};
|
||||
}
|
||||
else if (event.data instanceof ArrayBuffer)
|
||||
rrdFrames.push(event.data);
|
||||
}
|
||||
@@ -97,6 +131,9 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
const metadata = JSON.parse(event.data);
|
||||
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
|
||||
throw new Error('Invalid camera metadata');
|
||||
decoder.close();
|
||||
cameraDecoded = false; cameraFailed = false; lastVideoTime = -1;
|
||||
decoder = previewCamera(video.current!, () => {cameraDecoded = true; lastVideoProgress = performance.now();}, cameraFail);
|
||||
decoder.open(metadata.mime);
|
||||
}
|
||||
else if (event.data instanceof ArrayBuffer)
|
||||
@@ -128,13 +165,14 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
}
|
||||
catch { /* Native recording/timeline creation is asynchronous. */ }
|
||||
seenLidar ||= presented;
|
||||
view.hasScene ||= 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,
|
||||
update({ presented, hasScene: view.hasScene, cameraPresented, points: presented ? freshness.points : 0,
|
||||
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
|
||||
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
|
||||
}, 250);
|
||||
@@ -154,7 +192,7 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
sdp: string;
|
||||
type: 'answer';
|
||||
media_protocol: string;
|
||||
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id });
|
||||
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id, view_id: view.id, after: view.recording.after });
|
||||
peerID = answer.peer_id;
|
||||
if (!active) {
|
||||
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||
@@ -190,9 +228,8 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
|
||||
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]);
|
||||
}, [native, view, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Pure K1 telemetry and phase vocabulary, shared by direct and onboard views. */
|
||||
export type AcquisitionState =
|
||||
| "preparing"
|
||||
| "prepared"
|
||||
| "awaiting_external_start"
|
||||
| "starting"
|
||||
| "acquiring"
|
||||
| "awaiting_external_stop"
|
||||
| "stopping"
|
||||
| "finalizing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted"
|
||||
| "interrupted";
|
||||
|
||||
export interface XgridsK1Metrics {
|
||||
pcl_frames?: number | null;
|
||||
pose_frames?: number | null;
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
pipeline_ms?: number | null;
|
||||
end_to_end_ms?: number | null;
|
||||
frame_rate?: number | null;
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
ai_end_to_end_ms?: number | null;
|
||||
ai_end_to_end_p95_ms?: number | null;
|
||||
ai_frame_rate_hz?: number | null;
|
||||
ai_dropped_frames?: number | null;
|
||||
ai_stale_ms?: number | null;
|
||||
device_elapsed_seconds?: number | null;
|
||||
device_route_distance_meters?: number | null;
|
||||
device_speed_meters_per_second?: number | null;
|
||||
device_speed_mps?: number | null;
|
||||
elapsed_seconds?: number | null;
|
||||
route_distance_meters?: number | null;
|
||||
speed_meters_per_second?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
}
|
||||
|
||||
export function finiteMetric(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function nonNegativeMetric(value: number | null | undefined): number | null {
|
||||
const finite = finiteMetric(value);
|
||||
return finite !== null && finite >= 0 ? finite : null;
|
||||
}
|
||||
|
||||
export interface K1DeviceTelemetry {
|
||||
elapsedSeconds: number | null;
|
||||
routeDistanceMeters: number | null;
|
||||
speedMetersPerSecond: number | null;
|
||||
}
|
||||
|
||||
|
||||
export function deviceTelemetry(
|
||||
metrics: XgridsK1Metrics | null | undefined,
|
||||
): K1DeviceTelemetry {
|
||||
return {
|
||||
elapsedSeconds: nonNegativeMetric(
|
||||
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
|
||||
),
|
||||
routeDistanceMeters: nonNegativeMetric(
|
||||
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
|
||||
),
|
||||
speedMetersPerSecond: nonNegativeMetric(
|
||||
metrics?.device_speed_meters_per_second ??
|
||||
metrics?.device_speed_mps ??
|
||||
metrics?.speed_meters_per_second,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -984,21 +984,6 @@ box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls {
|
||||
display: flex;
|
||||
min-width: min(42rem, 100%);
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 1rem;
|
||||
background: rgb(9 10 13 / 0.88);
|
||||
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls--recovery {
|
||||
display: grid;
|
||||
min-width: min(46rem, 100%);
|
||||
@@ -1108,90 +1093,6 @@ box-sizing: border-box;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase {
|
||||
display: flex;
|
||||
min-width: 11rem;
|
||||
flex: 1 1 15rem;
|
||||
align-items: center;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong,
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase strong {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__phase small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry > span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__telemetry strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error {
|
||||
display: grid;
|
||||
max-width: 17rem;
|
||||
gap: 0.12rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error strong {
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__error small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.51rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label {
|
||||
display: block;
|
||||
width: 9.75rem;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.08;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xgrids-k1-spatial-controls__action-label--local {
|
||||
width: 8.75rem;
|
||||
}
|
||||
|
||||
.connection-action-progress {
|
||||
display: flex;
|
||||
min-height: 2.75rem;
|
||||
|
||||
Reference in New Issue
Block a user