feat(control-station): add atomic recorded-session playback
This commit is contained in:
parent
007ff9fff2
commit
e94c64eebd
|
|
@ -7,7 +7,9 @@
|
|||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
|
|
@ -834,6 +836,18 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/tokens": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"link": true
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
|
||||
if (manifest.version !== "0.34.1") {
|
||||
throw new Error(
|
||||
`Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const patches = [
|
||||
{
|
||||
label: "compiled watchdog",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "source watchdog",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "compiled singleton global listener",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
{
|
||||
label: "source singleton global listener",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const patch of patches) {
|
||||
const source = readFileSync(patch.path, "utf8");
|
||||
if (source.includes(patch.after)) continue;
|
||||
if (!source.includes(patch.before)) {
|
||||
throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`);
|
||||
}
|
||||
writeFileSync(patch.path, source.replace(patch.before, patch.after));
|
||||
}
|
||||
|
||||
for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1;
|
||||
if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) {
|
||||
throw new Error(`Vendor singleton global listener verification failed for ${path}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Patched @rerun-io/web-viewer 0.34.1 watchdog teardown and singleton global keyup listener.",
|
||||
);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
|
|
@ -26,10 +26,25 @@ import {
|
|||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
import {
|
||||
createLatestAsyncCommitter,
|
||||
type LatestAsyncCommitter,
|
||||
} from "./core/runtime/latestAsyncCommitter";
|
||||
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
|
||||
import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import {
|
||||
OBSERVATION_WORKSPACE_ID,
|
||||
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
type ObservationToolWindowId,
|
||||
type ObservationWorkspaceLayoutProfile,
|
||||
} from "./core/observation/workspaceLayout";
|
||||
import {
|
||||
rootById,
|
||||
roots,
|
||||
|
|
@ -48,7 +63,10 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
|||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import "./styles/scene-windows.css";
|
||||
|
||||
type SceneToolWindowId = "sources" | "display" | "layers" | "layout";
|
||||
type SceneToolWindowId = ObservationToolWindowId;
|
||||
|
||||
const sceneToolWindowIds: readonly SceneToolWindowId[] = ["sources", "display", "layers"];
|
||||
const viewerSettingsQuietPeriodMs = 750;
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
|
|
@ -116,28 +134,117 @@ export default function App() {
|
|||
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
const [sourceDraft, setSourceDraft] = useState("");
|
||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
|
||||
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
|
||||
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
|
||||
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
|
||||
const [layoutName, setLayoutName] = useState("Операторская сцена");
|
||||
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
|
||||
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||
const replayActiveRef = useRef(false);
|
||||
const sceneSettingsCommitterActiveRef = useRef(true);
|
||||
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
||||
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
||||
const observationLayout = useObservationLayout(
|
||||
runtime.state?.observationSources ?? [],
|
||||
runtime.setObservationSourceActive,
|
||||
const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl;
|
||||
const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
|
||||
const recordedSessionAdmission = useRecordedSessionAdmission(
|
||||
replayActive ? recordedReplay : null,
|
||||
);
|
||||
const focusedObservationSource = runtime.state?.observationSources?.find(
|
||||
const displayPaletteOptions = replayActive
|
||||
? paletteOptions.map((option) => {
|
||||
if (option.value === "turbo") {
|
||||
return {
|
||||
...option,
|
||||
label: "Цвет записи",
|
||||
description: "Вернуть цвета, сохранённые внутри RRD",
|
||||
};
|
||||
}
|
||||
return option.value === "custom" ? option : { ...option, disabled: true };
|
||||
})
|
||||
: paletteOptions;
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayActive;
|
||||
|
||||
if (!sceneSettingsCommitterRef.current) {
|
||||
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
|
||||
commit: (settings) => replayActiveRef.current
|
||||
? Promise.resolve(true)
|
||||
: runtimeUpdateViewerSettingsRef.current(toViewerSettings(settings)),
|
||||
onSettled: ({ value, applied, superseded }) => {
|
||||
if (!sceneSettingsCommitterActiveRef.current) return;
|
||||
if (applied) confirmedSceneSettingsRef.current = value;
|
||||
if (superseded) return;
|
||||
|
||||
const next = applied ? value : confirmedSceneSettingsRef.current;
|
||||
sceneSettingsRef.current = next;
|
||||
setSceneSettings(next);
|
||||
if (!applied && displayDraftRef.current === value) {
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
const replaySources = useMemo(
|
||||
() => recordedObservationSources(recordedReplay),
|
||||
[recordedReplay],
|
||||
);
|
||||
const activeObservationSources = replayActive
|
||||
? replaySources
|
||||
: runtime.state?.observationSources ?? [];
|
||||
const activeRuntimeState = useMemo(() => {
|
||||
if (!replayActive || !recordedReplay) return runtime.state;
|
||||
return {
|
||||
...(runtime.state ?? { phase: "replaying" as const }),
|
||||
phase: "replaying" as const,
|
||||
sourceMode: "replay" as const,
|
||||
spatialSource: {
|
||||
id: "recorded.spatial.primary",
|
||||
url: recordedReplay.sourceUrl,
|
||||
label: "Сохранённая пространственная сцена",
|
||||
kind: "rrd" as const,
|
||||
},
|
||||
observationSources: replaySources,
|
||||
observationTimeline: {
|
||||
mode: "recorded" as const,
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
synchronization: "host-arrival-best-effort" as const,
|
||||
range: {
|
||||
startSeconds: recordedReplay.timelineStartSeconds,
|
||||
endSeconds: recordedReplay.timelineEndSeconds,
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [recordedReplay, replayActive, replaySources, runtime.state]);
|
||||
const viewerSettingsTargetIdentity = [
|
||||
runtime.state?.activeDevice?.pluginId,
|
||||
runtime.state?.activeDevice?.modelId,
|
||||
runtime.state?.activeDevice?.instanceId,
|
||||
runtime.state?.deviceSession?.sessionId,
|
||||
runtime.state?.deviceSession?.deviceId,
|
||||
runtime.state?.acquisition?.acquisitionId,
|
||||
].filter(Boolean).join(":") || "local-runtime";
|
||||
const observationLayout = useObservationLayout(
|
||||
activeObservationSources,
|
||||
replayActive ? undefined : runtime.setObservationSourceActive,
|
||||
);
|
||||
const workspaceLayoutProfile = useWorkspaceLayoutProfile();
|
||||
const focusedObservationSource = activeObservationSources.find(
|
||||
(source) => source.id === observationLayout.focusedSourceId,
|
||||
);
|
||||
const observationFullscreenActive = Boolean(
|
||||
|
|
@ -148,12 +255,80 @@ export default function App() {
|
|||
|
||||
useEffect(() => {
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
if (!remote) return;
|
||||
setSceneSettings((current) => mergeViewerSettings(current, remote));
|
||||
if (!displayWindowOpen) {
|
||||
setDisplayDraft((current) => mergeViewerSettings(current, remote));
|
||||
if (
|
||||
!remote ||
|
||||
workspaceLayoutProfile.profile ||
|
||||
sceneSettingsCommitterRef.current?.isBusy()
|
||||
) return;
|
||||
const merged = mergeViewerSettings(sceneSettingsRef.current, remote);
|
||||
sceneSettingsRef.current = merged;
|
||||
confirmedSceneSettingsRef.current = merged;
|
||||
setSceneSettings(merged);
|
||||
if (viewerSettingsCommitTimerRef.current === null) {
|
||||
displayDraftRef.current = merged;
|
||||
setDisplayDraft(merged);
|
||||
}
|
||||
}, [runtime.state?.viewerSettings, displayWindowOpen]);
|
||||
}, [runtime.state?.viewerSettings, workspaceLayoutProfile.profile]);
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
if (!profile) return;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
}
|
||||
sceneSettingsRef.current = profile.sceneSettings;
|
||||
displayDraftRef.current = profile.sceneSettings;
|
||||
confirmedSceneSettingsRef.current = profile.sceneSettings;
|
||||
setSceneSettings(profile.sceneSettings);
|
||||
setDisplayDraft(profile.sceneSettings);
|
||||
setSourceWindowOpen(profile.toolWindows.sourcesOpen);
|
||||
setDisplayWindowOpen(profile.toolWindows.displayOpen);
|
||||
setLayerInspectorOpen(profile.toolWindows.layersOpen);
|
||||
setSceneWindowOrder(profile.toolWindows.order.filter((windowId) => {
|
||||
if (windowId === "sources") return profile.toolWindows.sourcesOpen;
|
||||
if (windowId === "display") return profile.toolWindows.displayOpen;
|
||||
return profile.toolWindows.layersOpen;
|
||||
}));
|
||||
observationLayout.restore(profile);
|
||||
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
|
||||
|
||||
useEffect(() => {
|
||||
sceneSettingsCommitterActiveRef.current = true;
|
||||
return () => {
|
||||
sceneSettingsCommitterActiveRef.current = false;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
const applicationKey = profile
|
||||
? `${profile.revision}:${viewerSettingsTargetIdentity}`
|
||||
: null;
|
||||
if (
|
||||
!profile ||
|
||||
runtime.backendStatus !== "online" ||
|
||||
!applicationKey ||
|
||||
appliedProfileKeyRef.current === applicationKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
appliedProfileKeyRef.current = applicationKey;
|
||||
void runtime.updateViewerSettings(toViewerSettings(profile.sceneSettings)).then((applied) => {
|
||||
if (!applied && appliedProfileKeyRef.current === applicationKey) {
|
||||
appliedProfileKeyRef.current = null;
|
||||
}
|
||||
});
|
||||
}, [
|
||||
runtime.backendStatus,
|
||||
runtime.updateViewerSettings,
|
||||
viewerSettingsTargetIdentity,
|
||||
workspaceLayoutProfile.profile,
|
||||
]);
|
||||
|
||||
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
setSceneWindowOrder((current) => [
|
||||
|
|
@ -166,7 +341,6 @@ export default function App() {
|
|||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
if (windowId === "layout") setLayoutWindowOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
|
||||
|
|
@ -204,8 +378,41 @@ export default function App() {
|
|||
activateSceneWindow("sources");
|
||||
};
|
||||
|
||||
const commitDisplaySettings = useCallback((next: SceneSettings) => {
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
}
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
sceneSettingsCommitterRef.current?.enqueue(next);
|
||||
}, []);
|
||||
|
||||
const stageDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
const next = { ...displayDraftRef.current, ...patch };
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
}
|
||||
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, viewerSettingsQuietPeriodMs);
|
||||
}, []);
|
||||
|
||||
const flushDisplaySettings = useCallback(() => {
|
||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, []);
|
||||
|
||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||
}, [commitDisplaySettings]);
|
||||
|
||||
const openDisplay = () => {
|
||||
setDisplayDraft(sceneSettings);
|
||||
setDisplayWindowOpen(true);
|
||||
activateSceneWindow("display");
|
||||
};
|
||||
|
|
@ -215,28 +422,90 @@ export default function App() {
|
|||
activateSceneWindow("layers");
|
||||
};
|
||||
|
||||
const openLayout = () => {
|
||||
setLayoutDraftSaved(false);
|
||||
setLayoutWindowOpen(true);
|
||||
activateSceneWindow("layout");
|
||||
};
|
||||
const beginRecordedReplaySwitch = useCallback(async () => {
|
||||
// useObservationSessions calls this only after backend preparation has
|
||||
// produced a validated launch descriptor. Keep the old scene mounted
|
||||
// before this point; now perform one controlled receiver teardown before
|
||||
// accepting the already-ready archive.
|
||||
setReplayTransitioning(true);
|
||||
setRecordedReplay(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyDisplaySettings = async () => {
|
||||
const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft));
|
||||
if (applied) setSceneSettings(displayDraft);
|
||||
};
|
||||
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
|
||||
setRecordedReplay(launch);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
setSourceDraft(launch.sourceUrl);
|
||||
setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
const applyScenePatch = async (patch: Partial<SceneSettings>) => {
|
||||
const previous = sceneSettings;
|
||||
const next = { ...sceneSettings, ...patch };
|
||||
setSceneSettings(next);
|
||||
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
|
||||
const applied = await runtime.updateViewerSettings(toViewerSettings(next));
|
||||
if (!applied) {
|
||||
setSceneSettings(previous);
|
||||
if (!displayWindowOpen) setDisplayDraft(previous);
|
||||
const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => {
|
||||
if (outcome !== "accepted") setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// ObservationSessionSelect owns the cancellable request and unmounts when
|
||||
// the operator leaves the spatial workspace. Its unmount cannot safely
|
||||
// call back into this owner, so clear the transient blanking state here.
|
||||
// Returning to Observation then starts from a deterministic idle source
|
||||
// instead of an orphaned `replayTransitioning=true` state.
|
||||
if (activeDefinition?.kind !== "spatial") setReplayTransitioning(false);
|
||||
}, [activeDefinition?.kind]);
|
||||
|
||||
const applyScenePatch = (patch: Partial<SceneSettings>) => commitDisplayPatch(patch);
|
||||
|
||||
const saveWorkspaceLayout = useCallback(async () => {
|
||||
flushDisplaySettings();
|
||||
await sceneSettingsCommitterRef.current?.waitForIdle();
|
||||
|
||||
const layout = observationLayout.snapshot();
|
||||
if (!layout) {
|
||||
setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
const openWindowOrder = sceneWindowOrder.filter((windowId) => {
|
||||
if (windowId === "sources") return sourceWindowOpen;
|
||||
if (windowId === "display") return displayWindowOpen;
|
||||
return layerInspectorOpen;
|
||||
});
|
||||
const completeWindowOrder = [
|
||||
...sceneToolWindowIds.filter((windowId) => !openWindowOrder.includes(windowId)),
|
||||
...openWindowOrder,
|
||||
];
|
||||
const draft: ObservationWorkspaceLayoutProfile = {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: workspaceLayoutProfile.profile?.revision ?? 0,
|
||||
workspaceId: OBSERVATION_WORKSPACE_ID,
|
||||
sceneSettings: sceneSettingsRef.current,
|
||||
toolWindows: {
|
||||
sourcesOpen: sourceWindowOpen,
|
||||
displayOpen: displayWindowOpen,
|
||||
layersOpen: layerInspectorOpen,
|
||||
order: completeWindowOrder,
|
||||
},
|
||||
...layout,
|
||||
};
|
||||
const saved = await workspaceLayoutProfile.save(draft);
|
||||
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
|
||||
}, [
|
||||
displayWindowOpen,
|
||||
flushDisplaySettings,
|
||||
layerInspectorOpen,
|
||||
observationLayout.snapshot,
|
||||
sceneWindowOrder,
|
||||
sourceWindowOpen,
|
||||
workspaceLayoutProfile,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutSaveNotice || layoutSaveNotice !== "Компоновка сохранена") return;
|
||||
const timer = window.setTimeout(() => setLayoutSaveNotice(null), 2600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
|
|
@ -251,25 +520,27 @@ export default function App() {
|
|||
|
||||
if (activeDefinition?.kind === "spatial") {
|
||||
actions.push(
|
||||
{
|
||||
label: workspaceLayoutProfile.state === "saving"
|
||||
? "Сохраняем компоновку"
|
||||
: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
disabled: workspaceLayoutProfile.state === "saving",
|
||||
onClick: () => void saveWorkspaceLayout(),
|
||||
},
|
||||
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
onClick: openLayout,
|
||||
});
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]);
|
||||
}, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]);
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
left={<span className="station-label">MISSION CORE</span>}
|
||||
center={
|
||||
<>
|
||||
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
|
||||
|
|
@ -363,9 +634,29 @@ export default function App() {
|
|||
{phaseLabel(runtime.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
limit={3}
|
||||
disabled={runtime.pendingAction !== null}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
/>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
<span
|
||||
className="workspace-layout-feedback"
|
||||
data-error={workspaceLayoutProfile.error ? "true" : undefined}
|
||||
role={workspaceLayoutProfile.error ? "alert" : "status"}
|
||||
>
|
||||
<i
|
||||
className="api-dot"
|
||||
data-status={workspaceLayoutProfile.error ? "error" : "online"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
|
|
@ -380,10 +671,16 @@ export default function App() {
|
|||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
state={runtime.state}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
recordedReplay={replayActive ? recordedReplay : null}
|
||||
recordedSessionAdmission={recordedSessionAdmission}
|
||||
sceneSettings={sceneSettings}
|
||||
accumulationSeconds={displayDraft.accumulationSeconds}
|
||||
onAccumulationChange={(accumulationSeconds) =>
|
||||
stageDisplayPatch({ accumulationSeconds })}
|
||||
onAccumulationCommit={flushDisplaySettings}
|
||||
observationLayout={observationLayout}
|
||||
navigation={{
|
||||
openView,
|
||||
|
|
@ -419,6 +716,7 @@ export default function App() {
|
|||
onClick={() => {
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
}}
|
||||
>
|
||||
Сбросить адрес
|
||||
|
|
@ -429,6 +727,7 @@ export default function App() {
|
|||
disabled={!sourceDraft.trim()}
|
||||
onClick={() => {
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
}}
|
||||
>
|
||||
Применить адрес
|
||||
|
|
@ -530,21 +829,6 @@ export default function App() {
|
|||
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={runtime.pendingAction === "viewer"}
|
||||
onClick={() => void applyDisplaySettings()}
|
||||
>
|
||||
{runtime.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["points"]}
|
||||
|
|
@ -556,41 +840,66 @@ export default function App() {
|
|||
description: "Размер и способ окрашивания",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
|
||||
/>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={paletteOptions}
|
||||
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
{displayDraft.palette === "custom" ? (
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
disabled={replayActive}
|
||||
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={displayPaletteOptions}
|
||||
onChange={(palette) => stageDisplayPatch({ palette })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
{displayDraft.palette === "custom" ? (
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => stageDisplayPatch({ customColor })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
) : null}
|
||||
{replayActive ? (
|
||||
<p className="scene-window-note">
|
||||
В архиве градиентные цвета уже записаны в RRD. Без переэкспорта
|
||||
можно вернуть цвет записи или назначить один свой цвет.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -601,21 +910,28 @@ export default function App() {
|
|||
description: "История облака и траектория",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
|
||||
/>
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
onKeyUp={flushDisplaySettings}
|
||||
onBlur={flushDisplaySettings}
|
||||
>
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
||||
/>
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
|
||||
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -627,7 +943,7 @@ export default function App() {
|
|||
description: "Сетка, подписи и камеры",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
||||
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
||||
|
|
@ -726,59 +1042,6 @@ export default function App() {
|
|||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layoutWindowOpen}
|
||||
title="Компоновка рабочей области"
|
||||
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
|
||||
size="sm"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--layout"
|
||||
data-scene-window="layout"
|
||||
data-scene-active={activeSceneWindow === "layout" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("layout")}
|
||||
onClose={() => closeSceneWindow("layout")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!layoutName.trim()}
|
||||
onClick={() => setLayoutDraftSaved(true)}
|
||||
>
|
||||
Зафиксировать черновик
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<TextField
|
||||
label="Название компоновки"
|
||||
value={layoutName}
|
||||
onChange={(event) => {
|
||||
setLayoutName(event.target.value);
|
||||
setLayoutDraftSaved(false);
|
||||
}}
|
||||
placeholder="Название профиля"
|
||||
/>
|
||||
<div className="modal-contract-note" data-tone={layoutDraftSaved ? "success" : "warning"}>
|
||||
<Icon name={layoutDraftSaved ? "check" : "save"} />
|
||||
<div>
|
||||
<strong>{layoutDraftSaved ? "Черновик зафиксирован в текущем сеансе" : "Экспорт RBL ещё не подключён"}</strong>
|
||||
<p>
|
||||
{layoutDraftSaved
|
||||
? "Это состояние интерфейса без записи на диск. Будущий адаптер сохранит профиль Rerun рядом с кодом."
|
||||
: "Окно и контракт сохранения готовы; запись файла не имитируется."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { WorkspaceWindow } from "@nodedc/ui-react";
|
|||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
||||
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const WINDOW_WIDTH = 336;
|
||||
const WINDOW_HEIGHT = 210;
|
||||
|
|
@ -11,6 +16,18 @@ const WINDOW_GAP = 16;
|
|||
const WINDOW_INSET = 18;
|
||||
const TIMELINE_CLEARANCE = 64;
|
||||
|
||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||
|
||||
export function shouldCaptureWorkspacePointer(
|
||||
button: number,
|
||||
target: ClosestTarget | null,
|
||||
): boolean {
|
||||
if (button !== 0 || !target) return false;
|
||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||
return !target.closest("button, input, select, textarea, a");
|
||||
}
|
||||
|
||||
export function initialObservationWindowRect(
|
||||
index: number,
|
||||
count = 1,
|
||||
|
|
@ -59,6 +76,11 @@ export function FloatingObservationWindow({
|
|||
onMaximizedChange,
|
||||
onActivate,
|
||||
onClose,
|
||||
playback,
|
||||
prepareRecorded,
|
||||
recordedSessionGate,
|
||||
recordedAdmissionKey,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
index: number;
|
||||
|
|
@ -71,6 +93,14 @@ export function FloatingObservationWindow({
|
|||
onMaximizedChange: (maximized: boolean) => void;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
|
|
@ -124,13 +154,29 @@ export function FloatingObservationWindow({
|
|||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className="floating-observation-window"
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture can fail if the browser ended the pointer between
|
||||
// dispatch and capture. The donor's window listeners remain fallback.
|
||||
}
|
||||
}}
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
<ObservationMedia source={source} />
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepareRecorded={prepareRecorded}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
import { Dropdown, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../core/observation/useObservationSessions";
|
||||
import type {
|
||||
ObservationPreparationPhase,
|
||||
ObservationReplayOutcome,
|
||||
} from "../core/observation/useObservationSessions";
|
||||
import "../styles/observation-sessions.css";
|
||||
|
||||
type SessionVisualState = "ready" | "processing" | "error";
|
||||
|
||||
export function observationSessionVisualState(
|
||||
session: ObservationSessionSummary,
|
||||
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
|
||||
): SessionVisualState {
|
||||
if (failed) return "error";
|
||||
if (pending) return "processing";
|
||||
if (session.preparation !== null) {
|
||||
if (["queued", "validating", "exporting", "finalizing"].includes(
|
||||
session.preparation.state,
|
||||
)) return "processing";
|
||||
if (session.preparation.state === "ready" && session.replayable) return "ready";
|
||||
return "error";
|
||||
}
|
||||
return session.status === "recording" ? "processing" : "error";
|
||||
}
|
||||
|
||||
export function observationSessionVisualLabel(state: SessionVisualState): string {
|
||||
if (state === "ready") return "Готово";
|
||||
if (state === "processing") return "Обработка";
|
||||
return "Ошибка";
|
||||
}
|
||||
|
||||
const modalityLabel: Record<string, string> = {
|
||||
"point-cloud": "облако точек",
|
||||
pose: "траектория",
|
||||
trajectory: "траектория",
|
||||
video: "видео",
|
||||
image: "изображения",
|
||||
depth: "глубина",
|
||||
telemetry: "телеметрия",
|
||||
};
|
||||
|
||||
const preparationLabel: Record<ObservationPreparationPhase, string> = {
|
||||
requesting: "Запрашиваем подготовку",
|
||||
queued: "В очереди",
|
||||
validating: "Проверяем запись",
|
||||
exporting: "Готовим облако точек",
|
||||
finalizing: "Завершаем подготовку",
|
||||
failed: "Подготовка не выполнена",
|
||||
cancelled: "Подготовка отменена",
|
||||
};
|
||||
|
||||
function progressCopy(
|
||||
phase: ObservationPreparationPhase,
|
||||
progress: number | null,
|
||||
): string {
|
||||
const label = preparationLabel[phase];
|
||||
return progress === null ? label : `${label} · ${Math.round(progress * 100)}%`;
|
||||
}
|
||||
|
||||
function formatStartedAt(value: string): string {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
|
||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function sessionDescription(session: ObservationSessionSummary): string {
|
||||
const modalities = session.modalities.length
|
||||
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
|
||||
: "каналы не зафиксированы";
|
||||
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
|
||||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 3,
|
||||
disabled = false,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplayAccepted?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplaySettled?: (
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
});
|
||||
const triggerCopy = sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase, sessions.replayProgress.progress)
|
||||
: sessions.state === "loading"
|
||||
? "Загружаем сессии…"
|
||||
: "Сохранённые сессии";
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="observation-session-select"
|
||||
placement="bottom-end"
|
||||
width={390}
|
||||
offset={10}
|
||||
disabled={disabled}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-session-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="observation-session-select__trigger"
|
||||
data-active={open ? "true" : undefined}
|
||||
aria-label="Открыть сохранённые сессии наблюдения"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={15} />
|
||||
<span>{triggerCopy}</span>
|
||||
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="observation-session-menu__content">
|
||||
<header className="observation-session-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
|
||||
<strong>Сохранённые сессии</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Обновить каталог сессий"
|
||||
disabled={sessions.state === "loading"}
|
||||
onClick={() => void sessions.refresh()}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{sessions.items.length > 0 ? (
|
||||
<div className="observation-session-menu__list">
|
||||
{sessions.items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option"
|
||||
disabled={pending || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : sessions.state === "loading" ? (
|
||||
<div className="observation-session-menu__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Читаем каталог</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-session-menu__empty">
|
||||
<Icon name={sessions.error ? "alert" : "database"} size={18} />
|
||||
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
|
||||
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.error && sessions.items.length > 0 ? (
|
||||
<footer className="observation-session-menu__error" role="alert">
|
||||
<Icon name="alert" size={13} />
|
||||
<span>{sessions.error}</span>
|
||||
{sessions.failedSessionId ? (
|
||||
<button type="button" onClick={() => void sessions.retry()}>
|
||||
Повторить
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,14 @@ import type {
|
|||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
|
|
@ -29,7 +37,24 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
|
|||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
|
||||
export function ObservationMedia({
|
||||
source,
|
||||
playback,
|
||||
prepareRecorded = true,
|
||||
recordedSessionGate = "ready",
|
||||
recordedAdmissionKey = null,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const sourceSelected = source.activation ? source.activation.selected : true;
|
||||
const deliveryActive = Boolean(source.delivery && sourceSelected);
|
||||
|
||||
|
|
@ -41,6 +66,32 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
|
|||
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
if (!prepareRecorded) {
|
||||
return (
|
||||
<div className="observation-media__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ожидает подготовки</strong>
|
||||
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepare
|
||||
sessionGate={recordedSessionGate}
|
||||
admissionKey={recordedAdmissionKey}
|
||||
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
|
|
@ -193,7 +244,8 @@ export function ObservationSourcePicker({
|
|||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного device-плагина; сцена не знает модель оборудования.
|
||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||
модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ export function ObservationTimeline({
|
|||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
rangeNs = null,
|
||||
currentNs = null,
|
||||
playing = false,
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
|
|
@ -15,38 +24,151 @@ export function ObservationTimeline({
|
|||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
rangeNs?: { min: number; max: number } | null;
|
||||
currentNs?: number | null;
|
||||
playing?: boolean;
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const buffered = seekable && (mode === "buffered" || mode === "recorded");
|
||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||
const buffered = Boolean(
|
||||
seekable &&
|
||||
(mode === "buffered" || mode === "recorded") &&
|
||||
playbackRange,
|
||||
);
|
||||
const elapsedSeconds = playbackRange
|
||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||
: 0;
|
||||
const durationSeconds = playbackRange
|
||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||
: 0;
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
const accumulationValue = accumulationSeconds === undefined
|
||||
? null
|
||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||
>
|
||||
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" disabled>
|
||||
{buffered ? "Воспроизвести" : "Только эфир"}
|
||||
</Button>
|
||||
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}>
|
||||
<span style={{ width: active ? "100%" : "0%" }} />
|
||||
{accumulationValue !== null ? (
|
||||
<div className="observation-timeline__accumulation">
|
||||
<span>Накопление</span>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||
onChange={(event) =>
|
||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||
onPointerUp={onAccumulationCommit}
|
||||
onTouchEnd={onAccumulationCommit}
|
||||
onKeyUp={onAccumulationCommit}
|
||||
onBlur={onAccumulationCommit}
|
||||
/>
|
||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="observation-timeline__playback">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Перейти к началу"
|
||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(durationSeconds, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Позиция воспроизведения"
|
||||
onChange={(event) => {
|
||||
if (!playbackRange) return;
|
||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>
|
||||
{buffered
|
||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||
: active ? "LIVE" : "—:—:—.———"}
|
||||
</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>{active ? "LIVE" : "—:—:—.———"}</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}>
|
||||
ЭФИР
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
range: { min: number; max: number } | null | undefined,
|
||||
): { min: number; max: number } | null {
|
||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||
if (range.max <= range.min) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
export function timelineOffsetSeconds(
|
||||
range: { min: number; max: number },
|
||||
currentNs: number,
|
||||
): number {
|
||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||
}
|
||||
|
||||
export function formatTimelineDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||
const wholeMinutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - wholeMinutes * 60;
|
||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,735 @@
|
|||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { bytesToHex } from "@noble/hashes/utils.js";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationRecordedMediaManifest,
|
||||
ObservationSessionContractError,
|
||||
type ObservationRecordedMediaEpoch,
|
||||
type ObservationRecordedMediaManifest,
|
||||
type ObservationRecordedMediaSource,
|
||||
type ObservationSessionFetch,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import {
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
type RecordedAdmissionPhase,
|
||||
type RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
|
||||
export interface RecordedObservationPlayback {
|
||||
currentSeconds: number;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
export interface RecordedMediaLoadProgress {
|
||||
loadedBytes: number;
|
||||
totalBytes: number;
|
||||
loadedParts: number;
|
||||
totalParts: number;
|
||||
}
|
||||
|
||||
interface VerifiedRecordedMediaEpoch {
|
||||
descriptor: ObservationRecordedMediaEpoch;
|
||||
readonly init: ArrayBuffer;
|
||||
readonly segments: readonly ArrayBuffer[];
|
||||
}
|
||||
|
||||
export interface VerifiedRecordedMediaArchive {
|
||||
manifest: ObservationRecordedMediaManifest;
|
||||
epochs: readonly VerifiedRecordedMediaEpoch[];
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
interface RecordedMediaBinaryDescriptor {
|
||||
url: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
accept: string;
|
||||
}
|
||||
|
||||
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
|
||||
|
||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||
let recordedMediaWorkerGeneration = 0;
|
||||
|
||||
export function recordedMediaPresentationState(
|
||||
state: "loading" | "ready" | "error",
|
||||
readyGeneration: string | null,
|
||||
selectedGeneration: string | null,
|
||||
waitingForEpoch: boolean,
|
||||
sessionGate: RecordedAdmissionPhase = "ready",
|
||||
): RecordedMediaPresentationState {
|
||||
if (state === "error" || sessionGate === "error") return "error";
|
||||
if (sessionGate !== "ready") return "loading";
|
||||
if (waitingForEpoch) return "waiting";
|
||||
return state === "ready" &&
|
||||
selectedGeneration !== null &&
|
||||
readyGeneration === selectedGeneration
|
||||
? "ready"
|
||||
: "loading";
|
||||
}
|
||||
|
||||
export function selectRecordedMediaEpoch(
|
||||
epochs: readonly ObservationRecordedMediaEpoch[],
|
||||
currentSeconds: number,
|
||||
): ObservationRecordedMediaEpoch | null {
|
||||
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
|
||||
let selected: ObservationRecordedMediaEpoch | null = null;
|
||||
for (const epoch of epochs) {
|
||||
if (epoch.timelineStartSeconds > currentSeconds) break;
|
||||
selected = epoch;
|
||||
}
|
||||
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
|
||||
}
|
||||
|
||||
export function recordedMediaSeekableCoverage(
|
||||
durationSeconds: number,
|
||||
seekableEndSeconds: number,
|
||||
declaredDurationSeconds: number,
|
||||
toleranceSeconds = RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
||||
seekableStartSeconds = 0,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(durationSeconds) &&
|
||||
Number.isFinite(seekableEndSeconds) &&
|
||||
Number.isFinite(declaredDurationSeconds) &&
|
||||
Number.isFinite(seekableStartSeconds) &&
|
||||
declaredDurationSeconds > 0 &&
|
||||
toleranceSeconds >= 0 &&
|
||||
durationSeconds + toleranceSeconds >= declaredDurationSeconds &&
|
||||
seekableStartSeconds <= toleranceSeconds &&
|
||||
seekableStartSeconds >= -toleranceSeconds &&
|
||||
seekableEndSeconds + toleranceSeconds >= declaredDurationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function recordedMediaLocalTime(
|
||||
epochStartSeconds: number,
|
||||
currentSeconds: number,
|
||||
durationSeconds = Number.POSITIVE_INFINITY,
|
||||
): number {
|
||||
if (!Number.isFinite(epochStartSeconds) || !Number.isFinite(currentSeconds)) return 0;
|
||||
const local = Math.max(0, currentSeconds - epochStartSeconds);
|
||||
return Number.isFinite(durationSeconds)
|
||||
? Math.min(local, Math.max(0, durationSeconds))
|
||||
: local;
|
||||
}
|
||||
|
||||
function sourceContract(source: ObservationSourceDescriptor): ObservationRecordedMediaSource | null {
|
||||
const delivery = source.delivery;
|
||||
if (!delivery || delivery.kind !== "recorded-fmp4-manifest") return null;
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
modality: "video",
|
||||
manifestUrl: delivery.url,
|
||||
manifestGenerationSha256: delivery.manifestGenerationSha256,
|
||||
byteLength: delivery.byteLength,
|
||||
mediaType: delivery.mediaType,
|
||||
timelineStartSeconds: delivery.timelineStartSeconds,
|
||||
timelineEndSeconds: delivery.timelineEndSeconds,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
}
|
||||
|
||||
function expectedPayloadEtag(digest: string): string {
|
||||
return `"sha256:${digest}"`;
|
||||
}
|
||||
|
||||
export async function fetchVerifiedRecordedMediaBytes(
|
||||
descriptor: RecordedMediaBinaryDescriptor,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
|
||||
): Promise<ArrayBuffer> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(descriptor.url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: descriptor.accept,
|
||||
"If-Match": expectedPayloadEtag(descriptor.sha256),
|
||||
},
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservationSessionContractError(
|
||||
"Не удалось загрузить канонический фрагмент записанного видео.",
|
||||
);
|
||||
}
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Фрагмент записанного видео вернул HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
if (response.headers.get("ETag") !== expectedPayloadEtag(descriptor.sha256)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Фрагмент записанного видео не соответствует immutable manifest.",
|
||||
);
|
||||
}
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (
|
||||
contentLength === null ||
|
||||
!/^[1-9][0-9]*$/.test(contentLength) ||
|
||||
Number(contentLength) !== descriptor.byteLength
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Длина фрагмента записанного видео не соответствует immutable manifest.",
|
||||
);
|
||||
}
|
||||
const payload = await response.arrayBuffer();
|
||||
if (payload.byteLength !== descriptor.byteLength) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Фрагмент записанного видео был усечён во время передачи.",
|
||||
);
|
||||
}
|
||||
const digest = bytesToHex(sha256(new Uint8Array(payload)));
|
||||
if (digest !== descriptor.sha256) {
|
||||
throw new ObservationSessionContractError(
|
||||
"SHA-256 фрагмента записанного видео не совпадает с immutable manifest.",
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function manifestIdentity(manifest: ObservationRecordedMediaManifest): string {
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
export async function fetchVerifiedRecordedMediaArchive(
|
||||
source: ObservationRecordedMediaSource,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
onProgress,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
onProgress?: (progress: RecordedMediaLoadProgress) => void;
|
||||
} = {},
|
||||
): Promise<VerifiedRecordedMediaArchive> {
|
||||
const manifest = await fetchObservationRecordedMediaManifest(source, {
|
||||
signal,
|
||||
fetcher,
|
||||
expectedGenerationSha256: source.manifestGenerationSha256,
|
||||
});
|
||||
const totalBytes = manifest.epochs.reduce(
|
||||
(archiveTotal, epoch) => archiveTotal + epoch.initByteLength + epoch.segments.reduce(
|
||||
(epochTotal, segment) => epochTotal + segment.byteLength,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
);
|
||||
const totalParts = manifest.epochs.reduce(
|
||||
(total, epoch) => total + 1 + epoch.segments.length,
|
||||
0,
|
||||
);
|
||||
if (
|
||||
totalBytes < 1 ||
|
||||
totalBytes > MAX_RECORDED_MEDIA_SOURCE_BYTES ||
|
||||
totalBytes !== manifest.byteLength ||
|
||||
totalBytes !== source.byteLength
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Размер записанного медиаканала выходит за безопасный лимит браузера.",
|
||||
);
|
||||
}
|
||||
let loadedBytes = 0;
|
||||
let loadedParts = 0;
|
||||
const publishProgress = () => onProgress?.({
|
||||
loadedBytes,
|
||||
totalBytes,
|
||||
loadedParts,
|
||||
totalParts,
|
||||
});
|
||||
publishProgress();
|
||||
|
||||
const epochs: VerifiedRecordedMediaEpoch[] = [];
|
||||
for (const epoch of manifest.epochs) {
|
||||
const init = await fetchVerifiedRecordedMediaBytes({
|
||||
url: epoch.initUrl,
|
||||
byteLength: epoch.initByteLength,
|
||||
sha256: epoch.initSha256,
|
||||
accept: "video/mp4",
|
||||
}, { signal, fetcher });
|
||||
loadedBytes += init.byteLength;
|
||||
loadedParts += 1;
|
||||
publishProgress();
|
||||
|
||||
const segments: ArrayBuffer[] = [];
|
||||
for (const segment of epoch.segments) {
|
||||
const payload = await fetchVerifiedRecordedMediaBytes({
|
||||
url: segment.url,
|
||||
byteLength: segment.byteLength,
|
||||
sha256: segment.sha256,
|
||||
accept: "video/iso.segment",
|
||||
}, { signal, fetcher });
|
||||
segments.push(payload);
|
||||
loadedBytes += payload.byteLength;
|
||||
loadedParts += 1;
|
||||
publishProgress();
|
||||
}
|
||||
epochs.push({ descriptor: epoch, init, segments });
|
||||
}
|
||||
|
||||
// Bind the complete byte set to the same manifest generation at both ends
|
||||
// of the transfer. A replacement during a long camera download fails closed.
|
||||
const confirmed = await fetchObservationRecordedMediaManifest(source, {
|
||||
signal,
|
||||
fetcher,
|
||||
expectedGenerationSha256: manifest.generationSha256,
|
||||
});
|
||||
if (manifestIdentity(confirmed) !== manifestIdentity(manifest)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Manifest записанного видео изменился во время полной загрузки.",
|
||||
);
|
||||
}
|
||||
if (loadedBytes !== source.byteLength) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Полная загрузка камеры не совпала с launch byte_length.",
|
||||
);
|
||||
}
|
||||
return { manifest, epochs, byteLength: loadedBytes };
|
||||
}
|
||||
|
||||
export function appendRecordedMediaBuffer(
|
||||
sourceBuffer: SourceBuffer,
|
||||
payload: ArrayBuffer,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onUpdateEnd = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("SourceBuffer rejected archived fMP4 data"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanup = () => {
|
||||
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
|
||||
sourceBuffer.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
|
||||
sourceBuffer.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
sourceBuffer.appendBuffer(payload);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function videoHasSeekableArchive(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
): boolean {
|
||||
if (video.readyState < 1 || video.seekable.length < 1) return false;
|
||||
return recordedMediaSeekableCoverage(
|
||||
video.duration,
|
||||
video.seekable.end(video.seekable.length - 1),
|
||||
declaredDurationSeconds,
|
||||
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
||||
video.seekable.start(0),
|
||||
);
|
||||
}
|
||||
|
||||
function waitForSeekableArchive(
|
||||
video: HTMLVideoElement,
|
||||
declaredDurationSeconds: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera did not become seekable"));
|
||||
}, 20_000);
|
||||
const cleanup = () => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
for (const event of events) video.removeEventListener(event, onProgress);
|
||||
video.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onProgress = () => {
|
||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera decode failed"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
for (const event of events) video.addEventListener(event, onProgress);
|
||||
video.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function mountVerifiedRecordedEpoch(
|
||||
video: HTMLVideoElement,
|
||||
epoch: VerifiedRecordedMediaEpoch,
|
||||
signal: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
const descriptor = epoch.descriptor;
|
||||
if (
|
||||
descriptor.mediaType === "video/mp4" ||
|
||||
!globalThis.MediaSource ||
|
||||
!MediaSource.isTypeSupported(descriptor.mediaType)
|
||||
) {
|
||||
throw new Error("Archived camera codec is not supported");
|
||||
}
|
||||
const mediaSource = new MediaSource();
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
const cleanup = () => {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
video.src = objectUrl;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
cleanupListeners();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanupListeners();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanupListeners = () => {
|
||||
mediaSource.removeEventListener("sourceopen", onOpen);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
if (signal.aborted || mediaSource.readyState !== "open") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(descriptor.mediaType);
|
||||
await appendRecordedMediaBuffer(sourceBuffer, epoch.init, signal);
|
||||
for (const segment of epoch.segments) {
|
||||
await appendRecordedMediaBuffer(sourceBuffer, segment, signal);
|
||||
}
|
||||
if (signal.aborted || mediaSource.readyState !== "open" || sourceBuffer.updating) {
|
||||
throw new Error("Archived camera MediaSource closed before full append");
|
||||
}
|
||||
mediaSource.endOfStream();
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
signal,
|
||||
);
|
||||
return cleanup;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function RecordedFmp4Player({
|
||||
source,
|
||||
playback,
|
||||
prepare = true,
|
||||
sessionGate = "ready",
|
||||
admissionKey = null,
|
||||
onAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepare?: boolean;
|
||||
sessionGate?: RecordedAdmissionPhase;
|
||||
admissionKey?: string | null;
|
||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||
onAdmissionChangeRef.current = onAdmissionChange;
|
||||
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
|
||||
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
|
||||
recordedMediaWorkerGeneration += 1;
|
||||
workerRef.current = { admissionKey, generation: recordedMediaWorkerGeneration };
|
||||
}
|
||||
const workerGeneration = workerRef.current.generation;
|
||||
const reportAdmission = (next: RecordedCameraAdmissionState) => {
|
||||
onAdmissionChangeRef.current?.({
|
||||
...next,
|
||||
admissionKey,
|
||||
workerGeneration,
|
||||
});
|
||||
};
|
||||
const recordedDelivery = source.delivery?.kind === "recorded-fmp4-manifest"
|
||||
? source.delivery
|
||||
: null;
|
||||
const contract = useMemo(
|
||||
() => sourceContract(source),
|
||||
[
|
||||
source.id,
|
||||
source.label,
|
||||
recordedDelivery?.id,
|
||||
recordedDelivery?.url,
|
||||
recordedDelivery?.mediaType,
|
||||
recordedDelivery?.manifestGenerationSha256,
|
||||
recordedDelivery?.byteLength,
|
||||
recordedDelivery?.timelineStartSeconds,
|
||||
recordedDelivery?.timelineEndSeconds,
|
||||
],
|
||||
);
|
||||
const [archive, setArchive] = useState<VerifiedRecordedMediaArchive | null>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<RecordedMediaLoadProgress | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const epoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
);
|
||||
const verifiedEpoch = useMemo(
|
||||
() => epoch
|
||||
? archive?.epochs.find(({ descriptor }) => descriptor.ordinal === epoch.ordinal) ?? null
|
||||
: null,
|
||||
[archive?.epochs, epoch],
|
||||
);
|
||||
const waitingForEpoch = Boolean(archive && !epoch);
|
||||
const selectedGeneration = contract && epoch
|
||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
||||
: null;
|
||||
const visualState = recordedMediaPresentationState(
|
||||
state,
|
||||
readyGeneration,
|
||||
selectedGeneration,
|
||||
waitingForEpoch,
|
||||
sessionGate,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contract) {
|
||||
setArchive(null);
|
||||
setProgress(null);
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: null,
|
||||
message: "Некорректный descriptor записанной камеры.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!prepare) return;
|
||||
const abort = new AbortController();
|
||||
setArchive(null);
|
||||
setProgress(null);
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
reportAdmission({
|
||||
phase: "loading",
|
||||
byteLength: contract.byteLength,
|
||||
message: null,
|
||||
});
|
||||
void fetchVerifiedRecordedMediaArchive(contract, {
|
||||
signal: abort.signal,
|
||||
onProgress: (next) => {
|
||||
if (!abort.signal.aborted) setProgress(next);
|
||||
},
|
||||
})
|
||||
.then((loaded) => {
|
||||
if (abort.signal.aborted) return;
|
||||
setArchive(loaded);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
|
||||
return;
|
||||
}
|
||||
setArchive(null);
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: contract.byteLength,
|
||||
message: "Архив записанной камеры не прошёл проверку.",
|
||||
});
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [admissionKey, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!archive || !contract || !prepare) return;
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
for (const candidate of archive.epochs) {
|
||||
const probe = document.createElement("video");
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
const cleanup = await mountVerifiedRecordedEpoch(probe, candidate, abort.signal);
|
||||
cleanup();
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
}
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
reportAdmission({
|
||||
phase: "ready",
|
||||
byteLength: archive.byteLength,
|
||||
message: null,
|
||||
});
|
||||
})().catch((error: unknown) => {
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) return;
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive.byteLength,
|
||||
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
};
|
||||
}, [admissionKey, archive, contract, prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !verifiedEpoch) return;
|
||||
const epochDescriptor = verifiedEpoch.descriptor;
|
||||
const generation = contract
|
||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||
: null;
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
const loadEpoch = async () => {
|
||||
try {
|
||||
cleanup = await mountVerifiedRecordedEpoch(video, verifiedEpoch, abort.signal);
|
||||
if (disposed || abort.signal.aborted) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
return;
|
||||
}
|
||||
setBufferRevision((revision) => revision + 1);
|
||||
setReadyGeneration(generation);
|
||||
setState("ready");
|
||||
} catch (error) {
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Записанная камера не стала seekable.",
|
||||
});
|
||||
}
|
||||
};
|
||||
void loadEpoch();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
cleanup?.();
|
||||
};
|
||||
}, [archive?.byteLength, contract, verifiedEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !epoch || visualState !== "ready") return;
|
||||
const target = recordedMediaLocalTime(
|
||||
epoch.timelineStartSeconds,
|
||||
currentSeconds,
|
||||
video.duration,
|
||||
);
|
||||
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
|
||||
try {
|
||||
video.currentTime = target;
|
||||
} catch {
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
phase: "error",
|
||||
byteLength: archive?.byteLength ?? null,
|
||||
message: "Seek записанной камеры завершился ошибкой.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (playback?.playing) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
|
||||
const progressPercent = progress && progress.totalBytes > 0
|
||||
? Math.min(100, Math.floor((progress.loadedBytes / progress.totalBytes) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="recorded-media-player"
|
||||
data-state={visualState}
|
||||
aria-busy={visualState === "loading"}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="observation-media__asset"
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
aria-label={source.label}
|
||||
/>
|
||||
{visualState !== "ready" ? (
|
||||
<div
|
||||
className="recorded-media-player__notice"
|
||||
role={visualState === "error" ? "alert" : "status"}
|
||||
>
|
||||
{visualState === "waiting"
|
||||
? "Камера на этой позиции ещё не записывалась"
|
||||
: visualState === "error"
|
||||
? "Записанное видео недоступно"
|
||||
: archive
|
||||
? "Проверяем полную готовность записанного видео…"
|
||||
: `Загружаем и проверяем записанное видео · ${progressPercent}%`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,84 @@
|
|||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
|
||||
|
||||
export function recordedObservationSources(
|
||||
launch: ObservationSessionReplayLaunch | null,
|
||||
): ObservationSourceDescriptor[] {
|
||||
if (!launch) return [];
|
||||
const spatial: ObservationSourceDescriptor = {
|
||||
id: "recorded.spatial.primary",
|
||||
sourceId: "recorded.spatial.primary",
|
||||
semanticChannelId: "spatial.point-cloud.recorded",
|
||||
label: "Сохранённая пространственная сцена",
|
||||
description: "Облако точек и траектория из записи Rerun",
|
||||
modality: "point-cloud",
|
||||
role: "primary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "RRD · session_time",
|
||||
previewUrl: launch.sourceUrl,
|
||||
delivery: null,
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: "recorded-spatial",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: false,
|
||||
fullscreen: true,
|
||||
resizable: false,
|
||||
defaultVisible: true,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: launch.timeline,
|
||||
spatialRegistration: "native",
|
||||
},
|
||||
};
|
||||
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => ({
|
||||
id: source.id,
|
||||
sourceId: source.id,
|
||||
semanticChannelId: "camera.video.recorded",
|
||||
label: source.label,
|
||||
description: "Сохранённый видеоканал на общей временной шкале сессии",
|
||||
modality: "video",
|
||||
role: "auxiliary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "Сохранённая сессия",
|
||||
previewUrl: null,
|
||||
delivery: {
|
||||
id: `${launch.sessionId}:${source.id}`,
|
||||
kind: "recorded-fmp4-manifest",
|
||||
url: source.manifestUrl,
|
||||
mediaType: source.mediaType,
|
||||
manifestGenerationSha256: source.manifestGenerationSha256,
|
||||
byteLength: source.byteLength,
|
||||
timelineStartSeconds: source.timelineStartSeconds,
|
||||
timelineEndSeconds: source.timelineEndSeconds,
|
||||
},
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: "recorded-media",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: true,
|
||||
fullscreen: true,
|
||||
resizable: true,
|
||||
defaultVisible: index < 2,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: "session_time",
|
||||
spatialRegistration: "unresolved",
|
||||
},
|
||||
}));
|
||||
return [spatial, ...media];
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
||||
// vehicles while the independent byte/concurrency limits keep admission
|
||||
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
|
||||
// this in-memory laboratory policy.
|
||||
export const MAX_RECORDED_CAMERA_SOURCES = 16;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
|
||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||
|
||||
export type RecordedAdmissionPhase = "loading" | "ready" | "error";
|
||||
export type RecordedCameraAdmissionPhase = "pending" | "loading" | "ready" | "error";
|
||||
|
||||
export interface RecordedCameraAdmissionState {
|
||||
phase: RecordedCameraAdmissionPhase;
|
||||
byteLength: number | null;
|
||||
message: string | null;
|
||||
admissionKey?: string | null;
|
||||
workerGeneration?: number;
|
||||
}
|
||||
|
||||
export type RecordedCameraAdmissionMap = Readonly<Record<string, RecordedCameraAdmissionState>>;
|
||||
|
||||
export interface RecordedCameraAdmissionDescriptor {
|
||||
id: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export function recordedCameraDescriptorPreflight(
|
||||
sources: readonly RecordedCameraAdmissionDescriptor[],
|
||||
): RecordedAdmissionPhase {
|
||||
if (sources.length > MAX_RECORDED_CAMERA_SOURCES) return "error";
|
||||
if (new Set(sources.map(({ id }) => id)).size !== sources.length) return "error";
|
||||
let totalBytes = 0;
|
||||
for (const source of sources) {
|
||||
if (
|
||||
!source.id ||
|
||||
!Number.isSafeInteger(source.byteLength) ||
|
||||
source.byteLength < 1 ||
|
||||
source.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
|
||||
) return "error";
|
||||
totalBytes += source.byteLength;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) {
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
export function initialRecordedCameraAdmissions(
|
||||
sourceIds: readonly string[],
|
||||
declaredByteLengths: Readonly<Record<string, number>> = {},
|
||||
): Record<string, RecordedCameraAdmissionState> {
|
||||
return Object.fromEntries(sourceIds.map((sourceId) => [sourceId, {
|
||||
phase: "pending" as const,
|
||||
byteLength: declaredByteLengths[sourceId] ?? null,
|
||||
message: null,
|
||||
}]));
|
||||
}
|
||||
|
||||
export function recordedSessionAdmissionPhase(
|
||||
spatialPhase: RecordedAdmissionPhase,
|
||||
sourceIds: readonly string[],
|
||||
cameras: RecordedCameraAdmissionMap,
|
||||
): RecordedAdmissionPhase {
|
||||
if (sourceIds.length > MAX_RECORDED_CAMERA_SOURCES || spatialPhase === "error") {
|
||||
return "error";
|
||||
}
|
||||
let totalBytes = 0;
|
||||
for (const sourceId of sourceIds) {
|
||||
const camera = cameras[sourceId];
|
||||
if (!camera || camera.phase === "error") return "error";
|
||||
if (camera.phase === "ready" && camera.byteLength === null) return "error";
|
||||
if (camera.byteLength !== null) {
|
||||
if (
|
||||
!Number.isSafeInteger(camera.byteLength) ||
|
||||
camera.byteLength < 1 ||
|
||||
camera.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
|
||||
) return "error";
|
||||
totalBytes += camera.byteLength;
|
||||
}
|
||||
}
|
||||
if (totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) return "error";
|
||||
if (spatialPhase !== "ready") return "loading";
|
||||
return sourceIds.every((sourceId) => cameras[sourceId]?.phase === "ready")
|
||||
? "ready"
|
||||
: "loading";
|
||||
}
|
||||
|
||||
export function nextRecordedCameraPreparationIds(
|
||||
sourceIds: readonly string[],
|
||||
cameras: RecordedCameraAdmissionMap,
|
||||
preferredSourceIds: ReadonlySet<string> = new Set(),
|
||||
concurrency = MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS,
|
||||
): string[] {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) return [];
|
||||
const pending = sourceIds.filter((sourceId) => {
|
||||
const phase = cameras[sourceId]?.phase;
|
||||
return phase === "pending" || phase === "loading" || phase === undefined;
|
||||
});
|
||||
pending.sort((left, right) => (
|
||||
Number(cameras[right]?.phase === "loading") - Number(cameras[left]?.phase === "loading") ||
|
||||
Number(preferredSourceIds.has(right)) - Number(preferredSourceIds.has(left)) ||
|
||||
sourceIds.indexOf(left) - sourceIds.indexOf(right)
|
||||
));
|
||||
return pending.slice(0, concurrency);
|
||||
}
|
||||
|
||||
export function mergeRecordedCameraAdmission(
|
||||
current: RecordedCameraAdmissionState,
|
||||
next: RecordedCameraAdmissionState,
|
||||
): RecordedCameraAdmissionState {
|
||||
const normalized = next.byteLength === null && current.byteLength !== null
|
||||
? { ...next, byteLength: current.byteLength }
|
||||
: next;
|
||||
const currentWorker = current.workerGeneration ?? 0;
|
||||
const nextWorker = normalized.workerGeneration ?? currentWorker;
|
||||
if (nextWorker < currentWorker) return current;
|
||||
if (current.phase === "error") return current;
|
||||
if (nextWorker > currentWorker) return normalized;
|
||||
if (normalized.phase === "error") return normalized;
|
||||
if (current.phase === "ready") return current;
|
||||
return normalized;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,12 +6,25 @@ import {
|
|||
openObservationSource,
|
||||
shouldRestartObservationSource,
|
||||
} from "./layoutPolicy";
|
||||
import {
|
||||
normalizeObservationWindowRect,
|
||||
projectObservationLayoutSnapshot,
|
||||
validateObservationLayoutSnapshot,
|
||||
validateObservationViewportSize,
|
||||
type ObservationLayoutSnapshot,
|
||||
type ObservationViewportSize,
|
||||
type ObservationWindowRect,
|
||||
} from "./workspaceLayout";
|
||||
|
||||
export interface ObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
export type { ObservationWindowRect } from "./workspaceLayout";
|
||||
|
||||
export type ObservationLayoutPresentationMode = "preserve" | "reset";
|
||||
|
||||
export function observationPresentationSourceAfterLayoutApply(
|
||||
currentSourceId: string | null,
|
||||
mode: ObservationLayoutPresentationMode,
|
||||
): string | null {
|
||||
return mode === "preserve" ? currentSourceId : null;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
|
|
@ -20,6 +33,7 @@ export interface ObservationLayoutController {
|
|||
activeFloatingSourceId: string | null;
|
||||
maximizedFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
viewportSize: ObservationViewportSize | null;
|
||||
pendingSourceIds: ReadonlySet<string>;
|
||||
toggleSource: (sourceId: string) => Promise<boolean>;
|
||||
hideSource: (sourceId: string) => Promise<boolean>;
|
||||
|
|
@ -27,6 +41,9 @@ export interface ObservationLayoutController {
|
|||
activateFloatingSource: (sourceId: string) => void;
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
setViewportSize: (size: ObservationViewportSize) => void;
|
||||
snapshot: () => ObservationLayoutSnapshot | null;
|
||||
restore: (snapshot: ObservationLayoutSnapshot) => void;
|
||||
}
|
||||
|
||||
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
|
|
@ -50,6 +67,17 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
|
|||
.join("|");
|
||||
}
|
||||
|
||||
function cloneSnapshot(snapshot: ObservationLayoutSnapshot): ObservationLayoutSnapshot {
|
||||
return {
|
||||
visibleSourceIds: [...snapshot.visibleSourceIds],
|
||||
activeFloatingSourceId: snapshot.activeFloatingSourceId,
|
||||
windowRects: Object.fromEntries(
|
||||
Object.entries(snapshot.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
|
||||
),
|
||||
viewportSize: { ...snapshot.viewportSize },
|
||||
};
|
||||
}
|
||||
|
||||
export function useObservationLayout(
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
|
||||
|
|
@ -58,45 +86,151 @@ export function useObservationLayout(
|
|||
const visibleIdsRef = useRef<string[]>([]);
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
||||
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
||||
const [activeFloatingSourceId, setActiveFloatingSourceIdState] = useState<string | null>(null);
|
||||
const activeFloatingSourceIdRef = useRef<string | null>(null);
|
||||
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
|
||||
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
|
||||
const [windowRects, setWindowRectsState] = useState<Record<string, ObservationWindowRect>>({});
|
||||
const windowRectsRef = useRef<Record<string, ObservationWindowRect>>({});
|
||||
const [viewportSize, setViewportSizeState] = useState<ObservationViewportSize | null>(null);
|
||||
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
|
||||
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
|
||||
const restoredLayoutAuthorityRef = useRef(false);
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
||||
const sourceIdsRef = useRef<ReadonlySet<string>>(sourceIds);
|
||||
sourceIdsRef.current = sourceIds;
|
||||
const sourcesRef = useRef<readonly ObservationSourceDescriptor[]>(sources);
|
||||
sourcesRef.current = sources;
|
||||
const identity = catalogIdentity(sources);
|
||||
|
||||
const commitVisibleIds = useCallback((next: string[]) => {
|
||||
visibleIdsRef.current = next;
|
||||
setVisibleIds(next);
|
||||
const commitVisibleIds = useCallback((next: readonly string[]) => {
|
||||
const unique = [...new Set(next)];
|
||||
visibleIdsRef.current = unique;
|
||||
setVisibleIds(unique);
|
||||
}, []);
|
||||
|
||||
const clearPresentation = useCallback((removedIds: readonly string[]) => {
|
||||
const commitActiveFloatingSourceId = useCallback((next: string | null) => {
|
||||
activeFloatingSourceIdRef.current = next;
|
||||
setActiveFloatingSourceIdState(next);
|
||||
}, []);
|
||||
|
||||
const commitWindowRects = useCallback((next: Record<string, ObservationWindowRect>) => {
|
||||
windowRectsRef.current = next;
|
||||
setWindowRectsState(next);
|
||||
}, []);
|
||||
|
||||
const persistLiveLayout = useCallback(() => {
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
if (!currentViewport) return;
|
||||
const currentKnownIds = sourceIdsRef.current;
|
||||
const previous = desiredSnapshotRef.current;
|
||||
const unknownVisibleIds = previous?.visibleSourceIds.filter(
|
||||
(sourceId) => !currentKnownIds.has(sourceId),
|
||||
) ?? [];
|
||||
const visibleSourceIds = [...new Set([...unknownVisibleIds, ...visibleIdsRef.current])];
|
||||
const unknownRects = Object.entries(previous?.windowRects ?? {}).filter(
|
||||
([sourceId]) => !currentKnownIds.has(sourceId),
|
||||
);
|
||||
const knownRects = Object.entries(windowRectsRef.current).map(([sourceId, rect]) => [
|
||||
sourceId,
|
||||
normalizeObservationWindowRect(rect, currentViewport),
|
||||
] as const);
|
||||
const previousActive = previous?.activeFloatingSourceId ?? null;
|
||||
const activeFloatingSourceId = activeFloatingSourceIdRef.current ?? (
|
||||
previousActive && !currentKnownIds.has(previousActive) ? previousActive : null
|
||||
);
|
||||
desiredSnapshotRef.current = validateObservationLayoutSnapshot({
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId: activeFloatingSourceId && visibleSourceIds.includes(activeFloatingSourceId)
|
||||
? activeFloatingSourceId
|
||||
: null,
|
||||
windowRects: Object.fromEntries([...unknownRects, ...knownRects]),
|
||||
viewportSize: currentViewport,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyDesiredSnapshot = useCallback((
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
presentationMode: ObservationLayoutPresentationMode,
|
||||
) => {
|
||||
const targetViewport = viewportSizeRef.current ?? snapshot.viewportSize;
|
||||
const projected = projectObservationLayoutSnapshot(
|
||||
snapshot,
|
||||
sourceIdsRef.current,
|
||||
targetViewport,
|
||||
);
|
||||
let visibleSourceIds = [...projected.visibleSourceIds];
|
||||
let activeFloatingSourceId = projected.activeFloatingSourceId;
|
||||
if (visibleSourceIds.length === 0 && sourcesRef.current.length > 0) {
|
||||
for (const source of sourcesRef.current.filter(canOpenByDefault)) {
|
||||
visibleSourceIds = openObservationSource(
|
||||
visibleSourceIds,
|
||||
source.id,
|
||||
sourcesRef.current,
|
||||
).visibleIds;
|
||||
}
|
||||
activeFloatingSourceId = sourcesRef.current.find(
|
||||
(source) => visibleSourceIds.includes(source.id) && source.capabilities.overlay,
|
||||
)?.id ?? null;
|
||||
}
|
||||
commitVisibleIds(visibleSourceIds);
|
||||
commitActiveFloatingSourceId(activeFloatingSourceId);
|
||||
commitWindowRects({ ...projected.windowRects });
|
||||
setFocusedSourceIdState((current) =>
|
||||
observationPresentationSourceAfterLayoutApply(current, presentationMode));
|
||||
setMaximizedFloatingSourceId((current) =>
|
||||
observationPresentationSourceAfterLayoutApply(current, presentationMode));
|
||||
}, [commitActiveFloatingSourceId, commitVisibleIds, commitWindowRects]);
|
||||
|
||||
const clearPresentation = useCallback((removedIds: readonly string[], persist = true) => {
|
||||
if (!removedIds.length) return;
|
||||
const removed = new Set(removedIds);
|
||||
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
|
||||
setActiveFloatingSourceId((current) => current && removed.has(current) ? null : current);
|
||||
if (
|
||||
activeFloatingSourceIdRef.current &&
|
||||
removed.has(activeFloatingSourceIdRef.current)
|
||||
) {
|
||||
commitActiveFloatingSourceId(null);
|
||||
}
|
||||
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
|
||||
}, []);
|
||||
if (persist) persistLiveLayout();
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
applyDesiredSnapshot(desired, "reset");
|
||||
return;
|
||||
}
|
||||
const currentVisible = visibleIdsRef.current;
|
||||
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
|
||||
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
|
||||
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
||||
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
if (activeFloatingSourceIdRef.current && !sourceIds.has(activeFloatingSourceIdRef.current)) {
|
||||
commitActiveFloatingSourceId(null);
|
||||
}
|
||||
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
setWindowRects((current) => {
|
||||
const entries = Object.entries(current);
|
||||
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
||||
});
|
||||
}, [commitVisibleIds, sourceIds]);
|
||||
const entries = Object.entries(windowRectsRef.current);
|
||||
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||
if (nextEntries.length !== entries.length) commitWindowRects(Object.fromEntries(nextEntries));
|
||||
}, [
|
||||
applyDesiredSnapshot,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
commitWindowRects,
|
||||
sourceIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity) {
|
||||
initializedCatalog.current = null;
|
||||
if (!desiredSnapshotRef.current) initializedCatalog.current = null;
|
||||
return;
|
||||
}
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
initializedCatalog.current = identity;
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
|
|
@ -109,8 +243,16 @@ export function useObservationLayout(
|
|||
const firstFloating = sources.find(
|
||||
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
||||
);
|
||||
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||
}, [commitVisibleIds, identity, sources]);
|
||||
commitActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||
persistLiveLayout();
|
||||
}, [
|
||||
applyDesiredSnapshot,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
identity,
|
||||
persistLiveLayout,
|
||||
sources,
|
||||
]);
|
||||
|
||||
const selectedDeliveryIdentity = sources
|
||||
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
||||
|
|
@ -124,6 +266,7 @@ export function useObservationLayout(
|
|||
.join("|");
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredLayoutAuthorityRef.current) return;
|
||||
const selected = sources.filter(
|
||||
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
||||
);
|
||||
|
|
@ -135,8 +278,9 @@ export function useObservationLayout(
|
|||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
}
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation([...removed]);
|
||||
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]);
|
||||
clearPresentation([...removed], false);
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
|
||||
|
||||
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
|
||||
const groupId = source.activation?.groupId;
|
||||
|
|
@ -160,11 +304,13 @@ export function useObservationLayout(
|
|||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = closeObservationSource(visibleIdsRef.current, sourceId);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation(change.removedIds);
|
||||
clearPresentation(change.removedIds, false);
|
||||
persistLiveLayout();
|
||||
return true;
|
||||
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]);
|
||||
}, [clearPresentation, commitVisibleIds, markPending, persistLiveLayout, setSourceActive, sources]);
|
||||
|
||||
const toggleSource = useCallback(async (sourceId: string) => {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
|
|
@ -180,30 +326,95 @@ export function useObservationLayout(
|
|||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation(change.removedIds);
|
||||
setActiveFloatingSourceId(sourceId);
|
||||
clearPresentation(change.removedIds, false);
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
return true;
|
||||
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]);
|
||||
}, [
|
||||
clearPresentation,
|
||||
commitActiveFloatingSourceId,
|
||||
commitVisibleIds,
|
||||
hideSource,
|
||||
markPending,
|
||||
persistLiveLayout,
|
||||
setSourceActive,
|
||||
sources,
|
||||
]);
|
||||
|
||||
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
||||
setFocusedSourceIdState(sourceId);
|
||||
if (sourceId) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
if (sourceId) {
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const activateFloatingSource = useCallback((sourceId: string) => {
|
||||
setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
|
||||
setMaximizedFloatingSourceId(maximized ? sourceId : null);
|
||||
if (maximized) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
if (maximized) {
|
||||
commitActiveFloatingSourceId(sourceId);
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [commitActiveFloatingSourceId, persistLiveLayout]);
|
||||
|
||||
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
|
||||
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
|
||||
}, []);
|
||||
if (
|
||||
!Object.values(rect).every((value) => Number.isFinite(value)) ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
if (currentViewport) normalizeObservationWindowRect(rect, currentViewport);
|
||||
commitWindowRects({ ...windowRectsRef.current, [sourceId]: { ...rect } });
|
||||
persistLiveLayout();
|
||||
}, [commitWindowRects, persistLiveLayout]);
|
||||
|
||||
const setViewportSize = useCallback((size: ObservationViewportSize) => {
|
||||
const next = validateObservationViewportSize(size);
|
||||
const current = viewportSizeRef.current;
|
||||
if (current && current.width === next.width && current.height === next.height) return;
|
||||
viewportSizeRef.current = next;
|
||||
setViewportSizeState(next);
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
desiredSnapshotRef.current = { ...desired, viewportSize: next };
|
||||
// Entering fullscreen changes the shell geometry, which triggers this
|
||||
// ResizeObserver path. Reproject persistent window rectangles without
|
||||
// clearing the transient fullscreen/focus state that caused the resize.
|
||||
applyDesiredSnapshot(desiredSnapshotRef.current, "preserve");
|
||||
} else if (initializedCatalog.current) {
|
||||
persistLiveLayout();
|
||||
}
|
||||
}, [applyDesiredSnapshot, persistLiveLayout]);
|
||||
|
||||
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
|
||||
if (!viewportSizeRef.current) return null;
|
||||
persistLiveLayout();
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (!desired) return null;
|
||||
return cloneSnapshot(desired);
|
||||
}, [persistLiveLayout]);
|
||||
|
||||
const restore = useCallback((saved: ObservationLayoutSnapshot) => {
|
||||
const validated = validateObservationLayoutSnapshot(saved);
|
||||
const currentViewport = viewportSizeRef.current;
|
||||
desiredSnapshotRef.current = cloneSnapshot({
|
||||
...validated,
|
||||
viewportSize: currentViewport ?? validated.viewportSize,
|
||||
});
|
||||
restoredLayoutAuthorityRef.current = true;
|
||||
applyDesiredSnapshot(desiredSnapshotRef.current, "reset");
|
||||
}, [applyDesiredSnapshot]);
|
||||
|
||||
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
|
||||
|
|
@ -214,6 +425,7 @@ export function useObservationLayout(
|
|||
activeFloatingSourceId,
|
||||
maximizedFloatingSourceId,
|
||||
windowRects,
|
||||
viewportSize,
|
||||
pendingSourceIds,
|
||||
toggleSource,
|
||||
hideSource,
|
||||
|
|
@ -221,5 +433,8 @@ export function useObservationLayout(
|
|||
activateFloatingSource,
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
setViewportSize,
|
||||
snapshot,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,591 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
fetchObservationSessionPreparation,
|
||||
replayObservationSession,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionPreparation,
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionSummary,
|
||||
} from "./sessionArchive";
|
||||
|
||||
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
|
||||
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
|
||||
export type ObservationPreparationPhase =
|
||||
| "requesting"
|
||||
| ObservationSessionPreparation["state"];
|
||||
|
||||
export interface ObservationReplayProgress {
|
||||
readonly sessionId: string;
|
||||
readonly phase: ObservationPreparationPhase;
|
||||
readonly progress: number | null;
|
||||
readonly cancellable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationSessionsController {
|
||||
items: readonly ObservationSessionSummary[];
|
||||
state: ObservationSessionsLoadState;
|
||||
error: string | null;
|
||||
replayingSessionId: string | null;
|
||||
preparation: ObservationSessionPreparation | null;
|
||||
replayProgress: ObservationReplayProgress | null;
|
||||
failedSessionId: string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
replay: (sessionId: string) => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ObservationReplayAttempt {
|
||||
readonly signal: AbortSignal;
|
||||
isCurrent: () => boolean;
|
||||
finish: () => boolean;
|
||||
}
|
||||
|
||||
export interface ObservationReplayCoordinator {
|
||||
begin: () => ObservationReplayAttempt;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface ObservationPreparationPollingOptions {
|
||||
signal: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
onUpdate?: (preparation: ObservationSessionPreparation) => void;
|
||||
requestTimeoutMs?: number;
|
||||
heartbeatStallMs?: number;
|
||||
maximumWaitMs?: number;
|
||||
initialPollIntervalMs?: number;
|
||||
maximumPollIntervalMs?: number;
|
||||
now?: () => number;
|
||||
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
||||
}
|
||||
|
||||
const PREPARATION_STORAGE_KEY = "missioncore.observation-session-preparation/v1";
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_HEARTBEAT_STALL_MS = 45_000;
|
||||
const DEFAULT_MAXIMUM_WAIT_MS = 30 * 60_000;
|
||||
const DEFAULT_INITIAL_POLL_INTERVAL_MS = 750;
|
||||
const DEFAULT_MAXIMUM_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
export class ObservationPreparationStalledError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservationPreparationStalledError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest selection wins, even if an obsolete server job finishes later. */
|
||||
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
||||
let sequence = 0;
|
||||
let active: AbortController | null = null;
|
||||
|
||||
return {
|
||||
begin() {
|
||||
active?.abort();
|
||||
const controller = new AbortController();
|
||||
const attemptSequence = ++sequence;
|
||||
active = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => (
|
||||
!controller.signal.aborted &&
|
||||
active === controller &&
|
||||
sequence === attemptSequence
|
||||
),
|
||||
finish: () => {
|
||||
if (active !== controller || sequence !== attemptSequence) return false;
|
||||
active = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
sequence += 1;
|
||||
active?.abort();
|
||||
active = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция с сохранёнными сессиями завершилась ошибкой.";
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
type PendingPreparationState = Exclude<
|
||||
ObservationSessionPreparation["state"],
|
||||
"failed" | "cancelled"
|
||||
>;
|
||||
|
||||
function pendingPreparation(
|
||||
preparation: ObservationSessionPreparation,
|
||||
): preparation is ObservationSessionPreparation & { state: PendingPreparationState } {
|
||||
return preparation.state !== "failed" && preparation.state !== "cancelled";
|
||||
}
|
||||
|
||||
const PREPARATION_PHASE_ORDER: Record<
|
||||
PendingPreparationState,
|
||||
number
|
||||
> = {
|
||||
queued: 0,
|
||||
validating: 1,
|
||||
exporting: 2,
|
||||
finalizing: 3,
|
||||
};
|
||||
|
||||
function terminalPreparationError(preparation: ObservationSessionPreparation): Error {
|
||||
if (preparation.error) return new Error(preparation.error);
|
||||
return new Error(
|
||||
preparation.state === "cancelled"
|
||||
? "Подготовка записи отменена."
|
||||
: "Сервер не смог подготовить сохранённую сессию.",
|
||||
);
|
||||
}
|
||||
|
||||
function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
};
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function withRequestTimeout<T>(
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const forwardAbort = () => controller.abort();
|
||||
signal.addEventListener("abort", forwardAbort, { once: true });
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
return await operation(controller.signal);
|
||||
} catch (error) {
|
||||
if (timedOut && isAbortError(error)) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Сервер слишком долго не отвечает. Подготовку можно повторить.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timer);
|
||||
signal.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForObservationReplayPreparation(
|
||||
initial: ObservationSessionPreparation,
|
||||
options: ObservationPreparationPollingOptions,
|
||||
): Promise<ObservationSessionReplayLaunch> {
|
||||
if (!pendingPreparation(initial)) throw terminalPreparationError(initial);
|
||||
const requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
const heartbeatStallMs = Math.max(250, options.heartbeatStallMs ?? DEFAULT_HEARTBEAT_STALL_MS);
|
||||
const maximumWaitMs = Math.max(heartbeatStallMs, options.maximumWaitMs ?? DEFAULT_MAXIMUM_WAIT_MS);
|
||||
const initialInterval = Math.max(
|
||||
50,
|
||||
options.initialPollIntervalMs ?? DEFAULT_INITIAL_POLL_INTERVAL_MS,
|
||||
);
|
||||
const maximumInterval = Math.max(
|
||||
initialInterval,
|
||||
options.maximumPollIntervalMs ?? DEFAULT_MAXIMUM_POLL_INTERVAL_MS,
|
||||
);
|
||||
const now = options.now ?? Date.now;
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
const startedAt = now();
|
||||
let lastHeartbeatAt = startedAt;
|
||||
let lastUpdatedAt = Date.parse(initial.updatedAtUtc);
|
||||
let current: ObservationSessionPreparation = initial;
|
||||
let interval = initialInterval;
|
||||
options.onUpdate?.(current);
|
||||
|
||||
while (true) {
|
||||
await sleep(interval, options.signal);
|
||||
const response = await withRequestTimeout(
|
||||
options.signal,
|
||||
requestTimeoutMs,
|
||||
(signal) => fetchObservationSessionPreparation(current, {
|
||||
signal,
|
||||
fetcher: options.fetcher,
|
||||
}),
|
||||
);
|
||||
if (response.kind === "ready") return response.launch;
|
||||
|
||||
const next = response.preparation;
|
||||
const nextUpdatedAt = Date.parse(next.updatedAtUtc);
|
||||
if (nextUpdatedAt < lastUpdatedAt) {
|
||||
throw new Error("Сервер вернул устаревшее состояние подготовки записи.");
|
||||
}
|
||||
if (nextUpdatedAt > lastUpdatedAt) {
|
||||
lastUpdatedAt = nextUpdatedAt;
|
||||
lastHeartbeatAt = now();
|
||||
} else if (
|
||||
next.state !== current.state ||
|
||||
next.progress !== current.progress ||
|
||||
next.cancellable !== current.cancellable
|
||||
) {
|
||||
throw new Error("Сервер изменил подготовку без обновления heartbeat timestamp.");
|
||||
}
|
||||
if (pendingPreparation(next) && pendingPreparation(current)) {
|
||||
if (PREPARATION_PHASE_ORDER[next.state] < PREPARATION_PHASE_ORDER[current.state]) {
|
||||
throw new Error("Сервер вернул подготовку на уже завершённую фазу.");
|
||||
}
|
||||
if (
|
||||
next.progress !== null &&
|
||||
current.progress !== null &&
|
||||
next.progress + Number.EPSILON < current.progress
|
||||
) {
|
||||
throw new Error("Прогресс подготовки не может уменьшаться.");
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
options.onUpdate?.(current);
|
||||
if (!pendingPreparation(current)) throw terminalPreparationError(current);
|
||||
if (current.state !== "queued" && now() - lastHeartbeatAt > heartbeatStallMs) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Подготовка перестала обновляться. Текущая сцена сохранена; повторите запуск.",
|
||||
);
|
||||
}
|
||||
if (now() - startedAt > maximumWaitMs) {
|
||||
throw new ObservationPreparationStalledError(
|
||||
"Подготовка превысила допустимое время. Текущая сцена сохранена; повторите запуск.",
|
||||
);
|
||||
}
|
||||
interval = Math.min(maximumInterval, Math.round(interval * 1.35));
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveObservationSessionReplay(
|
||||
sessionId: string,
|
||||
options: ObservationPreparationPollingOptions,
|
||||
): Promise<ObservationSessionReplayLaunch> {
|
||||
const response = await withRequestTimeout(
|
||||
options.signal,
|
||||
Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS),
|
||||
(signal) => replayObservationSession(sessionId, { signal, fetcher: options.fetcher }),
|
||||
);
|
||||
if (response.kind === "ready") return response.launch;
|
||||
if (!pendingPreparation(response.preparation)) {
|
||||
options.onUpdate?.(response.preparation);
|
||||
throw terminalPreparationError(response.preparation);
|
||||
}
|
||||
return waitForObservationReplayPreparation(response.preparation, options);
|
||||
}
|
||||
|
||||
function preparationStoragePayload(preparation: ObservationSessionPreparation): unknown {
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-preparation/v1",
|
||||
preparation: {
|
||||
preparation_id: preparation.preparationId,
|
||||
session_id: preparation.sessionId,
|
||||
state: preparation.state,
|
||||
progress: preparation.progress,
|
||||
updated_at_utc: preparation.updatedAtUtc,
|
||||
status_url: preparation.statusUrl,
|
||||
cancellable: preparation.cancellable,
|
||||
...(preparation.state === "failed" || preparation.state === "cancelled"
|
||||
? { retryable: preparation.retryable, error: preparation.error }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function storeObservationReplayPreparation(
|
||||
preparation: ObservationSessionPreparation,
|
||||
storage: Storage = window.localStorage,
|
||||
): void {
|
||||
if (!pendingPreparation(preparation)) {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(PREPARATION_STORAGE_KEY, JSON.stringify(preparationStoragePayload(preparation)));
|
||||
}
|
||||
|
||||
export function loadObservationReplayPreparation(
|
||||
storage: Storage = window.localStorage,
|
||||
): ObservationSessionPreparation | null {
|
||||
const serialized = storage.getItem(PREPARATION_STORAGE_KEY);
|
||||
if (!serialized) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("preparation" in parsed) ||
|
||||
typeof parsed.preparation !== "object" ||
|
||||
parsed.preparation === null ||
|
||||
!("session_id" in parsed.preparation) ||
|
||||
typeof parsed.preparation.session_id !== "string"
|
||||
) {
|
||||
throw new Error("invalid persisted preparation");
|
||||
}
|
||||
const preparation = decodeObservationSessionPreparation(
|
||||
parsed,
|
||||
parsed.preparation.session_id,
|
||||
);
|
||||
return pendingPreparation(preparation) ? preparation : null;
|
||||
} catch {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearObservationReplayPreparation(
|
||||
storage: Storage = window.localStorage,
|
||||
): void {
|
||||
storage.removeItem(PREPARATION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 3,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplayAccepted?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplaySettled?: (
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
} = {}): ObservationSessionsController {
|
||||
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
|
||||
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replayingSessionId, setReplayingSessionId] = useState<string | null>(null);
|
||||
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
||||
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
||||
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const preparationPollSequence = useRef(0);
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
}
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const sequence = ++catalogSequence.current;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
setState("ready");
|
||||
return true;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return false;
|
||||
}
|
||||
}, [safeLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
catalogSequence.current += 1;
|
||||
replayCoordinator.current?.cancel();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const catalogHasActivePreparation = items.some((item) => (
|
||||
item.preparation !== null &&
|
||||
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !catalogHasActivePreparation) return;
|
||||
const sequence = ++preparationPollSequence.current;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({
|
||||
limit: safeLimit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setError(errorMessage(pollError));
|
||||
}
|
||||
}
|
||||
}, 1_500);
|
||||
return () => {
|
||||
preparationPollSequence.current += 1;
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [catalogHasActivePreparation, items, safeLimit, state]);
|
||||
|
||||
const executeReplay = useCallback(async (
|
||||
session: ObservationSessionSummary,
|
||||
resumedPreparation?: ObservationSessionPreparation,
|
||||
) => {
|
||||
const attempt = replayCoordinator.current!.begin();
|
||||
setReplayingSessionId(session.id);
|
||||
setFailedSessionId(null);
|
||||
setError(null);
|
||||
setReplayProgress({
|
||||
sessionId: session.id,
|
||||
phase: resumedPreparation?.state ?? "requesting",
|
||||
progress: resumedPreparation?.progress ?? null,
|
||||
cancellable: resumedPreparation?.cancellable ?? false,
|
||||
});
|
||||
let outcome: ObservationReplayOutcome = "cancelled";
|
||||
try {
|
||||
const onUpdate = (next: ObservationSessionPreparation) => {
|
||||
if (!mounted.current || !attempt.isCurrent()) return;
|
||||
setPreparation(next);
|
||||
setReplayProgress({
|
||||
sessionId: next.sessionId,
|
||||
phase: next.state,
|
||||
progress: next.progress,
|
||||
cancellable: next.cancellable,
|
||||
});
|
||||
try {
|
||||
storeObservationReplayPreparation(next);
|
||||
} catch {
|
||||
// Private browsing/storage quota must not break replay preparation.
|
||||
}
|
||||
};
|
||||
const launch = resumedPreparation
|
||||
? await waitForObservationReplayPreparation(resumedPreparation, {
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
})
|
||||
: await resolveObservationSessionReplay(session.id, {
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
});
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
|
||||
// The current scene stays mounted throughout preparation. Only now that
|
||||
// the launch descriptor exists do we release the previous viewer.
|
||||
await onReplayBegin?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
await onReplayAccepted?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
outcome = "accepted";
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage is optional; an accepted replay must not turn into an error.
|
||||
}
|
||||
setPreparation(null);
|
||||
setReplayProgress(null);
|
||||
return true;
|
||||
} catch (replayError) {
|
||||
const aborted = isAbortError(replayError);
|
||||
outcome = aborted ? "cancelled" : "error";
|
||||
if (mounted.current && attempt.isCurrent() && !aborted) {
|
||||
setError(errorMessage(replayError));
|
||||
setFailedSessionId(session.id);
|
||||
setReplayProgress(null);
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage is optional for the current browser lifetime.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
const current = attempt.finish();
|
||||
if (mounted.current && current) {
|
||||
setReplayingSessionId(null);
|
||||
await onReplaySettled?.(session, outcome);
|
||||
}
|
||||
}
|
||||
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
|
||||
|
||||
const replay = useCallback(async (sessionId: string) => {
|
||||
const session = items.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !session.replayable) return false;
|
||||
reattachStarted.current = true;
|
||||
return executeReplay(session);
|
||||
}, [executeReplay, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || reattachStarted.current) return;
|
||||
reattachStarted.current = true;
|
||||
let stored: ObservationSessionPreparation | null = null;
|
||||
try {
|
||||
stored = loadObservationReplayPreparation();
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
if (!stored) return;
|
||||
const session = items.find((candidate) => candidate.id === stored?.sessionId);
|
||||
if (!session?.replayable) {
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
} catch {
|
||||
// Storage may be unavailable in hardened browser profiles.
|
||||
}
|
||||
return;
|
||||
}
|
||||
void executeReplay(session, stored);
|
||||
}, [executeReplay, items, state]);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
if (!failedSessionId) return false;
|
||||
return replay(failedSessionId);
|
||||
}, [failedSessionId, replay]);
|
||||
|
||||
return {
|
||||
items,
|
||||
state,
|
||||
error,
|
||||
replayingSessionId,
|
||||
preparation,
|
||||
replayProgress,
|
||||
failedSessionId,
|
||||
refresh,
|
||||
replay,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
|
||||
import {
|
||||
initialRecordedCameraAdmissions,
|
||||
mergeRecordedCameraAdmission,
|
||||
nextRecordedCameraPreparationIds,
|
||||
recordedCameraDescriptorPreflight,
|
||||
recordedSessionAdmissionPhase,
|
||||
type RecordedAdmissionPhase,
|
||||
type RecordedCameraAdmissionMap,
|
||||
type RecordedCameraAdmissionState,
|
||||
} from "./recordedSessionAdmission";
|
||||
|
||||
interface AdmissionSnapshot {
|
||||
key: string | null;
|
||||
spatialPhase: RecordedAdmissionPhase;
|
||||
cameras: Record<string, RecordedCameraAdmissionState>;
|
||||
}
|
||||
|
||||
export interface RecordedSessionAdmissionController {
|
||||
key: string;
|
||||
phase: RecordedAdmissionPhase;
|
||||
cameraSourceIds: readonly string[];
|
||||
cameras: RecordedCameraAdmissionMap;
|
||||
activeCameraSourceIds: ReadonlySet<string>;
|
||||
reportSpatial: (key: string, phase: RecordedAdmissionPhase) => void;
|
||||
reportCamera: (
|
||||
key: string,
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function replayAdmissionKey(replay: ObservationSessionReplayLaunch): string {
|
||||
return [
|
||||
replay.sessionId,
|
||||
replay.sha256,
|
||||
...replay.mediaSources.map((source) => (
|
||||
[
|
||||
source.id,
|
||||
source.manifestGenerationSha256,
|
||||
source.byteLength,
|
||||
source.timelineStartSeconds,
|
||||
source.timelineEndSeconds,
|
||||
].join(":")
|
||||
)),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function useRecordedSessionAdmission(
|
||||
replay: ObservationSessionReplayLaunch | null,
|
||||
): RecordedSessionAdmissionController | null {
|
||||
const key = replay ? replayAdmissionKey(replay) : null;
|
||||
const cameraSourceIds = useMemo(
|
||||
() => replay?.mediaSources.map(({ id }) => id) ?? [],
|
||||
[replay],
|
||||
);
|
||||
const declaredByteLengths = useMemo(
|
||||
() => Object.fromEntries(
|
||||
(replay?.mediaSources ?? []).map(({ id, byteLength }) => [id, byteLength]),
|
||||
),
|
||||
[replay],
|
||||
);
|
||||
const [snapshot, setSnapshot] = useState<AdmissionSnapshot>(() => ({
|
||||
key,
|
||||
spatialPhase: "loading",
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot({
|
||||
key,
|
||||
spatialPhase: "loading",
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
});
|
||||
}, [cameraSourceIds, declaredByteLengths, key]);
|
||||
|
||||
const reportSpatial = useCallback((reportedKey: string, phase: RecordedAdmissionPhase) => {
|
||||
setSnapshot((current) => current.key !== reportedKey
|
||||
? current
|
||||
: { ...current, spatialPhase: phase });
|
||||
}, []);
|
||||
|
||||
const reportCamera = useCallback((
|
||||
reportedKey: string,
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
setSnapshot((current) => {
|
||||
if (current.key !== reportedKey || !(sourceId in current.cameras)) return current;
|
||||
const merged = mergeRecordedCameraAdmission(current.cameras[sourceId], state);
|
||||
if (merged === current.cameras[sourceId]) return current;
|
||||
return { ...current, cameras: { ...current.cameras, [sourceId]: merged } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!replay || !key) return null;
|
||||
const current = snapshot.key === key
|
||||
? snapshot
|
||||
: {
|
||||
key,
|
||||
spatialPhase: "loading" as const,
|
||||
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
|
||||
};
|
||||
const descriptorPhase = recordedCameraDescriptorPreflight(replay.mediaSources);
|
||||
const phase = descriptorPhase === "error"
|
||||
? "error"
|
||||
: recordedSessionAdmissionPhase(
|
||||
current.spatialPhase,
|
||||
cameraSourceIds,
|
||||
current.cameras,
|
||||
);
|
||||
const activeCameraSourceIds = new Set(phase === "error"
|
||||
? []
|
||||
: nextRecordedCameraPreparationIds(cameraSourceIds, current.cameras));
|
||||
return {
|
||||
key,
|
||||
phase,
|
||||
cameraSourceIds,
|
||||
cameras: current.cameras,
|
||||
activeCameraSourceIds,
|
||||
reportSpatial,
|
||||
reportCamera,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservationWorkspaceLayoutProfile,
|
||||
saveObservationWorkspaceLayoutProfile,
|
||||
WorkspaceLayoutApiError,
|
||||
type ObservationWorkspaceLayoutProfile,
|
||||
} from "./workspaceLayout";
|
||||
|
||||
export type WorkspaceLayoutProfileState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "saving"
|
||||
| "error"
|
||||
| "conflict";
|
||||
|
||||
export interface WorkspaceLayoutProfileController {
|
||||
profile: ObservationWorkspaceLayoutProfile | null;
|
||||
state: WorkspaceLayoutProfileState;
|
||||
error: string | null;
|
||||
refresh: () => Promise<ObservationWorkspaceLayoutProfile | null>;
|
||||
save: (
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
) => Promise<ObservationWorkspaceLayoutProfile | null>;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция с профилем рабочей поверхности завершилась ошибкой.";
|
||||
}
|
||||
|
||||
export function useWorkspaceLayoutProfile(): WorkspaceLayoutProfileController {
|
||||
const [profile, setProfile] = useState<ObservationWorkspaceLayoutProfile | null>(null);
|
||||
const profileRef = useRef<ObservationWorkspaceLayoutProfile | null>(null);
|
||||
const [state, setState] = useState<WorkspaceLayoutProfileState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const requestSequence = useRef(0);
|
||||
const saveInFlight = useRef(false);
|
||||
|
||||
const commitProfile = useCallback((next: ObservationWorkspaceLayoutProfile | null) => {
|
||||
profileRef.current = next;
|
||||
setProfile(next);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const loaded = await fetchObservationWorkspaceLayoutProfile();
|
||||
if (!mounted.current || sequence !== requestSequence.current) return null;
|
||||
commitProfile(loaded);
|
||||
setState("ready");
|
||||
return loaded;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== requestSequence.current) return null;
|
||||
setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return null;
|
||||
}
|
||||
}, [commitProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
requestSequence.current += 1;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "error") return;
|
||||
// The desktop shell can become ready before its loopback API. Keep the
|
||||
// last-layout restore self-healing instead of requiring a page reload once
|
||||
// the local service finishes starting.
|
||||
const timer = window.setTimeout(() => void refresh(), 5_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [refresh, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshAfterNetworkRecovery = () => void refresh();
|
||||
window.addEventListener("online", refreshAfterNetworkRecovery);
|
||||
return () => window.removeEventListener("online", refreshAfterNetworkRecovery);
|
||||
}, [refresh]);
|
||||
|
||||
const save = useCallback(async (draft: ObservationWorkspaceLayoutProfile) => {
|
||||
if (saveInFlight.current) return null;
|
||||
const current = profileRef.current;
|
||||
if (current && draft.revision !== current.revision) {
|
||||
setState("conflict");
|
||||
setError("Профиль изменился после открытия. Обновите данные перед сохранением.");
|
||||
return null;
|
||||
}
|
||||
saveInFlight.current = true;
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await saveObservationWorkspaceLayoutProfile(draft);
|
||||
if (!mounted.current) return null;
|
||||
commitProfile(saved);
|
||||
setState("ready");
|
||||
return saved;
|
||||
} catch (saveError) {
|
||||
if (!mounted.current) return null;
|
||||
const conflict = saveError instanceof WorkspaceLayoutApiError && saveError.conflict;
|
||||
setState(conflict ? "conflict" : "error");
|
||||
setError(errorMessage(saveError));
|
||||
return null;
|
||||
} finally {
|
||||
saveInFlight.current = false;
|
||||
}
|
||||
}, [commitProfile]);
|
||||
|
||||
return { profile, state, error, refresh, save };
|
||||
}
|
||||
|
|
@ -0,0 +1,599 @@
|
|||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
|
||||
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 1 as const;
|
||||
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
|
||||
"/api/v1/workspace-layouts/observation.spatial" as const;
|
||||
|
||||
export type ObservationToolWindowId = "sources" | "display" | "layers";
|
||||
|
||||
export interface ObservationToolWindows {
|
||||
sourcesOpen: boolean;
|
||||
displayOpen: boolean;
|
||||
layersOpen: boolean;
|
||||
order: readonly ObservationToolWindowId[];
|
||||
}
|
||||
|
||||
export interface ObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface NormalizedObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutSnapshot {
|
||||
visibleSourceIds: readonly string[];
|
||||
activeFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, NormalizedObservationWindowRect>>;
|
||||
viewportSize: ObservationViewportSize;
|
||||
}
|
||||
|
||||
export interface ProjectedObservationLayout {
|
||||
visibleSourceIds: readonly string[];
|
||||
activeFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
}
|
||||
|
||||
export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnapshot {
|
||||
version: typeof OBSERVATION_WORKSPACE_LAYOUT_VERSION;
|
||||
revision: number;
|
||||
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
|
||||
sceneSettings: SceneSettings;
|
||||
toolWindows: ObservationToolWindows;
|
||||
}
|
||||
|
||||
export type WorkspaceLayoutFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
type WireProfile = {
|
||||
version: number;
|
||||
revision: number;
|
||||
workspace_id: string;
|
||||
scene_settings: {
|
||||
projection: string;
|
||||
point_size: number;
|
||||
color_mode: string;
|
||||
palette: string;
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
show_labels: boolean;
|
||||
show_camera_frustums: boolean;
|
||||
};
|
||||
tool_windows: {
|
||||
sources_open: boolean;
|
||||
display_open: boolean;
|
||||
layers_open: boolean;
|
||||
order: ObservationToolWindowId[];
|
||||
};
|
||||
visible_source_ids: string[];
|
||||
active_floating_source_id: string | null;
|
||||
window_rects: Record<string, NormalizedObservationWindowRect>;
|
||||
viewport_size: ObservationViewportSize;
|
||||
};
|
||||
|
||||
const PROFILE_KEYS = new Set([
|
||||
"version",
|
||||
"revision",
|
||||
"workspace_id",
|
||||
"scene_settings",
|
||||
"tool_windows",
|
||||
"visible_source_ids",
|
||||
"active_floating_source_id",
|
||||
"window_rects",
|
||||
"viewport_size",
|
||||
]);
|
||||
const SCENE_KEYS = new Set([
|
||||
"projection",
|
||||
"point_size",
|
||||
"color_mode",
|
||||
"palette",
|
||||
"custom_color",
|
||||
"accumulation_seconds",
|
||||
"show_points",
|
||||
"show_trajectory",
|
||||
"show_grid",
|
||||
"show_labels",
|
||||
"show_camera_frustums",
|
||||
]);
|
||||
const TOOL_WINDOW_KEYS = new Set(["sources_open", "display_open", "layers_open", "order"]);
|
||||
const VIEWPORT_KEYS = new Set(["width", "height"]);
|
||||
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
|
||||
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
|
||||
const COLOR_MODES = new Set<SceneSettings["colorMode"]>([
|
||||
"intensity",
|
||||
"height",
|
||||
"distance",
|
||||
"rgb",
|
||||
"class",
|
||||
]);
|
||||
const PALETTES = new Set<SceneSettings["palette"]>([
|
||||
"turbo",
|
||||
"viridis",
|
||||
"plasma",
|
||||
"grayscale",
|
||||
"custom",
|
||||
]);
|
||||
const TOOL_WINDOW_IDS = new Set<ObservationToolWindowId>(["sources", "display", "layers"]);
|
||||
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const MAX_SOURCES = 256;
|
||||
const MAX_VIEWPORT_EDGE = 100_000;
|
||||
const NORMALIZED_EPSILON = 1e-9;
|
||||
|
||||
export class WorkspaceLayoutContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WorkspaceLayoutContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceLayoutApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "WorkspaceLayoutApiError";
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
get conflict(): boolean {
|
||||
return this.status === 409 || this.status === 412;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, field: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть объектом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
field: string,
|
||||
): void {
|
||||
const actual = Object.keys(value);
|
||||
const unknown = actual.filter((key) => !expected.has(key));
|
||||
const missing = [...expected].filter((key) => !(key in value));
|
||||
if (unknown.length || missing.length) {
|
||||
const details = [
|
||||
unknown.length ? `неизвестные: ${unknown.sort().join(", ")}` : "",
|
||||
missing.length ? `отсутствуют: ${missing.sort().join(", ")}` : "",
|
||||
].filter(Boolean).join("; ");
|
||||
throw new WorkspaceLayoutContractError(`${field}: неверная схема (${details}).`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireFiniteInRange(
|
||||
value: unknown,
|
||||
field: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
{ integer = false, minimumExclusive = false }: { integer?: boolean; minimumExclusive?: boolean } = {},
|
||||
): number {
|
||||
const belowMinimum = minimumExclusive
|
||||
? typeof value === "number" && value <= minimum
|
||||
: typeof value === "number" && value < minimum;
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
belowMinimum ||
|
||||
value > maximum ||
|
||||
(integer && !Number.isInteger(value))
|
||||
) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле ${field} должно быть конечным ${integer ? "целым " : ""}числом в допустимом диапазоне.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStableId(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !SAFE_STABLE_ID.test(value)) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле ${field} должно содержать безопасный стабильный идентификатор источника.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeViewport(value: unknown, field = "viewport_size"): ObservationViewportSize {
|
||||
const record = requireRecord(value, field);
|
||||
assertExactKeys(record, VIEWPORT_KEYS, field);
|
||||
return {
|
||||
width: requireFiniteInRange(record.width, `${field}.width`, 1, MAX_VIEWPORT_EDGE),
|
||||
height: requireFiniteInRange(record.height, `${field}.height`, 1, MAX_VIEWPORT_EDGE),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateObservationViewportSize(
|
||||
value: ObservationViewportSize,
|
||||
): ObservationViewportSize {
|
||||
return decodeViewport(value, "viewport");
|
||||
}
|
||||
|
||||
function decodeNormalizedRect(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): NormalizedObservationWindowRect {
|
||||
const record = requireRecord(value, field);
|
||||
assertExactKeys(record, RECT_KEYS, field);
|
||||
const rect = {
|
||||
x: requireFiniteInRange(record.x, `${field}.x`, 0, 1),
|
||||
y: requireFiniteInRange(record.y, `${field}.y`, 0, 1),
|
||||
width: requireFiniteInRange(record.width, `${field}.width`, 0, 1, { minimumExclusive: true }),
|
||||
height: requireFiniteInRange(record.height, `${field}.height`, 0, 1, { minimumExclusive: true }),
|
||||
};
|
||||
if (
|
||||
rect.x + rect.width > 1 + NORMALIZED_EPSILON ||
|
||||
rect.y + rect.height > 1 + NORMALIZED_EPSILON
|
||||
) {
|
||||
throw new WorkspaceLayoutContractError(`${field} выходит за нормализованные границы viewport.`);
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
|
||||
function decodeStableIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_SOURCES) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле visible_source_ids должно быть массивом не более ${MAX_SOURCES} элементов.`,
|
||||
);
|
||||
}
|
||||
const result = value.map((entry, index) => requireStableId(entry, `visible_source_ids[${index}]`));
|
||||
if (new Set(result).size !== result.length) {
|
||||
throw new WorkspaceLayoutContractError("Поле visible_source_ids содержит повторяющиеся id.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeWindowRects(value: unknown): Record<string, NormalizedObservationWindowRect> {
|
||||
const record = requireRecord(value, "window_rects");
|
||||
if (Object.keys(record).length > MAX_SOURCES) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Поле window_rects содержит более ${MAX_SOURCES} элементов.`,
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(record).map(([sourceId, rect]) => [
|
||||
requireStableId(sourceId, "window_rects key"),
|
||||
decodeNormalizedRect(rect, `window_rects.${sourceId}`),
|
||||
]));
|
||||
}
|
||||
|
||||
function requireEnum<T extends string>(
|
||||
value: unknown,
|
||||
allowed: ReadonlySet<T>,
|
||||
field: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !allowed.has(value as T)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле ${field} содержит неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function decodeSceneSettings(value: unknown): SceneSettings {
|
||||
const record = requireRecord(value, "scene_settings");
|
||||
assertExactKeys(record, SCENE_KEYS, "scene_settings");
|
||||
if (typeof record.custom_color !== "string" || !HEX_COLOR.test(record.custom_color)) {
|
||||
throw new WorkspaceLayoutContractError("Поле scene_settings.custom_color должно быть цветом #RRGGBB.");
|
||||
}
|
||||
return {
|
||||
projection: requireEnum(record.projection, PROJECTIONS, "scene_settings.projection"),
|
||||
pointSize: requireFiniteInRange(record.point_size, "scene_settings.point_size", 0.1, 32),
|
||||
colorMode: requireEnum(record.color_mode, COLOR_MODES, "scene_settings.color_mode"),
|
||||
palette: requireEnum(record.palette, PALETTES, "scene_settings.palette"),
|
||||
customColor: record.custom_color,
|
||||
accumulationSeconds: requireFiniteInRange(
|
||||
record.accumulation_seconds,
|
||||
"scene_settings.accumulation_seconds",
|
||||
0,
|
||||
3_600,
|
||||
),
|
||||
showPoints: requireBoolean(record.show_points, "scene_settings.show_points"),
|
||||
showTrajectory: requireBoolean(record.show_trajectory, "scene_settings.show_trajectory"),
|
||||
showGrid: requireBoolean(record.show_grid, "scene_settings.show_grid"),
|
||||
showLabels: requireBoolean(record.show_labels, "scene_settings.show_labels"),
|
||||
showCameraFrustums: requireBoolean(
|
||||
record.show_camera_frustums,
|
||||
"scene_settings.show_camera_frustums",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeToolWindows(value: unknown): ObservationToolWindows {
|
||||
const record = requireRecord(value, "tool_windows");
|
||||
assertExactKeys(record, TOOL_WINDOW_KEYS, "tool_windows");
|
||||
if (!Array.isArray(record.order) || record.order.length !== TOOL_WINDOW_IDS.size) {
|
||||
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно содержать три окна.");
|
||||
}
|
||||
const order = record.order.map((entry, index) =>
|
||||
requireEnum(entry, TOOL_WINDOW_IDS, `tool_windows.order[${index}]`));
|
||||
if (new Set(order).size !== TOOL_WINDOW_IDS.size) {
|
||||
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно быть перестановкой окон.");
|
||||
}
|
||||
return {
|
||||
sourcesOpen: requireBoolean(record.sources_open, "tool_windows.sources_open"),
|
||||
displayOpen: requireBoolean(record.display_open, "tool_windows.display_open"),
|
||||
layersOpen: requireBoolean(record.layers_open, "tool_windows.layers_open"),
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeObservationWorkspaceLayoutProfile(
|
||||
value: unknown,
|
||||
): ObservationWorkspaceLayoutProfile {
|
||||
const record = requireRecord(value, "workspace layout");
|
||||
assertExactKeys(record, PROFILE_KEYS, "workspace layout");
|
||||
if (record.version !== OBSERVATION_WORKSPACE_LAYOUT_VERSION) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
`Неподдерживаемая версия workspace layout: ${String(record.version)}.`,
|
||||
);
|
||||
}
|
||||
if (record.workspace_id !== OBSERVATION_WORKSPACE_ID) {
|
||||
throw new WorkspaceLayoutContractError("Профиль относится к другой рабочей поверхности.");
|
||||
}
|
||||
const visibleSourceIds = decodeStableIds(record.visible_source_ids);
|
||||
const activeFloatingSourceId = record.active_floating_source_id === null
|
||||
? null
|
||||
: requireStableId(record.active_floating_source_id, "active_floating_source_id");
|
||||
if (activeFloatingSourceId && !visibleSourceIds.includes(activeFloatingSourceId)) {
|
||||
throw new WorkspaceLayoutContractError(
|
||||
"Активное плавающее окно должно относиться к видимому источнику.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: requireFiniteInRange(record.revision, "revision", 0, Number.MAX_SAFE_INTEGER, {
|
||||
integer: true,
|
||||
}),
|
||||
workspaceId: OBSERVATION_WORKSPACE_ID,
|
||||
sceneSettings: decodeSceneSettings(record.scene_settings),
|
||||
toolWindows: decodeToolWindows(record.tool_windows),
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId,
|
||||
windowRects: decodeWindowRects(record.window_rects),
|
||||
viewportSize: decodeViewport(record.viewport_size),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeObservationWorkspaceLayoutProfile(
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
): WireProfile {
|
||||
const wire: WireProfile = {
|
||||
version: profile.version,
|
||||
revision: profile.revision,
|
||||
workspace_id: profile.workspaceId,
|
||||
scene_settings: {
|
||||
projection: profile.sceneSettings.projection,
|
||||
point_size: profile.sceneSettings.pointSize,
|
||||
color_mode: profile.sceneSettings.colorMode,
|
||||
palette: profile.sceneSettings.palette,
|
||||
custom_color: profile.sceneSettings.customColor,
|
||||
accumulation_seconds: profile.sceneSettings.accumulationSeconds,
|
||||
show_points: profile.sceneSettings.showPoints,
|
||||
show_trajectory: profile.sceneSettings.showTrajectory,
|
||||
show_grid: profile.sceneSettings.showGrid,
|
||||
show_labels: profile.sceneSettings.showLabels,
|
||||
show_camera_frustums: profile.sceneSettings.showCameraFrustums,
|
||||
},
|
||||
tool_windows: {
|
||||
sources_open: profile.toolWindows.sourcesOpen,
|
||||
display_open: profile.toolWindows.displayOpen,
|
||||
layers_open: profile.toolWindows.layersOpen,
|
||||
order: [...profile.toolWindows.order],
|
||||
},
|
||||
visible_source_ids: [...profile.visibleSourceIds],
|
||||
active_floating_source_id: profile.activeFloatingSourceId,
|
||||
window_rects: Object.fromEntries(
|
||||
Object.entries(profile.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
|
||||
),
|
||||
viewport_size: { ...profile.viewportSize },
|
||||
};
|
||||
// The decoder is the single runtime schema authority for inbound and outbound documents.
|
||||
decodeObservationWorkspaceLayoutProfile(wire);
|
||||
return wire;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
export function normalizeObservationWindowRect(
|
||||
rect: ObservationWindowRect,
|
||||
viewport: ObservationViewportSize,
|
||||
): NormalizedObservationWindowRect {
|
||||
const safeViewport = decodeViewport(viewport, "viewport");
|
||||
for (const [field, value] of Object.entries(rect)) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new WorkspaceLayoutContractError(`Поле rect.${field} должно быть конечным числом.`);
|
||||
}
|
||||
}
|
||||
const x = clamp(rect.x, 0, Math.max(0, safeViewport.width - 1));
|
||||
const y = clamp(rect.y, 0, Math.max(0, safeViewport.height - 1));
|
||||
const width = clamp(rect.width, 1, Math.max(1, safeViewport.width - x));
|
||||
const height = clamp(rect.height, 1, Math.max(1, safeViewport.height - y));
|
||||
return {
|
||||
x: x / safeViewport.width,
|
||||
y: y / safeViewport.height,
|
||||
width: width / safeViewport.width,
|
||||
height: height / safeViewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function denormalizeObservationWindowRect(
|
||||
rect: NormalizedObservationWindowRect,
|
||||
viewport: ObservationViewportSize,
|
||||
): ObservationWindowRect {
|
||||
const normalized = decodeNormalizedRect(rect, "rect");
|
||||
const safeViewport = decodeViewport(viewport, "viewport");
|
||||
return {
|
||||
x: normalized.x * safeViewport.width,
|
||||
y: normalized.y * safeViewport.height,
|
||||
width: normalized.width * safeViewport.width,
|
||||
height: normalized.height * safeViewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateObservationLayoutSnapshot(
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
): ObservationLayoutSnapshot {
|
||||
const wire = {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: 0,
|
||||
workspace_id: OBSERVATION_WORKSPACE_ID,
|
||||
scene_settings: {
|
||||
projection: "3d",
|
||||
point_size: 1,
|
||||
color_mode: "intensity",
|
||||
palette: "turbo",
|
||||
custom_color: "#ffffff",
|
||||
accumulation_seconds: 0,
|
||||
show_points: true,
|
||||
show_trajectory: true,
|
||||
show_grid: true,
|
||||
show_labels: false,
|
||||
show_camera_frustums: true,
|
||||
},
|
||||
tool_windows: {
|
||||
sources_open: false,
|
||||
display_open: false,
|
||||
layers_open: false,
|
||||
order: ["sources", "display", "layers"],
|
||||
},
|
||||
visible_source_ids: [...snapshot.visibleSourceIds],
|
||||
active_floating_source_id: snapshot.activeFloatingSourceId,
|
||||
window_rects: snapshot.windowRects,
|
||||
viewport_size: snapshot.viewportSize,
|
||||
};
|
||||
const decoded = decodeObservationWorkspaceLayoutProfile(wire);
|
||||
return {
|
||||
visibleSourceIds: decoded.visibleSourceIds,
|
||||
activeFloatingSourceId: decoded.activeFloatingSourceId,
|
||||
windowRects: decoded.windowRects,
|
||||
viewportSize: decoded.viewportSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectObservationLayoutSnapshot(
|
||||
snapshot: ObservationLayoutSnapshot,
|
||||
knownSourceIds: ReadonlySet<string>,
|
||||
viewport: ObservationViewportSize,
|
||||
): ProjectedObservationLayout {
|
||||
const validated = validateObservationLayoutSnapshot(snapshot);
|
||||
const targetViewport = decodeViewport(viewport, "viewport");
|
||||
const visibleSourceIds = validated.visibleSourceIds.filter((sourceId) => knownSourceIds.has(sourceId));
|
||||
const visibleSet = new Set(visibleSourceIds);
|
||||
return {
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId: validated.activeFloatingSourceId && visibleSet.has(validated.activeFloatingSourceId)
|
||||
? validated.activeFloatingSourceId
|
||||
: null,
|
||||
windowRects: Object.fromEntries(
|
||||
Object.entries(validated.windowRects)
|
||||
.filter(([sourceId]) => knownSourceIds.has(sourceId))
|
||||
.map(([sourceId, rect]) => [
|
||||
sourceId,
|
||||
denormalizeObservationWindowRect(rect, targetViewport),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||
return body.detail.trim();
|
||||
}
|
||||
} catch {
|
||||
// A non-JSON error body is represented by the stable fallback.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function fetchObservationWorkspaceLayoutProfile({
|
||||
fetcher = fetch,
|
||||
}: { fetcher?: WorkspaceLayoutFetch } = {}): Promise<ObservationWorkspaceLayoutProfile | null> {
|
||||
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new WorkspaceLayoutApiError(
|
||||
await responseMessage(response, "Не удалось загрузить профиль рабочей поверхности."),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new WorkspaceLayoutContractError("Сервер вернул профиль не в формате JSON.");
|
||||
}
|
||||
return decodeObservationWorkspaceLayoutProfile(body);
|
||||
}
|
||||
|
||||
export async function saveObservationWorkspaceLayoutProfile(
|
||||
profile: ObservationWorkspaceLayoutProfile,
|
||||
{ fetcher = fetch }: { fetcher?: WorkspaceLayoutFetch } = {},
|
||||
): Promise<ObservationWorkspaceLayoutProfile> {
|
||||
const wire = encodeObservationWorkspaceLayoutProfile(profile);
|
||||
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"If-Match": `"${profile.revision}"`,
|
||||
},
|
||||
body: JSON.stringify(wire),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new WorkspaceLayoutApiError(
|
||||
await responseMessage(response, response.status === 409 || response.status === 412
|
||||
? "Профиль уже изменён в другом окне. Обновите данные и повторите сохранение."
|
||||
: "Не удалось сохранить профиль рабочей поверхности."),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new WorkspaceLayoutContractError("Сервер не вернул сохранённый профиль в формате JSON.");
|
||||
}
|
||||
const saved = decodeObservationWorkspaceLayoutProfile(body);
|
||||
if (saved.revision <= profile.revision) {
|
||||
throw new WorkspaceLayoutContractError("Сервер не увеличил revision сохранённого профиля.");
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
|
@ -123,6 +123,16 @@ export type ObservationSourceDelivery =
|
|||
kind: "video-url" | "image-url";
|
||||
url: string;
|
||||
mediaType?: string | null;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "recorded-fmp4-manifest";
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
manifestGenerationSha256: string;
|
||||
byteLength: number;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
};
|
||||
|
||||
export interface ObservationSourceActivation {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
export interface LatestAsyncCommitter<T> {
|
||||
enqueue: (value: T) => void;
|
||||
hasPending: () => boolean;
|
||||
isBusy: () => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface LatestAsyncCommitResult<T> {
|
||||
value: T;
|
||||
applied: boolean;
|
||||
superseded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an expensive settings commit while retaining only the newest
|
||||
* value submitted during the active request. This keeps slider and color
|
||||
* interactions from creating an unbounded backend queue.
|
||||
*/
|
||||
export function createLatestAsyncCommitter<T>({
|
||||
commit,
|
||||
onSettled,
|
||||
}: {
|
||||
commit: (value: T) => Promise<boolean>;
|
||||
onSettled?: (result: LatestAsyncCommitResult<T>) => void;
|
||||
}): LatestAsyncCommitter<T> {
|
||||
let pending: T | undefined;
|
||||
let running = false;
|
||||
let disposed = false;
|
||||
const idleWaiters = new Set<() => void>();
|
||||
|
||||
const settleIdleWaiters = () => {
|
||||
if (running || pending !== undefined) return;
|
||||
for (const resolve of idleWaiters) resolve();
|
||||
idleWaiters.clear();
|
||||
};
|
||||
|
||||
const drain = async () => {
|
||||
if (running || disposed) return;
|
||||
running = true;
|
||||
|
||||
try {
|
||||
while (!disposed && pending !== undefined) {
|
||||
const value = pending;
|
||||
pending = undefined;
|
||||
|
||||
let applied = false;
|
||||
try {
|
||||
applied = await commit(value);
|
||||
} catch {
|
||||
applied = false;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
onSettled?.({
|
||||
value,
|
||||
applied,
|
||||
superseded: pending !== undefined,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
if (!disposed && pending !== undefined) {
|
||||
void drain();
|
||||
} else {
|
||||
settleIdleWaiters();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue(value) {
|
||||
if (disposed) return;
|
||||
pending = value;
|
||||
void drain();
|
||||
},
|
||||
hasPending: () => pending !== undefined,
|
||||
isBusy: () => running || pending !== undefined,
|
||||
waitForIdle() {
|
||||
if (!running && pending === undefined) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => idleWaiters.add(resolve));
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
pending = undefined;
|
||||
settleIdleWaiters();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
.observation-header-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.workspace-layout-feedback {
|
||||
display: inline-flex;
|
||||
max-width: 15rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.workspace-layout-feedback[data-error="true"] {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observation-session-select__trigger {
|
||||
display: inline-flex;
|
||||
min-width: 15rem;
|
||||
height: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger:hover,
|
||||
.observation-session-select__trigger[data-active="true"] {
|
||||
background: rgb(255 255 255 / 0.095);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > span {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > small {
|
||||
display: grid;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observation-session-menu {
|
||||
overflow: hidden;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observation-session-menu__content,
|
||||
.observation-session-menu__list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.observation-session-menu__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 0.9rem 0.6rem;
|
||||
}
|
||||
|
||||
.observation-session-menu__head span,
|
||||
.observation-session-menu__head strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observation-session-menu__head strong {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
.observation-session-menu__head button {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-menu__head button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-session-menu__list {
|
||||
max-height: min(26rem, 60vh);
|
||||
gap: 0.2rem;
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-session-option {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-session-option i {
|
||||
display: block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="ready"],
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
animation: observation-session-processing 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.observation-session-option i[data-session-visual-state="error"] {
|
||||
background: var(--nodedc-text-muted);
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
@keyframes observation-session-processing {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.observation-session-option i[data-session-visual-state="processing"] {
|
||||
animation: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.observation-session-option__state {
|
||||
max-width: 9.5rem;
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-session-menu__empty {
|
||||
display: grid;
|
||||
min-height: 9rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observation-session-menu__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observation-session-menu__empty span {
|
||||
max-width: 24rem;
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observation-session-menu__error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.9rem 0.8rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observation-session-menu__error > span {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.observation-session-menu__error button {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.4rem 0.65rem;
|
||||
font: inherit;
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.workspace-layout-feedback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger {
|
||||
min-width: 2.75rem;
|
||||
width: 2.75rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.observation-session-select__trigger > span,
|
||||
.observation-session-select__trigger > small,
|
||||
.observation-session-select__trigger > svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +191,32 @@ i[data-availability="error"] {
|
|||
background: #050608;
|
||||
}
|
||||
|
||||
.recorded-media-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #070809;
|
||||
}
|
||||
|
||||
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-media-player__notice {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
color: rgba(247, 248, 244, 0.72);
|
||||
background: #070809;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mse-fmp4-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
@ -316,11 +342,8 @@ i[data-availability="error"] {
|
|||
}
|
||||
|
||||
.observation-timeline {
|
||||
display: grid;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(9 10 13 / 0.78);
|
||||
|
|
@ -328,22 +351,73 @@ i[data-availability="error"] {
|
|||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
height: 0.3rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.observation-timeline__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
.observation-timeline__playback {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation span,
|
||||
.observation-timeline__accumulation code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation code {
|
||||
min-width: 2.65rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
width: 100%;
|
||||
height: 0.3rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__track::-webkit-slider-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track[data-disabled="true"] {
|
||||
.observation-timeline__track::-moz-range-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track:disabled {
|
||||
opacity: 0.46;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-timeline__meta {
|
||||
|
|
@ -364,11 +438,17 @@ i[data-availability="error"] {
|
|||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.observation-timeline__follow:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
|
|
@ -514,11 +594,15 @@ i[data-availability="error"] {
|
|||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-timeline > .nodedc-button:first-child,
|
||||
.observation-timeline__playback > .nodedc-button:first-child,
|
||||
.observation-timeline__meta {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@
|
|||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.station-label,
|
||||
.control-station .nodedc-header__profile-button {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,3 @@
|
|||
.station-label {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.13em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.api-dot {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
|
|
|
|||
|
|
@ -45,17 +45,34 @@
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas[data-presented="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-session-preloaders {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: -10000px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport {
|
||||
--rerun-tab-bar-height: 24px;
|
||||
--rerun-native-chrome-height: 72px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Rerun WebViewer 0.34.1 draws its selected recording tab inside WASM.
|
||||
The native panels are already disabled; crop only that fixed tab row. */
|
||||
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
.rerun-viewport__canvas {
|
||||
top: calc(-1 * var(--rerun-tab-bar-height));
|
||||
top: calc(-1 * var(--rerun-native-chrome-height));
|
||||
bottom: auto;
|
||||
height: calc(100% + var(--rerun-tab-bar-height));
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
|
|
@ -65,8 +82,9 @@
|
|||
}
|
||||
|
||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||
.rerun-viewport__canvas > div {
|
||||
.rerun-viewport__canvas {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ import {
|
|||
} from "../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type {
|
||||
BackendStatus,
|
||||
|
|
@ -22,6 +28,10 @@ import type {
|
|||
} from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
rerunPresentationStatus,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
} from "../components/RerunViewport";
|
||||
|
|
@ -95,7 +105,12 @@ export interface WorkspaceRendererProps {
|
|||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
recordedReplay: ObservationSessionReplayLaunch | null;
|
||||
recordedSessionAdmission: RecordedSessionAdmissionController | null;
|
||||
sceneSettings: SceneSettings;
|
||||
accumulationSeconds: number;
|
||||
onAccumulationChange: (value: number) => void;
|
||||
onAccumulationCommit: () => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
|
@ -248,13 +263,27 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
|||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
sceneSettings,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
observationLayout,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const recordedPlaybackReady = !recordedSource ||
|
||||
(recordedSessionGate === "ready" &&
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
|
|
@ -277,28 +306,83 @@ function SpatialWorkspace({
|
|||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const presentedViewerStatus = rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
}, []);
|
||||
if (recordedSessionAdmission) {
|
||||
recordedSessionAdmission.reportSpatial(
|
||||
recordedSessionAdmission.key,
|
||||
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
|
||||
);
|
||||
}
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
(next: RerunPlaybackState | null) => setPlaybackState(next),
|
||||
[],
|
||||
);
|
||||
const onPlaybackControllerChange = useCallback(
|
||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const publishViewportSize = () => {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (bounds.width < 1 || bounds.height < 1) return;
|
||||
observationLayout.setViewportSize({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
};
|
||||
publishViewportSize();
|
||||
const observer = new ResizeObserver(publishViewportSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[viewerStatus];
|
||||
}[presentedViewerStatus];
|
||||
|
||||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||
const viewerStatusTone = presentedViewerStatus === "ready"
|
||||
? "success"
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -331,9 +415,21 @@ function SpatialWorkspace({
|
|||
{sourceUrl.trim() && pointCloudVisible ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
followLive={state?.sourceMode === "live"}
|
||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
||||
followLive={!recordedSource && state?.sourceMode === "live"}
|
||||
autoplayWhenReady={recordedSource}
|
||||
presentationGate={recordedSessionGate}
|
||||
expectedTimelineStartSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.startSeconds
|
||||
: undefined}
|
||||
expectedTimelineEndSeconds={recordedSource
|
||||
? state?.observationTimeline?.range?.endSeconds
|
||||
: undefined}
|
||||
sceneSettings={sceneSettings}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackControllerChange={onPlaybackControllerChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
|
|
@ -415,13 +511,26 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized ? (
|
||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={viewerStatus === "ready"}
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
|
||||
mode={timeline?.mode}
|
||||
seekable={timeline?.seekable}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
seekable={recordedSource && playbackState?.rangeNs
|
||||
? true
|
||||
: timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
rangeNs={playbackState?.rangeNs}
|
||||
currentNs={playbackState?.currentNs}
|
||||
playing={playbackState?.playing}
|
||||
onSeek={playbackController?.seek}
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -440,6 +549,14 @@ function SpatialWorkspace({
|
|||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
playback={recordedSource && playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: playbackState.playing,
|
||||
} : null}
|
||||
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
onClose={() => {
|
||||
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||
observationLayout.setFloatingMaximized(source.id, false);
|
||||
|
|
@ -447,6 +564,28 @@ function SpatialWorkspace({
|
|||
}}
|
||||
/>
|
||||
)) : null}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
(pointCloudFocused || !observationLayout.visibleSourceIds.has(source.id)) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
playback={playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: false,
|
||||
} : null}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
|
|
@ -470,6 +609,10 @@ function CameraSourceCard({
|
|||
onToggle,
|
||||
onFocus,
|
||||
onClose,
|
||||
prepareRecorded,
|
||||
recordedSessionGate,
|
||||
recordedAdmissionKey,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
focused: boolean;
|
||||
|
|
@ -479,6 +622,13 @@ function CameraSourceCard({
|
|||
onToggle: () => void;
|
||||
onFocus: () => void;
|
||||
onClose: () => void;
|
||||
prepareRecorded: boolean;
|
||||
recordedSessionGate: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey: string | null;
|
||||
onRecordedAdmissionChange: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const deliveryActive = Boolean(
|
||||
(source.delivery || source.previewUrl) &&
|
||||
|
|
@ -542,7 +692,13 @@ function CameraSourceCard({
|
|||
</div>
|
||||
</header>
|
||||
<div className="camera-slot__body">
|
||||
<ObservationMedia source={source} />
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
prepareRecorded={prepareRecorded}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{source.description}</span>
|
||||
|
|
@ -552,13 +708,36 @@ function CameraSourceCard({
|
|||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
|
||||
function CamerasWorkspace({
|
||||
definition,
|
||||
state,
|
||||
observationLayout,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
}: WorkspaceRendererProps) {
|
||||
const sources = (state?.observationSources ?? []).filter(
|
||||
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
|
||||
);
|
||||
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
|
||||
const timeline = state?.observationTimeline;
|
||||
const displayedSources = focusedSource ? [focusedSource] : sources;
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedReplay
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedReplay) return true;
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
||||
}, [recordedReplay, recordedSessionAdmission]);
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
|
|
@ -587,6 +766,10 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
|||
observationLayout.setFocusedSourceId(null);
|
||||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
prepareRecorded={shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
)) : (
|
||||
<div className="camera-grid__empty">
|
||||
|
|
@ -596,7 +779,25 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ObservationTimeline
|
||||
{recordedReplay ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{sources.filter((source) => (
|
||||
!displayedSources.some(({ id }) => id === source.id) &&
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!recordedReplay || recordedSessionGate === "ready" ? <ObservationTimeline
|
||||
active={sources.some((source) =>
|
||||
source.availability === "streaming" &&
|
||||
(!source.activation || source.activation.selected))}
|
||||
|
|
@ -606,7 +807,7 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
|||
seekable={timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
className="camera-timeline"
|
||||
/>
|
||||
/> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,832 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeObservationSessionCatalog;
|
||||
let decodeObservationSessionReplay;
|
||||
let decodeObservationSessionPreparation;
|
||||
let fetchObservationSessionCatalog;
|
||||
let replayObservationSession;
|
||||
let fetchObservationSessionPreparation;
|
||||
let cancelObservationSessionPreparation;
|
||||
let ObservationSessionApiError;
|
||||
let ObservationSessionContractError;
|
||||
let decodeObservationRecordedMediaManifest;
|
||||
let createObservationReplayCoordinator;
|
||||
let resolveObservationSessionReplay;
|
||||
let waitForObservationReplayPreparation;
|
||||
let storeObservationReplayPreparation;
|
||||
let loadObservationReplayPreparation;
|
||||
let observationSessionVisualState;
|
||||
let observationSessionVisualLabel;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeObservationSessionCatalog,
|
||||
decodeObservationSessionReplay,
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
replayObservationSession,
|
||||
fetchObservationSessionPreparation,
|
||||
cancelObservationSessionPreparation,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
decodeObservationRecordedMediaManifest,
|
||||
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
|
||||
({
|
||||
createObservationReplayCoordinator,
|
||||
resolveObservationSessionReplay,
|
||||
waitForObservationReplayPreparation,
|
||||
storeObservationReplayPreparation,
|
||||
loadObservationReplayPreparation,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationSessions.ts",
|
||||
));
|
||||
({ observationSessionVisualState, observationSessionVisualLabel } = await server.ssrLoadModule(
|
||||
"/src/components/ObservationSessionSelect.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function session(overrides = {}) {
|
||||
return {
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
started_at_utc: "2026-07-16T20:56:32.635Z",
|
||||
completed_at_utc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
duration_seconds: 1_450.744,
|
||||
replayable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replay(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
const sourceUrl = overrides.source_url ??
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording.rrd`;
|
||||
const sha256 = overrides.sha256 ?? "a".repeat(64);
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-replay/v2",
|
||||
launch: {
|
||||
kind: "rerun-recording",
|
||||
session_id: sessionId,
|
||||
source_url: sourceUrl,
|
||||
viewer_source_url: overrides.viewer_source_url ?? `${sourceUrl}?generation=${sha256}`,
|
||||
media_type: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 1_450.744,
|
||||
seekable: true,
|
||||
byte_length: 123_456,
|
||||
sha256,
|
||||
playback: { speed: 1, loop: false },
|
||||
media_sources: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function preparation(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-preparation/v1",
|
||||
preparation: {
|
||||
preparation_id: "prepare-20260717T131400Z",
|
||||
session_id: sessionId,
|
||||
state: "queued",
|
||||
progress: null,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
status_url: `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording-preparation`,
|
||||
cancellable: true,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const preparationEtag = '"prepare-20260717T131400Z"';
|
||||
|
||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
||||
const catalog = decodeObservationSessionCatalog({ items: [session()] });
|
||||
|
||||
assert.deepEqual(catalog.items, [{
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
startedAtUtc: "2026-07-16T20:56:32.635Z",
|
||||
completedAtUtc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
durationSeconds: 1_450.744,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
}]);
|
||||
assert.equal("raw_capture" in catalog.items[0], false);
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
preparation: {
|
||||
preparation_id: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
},
|
||||
})],
|
||||
});
|
||||
assert.deepEqual(catalog.items[0].preparation, {
|
||||
preparationId: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updatedAtUtc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("saved-session rows use only authoritative ready, processing and error states", () => {
|
||||
const makeDecoded = (state, overrides = {}) => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
status: "interrupted",
|
||||
preparation: state === null ? null : {
|
||||
preparation_id: `prepare-${state}`,
|
||||
state,
|
||||
progress: state === "ready" ? 1 : 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: ["queued", "validating", "exporting", "finalizing"].includes(state),
|
||||
retryable: state === "failed",
|
||||
...(state === "failed" ? { error: "export failed" } : {}),
|
||||
},
|
||||
...overrides,
|
||||
})],
|
||||
}).items[0];
|
||||
|
||||
assert.equal(observationSessionVisualState(makeDecoded("ready")), "ready");
|
||||
for (const state of ["queued", "validating", "exporting", "finalizing"]) {
|
||||
assert.equal(observationSessionVisualState(makeDecoded(state)), "processing");
|
||||
}
|
||||
assert.equal(observationSessionVisualState(makeDecoded("failed")), "error");
|
||||
assert.equal(observationSessionVisualState(makeDecoded("cancelled")), "error");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed", { status: "recording" })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualState(makeDecoded(null)), "error");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded(null, { status: "recording" })),
|
||||
"processing",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready", { replayable: false })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready"), { failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed"), { pending: true, failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualLabel("ready"), "Готово");
|
||||
assert.equal(observationSessionVisualLabel("processing"), "Обработка");
|
||||
assert.equal(observationSessionVisualLabel("error"), "Ошибка");
|
||||
});
|
||||
|
||||
test("saved-session lamps use green or dim gray only, never warning or danger colors", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const start = css.indexOf(".observation-session-option i {");
|
||||
const end = css.indexOf(".observation-session-option__state", start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
const lampRules = css.slice(start, end);
|
||||
assert.match(lampRules, /data-session-visual-state="ready"[\s\S]*--nodedc-success-rgb/);
|
||||
assert.match(lampRules, /data-session-visual-state="processing"[\s\S]*animation:/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*--nodedc-text-muted/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*opacity:\s*0\.48/);
|
||||
assert.doesNotMatch(lampRules, /\b(?:warning|danger|yellow|red)\b/i);
|
||||
});
|
||||
|
||||
test("session catalog sorts newest first and uses id as a deterministic tie-breaker", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [
|
||||
session({ id: "same-b", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "newest", started_at_utc: "2026-07-17T00:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "same-a", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(catalog.items.map(({ id }) => id), ["newest", "same-a", "same-b"]);
|
||||
});
|
||||
|
||||
test("session catalog rejects raw storage fields, unsafe ids and duplicate ids", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ raw_capture: "sessions/private/mqtt.raw.k1mqtt" })],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ id: "../private" })] }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session(), session()] }),
|
||||
/повторяющийся id/,
|
||||
);
|
||||
});
|
||||
|
||||
test("session catalog rejects invalid temporal, status and duration values", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ started_at_utc: "16 July 2026" })],
|
||||
}),
|
||||
/ISO-датой/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ status: "complete" })] }),
|
||||
/неизвестное состояние/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ duration_seconds: -1 })] }),
|
||||
/неотрицательным числом/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ completed_at_utc: "2026-07-16T19:00:00Z" })],
|
||||
}),
|
||||
/раньше времени начала/,
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog API preserves HTTP detail without accepting a malformed success body", async () => {
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "storage unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 503 && error.message === "storage unavailable",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ items: "not-an-array" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||
const calls = [];
|
||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "/api/v1/observation-sessions/session-20260716T205632Z/replay");
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(launch.kind, "ready");
|
||||
assert.equal(launch.launch.timeline, "session_time");
|
||||
assert.equal(
|
||||
launch.launch.sourceUrl,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("preparation contract is strict, session-scoped and same-origin", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const decoded = decodeObservationSessionPreparation(preparation(), sessionId);
|
||||
assert.equal(decoded.sessionId, sessionId);
|
||||
assert.equal(decoded.state, "queued");
|
||||
assert.equal(decoded.progress, null);
|
||||
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(
|
||||
preparation({ status_url: "https://invalid.test/status" }),
|
||||
sessionId,
|
||||
),
|
||||
/канонический same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ debug_path: "/tmp/raw" }), sessionId),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ progress: 1.1 }), sessionId),
|
||||
/недопустимое число/,
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
/preparation ETag/,
|
||||
);
|
||||
});
|
||||
|
||||
test("202 preparation polls through explicit phases and reveals launch only when ready", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const calls = [];
|
||||
const phases = [];
|
||||
let poll = 0;
|
||||
let clock = 0;
|
||||
let previousViewerMounted = true;
|
||||
const launch = await resolveObservationSessionReplay(sessionId, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 5_000,
|
||||
maximumWaitMs: 10_000,
|
||||
initialPollIntervalMs: 50,
|
||||
maximumPollIntervalMs: 50,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
clock += milliseconds;
|
||||
},
|
||||
onUpdate: (value) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
phases.push([value.state, value.progress]);
|
||||
},
|
||||
fetcher: async (input, init) => {
|
||||
calls.push([String(input), init.method, new Headers(init.headers).get("If-Match")]);
|
||||
if (init.method === "POST") {
|
||||
return new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
poll += 1;
|
||||
if (poll === 1) {
|
||||
return new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:01Z",
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
// This is the point where App is allowed to unmount the old viewer.
|
||||
previousViewerMounted = false;
|
||||
assert.equal(launch.sessionId, sessionId);
|
||||
assert.deepEqual(phases, [["queued", null], ["exporting", 0.5]]);
|
||||
assert.deepEqual(calls.map((entry) => entry[1]), ["POST", "GET", "GET"]);
|
||||
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
|
||||
});
|
||||
|
||||
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "finalizing", progress: 0.95 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let receivedIfMatch;
|
||||
await assert.rejects(
|
||||
fetchObservationSessionPreparation(decoded, {
|
||||
fetcher: async (_input, init) => {
|
||||
receivedIfMatch = new Headers(init.headers).get("If-Match");
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: '"replacement-job"' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
/актуальный preparation ETag/,
|
||||
);
|
||||
assert.equal(receivedIfMatch, preparationEtag);
|
||||
});
|
||||
|
||||
test("preparation cancellation is conditional on the same preparation ETag", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.5 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let request;
|
||||
const result = await cancelObservationSessionPreparation(decoded, {
|
||||
fetcher: async (input, init) => {
|
||||
request = {
|
||||
input: String(input),
|
||||
method: init.method,
|
||||
ifMatch: new Headers(init.headers).get("If-Match"),
|
||||
};
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual(request, {
|
||||
input: decoded.statusUrl,
|
||||
method: "DELETE",
|
||||
ifMatch: preparationEtag,
|
||||
});
|
||||
});
|
||||
|
||||
test("preparation polling fails explicitly when the server heartbeat stalls", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.1 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
await assert.rejects(
|
||||
waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.1,
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
}),
|
||||
/перестала обновляться/,
|
||||
);
|
||||
});
|
||||
|
||||
test("queued preparation can wait behind the bounded backend worker without false heartbeat failure", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "queued", progress: 0 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
let polls = 0;
|
||||
const launch = await waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => {
|
||||
polls += 1;
|
||||
if (polls <= 2) {
|
||||
return new Response(JSON.stringify(preparation({ state: "queued", progress: 0 })), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(clock, 450);
|
||||
assert.equal(launch.sessionId, "session-20260716T205632Z");
|
||||
});
|
||||
|
||||
test("terminal preparation failure is explicit and retryable", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const response = await replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "failed",
|
||||
progress: 0.4,
|
||||
cancellable: false,
|
||||
retryable: true,
|
||||
error: "Повреждён индекс исходной записи.",
|
||||
})), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.kind, "preparing");
|
||||
assert.equal(response.preparation.retryable, true);
|
||||
assert.equal(response.preparation.error, "Повреждён индекс исходной записи.");
|
||||
});
|
||||
|
||||
test("pending preparation survives reload only through its strict canonical contract", () => {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.7 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
storeObservationReplayPreparation(decoded, storage);
|
||||
assert.deepEqual(loadObservationReplayPreparation(storage), decoded);
|
||||
|
||||
const key = storage.key(0);
|
||||
storage.setItem(key, JSON.stringify({ ...preparation(), raw_path: "/private/raw" }));
|
||||
assert.equal(loadObservationReplayPreparation(storage), null);
|
||||
assert.equal(storage.length, 0);
|
||||
});
|
||||
|
||||
test("replay API forwards cancellation and preserves AbortError semantics", async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal;
|
||||
const pending = replayObservationSession("session-20260716T205632Z", {
|
||||
signal: controller.signal,
|
||||
fetcher: async (_input, init) => {
|
||||
receivedSignal = init.signal;
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () => {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
});
|
||||
|
||||
test("replay coordinator makes rapid saved-session selection latest-request-wins", () => {
|
||||
const coordinator = createObservationReplayCoordinator();
|
||||
const first = coordinator.begin();
|
||||
assert.equal(first.isCurrent(), true);
|
||||
|
||||
const second = coordinator.begin();
|
||||
assert.equal(first.signal.aborted, true);
|
||||
assert.equal(first.isCurrent(), false);
|
||||
assert.equal(first.finish(), false);
|
||||
assert.equal(second.isCurrent(), true);
|
||||
assert.equal(second.finish(), true);
|
||||
assert.equal(second.isCurrent(), false);
|
||||
|
||||
const third = coordinator.begin();
|
||||
coordinator.cancel();
|
||||
assert.equal(third.signal.aborted, true);
|
||||
assert.equal(third.isCurrent(), false);
|
||||
});
|
||||
|
||||
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
|
||||
const hookSource = await readFile(
|
||||
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const selectorSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(hookSource, /cancelObservationSessionPreparation|method:\s*["']DELETE/);
|
||||
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
|
||||
});
|
||||
|
||||
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "/api/v1/observation-sessions/other/recording.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ seekable: false })),
|
||||
/seekable/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: "/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: `https://foreign.test/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ raw_path: "/private/raw.k1mqtt" })),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ byte_length: Number.MAX_SAFE_INTEGER + 1 })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ playback: { speed: 101, loop: false } })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const source = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [source],
|
||||
}));
|
||||
assert.equal(
|
||||
decoded.viewerSourceUrl,
|
||||
`/api/v1/observation-sessions/${sessionId}/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
);
|
||||
assert.equal(decoded.mediaSources.length, 1);
|
||||
assert.equal(decoded.mediaSources[0].id, source.id);
|
||||
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
|
||||
assert.equal(
|
||||
decoded.mediaSources[0].manifestGenerationSha256,
|
||||
source.manifest_generation_sha256,
|
||||
);
|
||||
|
||||
const manifest = decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: source.manifest_url.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 12,
|
||||
segment_url_prefix: source.manifest_url.replace("/manifest", "/epochs/1/segments/"),
|
||||
segments: Array.from({ length: 12 }, (_, index) => ({
|
||||
sequence: index + 1,
|
||||
url: source.manifest_url.replace("/manifest", `/epochs/1/segments/${index + 1}.m4s`),
|
||||
byte_length: 256 + index,
|
||||
sha256: "b".repeat(64),
|
||||
})),
|
||||
}],
|
||||
}, decoded.mediaSources[0]);
|
||||
assert.equal(manifest.epochs[0].segmentCount, 12);
|
||||
assert.equal(manifest.epochs[0].segments.length, 12);
|
||||
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
...{
|
||||
schema_version: "missioncore.observation-recorded-media/v1",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
},
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_267,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим|launch descriptor/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const base = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_url: "https://invalid.test/private" }],
|
||||
})),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, path: "/private/archive" }],
|
||||
})),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: undefined }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: "not-a-digest" }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [base],
|
||||
}));
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: base.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: "file:///private/init.mp4",
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 1,
|
||||
segment_url_prefix: "file:///private/segments/",
|
||||
segments: [{
|
||||
sequence: 1,
|
||||
url: "file:///private/segments/1.m4s",
|
||||
byte_length: 256,
|
||||
sha256: "b".repeat(64),
|
||||
}],
|
||||
}],
|
||||
}, decoded.mediaSources[0]),
|
||||
/небезопасный API URL/,
|
||||
);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchVerifiedRecordedMediaArchive;
|
||||
let recordedMediaPresentationState;
|
||||
let recordedMediaSeekableCoverage;
|
||||
let appendRecordedMediaBuffer;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchVerifiedRecordedMediaArchive,
|
||||
recordedMediaPresentationState,
|
||||
recordedMediaSeekableCoverage,
|
||||
appendRecordedMediaBuffer,
|
||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function digest(payload) {
|
||||
return createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
|
||||
const init = Buffer.from("canonical-init");
|
||||
const first = Buffer.from("canonical-first-fragment");
|
||||
const second = Buffer.from("canonical-second-fragment");
|
||||
const generation = "a".repeat(64);
|
||||
const segmentPrefix = manifestUrl.replace("/manifest", "/epochs/1/segments/");
|
||||
const manifest = {
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: "recorded.camera.camera-1",
|
||||
generation_sha256: generation,
|
||||
byte_length: init.byteLength + first.byteLength + second.byteLength,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: manifestUrl.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: init.byteLength,
|
||||
init_sha256: digest(init),
|
||||
segment_count: 2,
|
||||
segment_url_prefix: segmentPrefix,
|
||||
segments: [first, second].map((payload, index) => ({
|
||||
sequence: index + 1,
|
||||
url: `${segmentPrefix}${index + 1}.m4s`,
|
||||
byte_length: payload.byteLength,
|
||||
sha256: digest(payload),
|
||||
})),
|
||||
}],
|
||||
};
|
||||
const source = {
|
||||
id: manifest.source_id,
|
||||
label: "Записанная камера",
|
||||
modality: "video",
|
||||
manifestUrl,
|
||||
manifestGenerationSha256: generation,
|
||||
byteLength: manifest.byte_length,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
return { source, manifest, generation, init, first, second };
|
||||
}
|
||||
|
||||
function jsonResponse(payload, generation) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
ETag: `"sha256:${generation}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mediaResponse(payload, sha = digest(payload), length = payload.byteLength) {
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Length": String(length),
|
||||
ETag: `"sha256:${sha}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test("recorded camera archive stays pending until every canonical byte is verified", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const finalSegment = deferred();
|
||||
const requested = [];
|
||||
let manifestRequests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
const url = String(input);
|
||||
requested.push(url);
|
||||
const headers = new Headers(request.headers);
|
||||
if (url === source.manifestUrl) {
|
||||
manifestRequests += 1;
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(manifest, generation);
|
||||
}
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) {
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${epoch.init_sha256}"`);
|
||||
return mediaResponse(init);
|
||||
}
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) return finalSegment.promise;
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const archivePromise = fetchVerifiedRecordedMediaArchive(source, { fetcher })
|
||||
.then((archive) => {
|
||||
settled = true;
|
||||
return archive;
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
assert.equal(settled, false, "init and a partial segment prefix must not publish the camera");
|
||||
assert.deepEqual(requested.slice(0, 4), [
|
||||
source.manifestUrl,
|
||||
manifest.epochs[0].init_url,
|
||||
manifest.epochs[0].segments[0].url,
|
||||
manifest.epochs[0].segments[1].url,
|
||||
]);
|
||||
|
||||
finalSegment.resolve(mediaResponse(second));
|
||||
const archive = await archivePromise;
|
||||
assert.equal(manifestRequests, 2, "the immutable generation is revalidated after transfer");
|
||||
assert.equal(archive.byteLength, init.byteLength + first.byteLength + second.byteLength);
|
||||
assert.equal(archive.epochs[0].segments.length, 2);
|
||||
});
|
||||
|
||||
test("first camera manifest request is launch-generation bound and rejects replacement", async () => {
|
||||
const { source, manifest, generation } = fixture();
|
||||
const replacementGeneration = "d".repeat(64);
|
||||
let requests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
requests += 1;
|
||||
assert.equal(String(input), source.manifestUrl);
|
||||
assert.equal(
|
||||
new Headers(request.headers).get("If-Match"),
|
||||
`"sha256:${generation}"`,
|
||||
);
|
||||
return jsonResponse(
|
||||
{ ...manifest, generation_sha256: replacementGeneration },
|
||||
replacementGeneration,
|
||||
);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/несовместим|заменён/,
|
||||
);
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed on truncation despite plausible headers", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(second.subarray(0, second.byteLength - 1), digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/усечён/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed when bytes are replaced under an old ETag", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const replacement = Buffer.from(second.map((value) => value ^ 0xff));
|
||||
assert.equal(replacement.byteLength, second.byteLength);
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(replacement, digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/SHA-256/,
|
||||
);
|
||||
});
|
||||
|
||||
test("camera presentation gate opens only for the completely appended selected epoch", () => {
|
||||
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false), "ready");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true), "waiting");
|
||||
assert.equal(recordedMediaPresentationState("error", "g1", "g1", false), "error");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "error"), "error");
|
||||
});
|
||||
|
||||
test("decoded duration and seekable range cover the complete declared epoch", () => {
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(19, 19, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(18.99, 20, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 18.99, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
||||
});
|
||||
|
||||
test("aborting SourceBuffer append removes every temporary listener", async () => {
|
||||
const listeners = new Map();
|
||||
const sourceBuffer = {
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? new Set();
|
||||
bucket.add(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
appendBuffer() {},
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const pending = appendRecordedMediaBuffer(
|
||||
sourceBuffer,
|
||||
new ArrayBuffer(8),
|
||||
abort.signal,
|
||||
);
|
||||
assert.equal(listeners.get("updateend")?.size, 1);
|
||||
assert.equal(listeners.get("error")?.size, 1);
|
||||
abort.abort();
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
assert.equal(listeners.get("updateend")?.size, 0);
|
||||
assert.equal(listeners.get("error")?.size, 0);
|
||||
});
|
||||
|
||||
test("verified camera archives remain immutable for safe player remount", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /\.init\s*=\s*new ArrayBuffer/);
|
||||
assert.doesNotMatch(source, /\.segments\.length\s*=\s*0/);
|
||||
});
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player:not\(\[data-state="ready"\]\) \.observation-media__asset\s*\{[^}]*visibility:\s*hidden/s,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let admission;
|
||||
let rerunPresentationStatus;
|
||||
let recordedMediaPresentationState;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
admission = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedSessionAdmission.ts",
|
||||
);
|
||||
({ rerunPresentationStatus } = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedMediaPresentationState } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function camera(phase, byteLength = 1_024) {
|
||||
return { phase, byteLength, message: null };
|
||||
}
|
||||
|
||||
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const oneCamera = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("pending"),
|
||||
};
|
||||
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
||||
assert.equal(partialGate, "loading");
|
||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
||||
"loading",
|
||||
);
|
||||
|
||||
const completeGate = admission.recordedSessionAdmissionPhase("ready", ids, {
|
||||
...oneCamera,
|
||||
"camera.right": camera("ready"),
|
||||
});
|
||||
assert.equal(completeGate, "ready");
|
||||
assert.equal(rerunPresentationStatus("ready", completeGate, true), "ready");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, completeGate),
|
||||
"ready",
|
||||
);
|
||||
});
|
||||
|
||||
test("any RRD or camera failure closes the complete recorded session", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const cameras = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("error"),
|
||||
};
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("ready", ids, cameras), "error");
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("error", ids, {
|
||||
...cameras,
|
||||
"camera.right": camera("ready"),
|
||||
}), "error");
|
||||
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
MAX_RECORDED_SESSION_CAMERA_BYTES,
|
||||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
})),
|
||||
), "ready");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 17 }, (_, index) => ({ id: `camera.${index}`, byteLength: 1 })),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.large", byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES + 1 },
|
||||
]), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: 110 * 1024 * 1024,
|
||||
})),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
]), "error");
|
||||
});
|
||||
|
||||
test("camera preparation remains single-flight and retains the loading permit", () => {
|
||||
const ids = ["camera.first", "camera.preferred"];
|
||||
const cameras = {
|
||||
"camera.first": camera("loading"),
|
||||
"camera.preferred": camera("pending"),
|
||||
};
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(
|
||||
ids,
|
||||
cameras,
|
||||
new Set(["camera.preferred"]),
|
||||
),
|
||||
["camera.first"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(ids, {
|
||||
...cameras,
|
||||
"camera.first": camera("ready"),
|
||||
}),
|
||||
["camera.preferred"],
|
||||
);
|
||||
});
|
||||
|
||||
test("new render workers close readiness and stale callbacks cannot reopen it", () => {
|
||||
const ready = { ...camera("ready"), workerGeneration: 1 };
|
||||
const remounted = admission.mergeRecordedCameraAdmission(ready, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(remounted.phase, "loading");
|
||||
assert.equal(remounted.workerGeneration, 2);
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 1,
|
||||
}), remounted);
|
||||
const admitted = admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(admitted.phase, "ready");
|
||||
const failed = admission.mergeRecordedCameraAdmission(admitted, {
|
||||
...camera("error"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(failed.phase, "error");
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(failed, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 3,
|
||||
}), failed);
|
||||
});
|
||||
|
||||
test("recorded player is not mounted for a camera without a scheduler permit", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/ObservationSources.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const placeholder = source.indexOf("if (!prepareRecorded)");
|
||||
const player = source.indexOf("<RecordedFmp4Player", placeholder);
|
||||
assert.ok(placeholder >= 0 && player > placeholder);
|
||||
assert.match(source.slice(placeholder, player), /return\s*\(/);
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let createRecordedOpenWatchdog;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
createRecordedOpenWatchdog,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const sourceUrl = "/api/v1/observation-sessions/session-atomic/recording.rrd";
|
||||
const sha256 = "a".repeat(64);
|
||||
const viewerSourceUrl = `${sourceUrl}?generation=${sha256}`;
|
||||
|
||||
test("only the canonical digest-bound generation URL reaches the native Rerun receiver", () => {
|
||||
const descriptor = {
|
||||
sourceUrl,
|
||||
viewerSourceUrl,
|
||||
byteLength: 246_331_680,
|
||||
sha256,
|
||||
};
|
||||
const resolved = resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test");
|
||||
assert.equal(resolved, `http://mission-core.test${viewerSourceUrl}`);
|
||||
assert.equal(rerunViewerInitialSource(resolved), resolved);
|
||||
|
||||
for (const unsafeViewerSourceUrl of [
|
||||
sourceUrl,
|
||||
`${sourceUrl}?generation=${"b".repeat(64)}`,
|
||||
`${viewerSourceUrl}&extra=true`,
|
||||
`https://foreign.test${viewerSourceUrl}`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => resolveRecordedViewerSourceUrl({
|
||||
...descriptor,
|
||||
viewerSourceUrl: unsafeViewerSourceUrl,
|
||||
}, "http://mission-core.test"),
|
||||
/Unsafe recorded RRD viewer descriptor/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
|
||||
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
||||
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
|
||||
const largeDelay = recordedOpenWatchdogTimeoutMs(805_687_122);
|
||||
assert.ok(smallDelay >= 120_000);
|
||||
assert.ok(currentDelay >= smallDelay);
|
||||
assert.ok(largeDelay > currentDelay);
|
||||
assert.ok(largeDelay <= 1_800_000);
|
||||
assert.ok(largeDelay > 12_000);
|
||||
|
||||
let scheduledCallback;
|
||||
let scheduledDelay;
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 246_331_680,
|
||||
schedule(callback, timeoutMs) {
|
||||
scheduledCallback = callback;
|
||||
scheduledDelay = timeoutMs;
|
||||
return 17;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
assert.equal(watchdog.pending(), true);
|
||||
assert.equal(scheduledDelay, currentDelay);
|
||||
scheduledCallback();
|
||||
assert.equal(timeoutCount, 1);
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.deepEqual(cancelled, []);
|
||||
});
|
||||
|
||||
test("complete recorded admission clears its watchdog", () => {
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 805_687_122,
|
||||
schedule() {
|
||||
return 23;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
watchdog.clear();
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.equal(timeoutCount, 0);
|
||||
assert.deepEqual(cancelled, [23]);
|
||||
});
|
||||
|
||||
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RerunViewport.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
|
||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||
assert.doesNotMatch(source, /recordedChannel/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*if \(!isRecordedSource\) clearLiveRecordingOpenTimer\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/viewerStartResolved = true;\s*if \(isRecordedSource && !recordedSceneAdmitted\) recordedOpenWatchdog\?\.arm\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
css,
|
||||
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/data-status="loading"[^}]*\.rerun-viewport__canvas\s*>\s*div/s,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let attemptRecordedAutoplay;
|
||||
let createLatestAnimationFrameEmitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ attemptRecordedAutoplay, createLatestAnimationFrameEmitter } =
|
||||
await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("recorded autoplay reports success only after seek and play both succeed", () => {
|
||||
let seekAttempts = 0;
|
||||
let playAttempts = 0;
|
||||
const seekToStart = () => {
|
||||
seekAttempts += 1;
|
||||
};
|
||||
const startPlaying = () => {
|
||||
playAttempts += 1;
|
||||
if (playAttempts === 1) throw new Error("receiver is not ready yet");
|
||||
};
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), false);
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), true);
|
||||
assert.equal(seekAttempts, 2);
|
||||
assert.equal(playAttempts, 2);
|
||||
});
|
||||
|
||||
test("recorded autoplay does not try play when the seek itself fails", () => {
|
||||
let playAttempts = 0;
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(
|
||||
() => {
|
||||
throw new Error("timeline is not ready yet");
|
||||
},
|
||||
() => {
|
||||
playAttempts += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(playAttempts, 0);
|
||||
});
|
||||
|
||||
test("time updates coalesce to the latest value once per animation frame", () => {
|
||||
let nextHandle = 1;
|
||||
const frames = new Map();
|
||||
const cancelled = [];
|
||||
const emitted = [];
|
||||
const emitter = createLatestAnimationFrameEmitter({
|
||||
emit(value) {
|
||||
emitted.push(value);
|
||||
},
|
||||
requestFrame(callback) {
|
||||
const handle = nextHandle;
|
||||
nextHandle += 1;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
cancelFrame(handle) {
|
||||
cancelled.push(handle);
|
||||
frames.delete(handle);
|
||||
},
|
||||
});
|
||||
|
||||
emitter.push(1);
|
||||
emitter.push(2);
|
||||
emitter.push(3);
|
||||
assert.equal(frames.size, 1);
|
||||
assert.deepEqual(emitted, []);
|
||||
|
||||
const firstFrame = frames.get(1);
|
||||
frames.delete(1);
|
||||
firstFrame(0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(4);
|
||||
assert.equal(frames.size, 1);
|
||||
emitter.cancel();
|
||||
assert.deepEqual(cancelled, [2]);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(5);
|
||||
assert.equal(frames.size, 0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let canPublishRecordedPlaybackController;
|
||||
let createRecordedAutoplayGate;
|
||||
let isRecordedPlaybackReady;
|
||||
let isRecordedPlaybackPresentationReady;
|
||||
let isUsableRecordedPlaybackRange;
|
||||
let recordedPlaybackBufferState;
|
||||
let recordedPlaybackRangeWhenReady;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
canPublishRecordedPlaybackController,
|
||||
createRecordedAutoplayGate,
|
||||
isRecordedPlaybackReady,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
isUsableRecordedPlaybackRange,
|
||||
recordedPlaybackBufferState,
|
||||
recordedPlaybackRangeWhenReady,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
|
||||
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 0, max: 0 }), true);
|
||||
|
||||
const firstFrame = recordedPlaybackBufferState({ min: 0, max: 0 }, 20);
|
||||
assert.deepEqual(firstFrame, {
|
||||
bufferedEndNs: 0,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
|
||||
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
||||
});
|
||||
|
||||
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
||||
const partial = {
|
||||
recordingId: "partial",
|
||||
timeline: "session_time",
|
||||
rangeNs: null,
|
||||
currentNs: 0,
|
||||
playing: false,
|
||||
bufferedEndNs: 5,
|
||||
expectedEndNs: 10,
|
||||
bufferProgress: 0.5,
|
||||
fullyBuffered: false,
|
||||
};
|
||||
assert.equal(isRecordedPlaybackPresentationReady("loading", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("error", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", {
|
||||
...partial,
|
||||
rangeNs: { min: 0, max: 10 },
|
||||
bufferedEndNs: 10,
|
||||
bufferProgress: 1,
|
||||
fullyBuffered: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("buffer progress grows independently and preserves the full-buffer tolerance", () => {
|
||||
assert.deepEqual(recordedPlaybackBufferState({ min: 0, max: 5_000_000_000 }, 20), {
|
||||
bufferedEndNs: 5_000_000_000,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0.25,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
|
||||
const complete = recordedPlaybackBufferState({ min: 0, max: 19_999_500_000 }, 20);
|
||||
assert.equal(complete.bufferProgress, 0.999975);
|
||||
assert.equal(complete.fullyBuffered, true);
|
||||
assert.equal(isRecordedPlaybackReady(false, true, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, false, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, complete), true);
|
||||
assert.equal(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, false),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, true),
|
||||
{ min: 0, max: 19_999_500_000 },
|
||||
);
|
||||
|
||||
const wrongDeclaredStart = recordedPlaybackBufferState(
|
||||
{ min: 5_000_000_000, max: 20_000_000_000 },
|
||||
20,
|
||||
0,
|
||||
);
|
||||
assert.equal(wrongDeclaredStart.bufferProgress, 1);
|
||||
assert.equal(wrongDeclaredStart.fullyBuffered, false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, wrongDeclaredStart), false);
|
||||
});
|
||||
|
||||
test("recorded autoplay waits for the full range and then runs exactly once", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
const seeks = [];
|
||||
let plays = 0;
|
||||
const seek = (value) => seeks.push(value);
|
||||
const play = () => {
|
||||
plays += 1;
|
||||
};
|
||||
|
||||
assert.equal(gate.attempt(false, true, true, { min: 0, max: 0 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, false, true, { min: 0, max: 500_000_000 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, true, false, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, []);
|
||||
assert.equal(plays, 0);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), true);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, [0]);
|
||||
assert.equal(plays, 1);
|
||||
assert.equal(gate.attempted(), true);
|
||||
});
|
||||
|
||||
test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
let seeks = 0;
|
||||
let plays = 0;
|
||||
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 1 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
throw new Error("receiver raced its first frame");
|
||||
},
|
||||
), false);
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 2 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(seeks, 1);
|
||||
assert.equal(plays, 1);
|
||||
});
|
||||
|
||||
test("recorded playback controller stays unpublished until the aggregate gate is ready", () => {
|
||||
assert.equal(canPublishRecordedPlaybackController(false, "ready"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "loading"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "error"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "ready"), true);
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let createLatestAsyncCommitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ createLatestAsyncCommitter } = await server.ssrLoadModule(
|
||||
"/src/core/runtime/latestAsyncCommitter.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for async commit queue");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
test("viewer settings commits are serialized and intermediate slider values collapse", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue({ accumulationSeconds: 1 });
|
||||
committer.enqueue({ accumulationSeconds: 2 });
|
||||
committer.enqueue({ accumulationSeconds: 12 });
|
||||
|
||||
assert.deepEqual(calls, [{ accumulationSeconds: 1 }]);
|
||||
assert.equal(committer.isBusy(), true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
|
||||
assert.deepEqual(calls[1], { accumulationSeconds: 12 });
|
||||
assert.equal(settled[0].superseded, true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => !committer.isBusy());
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied, superseded }) => ({
|
||||
value,
|
||||
applied,
|
||||
superseded,
|
||||
})), [
|
||||
{ value: { accumulationSeconds: 1 }, applied: true, superseded: true },
|
||||
{ value: { accumulationSeconds: 12 }, applied: true, superseded: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("viewer settings commit failures settle as rejected without wedging the queue", async () => {
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
if (value === "broken") throw new Error("offline");
|
||||
return true;
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("broken");
|
||||
await eventually(() => settled.length === 1);
|
||||
committer.enqueue("healthy");
|
||||
await eventually(() => settled.length === 2);
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied }) => ({ value, applied })), [
|
||||
{ value: "broken", applied: false },
|
||||
{ value: "healthy", applied: true },
|
||||
]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle resolves only after the running commit and latest queued value settle", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("initial");
|
||||
committer.enqueue("latest");
|
||||
let idle = false;
|
||||
const idlePromise = committer.waitForIdle().then(() => {
|
||||
idle = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(idle, false);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
assert.deepEqual(calls, ["initial", "latest"]);
|
||||
assert.equal(idle, false);
|
||||
|
||||
resolvers.shift()(true);
|
||||
await idlePromise;
|
||||
assert.equal(idle, true);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle observes work enqueued by onSettled and resolves immediately when idle", async () => {
|
||||
const calls = [];
|
||||
let committer;
|
||||
committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
calls.push(value);
|
||||
return true;
|
||||
},
|
||||
onSettled({ value }) {
|
||||
if (value === "first") committer.enqueue("follow-up");
|
||||
},
|
||||
});
|
||||
|
||||
await committer.waitForIdle();
|
||||
committer.enqueue("first");
|
||||
await committer.waitForIdle();
|
||||
|
||||
assert.deepEqual(calls, ["first", "follow-up"]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("layout serialization can flush a staged setting and read the confirmed latest value", async () => {
|
||||
let staged = { pointSize: 2 };
|
||||
let confirmed = { pointSize: 1 };
|
||||
let resolveCommit;
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit() {
|
||||
return new Promise((resolve) => {
|
||||
resolveCommit = resolve;
|
||||
});
|
||||
},
|
||||
onSettled({ value, applied }) {
|
||||
if (applied) confirmed = value;
|
||||
},
|
||||
});
|
||||
|
||||
const saveLayout = async () => {
|
||||
committer.enqueue(staged);
|
||||
await committer.waitForIdle();
|
||||
return { sceneSettings: confirmed };
|
||||
};
|
||||
|
||||
const savePromise = saveLayout();
|
||||
staged = { pointSize: 9 };
|
||||
let saved = false;
|
||||
void savePromise.then(() => {
|
||||
saved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(saved, false);
|
||||
|
||||
resolveCommit(true);
|
||||
const layout = await savePromise;
|
||||
assert.deepEqual(layout, { sceneSettings: { pointSize: 2 } });
|
||||
});
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeObservationWorkspaceLayoutProfile;
|
||||
let encodeObservationWorkspaceLayoutProfile;
|
||||
let fetchObservationWorkspaceLayoutProfile;
|
||||
let normalizeObservationWindowRect;
|
||||
let projectObservationLayoutSnapshot;
|
||||
let saveObservationWorkspaceLayoutProfile;
|
||||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeObservationWorkspaceLayoutProfile,
|
||||
encodeObservationWorkspaceLayoutProfile,
|
||||
fetchObservationWorkspaceLayoutProfile,
|
||||
normalizeObservationWindowRect,
|
||||
projectObservationLayoutSnapshot,
|
||||
saveObservationWorkspaceLayoutProfile,
|
||||
WorkspaceLayoutApiError,
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationLayout.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function wireProfile(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
revision: 7,
|
||||
workspace_id: "observation.spatial",
|
||||
scene_settings: {
|
||||
projection: "3d",
|
||||
point_size: 2.5,
|
||||
color_mode: "intensity",
|
||||
palette: "turbo",
|
||||
custom_color: "#35d7c1",
|
||||
accumulation_seconds: 12,
|
||||
show_points: true,
|
||||
show_trajectory: true,
|
||||
show_grid: true,
|
||||
show_labels: false,
|
||||
show_camera_frustums: true,
|
||||
},
|
||||
tool_windows: {
|
||||
sources_open: true,
|
||||
display_open: false,
|
||||
layers_open: true,
|
||||
order: ["sources", "layers", "display"],
|
||||
},
|
||||
visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
|
||||
active_floating_source_id: "camera.left",
|
||||
window_rects: {
|
||||
"camera.left": { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
"camera.right": { x: 0.55, y: 0.1, width: 0.4, height: 0.4 },
|
||||
},
|
||||
viewport_size: { width: 1_000, height: 500 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("workspace layout performs a lossless strict wire/camel/wire round trip", () => {
|
||||
const wire = wireProfile();
|
||||
const profile = decodeObservationWorkspaceLayoutProfile(wire);
|
||||
|
||||
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
|
||||
assert.deepEqual(profile.toolWindows.order, ["sources", "layers", "display"]);
|
||||
assert.equal(profile.sceneSettings.pointSize, 2.5);
|
||||
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
|
||||
});
|
||||
|
||||
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile({
|
||||
...wireProfile(),
|
||||
source_url: "ws://192.168.68.52:9877",
|
||||
}),
|
||||
WorkspaceLayoutContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 2 })),
|
||||
/Неподдерживаемая версия/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ revision: 1.5 })),
|
||||
/revision/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
visible_source_ids: ["../camera"],
|
||||
active_floating_source_id: null,
|
||||
})),
|
||||
/стабильный идентификатор/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
window_rects: { "camera.left": { x: 0.8, y: 0, width: 0.4, height: 0.5 } },
|
||||
})),
|
||||
/выходит за нормализованные границы/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
tool_windows: {
|
||||
sources_open: true,
|
||||
display_open: true,
|
||||
layers_open: true,
|
||||
order: ["sources", "sources", "layers"],
|
||||
},
|
||||
})),
|
||||
/перестановкой/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
scene_settings: { ...wireProfile().scene_settings, point_size: Number.POSITIVE_INFINITY },
|
||||
})),
|
||||
/point_size/,
|
||||
);
|
||||
});
|
||||
|
||||
test("restored desired layout survives an empty catalog and reveals only known stable sources later", () => {
|
||||
const profile = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
const snapshot = {
|
||||
visibleSourceIds: profile.visibleSourceIds,
|
||||
activeFloatingSourceId: profile.activeFloatingSourceId,
|
||||
windowRects: profile.windowRects,
|
||||
viewportSize: profile.viewportSize,
|
||||
};
|
||||
|
||||
const empty = projectObservationLayoutSnapshot(snapshot, new Set(), { width: 500, height: 1_000 });
|
||||
assert.deepEqual(empty, {
|
||||
visibleSourceIds: [],
|
||||
activeFloatingSourceId: null,
|
||||
windowRects: {},
|
||||
});
|
||||
assert.deepEqual(snapshot.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
|
||||
|
||||
const cameraAppeared = projectObservationLayoutSnapshot(
|
||||
snapshot,
|
||||
new Set(["camera.left"]),
|
||||
{ width: 500, height: 1_000 },
|
||||
);
|
||||
assert.deepEqual(cameraAppeared.visibleSourceIds, ["camera.left"]);
|
||||
assert.equal(cameraAppeared.activeFloatingSourceId, "camera.left");
|
||||
assert.deepEqual(cameraAppeared.windowRects["camera.left"], {
|
||||
x: 50,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test("window rectangles normalize once and project proportionally into a different viewport", () => {
|
||||
assert.deepEqual(
|
||||
normalizeObservationWindowRect(
|
||||
{ x: 100, y: 50, width: 400, height: 200 },
|
||||
{ width: 1_000, height: 500 },
|
||||
),
|
||||
{ x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
|
||||
);
|
||||
});
|
||||
|
||||
test("fullscreen presentation survives the viewport resize it causes", () => {
|
||||
assert.equal(
|
||||
observationPresentationSourceAfterLayoutApply("camera.left", "preserve"),
|
||||
"camera.left",
|
||||
);
|
||||
assert.equal(
|
||||
observationPresentationSourceAfterLayoutApply("camera.left", "reset"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
const saved = await saveObservationWorkspaceLayoutProfile(current, {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(JSON.stringify(wireProfile({ revision: 8 })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "/api/v1/workspace-layouts/observation.spatial");
|
||||
assert.equal(calls[0].init.method, "PUT");
|
||||
assert.equal(calls[0].init.headers["If-Match"], '"7"');
|
||||
assert.equal(calls[0].body.revision, 7);
|
||||
assert.equal(calls[0].body.workspace_id, "observation.spatial");
|
||||
assert.equal(saved.revision, 8);
|
||||
});
|
||||
|
||||
test("workspace layout API treats 404 as no profile and exposes revision conflicts", async () => {
|
||||
assert.equal(
|
||||
await fetchObservationWorkspaceLayoutProfile({
|
||||
fetcher: async () => new Response(null, { status: 404 }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
await assert.rejects(
|
||||
saveObservationWorkspaceLayoutProfile(current, {
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "revision mismatch" }), {
|
||||
status: 412,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof WorkspaceLayoutApiError &&
|
||||
error.conflict && error.status === 412 && error.message === "revision mismatch",
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue