feat(control-station): add atomic recorded-session playback
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
@@ -11,6 +13,20 @@ let shouldRestartObservationSource;
|
||||
let consumeCameraLeaseRetry;
|
||||
let resetCameraLeaseRetryBudget;
|
||||
let initialObservationWindowRect;
|
||||
let ObservationTimeline;
|
||||
let shouldCaptureWorkspacePointer;
|
||||
let normalizeTimelineRange;
|
||||
let timelineOffsetSeconds;
|
||||
let formatTimelineDuration;
|
||||
let normalizeAccumulationSeconds;
|
||||
let formatAccumulationDuration;
|
||||
let resolveRerunSourceUrl;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let isRecordedPlaybackFullyBuffered;
|
||||
let recordedObservationSources;
|
||||
let selectRecordedMediaEpoch;
|
||||
let recordedMediaLocalTime;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -30,9 +46,32 @@ before(async () => {
|
||||
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
|
||||
"/src/components/MseFmp4WebSocketPlayer.tsx",
|
||||
));
|
||||
({ initialObservationWindowRect } = await server.ssrLoadModule(
|
||||
({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
|
||||
"/src/components/FloatingObservationWindow.tsx",
|
||||
));
|
||||
({
|
||||
ObservationTimeline,
|
||||
normalizeTimelineRange,
|
||||
timelineOffsetSeconds,
|
||||
formatTimelineDuration,
|
||||
normalizeAccumulationSeconds,
|
||||
formatAccumulationDuration,
|
||||
} =
|
||||
await server.ssrLoadModule("/src/components/ObservationTimeline.tsx"));
|
||||
({
|
||||
resolveRerunSourceUrl,
|
||||
resolveRecordedBlueprintUrl,
|
||||
fetchRecordedBlueprintRrd,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedObservationSources.ts",
|
||||
));
|
||||
({ selectRecordedMediaEpoch, recordedMediaLocalTime } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
@@ -75,6 +114,251 @@ test("observation camera tiling remains in bounds on a constrained viewport", ()
|
||||
assert.ok(rects[1].y + rects[1].height <= rects[0].y);
|
||||
});
|
||||
|
||||
test("floating observation interaction captures resize and movable header pointers", () => {
|
||||
const target = (matches) => ({ closest: (selector) => matches.includes(selector) });
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__resize"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__head"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(
|
||||
0,
|
||||
target([".nodedc-workspace-window__head", "button, input, select, textarea, a"]),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(2, target([".nodedc-workspace-window__resize"])),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded observation timeline clamps relative seek time without epoch precision in the UI", () => {
|
||||
const range = normalizeTimelineRange({
|
||||
min: 0,
|
||||
max: 12_500_000_000,
|
||||
});
|
||||
assert.ok(range);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min + 2_250_000_000), 2.25);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min - 1), 0);
|
||||
assert.equal(timelineOffsetSeconds(range, range.max + 1), 12.5);
|
||||
assert.equal(formatTimelineDuration(62.125), "01:02.125");
|
||||
});
|
||||
|
||||
test("recorded observation timeline rejects empty and non-finite ranges", () => {
|
||||
assert.equal(normalizeTimelineRange(null), null);
|
||||
assert.equal(normalizeTimelineRange({ min: 10, max: 10 }), null);
|
||||
assert.equal(normalizeTimelineRange({ min: Number.NaN, max: 10 }), null);
|
||||
});
|
||||
|
||||
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 120);
|
||||
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
||||
});
|
||||
|
||||
test("spatial timeline renders synchronized accumulation and playback controls", () => {
|
||||
const markup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
accumulationSeconds: 12,
|
||||
onAccumulationChange: () => undefined,
|
||||
onAccumulationCommit: () => undefined,
|
||||
onSeek: () => undefined,
|
||||
}));
|
||||
|
||||
assert.match(markup, /data-accumulation="true"/);
|
||||
assert.match(markup, /Накопление/);
|
||||
assert.match(markup, /aria-label="Окно накопления облака точек"/);
|
||||
assert.match(markup, /aria-valuetext="12 с"/);
|
||||
assert.match(markup, /aria-label="Позиция воспроизведения"/);
|
||||
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("Rerun expands only root-relative session recordings onto the current origin", () => {
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl(
|
||||
" /api/v1/observation-sessions/session-1/recording.rrd ",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("rerun+http://127.0.0.1:9877/proxy", "http://127.0.0.1:5174"),
|
||||
"rerun+http://127.0.0.1:9877/proxy",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("//different-authority.invalid/session.rrd", "http://127.0.0.1:5174"),
|
||||
"//different-authority.invalid/session.rrd",
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint endpoint is derived only from canonical same-origin RRD sources", () => {
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/../recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
||||
assert.equal(isRecordedPlaybackFullyBuffered(null, 20), false);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_500_000_000 }, 20),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_999_500_000 }, 20),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 1 }, undefined),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 20_000_000_000 }, Number.NaN),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||
const calls = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual([...result], [...payload]);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(calls[0].init.credentials, "same-origin");
|
||||
assert.deepEqual(calls[0].body, {
|
||||
application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording-001",
|
||||
accumulation_seconds: 24,
|
||||
show_grid: false,
|
||||
show_points: true,
|
||||
show_trajectory: false,
|
||||
point_size: 4.5,
|
||||
palette: "custom",
|
||||
custom_color: "#35d7c1",
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fetchRecordedBlueprintRrd(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{ origin: "http://127.0.0.1:5174", fetcher: async () => new Response(payload) },
|
||||
),
|
||||
/Unsafe recorded blueprint request/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||
const sources = recordedObservationSources({
|
||||
kind: "rerun-recording",
|
||||
sessionId: "session-1",
|
||||
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
viewerSourceUrl: `/api/v1/observation-sessions/session-1/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
mediaType: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
byteLength: 123,
|
||||
sha256: "a".repeat(64),
|
||||
playback: { speed: 1, loop: false },
|
||||
mediaSources: [{
|
||||
id: "recorded.camera.abc123",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifestUrl: "/api/v1/observation-sessions/session-1/media/recorded-video-abc123/manifest",
|
||||
manifestGenerationSha256: "c".repeat(64),
|
||||
byteLength: 1_024,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0.25,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
}],
|
||||
});
|
||||
assert.deepEqual(sources.map(({ modality }) => modality), ["point-cloud", "video"]);
|
||||
assert.equal(sources[1].delivery.kind, "recorded-fmp4-manifest");
|
||||
assert.equal(sources[1].delivery.manifestGenerationSha256, "c".repeat(64));
|
||||
assert.deepEqual(sources[1].binding, {});
|
||||
assert.equal(sources.some(({ provider }) => provider.pluginId.includes("xgrids")), false);
|
||||
assert.equal(JSON.stringify(sources).includes("192.168"), false);
|
||||
});
|
||||
|
||||
test("recorded camera epoch selection and shared-clock offset are deterministic", () => {
|
||||
const epochs = [
|
||||
{ ordinal: 1, timelineStartSeconds: 0.25, timelineEndSeconds: 9 },
|
||||
{ ordinal: 2, timelineStartSeconds: 10, timelineEndSeconds: 20 },
|
||||
];
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0.25).ordinal, 1);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9), epochs[0]);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9.9), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 10).ordinal, 2);
|
||||
assert.equal(recordedMediaLocalTime(10, 12.5), 2.5);
|
||||
assert.equal(recordedMediaLocalTime(10, 8), 0);
|
||||
assert.equal(recordedMediaLocalTime(10, 30, 4), 4);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user