feat(archive): complete recorded session lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-19 01:07:21 +03:00
parent ffffee1879
commit 71c85e9894
22 changed files with 922 additions and 144 deletions
+20 -5
View File
@@ -40,7 +40,10 @@ import {
} from "./core/runtime/latestAsyncCommitter";
import { useObservationLayout } from "./core/observation/useObservationLayout";
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive";
import type {
ObservationSessionReplayLaunch,
ObservationSessionSummary,
} from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import {
@@ -136,6 +139,7 @@ export default function App() {
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false);
const [sourceDraft, setSourceDraft] = useState("");
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
@@ -447,6 +451,7 @@ export default function App() {
// accepting the already-ready archive.
setReplayTransitioning(true);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
await new Promise<void>((resolve) => {
@@ -454,11 +459,15 @@ export default function App() {
});
}, []);
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
const acceptRecordedReplay = useCallback((
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
setRecordedReplay(launch);
setRecordedReplayLabel(session.label);
setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl);
setReplayTransitioning(false);
@@ -475,6 +484,7 @@ export default function App() {
// switch, so it intentionally does not consult the acquisition guard.
setReplayTransitioning(false);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
}, []);
@@ -634,7 +644,11 @@ export default function App() {
<ApplicationPanel
key={activeDefinition.id}
eyebrow={activeDefinition.eyebrow}
title={activeDefinition.title}
title={
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
? `${activeDefinition.title}: ${recordedReplayLabel}`
: activeDefinition.title
}
description={activeDefinition.description}
expanded={workspace.contentExpanded}
onExpandedChange={workspace.setContentExpanded}
@@ -646,11 +660,10 @@ export default function App() {
) : activeDefinition.kind === "spatial" ? (
<div className="observation-header-tools">
<ObservationSessionSelect
limit={3}
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
/>
{layoutSaveNotice || workspaceLayoutProfile.error ? (
@@ -739,6 +752,7 @@ export default function App() {
setSourceDraft("");
setSourceUrl("");
setRecordedReplay(null);
setRecordedReplayLabel(null);
}}
>
Сбросить адрес
@@ -752,6 +766,7 @@ export default function App() {
if (sourceSwitchBlockedRef.current) return;
setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
setRecordedReplayLabel(null);
}}
>
Применить адрес
@@ -1,4 +1,5 @@
import { Dropdown, Icon } from "@nodedc/ui-react";
import { useState } from "react";
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
import {
type ObservationSessionReplayLaunch,
@@ -93,7 +94,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
}
export function ObservationSessionSelect({
limit = 3,
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
@@ -116,6 +117,7 @@ export function ObservationSessionSelect({
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
replayEnabled: blockedReason === null,
@@ -130,7 +132,7 @@ export function ObservationSessionSelect({
: "Сохранённые сессии";
const presentedTriggerCopy = blockedReason ?? triggerCopy;
return (
return <>
<Dropdown
className="observation-session-select"
placement="bottom-end"
@@ -184,33 +186,48 @@ export function ObservationSessionSelect({
<div className="observation-session-menu__list">
{sessions.items.map((session) => {
const pending = sessions.replayingSessionId === session.id;
const deleting = sessions.deletingSessionId === session.id;
const failed = sessions.failedSessionId === session.id;
const visualState = observationSessionVisualState(session, { pending, failed });
return (
<button
<div
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();
});
}}
className="observation-session-option"
data-deleting={deleting ? "true" : undefined}
>
<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)}
<button
type="button"
className="nodedc-dropdown-option observation-session-option__open"
disabled={pending || deleting || !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>
<span className="observation-session-option__state">
{observationSessionVisualLabel(visualState)}
</span>
</button>
<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">
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
</span>
</button>
<button
type="button"
className="observation-session-option__delete"
aria-label={`Удалить сохранённую сессию ${session.label}`}
disabled={pending || deleting}
onClick={() => setDeleteTarget(session)}
>
<Icon name="trash" size={15} />
</button>
</div>
);
})}
</div>
@@ -242,5 +259,29 @@ export function ObservationSessionSelect({
</div>
)}
</Dropdown>
);
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить сохранённую сессию?"
description={deleteTarget ? <>
<strong>{deleteTarget.label}</strong>
<p>
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
видеоматериалы будут удалены с этого сервера без возможности восстановления.
</p>
{sessions.error && sessions.deletingSessionId === null ? (
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
) : null}
</> : null}
confirmLabel="Удалить сессию"
pendingLabel="Удаление…"
danger
onClose={() => {
if (sessions.deletingSessionId === null) setDeleteTarget(null);
}}
onConfirm={async () => {
if (!deleteTarget) return;
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
}}
/>
</>;
}
@@ -135,13 +135,17 @@ export function ObservationMedia({
return (
<div className="observation-media__empty">
<Icon name={sourceIcon[source.modality]} size={20} />
<strong>{observationSourceStatusLabel(source)}</strong>
<strong>
{source.modality === "video" && source.activation && !source.activation.selected
? "Источник отключён"
: observationSourceStatusLabel(source)}
</strong>
<span>
{source.modality === "video"
? source.activation?.controllable
? source.activation.selected
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
: "Откройте канал — локальный адаптер подключит выбранную камеру"
: "Канал остановлен оператором; при необходимости его можно открыть снова"
: "Канал известен, browser-preview сейчас недоступен"
: source.description}
</span>
@@ -393,15 +393,27 @@ async function mountVerifiedRecordedEpoch(
signal: AbortSignal,
): Promise<() => void> {
const descriptor = epoch.descriptor;
if (
descriptor.mediaType === "video/mp4" ||
!globalThis.MediaSource ||
!MediaSource.isTypeSupported(descriptor.mediaType)
) {
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
throw new Error("Archived camera codec is not supported");
}
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
// A sealed fragmented-MP4 generation is already complete and immutable.
// Present it as one Blob so Chromium can index and seek the whole archive
// without retaining the same 166+ MiB epoch in a quota-limited MSE
// SourceBuffer. Every constituent byte was fetched and SHA-256 verified
// before this boundary, and Blob preserves their canonical order.
const payload = new Blob([epoch.init, ...epoch.segments], {
type: descriptor.mediaType,
});
const expectedByteLength = epoch.init.byteLength + epoch.segments.reduce(
(total, segment) => total + segment.byteLength,
0,
);
if (payload.size !== expectedByteLength) {
throw new Error("Archived camera Blob is incomplete");
}
const objectUrl = URL.createObjectURL(payload);
const cleanup = () => {
video.pause();
video.removeAttribute("src");
@@ -409,39 +421,8 @@ async function mountVerifiedRecordedEpoch(
URL.revokeObjectURL(objectUrl);
};
video.src = objectUrl;
video.load();
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,
@@ -1,9 +1,11 @@
// 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.
// bounded. One real accepted archive is ~163 MiB for a single camera, so the
// old 128 MiB laboratory ceiling rejected a valid sealed generation before the
// first manifest request. OPFS-backed sealed generations remain the scaling
// path beyond this in-memory policy.
export const MAX_RECORDED_CAMERA_SOURCES = 16;
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 256 * 1024 * 1024;
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
@@ -1110,6 +1110,46 @@ export async function fetchObservationSessionCatalog({
return decodeObservationSessionCatalog(body);
}
export async function deleteObservationSession(
sessionId: string,
{
signal,
fetcher = globalThis.fetch,
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
): Promise<void> {
if (!SAFE_ID.test(sessionId)) {
throw new ObservationSessionContractError(
"Идентификатор удаляемой сессии имеет недопустимый формат.",
);
}
let response: Response;
try {
response = await fetcher(
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { Accept: "application/json" },
signal,
},
);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservationSessionApiError("Не удалось удалить сохранённую сессию.");
}
const body = await responseBody(response);
if (!response.ok || response.status !== 204) {
throw new ObservationSessionApiError(
apiErrorMessage(body, `Удаление сессии вернуло HTTP ${response.status}.`),
response.status,
);
}
if (body !== undefined) {
throw new ObservationSessionContractError(
"Сервер вернул данные после подтверждённого удаления сессии.",
);
}
}
export async function replayObservationSession(
sessionId: string,
{
@@ -27,6 +27,18 @@ export function observationPresentationSourceAfterLayoutApply(
return mode === "preserve" ? currentSourceId : null;
}
export function visibleSourceIdsAfterRecordedCatalogActivation(
currentIds: readonly string[],
sources: readonly ObservationSourceDescriptor[],
): string[] {
let visibleIds = [...currentIds];
for (const source of sources) {
if (source.transport !== "recording" || !canOpenByDefault(source)) continue;
visibleIds = openObservationSource(visibleIds, source.id, sources).visibleIds;
}
return visibleIds;
}
export interface ObservationLayoutController {
visibleSourceIds: ReadonlySet<string>;
focusedSourceId: string | null;
@@ -230,7 +242,25 @@ export function useObservationLayout(
}
const desired = desiredSnapshotRef.current;
if (desired) {
const previousIdentity = initializedCatalog.current;
initializedCatalog.current = identity;
if (
previousIdentity !== identity &&
sources.some((source) => source.transport === "recording")
) {
const nextVisible = visibleSourceIdsAfterRecordedCatalogActivation(
visibleIdsRef.current,
sources,
);
commitVisibleIds(nextVisible);
const firstRecordedOverlay = sources.find((source) => (
source.transport === "recording" &&
source.capabilities.overlay &&
nextVisible.includes(source.id)
));
commitActiveFloatingSourceId(firstRecordedOverlay?.id ?? null);
persistLiveLayout();
}
return;
}
if (initializedCatalog.current === identity) return;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
decodeObservationSessionPreparation,
deleteObservationSession,
fetchObservationSessionCatalog,
fetchObservationSessionPreparation,
replayObservationSession,
@@ -32,9 +33,11 @@ export interface ObservationSessionsController {
preparation: ObservationSessionPreparation | null;
replayProgress: ObservationReplayProgress | null;
failedSessionId: string | null;
deletingSessionId: string | null;
refresh: () => Promise<boolean>;
replay: (sessionId: string) => Promise<boolean>;
retry: () => Promise<boolean>;
remove: (sessionId: string) => Promise<boolean>;
}
export interface ObservationReplayAttempt {
@@ -360,7 +363,7 @@ export function clearObservationReplayPreparation(
}
export function useObservationSessions({
limit = 3,
limit = 100,
replayEnabled = true,
onReplayBegin,
onReplayAccepted,
@@ -389,12 +392,12 @@ export function useObservationSessions({
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
const mounted = useRef(true);
const catalogSequence = useRef(0);
const reattachStarted = useRef(false);
const replayEnabledRef = useRef(replayEnabled);
replayEnabledRef.current = replayEnabled;
const preparationPollSequence = useRef(0);
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
if (replayCoordinator.current === null) {
replayCoordinator.current = createObservationReplayCoordinator();
@@ -409,26 +412,30 @@ export function useObservationSessions({
setReplayingSessionId(null);
setReplayProgress(null);
}, [replayEnabled]);
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
const safeLimit = Number.isFinite(limit)
? Math.min(100, Math.max(1, Math.floor(limit)))
: 100;
const refresh = useCallback(async () => {
const loadCatalog = useCallback(async (foreground: boolean) => {
const sequence = ++catalogSequence.current;
setState("loading");
setError(null);
if (foreground) setState("loading");
try {
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
if (!mounted.current || sequence !== catalogSequence.current) return false;
setItems(catalog.items.slice(0, safeLimit));
setState("ready");
setError(null);
return true;
} catch (loadError) {
if (!mounted.current || sequence !== catalogSequence.current) return false;
setState("error");
if (foreground) setState("error");
setError(errorMessage(loadError));
return false;
}
}, [safeLimit]);
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
useEffect(() => {
mounted.current = true;
void refresh();
@@ -439,44 +446,21 @@ export function useObservationSessions({
};
}, [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();
if (state !== "ready") return;
let disposed = false;
let timer = 0;
const poll = async () => {
if (disposed) return;
await loadCatalog(false);
if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
};
}, [catalogHasActivePreparation, items, safeLimit, state]);
timer = window.setTimeout(() => void poll(), 2_000);
return () => {
disposed = true;
window.clearTimeout(timer);
};
}, [loadCatalog, state]);
const executeReplay = useCallback(async (
session: ObservationSessionSummary,
@@ -606,6 +590,24 @@ export function useObservationSessions({
return replay(failedSessionId);
}, [failedSessionId, replay]);
const remove = useCallback(async (sessionId: string) => {
if (deletingSessionId !== null || replayingSessionId === sessionId) return false;
setDeletingSessionId(sessionId);
setError(null);
try {
await deleteObservationSession(sessionId);
if (!mounted.current) return false;
setItems((current) => current.filter((item) => item.id !== sessionId));
setFailedSessionId((current) => current === sessionId ? null : current);
return true;
} catch (deleteError) {
if (mounted.current) setError(errorMessage(deleteError));
return false;
} finally {
if (mounted.current) setDeletingSessionId(null);
}
}, [deletingSessionId, replayingSessionId]);
return {
items,
state,
@@ -614,8 +616,10 @@ export function useObservationSessions({
preparation,
replayProgress,
failedSessionId,
deletingSessionId,
refresh,
replay,
retry,
remove,
};
}
@@ -117,9 +117,42 @@
}
.observation-session-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 2.35rem;
align-items: stretch;
gap: 0.2rem;
}
.observation-session-option__open {
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-session-option__delete {
display: grid;
min-width: 2.35rem;
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
color: var(--nodedc-text-muted);
cursor: pointer;
}
.observation-session-option__delete:hover:not(:disabled),
.observation-session-option__delete:focus-visible {
background: transparent;
color: rgb(var(--nodedc-danger-rgb));
outline: none;
}
.observation-session-option__delete:disabled,
.observation-session-option[data-deleting="true"] {
opacity: 0.55;
cursor: default;
}
.observation-session-option i {
display: block;
width: 0.45rem;
@@ -217,6 +250,10 @@
cursor: pointer;
}
.observation-session-delete-error {
color: rgb(var(--nodedc-danger-rgb));
}
@media (max-width: 860px) {
.workspace-layout-feedback {
display: none;
@@ -323,11 +323,19 @@ function SpatialWorkspace({
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const presentedViewerStatus = rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const intentionalSourceEnd = !recordedSource && [
"awaiting_external_stop",
"stopping",
"finalizing",
"completed",
].includes(state?.acquisition?.state ?? "");
const presentedViewerStatus = intentionalSourceEnd
? "idle"
: rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
@@ -389,7 +397,7 @@ function SpatialWorkspace({
}, [observationLayout.setViewportSize]);
const viewerStatusLabel = {
idle: "Источник не назначен",
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
@@ -500,7 +508,7 @@ function SpatialWorkspace({
>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
{viewerMessage ? <small>{viewerMessage}</small> : null}
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
</div>
<div
@@ -679,6 +687,7 @@ function CameraSourceCard({
: selectedPeer
? "Переключить"
: "Открыть канал";
const sourceDisconnected = Boolean(source.activation && !source.activation.selected && visible);
return (
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
@@ -686,8 +695,11 @@ function CameraSourceCard({
<span>{source.label}</span>
<div className="camera-slot__head-actions">
<span className="camera-slot__status">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
<i
data-availability={sourceDisconnected ? "unverified" : source.availability}
aria-hidden="true"
/>
{sourceDisconnected ? "Источник отключён" : observationSourceStatusLabel(source)}
</span>
{source.activation && !focused ? (
<button
@@ -9,6 +9,7 @@ let decodeObservationSessionCatalog;
let decodeObservationSessionReplay;
let decodeObservationSessionPreparation;
let fetchObservationSessionCatalog;
let deleteObservationSession;
let replayObservationSession;
let fetchObservationSessionPreparation;
let cancelObservationSessionPreparation;
@@ -34,6 +35,7 @@ before(async () => {
decodeObservationSessionReplay,
decodeObservationSessionPreparation,
fetchObservationSessionCatalog,
deleteObservationSession,
replayObservationSession,
fetchObservationSessionPreparation,
cancelObservationSessionPreparation,
@@ -136,6 +138,39 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
assert.equal("path" in catalog.items[0], false);
});
test("opened archive is named in the scene header and trash hover has no pill", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const styles = await readFile(
new URL("../src/styles/observation-sessions.css", import.meta.url),
"utf8",
);
const spatialControls = await readFile(
new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
import.meta.url,
),
"utf8",
);
const acquisitionPipeline = await readFile(
new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
import.meta.url,
),
"utf8",
);
assert.match(appSource, /setRecordedReplayLabel\(session\.label\)/);
assert.match(appSource, /`\$\{activeDefinition\.title\}: \$\{recordedReplayLabel\}`/);
const deleteStyle = styles.slice(
styles.indexOf(".observation-session-option__delete"),
styles.indexOf(".observation-session-option__delete:disabled"),
);
assert.match(deleteStyle, /background:\s*transparent/);
assert.doesNotMatch(deleteStyle, /danger-rgb\) \/ 0\.12/);
assert.doesNotMatch(spatialControls, /Индикатор постоянно зелёный/);
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
});
test("session catalog exposes authoritative background preparation state", () => {
const catalog = decodeObservationSessionCatalog({
items: [session({
@@ -302,6 +337,37 @@ test("catalog API preserves HTTP detail without accepting a malformed success bo
);
});
test("delete API uses one opaque same-origin target and requires an empty 204", async () => {
const calls = [];
await deleteObservationSession("session-20260716T205632Z", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(null, { status: 204 });
},
});
assert.equal(calls.length, 1);
assert.equal(
calls[0].input,
"/api/v1/observation-sessions/session-20260716T205632Z",
);
assert.equal(calls[0].init.method, "DELETE");
await assert.rejects(
deleteObservationSession("../private", { fetcher: async () => new Response(null) }),
ObservationSessionContractError,
);
await assert.rejects(
deleteObservationSession("session-20260716T205632Z", {
fetcher: async () => new Response(JSON.stringify({ detail: "recording is open" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
}),
(error) => error instanceof ObservationSessionApiError &&
error.status === 409 && error.message === "recording is open",
);
});
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
const calls = [];
const launch = await replayObservationSession("session-20260716T205632Z", {
@@ -78,7 +78,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
);
});
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
test("camera admission enforces independent 16/256/512 MiB limits before fetching", () => {
const {
MAX_RECORDED_CAMERA_SOURCES,
MAX_RECORDED_MEDIA_SOURCE_BYTES,
@@ -86,10 +86,10 @@ test("camera admission enforces independent 16/128/512 MiB limits before fetchin
recordedCameraDescriptorPreflight,
} = admission;
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 256 * 1024 * 1024);
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
assert.equal(recordedCameraDescriptorPreflight(
Array.from({ length: 4 }, (_, index) => ({
Array.from({ length: 2 }, (_, index) => ({
id: `camera.${index}`,
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
})),
@@ -14,6 +14,7 @@ let saveObservationWorkspaceLayoutProfile;
let WorkspaceLayoutApiError;
let WorkspaceLayoutContractError;
let observationPresentationSourceAfterLayoutApply;
let visibleSourceIdsAfterRecordedCatalogActivation;
before(async () => {
server = await createServer({
@@ -31,9 +32,10 @@ before(async () => {
WorkspaceLayoutApiError,
WorkspaceLayoutContractError,
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
"/src/core/observation/useObservationLayout.ts",
));
({
observationPresentationSourceAfterLayoutApply,
visibleSourceIdsAfterRecordedCatalogActivation,
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
});
after(async () => {
@@ -181,6 +183,36 @@ test("fullscreen presentation survives the viewport resize it causes", () => {
);
});
test("opening a recorded catalog reveals its sealed cameras beside the point cloud", () => {
const source = (id, modality, transport = "recording") => ({
id,
modality,
transport,
availability: "available",
previewUrl: null,
delivery: modality === "video" ? { kind: "recorded-fmp4-manifest" } : null,
activation: null,
capabilities: {
defaultVisible: true,
overlay: modality === "video",
},
});
const sources = [
source("recorded.spatial.primary", "point-cloud"),
source("recorded.camera.left", "video"),
source("recorded.camera.right", "video"),
source("live.camera", "video", "websocket"),
];
assert.deepEqual(
visibleSourceIdsAfterRecordedCatalogActivation(
["recorded.spatial.primary"],
sources,
),
["recorded.spatial.primary", "recorded.camera.left", "recorded.camera.right"],
);
});
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
const calls = [];
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());