Preserve onboard preview recordings across backpressure and share the complete spatial scene

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 17:47:10 +03:00
parent 9b6534287a
commit 9bba44f7c4
30 changed files with 957 additions and 494 deletions
@@ -1,3 +1,4 @@
import {SpatialScene, EmptySpatialStage} from '../../../../packages/spatial-ui/src';
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -33,7 +34,6 @@ import {
type WorkspaceDefinition,
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import type { WorkspaceRendererProps } from "./contracts";
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
@@ -91,26 +91,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
</section>
);
}
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
return (
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
<div className="empty-spatial-stage__grid" aria-hidden="true" />
<div className="spatial-axis" aria-hidden="true">
<span data-axis="x">X</span>
<span data-axis="z">Z</span>
</div>
<div className="empty-spatial-stage__message">
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
<strong>Визуальный источник не назначен</strong>
<p>
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
открыть реальные данные.
</p>
</div>
</div>
);
}
function SpatialWorkspace({
state,
sourceUrl,
@@ -412,14 +392,9 @@ function SpatialWorkspace({
: null,
});
return (
<div
className="spatial-workspace"
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
>
<div className="spatial-toolbar" data-viewer-controls="v1">
<div className="spatial-toolbar__actions">
{recordedPerceptionSupported || livePerceptionAvailable ? (
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
toolbar={<> {recordedPerceptionSupported || livePerceptionAvailable ? (
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
<Button
size="compact"
@@ -499,17 +474,7 @@ function SpatialWorkspace({
Сброс вида
</Button>
) : null}
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/>
</div>
</div>
<div
ref={viewportRef}
className="spatial-viewport-shell"
data-primary-focused={pointCloudFocused ? "true" : undefined}
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
>
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={<> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport
profile={rerunViewerProfile}
sceneSettings={sceneSettings}
@@ -523,20 +488,15 @@ function SpatialWorkspace({
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
{spatialControls && !recordedSource ? (
<div className="scene-device-controls">
<spatialControls.View
</>}
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
model={spatialControls.model}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>
</div>
) : null}
{pointCloudFocused ? (
/>):null}
sourceControls={<> {pointCloudFocused ? (
<button
type="button"
className="scene-focus-exit"
@@ -567,22 +527,9 @@ function SpatialWorkspace({
) : null}
</div>
) : null}
<div
className="scene-status scene-status--top-left"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
</div>
<div
className="scene-metrics"
aria-label="Метрики пространственной сцены"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<div>
</>}
status={{label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
metrics={<> <div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
@@ -603,10 +550,7 @@ function SpatialWorkspace({
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
</strong>
</div>
) : null}
</div>
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
) : null}</>} overlays={<> {!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
@@ -661,14 +605,8 @@ function SpatialWorkspace({
</div>
) : null}
{presentedViewerStatus === "ready" && !floatingSourceMaximized ? (
<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене">
<span>Колесо · зум к курсору</span>
<span>WASD · свободный проход</span>
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
</>}
navigationReady={presentedViewerStatus==='ready'} timeline={<> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
@@ -691,8 +629,8 @@ function SpatialWorkspace({
className="scene-timeline"
/>
) : null}
{visibleMediaSources.map((source, index) => (
</>}
media={<> {visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
@@ -743,10 +681,7 @@ function SpatialWorkspace({
/>
))}
</div>
) : null}
</div>
<div className="spatial-contract-strip">
) : null}</>} footer={ <div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="ready" />Преобразования</span>
@@ -755,9 +690,7 @@ function SpatialWorkspace({
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
<span><i data-state="contract" />Компоновка</span>
</div>
</div>
);
</div>}/>;
}
function CameraSourceCard({
@@ -191,11 +191,13 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
assert.match(spatialControls, /role="alert"/);
assert.doesNotMatch(spatialControls, /Повторить остановку/);
assert.match(spatialControls, /stopLocalReceiver/);
assert.match(spatialControls, /<ActivityIndicator size="compact"/);
assert.match(spatialControls, /aria-busy=\{phase\.busy\}/);
assert.match(spatialControls, /K1 калибруется и готовит облако точек/);
assert.match(spatialControls, /Первые данные могут появиться через десятки секунд/);
assert.match(spatialControls, /Не перемещайте устройство/);
assert.match(spatialControls, /<K1SpatialSession/);
const spatialSession = readFileSync(join(pluginFrontendRoot, "components/K1SpatialSession.tsx"), "utf8");
assert.match(spatialSession, /<ActivityIndicator size="compact"/);
assert.match(spatialSession, /aria-busy=\{phase\.busy\}/);
assert.match(spatialSession, /K1 калибруется и готовит облако точек/);
assert.match(spatialSession, /Первые данные могут появиться через десятки секунд/);
assert.match(spatialSession, /Не перемещайте устройство/);
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
assert.doesNotMatch(styles, /@keyframes xgrids-k1-spin/);
@@ -37,7 +37,7 @@ test('active acquisition exposes STOP and Rerun without another START',()=>{
assert.match(markup,/Остановить устройство/);
assert.match(markup,/rerun-viewport__canvas/);
assert.match(markup,/>Статус</);
assert.match(markup,/>Пространственная модель</);
assert.match(markup,/>Пространственная сцена</);
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
assert.ok(markup.includes('data-presented="false"'));
assert.ok(!markup.includes('Loading Rerun'));
@@ -53,8 +53,8 @@ test('calibration loader stays inside the primary action',()=>{
const starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}};
const markup=render(starting);
assert.match(markup,/<button[^>]*aria-busy="true"[\s\S]*?nodedc-activity-indicator[\s\S]*?Запускаем устройство<\/button>/);
assert.equal((markup.match(/nodedc-activity-indicator"/g)||[]).length,1);
assert.doesNotMatch(markup,/Остановить устройство|k1-preview-spatial/);
assert.match(markup,/K1 калибруется и готовит облако точек/);
assert.doesNotMatch(markup,/Обновить просмотр/);
});
test('completed STOP remains visible while the control connection is checked again',()=>{
const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}};
@@ -78,3 +78,16 @@ test('onboard camera admits immediate sourceopen and ignores callbacks after clo
assert.equal(ready,1);assert.equal(failed,0);
}finally{globalThis.MediaSource=saved.media;URL.createObjectURL=saved.create;URL.revokeObjectURL=saved.revoke;}
});
test('a recovered media peer resumes a recording without reinserting old RRD batches',async()=>{
const {previewRecording}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts');
const sent=[];const cursor=previewRecording(value=>sent.push(value));
const payload=new Uint8Array(12);payload.set([82,82,70,50]);
assert.equal(cursor.accept(1,payload),true);
// The ACK was lost, so the new peer resends the exact complete batch.
assert.equal(cursor.accept(1,payload),false);
assert.equal(cursor.after,1);
assert.throws(()=>cursor.accept(3,payload),/cursor gap/);
assert.equal(cursor.accept(2,payload),true);
assert.equal(sent.length,2);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.9"
VERSION = "0.8.10"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package