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
@@ -4,6 +4,11 @@ import { WorkspaceWindow } from "@nodedc/ui-react";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const WINDOW_WIDTH = 336;
const WINDOW_HEIGHT = 210;
@@ -11,6 +16,18 @@ const WINDOW_GAP = 16;
const WINDOW_INSET = 18;
const TIMELINE_CLEARANCE = 64;
type ClosestTarget = { closest: (selectors: string) => unknown };
export function shouldCaptureWorkspacePointer(
button: number,
target: ClosestTarget | null,
): boolean {
if (button !== 0 || !target) return false;
if (target.closest(".nodedc-workspace-window__resize")) return true;
if (!target.closest(".nodedc-workspace-window__head")) return false;
return !target.closest("button, input, select, textarea, a");
}
export function initialObservationWindowRect(
index: number,
count = 1,
@@ -59,6 +76,11 @@ export function FloatingObservationWindow({
onMaximizedChange,
onActivate,
onClose,
playback,
prepareRecorded,
recordedSessionGate,
recordedAdmissionKey,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
index: number;
@@ -71,6 +93,14 @@ export function FloatingObservationWindow({
onMaximizedChange: (maximized: boolean) => void;
onActivate: () => void;
onClose: () => void;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
@@ -124,13 +154,29 @@ export function FloatingObservationWindow({
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className="floating-observation-window"
onPointerDownCapture={(event) => {
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture can fail if the browser ended the pointer between
// dispatch and capture. The donor's window listeners remain fallback.
}
}}
closeLabel={`Закрыть ${source.label}`}
maximizeLabel={`Развернуть ${source.label}`}
restoreLabel={`Восстановить ${source.label}`}
moveLabel={`Переместить ${source.label}`}
resizeLabel={`Изменить размер ${source.label}`}
>
<ObservationMedia source={source} />
<ObservationMedia
source={source}
playback={playback}
prepareRecorded={prepareRecorded}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</WorkspaceWindow>
);
}
@@ -0,0 +1,239 @@
import { Dropdown, Icon } from "@nodedc/ui-react";
import {
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "../core/observation/sessionArchive";
import { useObservationSessions } from "../core/observation/useObservationSessions";
import type {
ObservationPreparationPhase,
ObservationReplayOutcome,
} from "../core/observation/useObservationSessions";
import "../styles/observation-sessions.css";
type SessionVisualState = "ready" | "processing" | "error";
export function observationSessionVisualState(
session: ObservationSessionSummary,
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
): SessionVisualState {
if (failed) return "error";
if (pending) return "processing";
if (session.preparation !== null) {
if (["queued", "validating", "exporting", "finalizing"].includes(
session.preparation.state,
)) return "processing";
if (session.preparation.state === "ready" && session.replayable) return "ready";
return "error";
}
return session.status === "recording" ? "processing" : "error";
}
export function observationSessionVisualLabel(state: SessionVisualState): string {
if (state === "ready") return "Готово";
if (state === "processing") return "Обработка";
return "Ошибка";
}
const modalityLabel: Record<string, string> = {
"point-cloud": "облако точек",
pose: "траектория",
trajectory: "траектория",
video: "видео",
image: "изображения",
depth: "глубина",
telemetry: "телеметрия",
};
const preparationLabel: Record<ObservationPreparationPhase, string> = {
requesting: "Запрашиваем подготовку",
queued: "В очереди",
validating: "Проверяем запись",
exporting: "Готовим облако точек",
finalizing: "Завершаем подготовку",
failed: "Подготовка не выполнена",
cancelled: "Подготовка отменена",
};
function progressCopy(
phase: ObservationPreparationPhase,
progress: number | null,
): string {
const label = preparationLabel[phase];
return progress === null ? label : `${label} · ${Math.round(progress * 100)}%`;
}
function formatStartedAt(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function sessionDescription(session: ObservationSessionSummary): string {
const modalities = session.modalities.length
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
: "каналы не зафиксированы";
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
}
export function ObservationSessionSelect({
limit = 3,
disabled = false,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
}) {
const sessions = useObservationSessions({
limit,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
});
const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase, sessions.replayProgress.progress)
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
return (
<Dropdown
className="observation-session-select"
placement="bottom-end"
width={390}
offset={10}
disabled={disabled}
surfaceRole="dialog"
surfaceClassName="observation-session-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="observation-session-select__trigger"
data-active={open ? "true" : undefined}
aria-label="Открыть сохранённые сессии наблюдения"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
disabled={disabled}
onClick={toggle}
>
<Icon name="database" size={15} />
<span>{triggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} />
</button>
)}
>
{({ close }) => (
<div className="observation-session-menu__content">
<header className="observation-session-menu__head">
<div>
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
<strong>Сохранённые сессии</strong>
</div>
<button
type="button"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
</button>
</header>
{sessions.items.length > 0 ? (
<div className="observation-session-menu__list">
{sessions.items.map((session) => {
const pending = sessions.replayingSessionId === session.id;
const failed = sessions.failedSessionId === session.id;
const visualState = observationSessionVisualState(session, { pending, failed });
return (
<button
key={session.id}
type="button"
className="nodedc-dropdown-option observation-session-option"
disabled={pending || !session.replayable}
onClick={() => {
void sessions.replay(session.id).then((accepted) => {
if (accepted) close();
});
}}
>
<span className="nodedc-dropdown-option__icon">
<i data-session-visual-state={visualState} aria-hidden="true" />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">{session.label}</span>
<span className="nodedc-dropdown-option__description">
{sessionDescription(session)}
</span>
</span>
<span className="observation-session-option__state">
{observationSessionVisualLabel(visualState)}
</span>
</button>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-menu__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-menu__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
</div>
)}
{sessions.error && sessions.items.length > 0 ? (
<footer className="observation-session-menu__error" role="alert">
<Icon name="alert" size={13} />
<span>{sessions.error}</span>
{sessions.failedSessionId ? (
<button type="button" onClick={() => void sessions.retry()}>
Повторить
</button>
) : null}
</footer>
) : null}
</div>
)}
</Dropdown>
);
}
@@ -6,6 +6,14 @@ import type {
ObservationSourceModality,
} from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
@@ -29,7 +37,24 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
return availabilityCopy[source.availability];
}
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
export function ObservationMedia({
source,
playback,
prepareRecorded = true,
recordedSessionGate = "ready",
recordedAdmissionKey = null,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const sourceSelected = source.activation ? source.activation.selected : true;
const deliveryActive = Boolean(source.delivery && sourceSelected);
@@ -41,6 +66,32 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
}
if (
deliveryActive &&
source.delivery?.kind === "recorded-fmp4-manifest" &&
source.modality === "video"
) {
if (!prepareRecorded) {
return (
<div className="observation-media__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Ожидает подготовки</strong>
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
</div>
);
}
return (
<RecordedFmp4Player
source={source}
playback={playback}
prepare
sessionGate={recordedSessionGate}
admissionKey={recordedAdmissionKey}
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
/>
);
}
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
return (
<video
@@ -193,7 +244,8 @@ export function ObservationSourcePicker({
</div>
)}
<footer className="observation-source-menu__foot">
Каналы приходят из активного device-плагина; сцена не знает модель оборудования.
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
модель оборудования.
</footer>
</Dropdown>
);
@@ -8,6 +8,15 @@ export function ObservationTimeline({
mode = "live-only",
seekable = false,
synchronization = "host-arrival-best-effort",
rangeNs = null,
currentNs = null,
playing = false,
onSeek,
onPlayingChange,
onJumpToEnd,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
className = "",
}: {
active: boolean;
@@ -15,38 +24,151 @@ export function ObservationTimeline({
mode?: ObservationTimelineMode;
seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
rangeNs?: { min: number; max: number } | null;
currentNs?: number | null;
playing?: boolean;
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
accumulationSeconds?: number;
onAccumulationChange?: (value: number) => void;
onAccumulationCommit?: () => void;
className?: string;
}) {
const buffered = seekable && (mode === "buffered" || mode === "recorded");
const playbackRange = normalizeTimelineRange(rangeNs);
const buffered = Boolean(
seekable &&
(mode === "buffered" || mode === "recorded") &&
playbackRange,
);
const elapsedSeconds = playbackRange
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
: 0;
const durationSeconds = playbackRange
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
: 0;
const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация",
}[synchronization];
const accumulationValue = accumulationSeconds === undefined
? null
: normalizeAccumulationSeconds(accumulationSeconds);
return (
<div
className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined}
data-mode={mode}
data-accumulation={accumulationValue !== null ? "true" : undefined}
>
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled>
{buffered ? "Воспроизвести" : "Только эфир"}
</Button>
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}>
<span style={{ width: active ? "100%" : "0%" }} />
{accumulationValue !== null ? (
<div className="observation-timeline__accumulation">
<span>Накопление</span>
<input
className="observation-timeline__track"
type="range"
min={0}
max={120}
step={1}
value={accumulationValue}
aria-label="Окно накопления облака точек"
aria-valuetext={formatAccumulationDuration(accumulationValue)}
onChange={(event) =>
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
onPointerUp={onAccumulationCommit}
onTouchEnd={onAccumulationCommit}
onKeyUp={onAccumulationCommit}
onBlur={onAccumulationCommit}
/>
<code>{formatAccumulationDuration(accumulationValue)}</code>
</div>
) : null}
<div className="observation-timeline__playback">
<Button
size="compact"
variant="ghost"
disabled={!buffered || !onSeek}
aria-label="Перейти к началу"
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
>
<Icon name="chevron-left" />
</Button>
<Button
size="compact"
variant="secondary"
disabled={!buffered || !onPlayingChange}
onClick={() => onPlayingChange?.(!playing)}
>
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
</Button>
<input
className="observation-timeline__track"
type="range"
min={0}
max={Math.max(durationSeconds, 0.001)}
step={0.01}
value={Math.min(elapsedSeconds, durationSeconds)}
disabled={!buffered || !onSeek}
aria-label="Позиция воспроизведения"
onChange={(event) => {
if (!playbackRange) return;
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
}}
/>
<div className="observation-timeline__meta">
<code>
{buffered
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
: active ? "LIVE" : "—:—:—.———"}
</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
</div>
<div className="observation-timeline__meta">
<code>{active ? "LIVE" : "—:—:—.———"}</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}>
ЭФИР
</span>
</div>
);
}
export function normalizeAccumulationSeconds(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(120, Math.max(0, Math.round(value)));
}
export function formatAccumulationDuration(value: number): string {
const seconds = normalizeAccumulationSeconds(value);
return seconds === 0 ? "Кадр" : `${seconds} с`;
}
export function normalizeTimelineRange(
range: { min: number; max: number } | null | undefined,
): { min: number; max: number } | null {
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
if (range.max <= range.min) return null;
return range;
}
export function timelineOffsetSeconds(
range: { min: number; max: number },
currentNs: number,
): number {
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
return Math.max(0, (clamped - range.min) / 1_000_000_000);
}
export function formatTimelineDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
const wholeMinutes = Math.floor(seconds / 60);
const remaining = seconds - wholeMinutes * 60;
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
}
@@ -0,0 +1,735 @@
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex } from "@noble/hashes/utils.js";
import { useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservationRecordedMediaManifest,
ObservationSessionContractError,
type ObservationRecordedMediaEpoch,
type ObservationRecordedMediaManifest,
type ObservationRecordedMediaSource,
type ObservationSessionFetch,
} from "../core/observation/sessionArchive";
import {
MAX_RECORDED_MEDIA_SOURCE_BYTES,
type RecordedAdmissionPhase,
type RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
export interface RecordedObservationPlayback {
currentSeconds: number;
playing: boolean;
}
export interface RecordedMediaLoadProgress {
loadedBytes: number;
totalBytes: number;
loadedParts: number;
totalParts: number;
}
interface VerifiedRecordedMediaEpoch {
descriptor: ObservationRecordedMediaEpoch;
readonly init: ArrayBuffer;
readonly segments: readonly ArrayBuffer[];
}
export interface VerifiedRecordedMediaArchive {
manifest: ObservationRecordedMediaManifest;
epochs: readonly VerifiedRecordedMediaEpoch[];
byteLength: number;
}
interface RecordedMediaBinaryDescriptor {
url: string;
byteLength: number;
sha256: string;
accept: string;
}
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
let recordedMediaWorkerGeneration = 0;
export function recordedMediaPresentationState(
state: "loading" | "ready" | "error",
readyGeneration: string | null,
selectedGeneration: string | null,
waitingForEpoch: boolean,
sessionGate: RecordedAdmissionPhase = "ready",
): RecordedMediaPresentationState {
if (state === "error" || sessionGate === "error") return "error";
if (sessionGate !== "ready") return "loading";
if (waitingForEpoch) return "waiting";
return state === "ready" &&
selectedGeneration !== null &&
readyGeneration === selectedGeneration
? "ready"
: "loading";
}
export function selectRecordedMediaEpoch(
epochs: readonly ObservationRecordedMediaEpoch[],
currentSeconds: number,
): ObservationRecordedMediaEpoch | null {
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
let selected: ObservationRecordedMediaEpoch | null = null;
for (const epoch of epochs) {
if (epoch.timelineStartSeconds > currentSeconds) break;
selected = epoch;
}
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
}
export function recordedMediaSeekableCoverage(
durationSeconds: number,
seekableEndSeconds: number,
declaredDurationSeconds: number,
toleranceSeconds = RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
seekableStartSeconds = 0,
): boolean {
return (
Number.isFinite(durationSeconds) &&
Number.isFinite(seekableEndSeconds) &&
Number.isFinite(declaredDurationSeconds) &&
Number.isFinite(seekableStartSeconds) &&
declaredDurationSeconds > 0 &&
toleranceSeconds >= 0 &&
durationSeconds + toleranceSeconds >= declaredDurationSeconds &&
seekableStartSeconds <= toleranceSeconds &&
seekableStartSeconds >= -toleranceSeconds &&
seekableEndSeconds + toleranceSeconds >= declaredDurationSeconds
);
}
export function recordedMediaLocalTime(
epochStartSeconds: number,
currentSeconds: number,
durationSeconds = Number.POSITIVE_INFINITY,
): number {
if (!Number.isFinite(epochStartSeconds) || !Number.isFinite(currentSeconds)) return 0;
const local = Math.max(0, currentSeconds - epochStartSeconds);
return Number.isFinite(durationSeconds)
? Math.min(local, Math.max(0, durationSeconds))
: local;
}
function sourceContract(source: ObservationSourceDescriptor): ObservationRecordedMediaSource | null {
const delivery = source.delivery;
if (!delivery || delivery.kind !== "recorded-fmp4-manifest") return null;
return {
id: source.id,
label: source.label,
modality: "video",
manifestUrl: delivery.url,
manifestGenerationSha256: delivery.manifestGenerationSha256,
byteLength: delivery.byteLength,
mediaType: delivery.mediaType,
timelineStartSeconds: delivery.timelineStartSeconds,
timelineEndSeconds: delivery.timelineEndSeconds,
seekable: true,
synchronization: "host-arrival-best-effort",
};
}
function expectedPayloadEtag(digest: string): string {
return `"sha256:${digest}"`;
}
export async function fetchVerifiedRecordedMediaBytes(
descriptor: RecordedMediaBinaryDescriptor,
{
signal,
fetcher = globalThis.fetch,
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
): Promise<ArrayBuffer> {
let response: Response;
try {
response = await fetcher(descriptor.url, {
method: "GET",
headers: {
Accept: descriptor.accept,
"If-Match": expectedPayloadEtag(descriptor.sha256),
},
signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservationSessionContractError(
"Не удалось загрузить канонический фрагмент записанного видео.",
);
}
if (!response.ok || response.status !== 200) {
throw new ObservationSessionContractError(
`Фрагмент записанного видео вернул HTTP ${response.status}.`,
);
}
if (response.headers.get("ETag") !== expectedPayloadEtag(descriptor.sha256)) {
throw new ObservationSessionContractError(
"Фрагмент записанного видео не соответствует immutable manifest.",
);
}
const contentLength = response.headers.get("Content-Length");
if (
contentLength === null ||
!/^[1-9][0-9]*$/.test(contentLength) ||
Number(contentLength) !== descriptor.byteLength
) {
throw new ObservationSessionContractError(
"Длина фрагмента записанного видео не соответствует immutable manifest.",
);
}
const payload = await response.arrayBuffer();
if (payload.byteLength !== descriptor.byteLength) {
throw new ObservationSessionContractError(
"Фрагмент записанного видео был усечён во время передачи.",
);
}
const digest = bytesToHex(sha256(new Uint8Array(payload)));
if (digest !== descriptor.sha256) {
throw new ObservationSessionContractError(
"SHA-256 фрагмента записанного видео не совпадает с immutable manifest.",
);
}
return payload;
}
function manifestIdentity(manifest: ObservationRecordedMediaManifest): string {
return JSON.stringify(manifest);
}
export async function fetchVerifiedRecordedMediaArchive(
source: ObservationRecordedMediaSource,
{
signal,
fetcher = globalThis.fetch,
onProgress,
}: {
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
onProgress?: (progress: RecordedMediaLoadProgress) => void;
} = {},
): Promise<VerifiedRecordedMediaArchive> {
const manifest = await fetchObservationRecordedMediaManifest(source, {
signal,
fetcher,
expectedGenerationSha256: source.manifestGenerationSha256,
});
const totalBytes = manifest.epochs.reduce(
(archiveTotal, epoch) => archiveTotal + epoch.initByteLength + epoch.segments.reduce(
(epochTotal, segment) => epochTotal + segment.byteLength,
0,
),
0,
);
const totalParts = manifest.epochs.reduce(
(total, epoch) => total + 1 + epoch.segments.length,
0,
);
if (
totalBytes < 1 ||
totalBytes > MAX_RECORDED_MEDIA_SOURCE_BYTES ||
totalBytes !== manifest.byteLength ||
totalBytes !== source.byteLength
) {
throw new ObservationSessionContractError(
"Размер записанного медиаканала выходит за безопасный лимит браузера.",
);
}
let loadedBytes = 0;
let loadedParts = 0;
const publishProgress = () => onProgress?.({
loadedBytes,
totalBytes,
loadedParts,
totalParts,
});
publishProgress();
const epochs: VerifiedRecordedMediaEpoch[] = [];
for (const epoch of manifest.epochs) {
const init = await fetchVerifiedRecordedMediaBytes({
url: epoch.initUrl,
byteLength: epoch.initByteLength,
sha256: epoch.initSha256,
accept: "video/mp4",
}, { signal, fetcher });
loadedBytes += init.byteLength;
loadedParts += 1;
publishProgress();
const segments: ArrayBuffer[] = [];
for (const segment of epoch.segments) {
const payload = await fetchVerifiedRecordedMediaBytes({
url: segment.url,
byteLength: segment.byteLength,
sha256: segment.sha256,
accept: "video/iso.segment",
}, { signal, fetcher });
segments.push(payload);
loadedBytes += payload.byteLength;
loadedParts += 1;
publishProgress();
}
epochs.push({ descriptor: epoch, init, segments });
}
// Bind the complete byte set to the same manifest generation at both ends
// of the transfer. A replacement during a long camera download fails closed.
const confirmed = await fetchObservationRecordedMediaManifest(source, {
signal,
fetcher,
expectedGenerationSha256: manifest.generationSha256,
});
if (manifestIdentity(confirmed) !== manifestIdentity(manifest)) {
throw new ObservationSessionContractError(
"Manifest записанного видео изменился во время полной загрузки.",
);
}
if (loadedBytes !== source.byteLength) {
throw new ObservationSessionContractError(
"Полная загрузка камеры не совпала с launch byte_length.",
);
}
return { manifest, epochs, byteLength: loadedBytes };
}
export function appendRecordedMediaBuffer(
sourceBuffer: SourceBuffer,
payload: ArrayBuffer,
signal: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const onUpdateEnd = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("SourceBuffer rejected archived fMP4 data"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
sourceBuffer.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
if (signal.aborted) {
onAbort();
return;
}
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
sourceBuffer.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
try {
sourceBuffer.appendBuffer(payload);
} catch (error) {
cleanup();
reject(error);
}
});
}
function videoHasSeekableArchive(
video: HTMLVideoElement,
declaredDurationSeconds: number,
): boolean {
if (video.readyState < 1 || video.seekable.length < 1) return false;
return recordedMediaSeekableCoverage(
video.duration,
video.seekable.end(video.seekable.length - 1),
declaredDurationSeconds,
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
video.seekable.start(0),
);
}
function waitForSeekableArchive(
video: HTMLVideoElement,
declaredDurationSeconds: number,
signal: AbortSignal,
): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
return new Promise((resolve, reject) => {
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
const timeout = globalThis.setTimeout(() => {
cleanup();
reject(new Error("Archived camera did not become seekable"));
}, 20_000);
const cleanup = () => {
globalThis.clearTimeout(timeout);
for (const event of events) video.removeEventListener(event, onProgress);
video.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onProgress = () => {
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Archived camera decode failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
for (const event of events) video.addEventListener(event, onProgress);
video.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function mountVerifiedRecordedEpoch(
video: HTMLVideoElement,
epoch: VerifiedRecordedMediaEpoch,
signal: AbortSignal,
): Promise<() => void> {
const descriptor = epoch.descriptor;
if (
descriptor.mediaType === "video/mp4" ||
!globalThis.MediaSource ||
!MediaSource.isTypeSupported(descriptor.mediaType)
) {
throw new Error("Archived camera codec is not supported");
}
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
const cleanup = () => {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(objectUrl);
};
video.src = objectUrl;
try {
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
cleanupListeners();
resolve();
};
const onAbort = () => {
cleanupListeners();
reject(new DOMException("Aborted", "AbortError"));
};
const cleanupListeners = () => {
mediaSource.removeEventListener("sourceopen", onOpen);
signal.removeEventListener("abort", onAbort);
};
if (signal.aborted) {
onAbort();
return;
}
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
});
if (signal.aborted || mediaSource.readyState !== "open") {
throw new DOMException("Aborted", "AbortError");
}
const sourceBuffer = mediaSource.addSourceBuffer(descriptor.mediaType);
await appendRecordedMediaBuffer(sourceBuffer, epoch.init, signal);
for (const segment of epoch.segments) {
await appendRecordedMediaBuffer(sourceBuffer, segment, signal);
}
if (signal.aborted || mediaSource.readyState !== "open" || sourceBuffer.updating) {
throw new Error("Archived camera MediaSource closed before full append");
}
mediaSource.endOfStream();
await waitForSeekableArchive(
video,
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
signal,
);
return cleanup;
} catch (error) {
cleanup();
throw error;
}
}
export function RecordedFmp4Player({
source,
playback,
prepare = true,
sessionGate = "ready",
admissionKey = null,
onAdmissionChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
prepare?: boolean;
sessionGate?: RecordedAdmissionPhase;
admissionKey?: string | null;
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const onAdmissionChangeRef = useRef(onAdmissionChange);
onAdmissionChangeRef.current = onAdmissionChange;
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
recordedMediaWorkerGeneration += 1;
workerRef.current = { admissionKey, generation: recordedMediaWorkerGeneration };
}
const workerGeneration = workerRef.current.generation;
const reportAdmission = (next: RecordedCameraAdmissionState) => {
onAdmissionChangeRef.current?.({
...next,
admissionKey,
workerGeneration,
});
};
const recordedDelivery = source.delivery?.kind === "recorded-fmp4-manifest"
? source.delivery
: null;
const contract = useMemo(
() => sourceContract(source),
[
source.id,
source.label,
recordedDelivery?.id,
recordedDelivery?.url,
recordedDelivery?.mediaType,
recordedDelivery?.manifestGenerationSha256,
recordedDelivery?.byteLength,
recordedDelivery?.timelineStartSeconds,
recordedDelivery?.timelineEndSeconds,
],
);
const [archive, setArchive] = useState<VerifiedRecordedMediaArchive | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [progress, setProgress] = useState<RecordedMediaLoadProgress | null>(null);
const [bufferRevision, setBufferRevision] = useState(0);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const epoch = useMemo(
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds],
);
const verifiedEpoch = useMemo(
() => epoch
? archive?.epochs.find(({ descriptor }) => descriptor.ordinal === epoch.ordinal) ?? null
: null,
[archive?.epochs, epoch],
);
const waitingForEpoch = Boolean(archive && !epoch);
const selectedGeneration = contract && epoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
: null;
const visualState = recordedMediaPresentationState(
state,
readyGeneration,
selectedGeneration,
waitingForEpoch,
sessionGate,
);
useEffect(() => {
if (!contract) {
setArchive(null);
setProgress(null);
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: null,
message: "Некорректный descriptor записанной камеры.",
});
return;
}
if (!prepare) return;
const abort = new AbortController();
setArchive(null);
setProgress(null);
setReadyGeneration(null);
setState("loading");
reportAdmission({
phase: "loading",
byteLength: contract.byteLength,
message: null,
});
void fetchVerifiedRecordedMediaArchive(contract, {
signal: abort.signal,
onProgress: (next) => {
if (!abort.signal.aborted) setProgress(next);
},
})
.then((loaded) => {
if (abort.signal.aborted) return;
setArchive(loaded);
})
.catch((error: unknown) => {
if (abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
return;
}
setArchive(null);
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: contract.byteLength,
message: "Архив записанной камеры не прошёл проверку.",
});
});
return () => abort.abort();
}, [admissionKey, contract, prepare]);
useEffect(() => {
if (!archive || !contract || !prepare) return;
const abort = new AbortController();
let disposed = false;
void (async () => {
for (const candidate of archive.epochs) {
const probe = document.createElement("video");
probe.muted = true;
probe.playsInline = true;
const cleanup = await mountVerifiedRecordedEpoch(probe, candidate, abort.signal);
cleanup();
if (disposed || abort.signal.aborted) return;
}
if (disposed || abort.signal.aborted) return;
reportAdmission({
phase: "ready",
byteLength: archive.byteLength,
message: null,
});
})().catch((error: unknown) => {
if (
disposed ||
abort.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) return;
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive.byteLength,
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
});
});
return () => {
disposed = true;
abort.abort();
};
}, [admissionKey, archive, contract, prepare]);
useEffect(() => {
const video = videoRef.current;
if (!video || !verifiedEpoch) return;
const epochDescriptor = verifiedEpoch.descriptor;
const generation = contract
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
: null;
setReadyGeneration(null);
setState("loading");
const abort = new AbortController();
let disposed = false;
let cleanup: (() => void) | null = null;
const loadEpoch = async () => {
try {
cleanup = await mountVerifiedRecordedEpoch(video, verifiedEpoch, abort.signal);
if (disposed || abort.signal.aborted) {
cleanup();
cleanup = null;
return;
}
setBufferRevision((revision) => revision + 1);
setReadyGeneration(generation);
setState("ready");
} catch (error) {
if (
disposed ||
abort.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) {
return;
}
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive?.byteLength ?? null,
message: "Записанная камера не стала seekable.",
});
}
};
void loadEpoch();
return () => {
disposed = true;
abort.abort();
cleanup?.();
};
}, [archive?.byteLength, contract, verifiedEpoch]);
useEffect(() => {
const video = videoRef.current;
if (!video || !epoch || visualState !== "ready") return;
const target = recordedMediaLocalTime(
epoch.timelineStartSeconds,
currentSeconds,
video.duration,
);
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
try {
video.currentTime = target;
} catch {
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive?.byteLength ?? null,
message: "Seek записанной камеры завершился ошибкой.",
});
return;
}
}
if (playback?.playing) {
void video.play().catch(() => undefined);
} else {
video.pause();
}
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
const progressPercent = progress && progress.totalBytes > 0
? Math.min(100, Math.floor((progress.loadedBytes / progress.totalBytes) * 100))
: 0;
return (
<div
className="recorded-media-player"
data-state={visualState}
aria-busy={visualState === "loading"}
>
<video
ref={videoRef}
className="observation-media__asset"
muted
playsInline
preload="auto"
aria-label={source.label}
/>
{visualState !== "ready" ? (
<div
className="recorded-media-player__notice"
role={visualState === "error" ? "alert" : "status"}
>
{visualState === "waiting"
? "Камера на этой позиции ещё не записывалась"
: visualState === "error"
? "Записанное видео недоступно"
: archive
? "Проверяем полную готовность записанного видео…"
: `Загружаем и проверяем записанное видео · ${progressPercent}%`}
</div>
) : null}
</div>
);
}
File diff suppressed because it is too large Load Diff