Preserve onboard preview recordings across backpressure and share the complete spatial scene
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user