feat(control-station): add atomic recorded-session playback

This commit is contained in:
DCCONSTRUCTIONS 2026-07-17 17:51:24 +03:00
parent 007ff9fff2
commit e94c64eebd
35 changed files with 8907 additions and 302 deletions

View File

@ -7,7 +7,9 @@
"": { "": {
"name": "@nodedc/mission-core-control-station", "name": "@nodedc/mission-core-control-station",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
@ -834,6 +836,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodedc/tokens": {
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"link": true "link": true

View File

@ -4,6 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
@ -11,6 +12,7 @@
"typecheck": "tsc -b --pretty false" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",

View File

@ -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.",
);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
AdminNavigationPanel, AdminNavigationPanel,
AppHeader, AppHeader,
@ -26,10 +26,25 @@ import {
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage"; import { LandingStage } from "./components/LandingStage";
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts"; import type { ViewerSettings } from "./core/runtime/contracts";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
} from "./core/runtime/latestAsyncCommitter";
import { useObservationLayout } from "./core/observation/useObservationLayout"; 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 { import {
rootById, rootById,
roots, roots,
@ -48,7 +63,10 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces"; import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css"; 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 }> = [ const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" }, { value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
@ -116,28 +134,117 @@ export default function App() {
const [activeRoot, setActiveRoot] = useState<RootId | null>(null); const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
const [sourceUrl, setSourceUrl] = useState(""); const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false);
const [sourceDraft, setSourceDraft] = useState(""); const [sourceDraft, setSourceDraft] = useState("");
const [sourceWindowOpen, setSourceWindowOpen] = useState(false); const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
const [displayWindowOpen, setDisplayWindowOpen] = useState(false); const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false); const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]); const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false); const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
const [layoutName, setLayoutName] = useState("Операторская сцена");
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings); const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = 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 currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView); const activeDefinition = workspaceById(workspace.activeView);
const rootWorkspaces = workspacesForRoot(activeRoot); const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null; const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? ""; const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = sourceUrl || automaticSourceUrl; const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl;
const observationLayout = useObservationLayout( const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
runtime.state?.observationSources ?? [], const recordedSessionAdmission = useRecordedSessionAdmission(
runtime.setObservationSourceActive, 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, (source) => source.id === observationLayout.focusedSourceId,
); );
const observationFullscreenActive = Boolean( const observationFullscreenActive = Boolean(
@ -148,12 +255,80 @@ export default function App() {
useEffect(() => { useEffect(() => {
const remote = runtime.state?.viewerSettings; const remote = runtime.state?.viewerSettings;
if (!remote) return; if (
setSceneSettings((current) => mergeViewerSettings(current, remote)); !remote ||
if (!displayWindowOpen) { workspaceLayoutProfile.profile ||
setDisplayDraft((current) => mergeViewerSettings(current, remote)); 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) => { const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
setSceneWindowOrder((current) => [ setSceneWindowOrder((current) => [
@ -166,7 +341,6 @@ export default function App() {
if (windowId === "sources") setSourceWindowOpen(false); if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false); if (windowId === "display") setDisplayWindowOpen(false);
if (windowId === "layers") setLayerInspectorOpen(false); if (windowId === "layers") setLayerInspectorOpen(false);
if (windowId === "layout") setLayoutWindowOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId)); setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []); }, []);
@ -204,8 +378,41 @@ export default function App() {
activateSceneWindow("sources"); 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 = () => { const openDisplay = () => {
setDisplayDraft(sceneSettings);
setDisplayWindowOpen(true); setDisplayWindowOpen(true);
activateSceneWindow("display"); activateSceneWindow("display");
}; };
@ -215,28 +422,90 @@ export default function App() {
activateSceneWindow("layers"); activateSceneWindow("layers");
}; };
const openLayout = () => { const beginRecordedReplaySwitch = useCallback(async () => {
setLayoutDraftSaved(false); // useObservationSessions calls this only after backend preparation has
setLayoutWindowOpen(true); // produced a validated launch descriptor. Keep the old scene mounted
activateSceneWindow("layout"); // 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 acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft)); setRecordedReplay(launch);
if (applied) setSceneSettings(displayDraft); setSourceUrl(launch.sourceUrl);
}; setSourceDraft(launch.sourceUrl);
setReplayTransitioning(false);
}, []);
const applyScenePatch = async (patch: Partial<SceneSettings>) => { const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => {
const previous = sceneSettings; if (outcome !== "accepted") setReplayTransitioning(false);
const next = { ...sceneSettings, ...patch }; }, []);
setSceneSettings(next);
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next)); useEffect(() => {
const applied = await runtime.updateViewerSettings(toViewerSettings(next)); // ObservationSessionSelect owns the cancellable request and unmounts when
if (!applied) { // the operator leaves the spatial workspace. Its unmount cannot safely
setSceneSettings(previous); // call back into this owner, so clear the transient blanking state here.
if (!displayWindowOpen) setDisplayDraft(previous); // 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 contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
const actions: ApplicationPanelUtilityAction[] = []; const actions: ApplicationPanelUtilityAction[] = [];
@ -251,25 +520,27 @@ export default function App() {
if (activeDefinition?.kind === "spatial") { if (activeDefinition?.kind === "spatial") {
actions.push( actions.push(
{
label: workspaceLayoutProfile.state === "saving"
? "Сохраняем компоновку"
: "Сохранить компоновку",
icon: "save",
disabled: workspaceLayoutProfile.state === "saving",
onClick: () => void saveWorkspaceLayout(),
},
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource }, { label: "Настроить визуальный движок", icon: "network", onClick: openSource },
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay }, { label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
{ label: "Открыть слои", icon: "list", onClick: openLayers }, { label: "Открыть слои", icon: "list", onClick: openLayers },
); );
} }
actions.push({
label: "Сохранить компоновку",
icon: "save",
onClick: openLayout,
});
return actions; return actions;
}, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]); }, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]);
const header = ( const header = (
<AppHeader <AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandLabel="NODEDC MISSION CORE" brandLabel="NODEDC MISSION CORE"
left={<span className="station-label">MISSION CORE</span>}
center={ center={
<> <>
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" /> <HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
@ -363,9 +634,29 @@ export default function App() {
{phaseLabel(runtime.state?.phase)} {phaseLabel(runtime.state?.phase)}
</StatusBadge> </StatusBadge>
) : activeDefinition.kind === "spatial" ? ( ) : activeDefinition.kind === "spatial" ? (
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}> <div className="observation-header-tools">
{effectiveSourceUrl ? "Источник назначен" : "Без источника"} <ObservationSessionSelect
</StatusBadge> 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> <StatusBadge tone="warning">Интерфейс готов</StatusBadge>
) )
@ -380,10 +671,16 @@ export default function App() {
) : ( ) : (
<WorkspaceRenderer <WorkspaceRenderer
definition={activeDefinition} definition={activeDefinition}
state={runtime.state} state={activeRuntimeState}
backendStatus={runtime.backendStatus} backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl} sourceUrl={effectiveSourceUrl}
recordedReplay={replayActive ? recordedReplay : null}
recordedSessionAdmission={recordedSessionAdmission}
sceneSettings={sceneSettings} sceneSettings={sceneSettings}
accumulationSeconds={displayDraft.accumulationSeconds}
onAccumulationChange={(accumulationSeconds) =>
stageDisplayPatch({ accumulationSeconds })}
onAccumulationCommit={flushDisplaySettings}
observationLayout={observationLayout} observationLayout={observationLayout}
navigation={{ navigation={{
openView, openView,
@ -419,6 +716,7 @@ export default function App() {
onClick={() => { onClick={() => {
setSourceDraft(""); setSourceDraft("");
setSourceUrl(""); setSourceUrl("");
setRecordedReplay(null);
}} }}
> >
Сбросить адрес Сбросить адрес
@ -429,6 +727,7 @@ export default function App() {
disabled={!sourceDraft.trim()} disabled={!sourceDraft.trim()}
onClick={() => { onClick={() => {
setSourceUrl(sourceDraft.trim()); setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
}} }}
> >
Применить адрес Применить адрес
@ -530,21 +829,6 @@ export default function App() {
data-scene-active={activeSceneWindow === "display" ? "true" : undefined} data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("display")} onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("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 <Inspector
defaultOpen={["points"]} defaultOpen={["points"]}
@ -556,6 +840,12 @@ export default function App() {
description: "Размер и способ окрашивания", description: "Размер и способ окрашивания",
content: ( content: (
<div className="inspector-control-stack"> <div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl <RangeControl
label="Размер точки" label="Размер точки"
value={displayDraft.pointSize} value={displayDraft.pointSize}
@ -563,34 +853,53 @@ export default function App() {
max={12} max={12}
step={0.5} step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`} formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))} onChange={(pointSize) => stageDisplayPatch({ pointSize })}
/> />
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Атрибут цвета"> <ControlRow label="Атрибут цвета">
<Select <Select
variant="split" variant="split"
label="Атрибут цвета" label="Атрибут цвета"
value={displayDraft.colorMode} value={displayDraft.colorMode}
options={colorModeOptions} options={colorModeOptions}
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))} disabled={replayActive}
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
/> />
</ControlRow> </ControlRow>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Палитра"> <ControlRow label="Палитра">
<Select <Select
variant="split" variant="split"
label="Палитра" label="Палитра"
value={displayDraft.palette} value={displayDraft.palette}
options={paletteOptions} options={displayPaletteOptions}
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))} onChange={(palette) => stageDisplayPatch({ palette })}
/> />
</ControlRow> </ControlRow>
</div>
{displayDraft.palette === "custom" ? ( {displayDraft.palette === "custom" ? (
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<ControlRow label="Цвет точек"> <ControlRow label="Цвет точек">
<ColorField <ColorField
label="Цвет точек" label="Цвет точек"
value={displayDraft.customColor} value={displayDraft.customColor}
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))} onChange={(customColor) => stageDisplayPatch({ customColor })}
/> />
</ControlRow> </ControlRow>
</div>
) : null}
{replayActive ? (
<p className="scene-window-note">
В архиве градиентные цвета уже записаны в RRD. Без переэкспорта
можно вернуть цвет записи или назначить один свой цвет.
</p>
) : null} ) : null}
</div> </div>
), ),
@ -601,6 +910,12 @@ export default function App() {
description: "История облака и траектория", description: "История облака и траектория",
content: ( content: (
<div className="inspector-control-stack"> <div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl <RangeControl
label="Окно накопления" label="Окно накопления"
value={displayDraft.accumulationSeconds} value={displayDraft.accumulationSeconds}
@ -608,14 +923,15 @@ export default function App() {
max={120} max={120}
step={1} step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)} formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))} onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
/> />
</div>
<div className="nodedc-field"> <div className="nodedc-field">
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span> <span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
<Checker <Checker
checked={displayDraft.showTrajectory} checked={displayDraft.showTrajectory}
label="Показывать траекторию" label="Показывать траекторию"
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))} onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
/> />
</div> </div>
</div> </div>
@ -627,7 +943,7 @@ export default function App() {
description: "Сетка, подписи и камеры", description: "Сетка, подписи и камеры",
content: ( content: (
<div className="inspector-control-stack"> <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"> <div className="nodedc-field">
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span> <span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} /> <Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
@ -726,59 +1042,6 @@ export default function App() {
/> />
</Window> </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>
</> </>
); );
} }

View File

@ -4,6 +4,11 @@ import { WorkspaceWindow } from "@nodedc/ui-react";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout"; import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts"; import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources"; import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const WINDOW_WIDTH = 336; const WINDOW_WIDTH = 336;
const WINDOW_HEIGHT = 210; const WINDOW_HEIGHT = 210;
@ -11,6 +16,18 @@ const WINDOW_GAP = 16;
const WINDOW_INSET = 18; const WINDOW_INSET = 18;
const TIMELINE_CLEARANCE = 64; 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( export function initialObservationWindowRect(
index: number, index: number,
count = 1, count = 1,
@ -59,6 +76,11 @@ export function FloatingObservationWindow({
onMaximizedChange, onMaximizedChange,
onActivate, onActivate,
onClose, onClose,
playback,
prepareRecorded,
recordedSessionGate,
recordedAdmissionKey,
onRecordedAdmissionChange,
}: { }: {
source: ObservationSourceDescriptor; source: ObservationSourceDescriptor;
index: number; index: number;
@ -71,6 +93,14 @@ export function FloatingObservationWindow({
onMaximizedChange: (maximized: boolean) => void; onMaximizedChange: (maximized: boolean) => void;
onActivate: () => void; onActivate: () => void;
onClose: () => 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); const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
@ -124,13 +154,29 @@ export function FloatingObservationWindow({
active={active} active={active}
zIndex={maximized ? 15 : active ? 9 : 7} zIndex={maximized ? 15 : active ? 9 : 7}
className="floating-observation-window" 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}`} closeLabel={`Закрыть ${source.label}`}
maximizeLabel={`Развернуть ${source.label}`} maximizeLabel={`Развернуть ${source.label}`}
restoreLabel={`Восстановить ${source.label}`} restoreLabel={`Восстановить ${source.label}`}
moveLabel={`Переместить ${source.label}`} moveLabel={`Переместить ${source.label}`}
resizeLabel={`Изменить размер ${source.label}`} resizeLabel={`Изменить размер ${source.label}`}
> >
<ObservationMedia source={source} /> <ObservationMedia
source={source}
playback={playback}
prepareRecorded={prepareRecorded}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</WorkspaceWindow> </WorkspaceWindow>
); );
} }

View File

@ -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>
);
}

View File

@ -6,6 +6,14 @@ import type {
ObservationSourceModality, ObservationSourceModality,
} from "../core/runtime/contracts"; } from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer"; import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const sourceIcon: Record<ObservationSourceModality, IconName> = { const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe", "point-cloud": "globe",
@ -29,7 +37,24 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
return availabilityCopy[source.availability]; 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 sourceSelected = source.activation ? source.activation.selected : true;
const deliveryActive = Boolean(source.delivery && sourceSelected); const deliveryActive = Boolean(source.delivery && sourceSelected);
@ -41,6 +66,32 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />; 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") { if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
return ( return (
<video <video
@ -193,7 +244,8 @@ export function ObservationSourcePicker({
</div> </div>
)} )}
<footer className="observation-source-menu__foot"> <footer className="observation-source-menu__foot">
Каналы приходят из активного device-плагина; сцена не знает модель оборудования. Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
модель оборудования.
</footer> </footer>
</Dropdown> </Dropdown>
); );

View File

@ -8,6 +8,15 @@ export function ObservationTimeline({
mode = "live-only", mode = "live-only",
seekable = false, seekable = false,
synchronization = "host-arrival-best-effort", synchronization = "host-arrival-best-effort",
rangeNs = null,
currentNs = null,
playing = false,
onSeek,
onPlayingChange,
onJumpToEnd,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
className = "", className = "",
}: { }: {
active: boolean; active: boolean;
@ -15,38 +24,151 @@ export function ObservationTimeline({
mode?: ObservationTimelineMode; mode?: ObservationTimelineMode;
seekable?: boolean; seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate"; 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; 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 = { const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу", "host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы", "shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация", "frame-accurate": "Покадровая синхронизация",
}[synchronization]; }[synchronization];
const accumulationValue = accumulationSeconds === undefined
? null
: normalizeAccumulationSeconds(accumulationSeconds);
return ( return (
<div <div
className={`observation-timeline ${className}`.trim()} className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined} data-active={active ? "true" : undefined}
data-mode={mode} data-mode={mode}
data-accumulation={accumulationValue !== null ? "true" : undefined}
>
{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)}
> >
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
<Icon name="chevron-left" /> <Icon name="chevron-left" />
</Button> </Button>
<Button size="compact" variant="secondary" disabled> <Button
{buffered ? "Воспроизвести" : "Только эфир"} size="compact"
variant="secondary"
disabled={!buffered || !onPlayingChange}
onClick={() => onPlayingChange?.(!playing)}
>
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
</Button> </Button>
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}> <input
<span style={{ width: active ? "100%" : "0%" }} /> className="observation-timeline__track"
</div> 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"> <div className="observation-timeline__meta">
<code>{active ? "LIVE" : "—:—:—.———"}</code> <code>
{buffered
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
: active ? "LIVE" : "—:—:—.———"}
</code>
<small> <small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`} {buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small> </small>
</div> </div>
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}> <button
ЭФИР type="button"
</span> className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
</div>
</div> </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")}`;
}

View File

@ -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

View File

@ -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];
}

View File

@ -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

View File

@ -6,12 +6,25 @@ import {
openObservationSource, openObservationSource,
shouldRestartObservationSource, shouldRestartObservationSource,
} from "./layoutPolicy"; } from "./layoutPolicy";
import {
normalizeObservationWindowRect,
projectObservationLayoutSnapshot,
validateObservationLayoutSnapshot,
validateObservationViewportSize,
type ObservationLayoutSnapshot,
type ObservationViewportSize,
type ObservationWindowRect,
} from "./workspaceLayout";
export interface ObservationWindowRect { export type { ObservationWindowRect } from "./workspaceLayout";
x: number;
y: number; export type ObservationLayoutPresentationMode = "preserve" | "reset";
width: number;
height: number; export function observationPresentationSourceAfterLayoutApply(
currentSourceId: string | null,
mode: ObservationLayoutPresentationMode,
): string | null {
return mode === "preserve" ? currentSourceId : null;
} }
export interface ObservationLayoutController { export interface ObservationLayoutController {
@ -20,6 +33,7 @@ export interface ObservationLayoutController {
activeFloatingSourceId: string | null; activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null; maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>; windowRects: Readonly<Record<string, ObservationWindowRect>>;
viewportSize: ObservationViewportSize | null;
pendingSourceIds: ReadonlySet<string>; pendingSourceIds: ReadonlySet<string>;
toggleSource: (sourceId: string) => Promise<boolean>; toggleSource: (sourceId: string) => Promise<boolean>;
hideSource: (sourceId: string) => Promise<boolean>; hideSource: (sourceId: string) => Promise<boolean>;
@ -27,6 +41,9 @@ export interface ObservationLayoutController {
activateFloatingSource: (sourceId: string) => void; activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void; setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void; setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
setViewportSize: (size: ObservationViewportSize) => void;
snapshot: () => ObservationLayoutSnapshot | null;
restore: (snapshot: ObservationLayoutSnapshot) => void;
} }
function canOpenByDefault(source: ObservationSourceDescriptor): boolean { function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
@ -50,6 +67,17 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
.join("|"); .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( export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[], sources: readonly ObservationSourceDescriptor[],
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>, setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
@ -58,45 +86,151 @@ export function useObservationLayout(
const visibleIdsRef = useRef<string[]>([]); const visibleIdsRef = useRef<string[]>([]);
const [pendingIds, setPendingIds] = useState<string[]>([]); const [pendingIds, setPendingIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null); 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 [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 initializedCatalog = useRef<string | null>(null);
const sourceIdList = sources.map((source) => source.id).sort(); const sourceIdList = sources.map((source) => source.id).sort();
const sourceIdsIdentity = sourceIdList.join("\u0000"); const sourceIdsIdentity = sourceIdList.join("\u0000");
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]); 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 identity = catalogIdentity(sources);
const commitVisibleIds = useCallback((next: string[]) => { const commitVisibleIds = useCallback((next: readonly string[]) => {
visibleIdsRef.current = next; const unique = [...new Set(next)];
setVisibleIds(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; if (!removedIds.length) return;
const removed = new Set(removedIds); const removed = new Set(removedIds);
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current); 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); setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
}, []); if (persist) persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
useEffect(() => { useEffect(() => {
const desired = desiredSnapshotRef.current;
if (desired) {
applyDesiredSnapshot(desired, "reset");
return;
}
const currentVisible = visibleIdsRef.current; const currentVisible = visibleIdsRef.current;
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId)); const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible); if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null); 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); setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
setWindowRects((current) => { const entries = Object.entries(windowRectsRef.current);
const entries = Object.entries(current);
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId)); const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries); if (nextEntries.length !== entries.length) commitWindowRects(Object.fromEntries(nextEntries));
}); }, [
}, [commitVisibleIds, sourceIds]); applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
commitWindowRects,
sourceIds,
]);
useEffect(() => { useEffect(() => {
if (!identity) { if (!identity) {
initializedCatalog.current = null; if (!desiredSnapshotRef.current) initializedCatalog.current = null;
return;
}
const desired = desiredSnapshotRef.current;
if (desired) {
initializedCatalog.current = identity;
return; return;
} }
if (initializedCatalog.current === identity) return; if (initializedCatalog.current === identity) return;
@ -109,8 +243,16 @@ export function useObservationLayout(
const firstFloating = sources.find( const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay, (source) => canOpenByDefault(source) && source.capabilities.overlay,
); );
setActiveFloatingSourceId(firstFloating?.id ?? null); commitActiveFloatingSourceId(firstFloating?.id ?? null);
}, [commitVisibleIds, identity, sources]); persistLiveLayout();
}, [
applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
identity,
persistLiveLayout,
sources,
]);
const selectedDeliveryIdentity = sources const selectedDeliveryIdentity = sources
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery) .filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
@ -124,6 +266,7 @@ export function useObservationLayout(
.join("|"); .join("|");
useEffect(() => { useEffect(() => {
if (restoredLayoutAuthorityRef.current) return;
const selected = sources.filter( const selected = sources.filter(
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery, (source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
); );
@ -135,8 +278,9 @@ export function useObservationLayout(
change.removedIds.forEach((sourceId) => removed.add(sourceId)); change.removedIds.forEach((sourceId) => removed.add(sourceId));
} }
commitVisibleIds(change.visibleIds); commitVisibleIds(change.visibleIds);
clearPresentation([...removed]); clearPresentation([...removed], false);
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]); persistLiveLayout();
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => { const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
const groupId = source.activation?.groupId; const groupId = source.activation?.groupId;
@ -160,11 +304,13 @@ export function useObservationLayout(
markPending(source, false); markPending(source, false);
} }
} }
restoredLayoutAuthorityRef.current = false;
const change = closeObservationSource(visibleIdsRef.current, sourceId); const change = closeObservationSource(visibleIdsRef.current, sourceId);
commitVisibleIds(change.visibleIds); commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds); clearPresentation(change.removedIds, false);
persistLiveLayout();
return true; return true;
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]); }, [clearPresentation, commitVisibleIds, markPending, persistLiveLayout, setSourceActive, sources]);
const toggleSource = useCallback(async (sourceId: string) => { const toggleSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId); const source = sources.find((candidate) => candidate.id === sourceId);
@ -180,30 +326,95 @@ export function useObservationLayout(
markPending(source, false); markPending(source, false);
} }
} }
restoredLayoutAuthorityRef.current = false;
const change = openObservationSource(visibleIdsRef.current, sourceId, sources); const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
commitVisibleIds(change.visibleIds); commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds); clearPresentation(change.removedIds, false);
setActiveFloatingSourceId(sourceId); commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
return true; return true;
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]); }, [
clearPresentation,
commitActiveFloatingSourceId,
commitVisibleIds,
hideSource,
markPending,
persistLiveLayout,
setSourceActive,
sources,
]);
const setFocusedSourceId = useCallback((sourceId: string | null) => { const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId); setFocusedSourceIdState(sourceId);
if (sourceId) setActiveFloatingSourceId(sourceId); if (sourceId) {
}, []); commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const activateFloatingSource = useCallback((sourceId: string) => { const activateFloatingSource = useCallback((sourceId: string) => {
setActiveFloatingSourceId(sourceId); commitActiveFloatingSourceId(sourceId);
}, []); persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => { const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
setMaximizedFloatingSourceId(maximized ? sourceId : null); setMaximizedFloatingSourceId(maximized ? sourceId : null);
if (maximized) setActiveFloatingSourceId(sourceId); if (maximized) {
}, []); commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => { 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 visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]); const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
@ -214,6 +425,7 @@ export function useObservationLayout(
activeFloatingSourceId, activeFloatingSourceId,
maximizedFloatingSourceId, maximizedFloatingSourceId,
windowRects, windowRects,
viewportSize,
pendingSourceIds, pendingSourceIds,
toggleSource, toggleSource,
hideSource, hideSource,
@ -221,5 +433,8 @@ export function useObservationLayout(
activateFloatingSource, activateFloatingSource,
setFloatingMaximized, setFloatingMaximized,
setWindowRect, setWindowRect,
setViewportSize,
snapshot,
restore,
}; };
} }

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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 };
}

View File

@ -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;
}

View File

@ -123,6 +123,16 @@ export type ObservationSourceDelivery =
kind: "video-url" | "image-url"; kind: "video-url" | "image-url";
url: string; url: string;
mediaType?: string | null; 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 { export interface ObservationSourceActivation {

View File

@ -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();
},
};
}

View File

@ -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;
}
}

View File

@ -191,6 +191,32 @@ i[data-availability="error"] {
background: #050608; 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 { .mse-fmp4-player {
position: relative; position: relative;
width: 100%; width: 100%;
@ -316,11 +342,8 @@ i[data-availability="error"] {
} }
.observation-timeline { .observation-timeline {
display: grid; display: block;
min-width: 0; min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
border: 0; border: 0;
border-radius: 0.9rem; border-radius: 0.9rem;
background: rgb(9 10 13 / 0.78); background: rgb(9 10 13 / 0.78);
@ -328,22 +351,73 @@ i[data-availability="error"] {
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
} }
.observation-timeline__track { .observation-timeline[data-accumulation="true"] {
height: 0.3rem; display: grid;
overflow: hidden; grid-template-columns: repeat(2, minmax(0, 1fr));
border-radius: 999px; align-items: center;
background: rgb(255 255 255 / 0.08); gap: 0.8rem;
} }
.observation-timeline__track span { .observation-timeline__playback {
display: block; display: grid;
height: 100%; min-width: 0;
border-radius: inherit; 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); 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; opacity: 0.46;
cursor: default;
} }
.observation-timeline__meta { .observation-timeline__meta {
@ -364,11 +438,17 @@ i[data-availability="error"] {
align-items: center; align-items: center;
gap: 0.38rem; gap: 0.38rem;
color: var(--nodedc-text-muted); color: var(--nodedc-text-muted);
border: 0;
background: transparent;
padding: 0.3rem 0.45rem; padding: 0.3rem 0.45rem;
font-size: 0.52rem; font-size: 0.52rem;
font-weight: 820; font-weight: 820;
} }
.observation-timeline__follow:not(:disabled) {
cursor: pointer;
}
.observation-timeline__follow::before { .observation-timeline__follow::before {
width: 0.4rem; width: 0.4rem;
height: 0.4rem; height: 0.4rem;
@ -514,11 +594,15 @@ i[data-availability="error"] {
left: 0.6rem; 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; grid-template-columns: auto minmax(0, 1fr) auto;
} }
.observation-timeline > .nodedc-button:first-child, .observation-timeline__playback > .nodedc-button:first-child,
.observation-timeline__meta { .observation-timeline__meta {
display: none; display: none;
} }

View File

@ -104,7 +104,6 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.station-label,
.control-station .nodedc-header__profile-button { .control-station .nodedc-header__profile-button {
display: none; display: none;
} }

View File

@ -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 { .api-dot {
width: 0.48rem; width: 0.48rem;
height: 0.48rem; height: 0.48rem;

View File

@ -45,17 +45,34 @@
min-height: 0; 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-viewport {
--rerun-tab-bar-height: 24px; --rerun-native-chrome-height: 72px;
overflow: hidden; overflow: hidden;
} }
/* Rerun WebViewer 0.34.1 draws its selected recording tab inside WASM. /* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
The native panels are already disabled; crop only that fixed tab row. */ 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 { .rerun-viewport__canvas {
top: calc(-1 * var(--rerun-tab-bar-height)); top: calc(-1 * var(--rerun-native-chrome-height));
bottom: auto; bottom: auto;
height: calc(100% + var(--rerun-tab-bar-height)); height: calc(100% + var(--rerun-native-chrome-height));
} }
.rerun-viewport__canvas canvas { .rerun-viewport__canvas canvas {
@ -65,8 +82,9 @@
} }
.rerun-viewport:is([data-status="loading"], [data-status="error"]) .rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas > div { .rerun-viewport__canvas {
visibility: hidden; visibility: hidden;
pointer-events: none;
} }
.rerun-viewport__notice { .rerun-viewport__notice {

View File

@ -14,6 +14,12 @@ import {
} from "../components/ObservationSources"; } from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline"; import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow"; 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 { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type { import type {
BackendStatus, BackendStatus,
@ -22,6 +28,10 @@ import type {
} from "../core/runtime/contracts"; } from "../core/runtime/contracts";
import { import {
RerunViewport, RerunViewport,
isRecordedPlaybackPresentationReady,
rerunPresentationStatus,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunSelection, type RerunSelection,
type RerunViewportStatus, type RerunViewportStatus,
} from "../components/RerunViewport"; } from "../components/RerunViewport";
@ -95,7 +105,12 @@ export interface WorkspaceRendererProps {
state: MissionRuntimeState | null; state: MissionRuntimeState | null;
backendStatus: BackendStatus; backendStatus: BackendStatus;
sourceUrl: string; sourceUrl: string;
recordedReplay: ObservationSessionReplayLaunch | null;
recordedSessionAdmission: RecordedSessionAdmissionController | null;
sceneSettings: SceneSettings; sceneSettings: SceneSettings;
accumulationSeconds: number;
onAccumulationChange: (value: number) => void;
onAccumulationCommit: () => void;
observationLayout: ObservationLayoutController; observationLayout: ObservationLayoutController;
navigation: WorkspaceNavigation; navigation: WorkspaceNavigation;
} }
@ -248,13 +263,27 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
function SpatialWorkspace({ function SpatialWorkspace({
state, state,
sourceUrl, sourceUrl,
recordedReplay,
recordedSessionAdmission,
sceneSettings, sceneSettings,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
observationLayout, observationLayout,
navigation, navigation,
}: WorkspaceRendererProps) { }: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle"); const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState(""); const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null); 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 streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined; const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics); const latency = pipelineLatency(metrics);
@ -277,28 +306,83 @@ function SpatialWorkspace({
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId); const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline; const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const presentedViewerStatus = rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => { const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status); setViewerStatus(status);
setViewerMessage(message ?? ""); 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 onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback(
(next: RerunPlaybackState | null) => setPlaybackState(next),
[],
);
const onPlaybackControllerChange = useCallback(
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
useEffect(() => { useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return; if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle"); setViewerStatus("idle");
setViewerMessage(""); setViewerMessage("");
setSelection(null); setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
}, [pointCloudVisible, sourceUrl]); }, [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 = { const viewerStatusLabel = {
idle: "Источник не назначен", idle: "Источник не назначен",
loading: "Подключение", loading: "Подключение",
ready: "Визуализатор готов", ready: "Визуализатор готов",
error: "Ошибка источника", error: "Ошибка источника",
}[viewerStatus]; }[presentedViewerStatus];
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral"; const viewerStatusTone = presentedViewerStatus === "ready"
? "success"
: presentedViewerStatus === "error"
? "danger"
: "neutral";
return ( return (
<div <div
@ -331,9 +415,21 @@ function SpatialWorkspace({
{sourceUrl.trim() && pointCloudVisible ? ( {sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport <RerunViewport
sourceUrl={sourceUrl} 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} onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange} onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}
onPlaybackControllerChange={onPlaybackControllerChange}
/> />
) : ( ) : (
<EmptySpatialStage settings={sceneSettings} /> <EmptySpatialStage settings={sceneSettings} />
@ -415,13 +511,26 @@ function SpatialWorkspace({
</div> </div>
) : null} ) : null}
{!pointCloudFocused && !floatingSourceMaximized ? ( {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline <ObservationTimeline
active={viewerStatus === "ready"} active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, 1 + visibleMediaSources.length)} sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
mode={timeline?.mode} mode={recordedSource && playbackState?.rangeNs
seekable={timeline?.seekable} ? "recorded"
: timeline?.mode}
seekable={recordedSource && playbackState?.rangeNs
? true
: timeline?.seekable}
synchronization={timeline?.synchronization} 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" className="scene-timeline"
/> />
) : null} ) : null}
@ -440,6 +549,14 @@ function SpatialWorkspace({
onMaximizedChange={(maximized) => onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)} observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)} 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={() => { onClose={() => {
if (observationLayout.pendingSourceIds.has(source.id)) return; if (observationLayout.pendingSourceIds.has(source.id)) return;
observationLayout.setFloatingMaximized(source.id, false); observationLayout.setFloatingMaximized(source.id, false);
@ -447,6 +564,28 @@ function SpatialWorkspace({
}} }}
/> />
)) : null} )) : 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>
<div className="spatial-contract-strip"> <div className="spatial-contract-strip">
@ -470,6 +609,10 @@ function CameraSourceCard({
onToggle, onToggle,
onFocus, onFocus,
onClose, onClose,
prepareRecorded,
recordedSessionGate,
recordedAdmissionKey,
onRecordedAdmissionChange,
}: { }: {
source: ObservationSourceDescriptor; source: ObservationSourceDescriptor;
focused: boolean; focused: boolean;
@ -479,6 +622,13 @@ function CameraSourceCard({
onToggle: () => void; onToggle: () => void;
onFocus: () => void; onFocus: () => void;
onClose: () => void; onClose: () => void;
prepareRecorded: boolean;
recordedSessionGate: RecordedAdmissionPhase;
recordedAdmissionKey: string | null;
onRecordedAdmissionChange: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) { }) {
const deliveryActive = Boolean( const deliveryActive = Boolean(
(source.delivery || source.previewUrl) && (source.delivery || source.previewUrl) &&
@ -542,7 +692,13 @@ function CameraSourceCard({
</div> </div>
</header> </header>
<div className="camera-slot__body"> <div className="camera-slot__body">
<ObservationMedia source={source} /> <ObservationMedia
source={source}
prepareRecorded={prepareRecorded}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</div> </div>
<footer> <footer>
<span>{source.description}</span> <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( const sources = (state?.observationSources ?? []).filter(
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth", (source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
); );
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null; const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
const timeline = state?.observationTimeline; const timeline = state?.observationTimeline;
const displayedSources = focusedSource ? [focusedSource] : sources; 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 ( return (
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}> <div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" /> <WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
@ -587,6 +766,10 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
observationLayout.setFocusedSourceId(null); observationLayout.setFocusedSourceId(null);
void observationLayout.hideSource(source.id); void observationLayout.hideSource(source.id);
}} }}
prepareRecorded={shouldPrepareRecordedSource(source.id)}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/> />
)) : ( )) : (
<div className="camera-grid__empty"> <div className="camera-grid__empty">
@ -596,7 +779,25 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
</div> </div>
)} )}
</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) => active={sources.some((source) =>
source.availability === "streaming" && source.availability === "streaming" &&
(!source.activation || source.activation.selected))} (!source.activation || source.activation.selected))}
@ -606,7 +807,7 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
seekable={timeline?.seekable} seekable={timeline?.seekable}
synchronization={timeline?.synchronization} synchronization={timeline?.synchronization}
className="camera-timeline" className="camera-timeline"
/> /> : null}
</div> </div>
); );
} }

View File

@ -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/,
);
});

View File

@ -1,6 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { after, before, test } from "node:test"; import { after, before, test } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite"; import { createServer } from "vite";
let server; let server;
@ -11,6 +13,20 @@ let shouldRestartObservationSource;
let consumeCameraLeaseRetry; let consumeCameraLeaseRetry;
let resetCameraLeaseRetryBudget; let resetCameraLeaseRetryBudget;
let initialObservationWindowRect; 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 () => { before(async () => {
server = await createServer({ server = await createServer({
@ -30,9 +46,32 @@ before(async () => {
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule( ({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
"/src/components/MseFmp4WebSocketPlayer.tsx", "/src/components/MseFmp4WebSocketPlayer.tsx",
)); ));
({ initialObservationWindowRect } = await server.ssrLoadModule( ({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
"/src/components/FloatingObservationWindow.tsx", "/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", () => { 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); 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 () => { after(async () => {
await server?.close(); await server?.close();
}); });

View File

@ -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,
);
});

View File

@ -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*\(/);
});

View File

@ -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,
);
});

View File

@ -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]);
});

View File

@ -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);
});

View File

@ -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 } });
});

View File

@ -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",
);
});